From 2514bb79a568217d7bb972bb8873fbf786014146 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 20 May 2026 14:47:57 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D0=BA=D1=83=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8?= =?UTF-8?q?=20=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD=D0=B8=D0=B7=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D1=81=20production.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В репозиторий включены css/ (f7support.css, fonts.css), img/ (иконки файлов и app.svg), fonts/, а также актуальные правки JS, PHP и info.xml с иконкой навигации. --- appinfo/info.xml | 1 + css/f7support.css | 1588 ++++++++++++++++++++++++++++- css/fonts.css | 18 + fonts/.gitkeep | 1 + fonts/raleway-medium.ttf | Bin 0 -> 164128 bytes fonts/ralewayt.ttf | Bin 0 -> 311856 bytes img/app.svg | 44 + img/file-application.svg | 42 + img/file-code.svg | 33 + img/file-default.svg | 31 + img/file-doc-docx.svg | 34 + img/file-fonts.svg | 33 + img/file-pdf.svg | 34 + img/file-presentation.svg | 34 + img/file-sheet.svg | 34 + img/file-txt.svg | 34 + js/main.js | 1441 ++++++++++++++++---------- lib/Controller/PageController.php | 1 + 18 files changed, 2838 insertions(+), 565 deletions(-) create mode 100644 css/fonts.css create mode 100644 fonts/.gitkeep create mode 100644 fonts/raleway-medium.ttf create mode 100644 fonts/ralewayt.ttf create mode 100644 img/app.svg create mode 100644 img/file-application.svg create mode 100644 img/file-code.svg create mode 100644 img/file-default.svg create mode 100644 img/file-doc-docx.svg create mode 100644 img/file-fonts.svg create mode 100644 img/file-pdf.svg create mode 100644 img/file-presentation.svg create mode 100644 img/file-sheet.svg create mode 100644 img/file-txt.svg diff --git a/appinfo/info.xml b/appinfo/info.xml index f23172f..41f0e37 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -18,6 +18,7 @@ f7support Support + app.svg f7support.page.index 30 diff --git a/css/f7support.css b/css/f7support.css index c002ca1..0bccfd0 100644 --- a/css/f7support.css +++ b/css/f7support.css @@ -1,7 +1,13 @@ -/* Базовая изоляция; основной UI — в `; const messageList = document.getElementById("message-list"); @@ -859,32 +1016,6 @@ el.setAttribute("aria-hidden", visible ? "false" : "true"); } - function updatePendingFileUi() { - const row = document.getElementById("pending-attachment-row"); - const label = document.getElementById("pending-attachment-label"); - if (!row || !label) return; - if (state.pendingFile) { - label.textContent = `Вложение: ${state.pendingFile.name}`; - row.hidden = false; - } else { - row.hidden = true; - label.textContent = ""; - } - } - - function setPendingFile(file) { - if (!file) return; - state.pendingFile = file; - updatePendingFileUi(); - } - - function clearPendingFile() { - state.pendingFile = null; - const inp = document.getElementById("attachment-input"); - if (inp) inp.value = ""; - updatePendingFileUi(); - } - function bindClientChatDnDOnce() { if (clientChatDndBound) return; clientChatDndBound = true; @@ -902,15 +1033,18 @@ e.preventDefault(); e.stopPropagation(); el.classList.remove("f7support-dnd-dragover"); - const f = e.dataTransfer?.files?.[0]; - if (f) setPendingFile(f); + const zone = dndZoneFromElement(el); + if (zone && e.dataTransfer?.files?.length) { + addFilesToZone(zone, e.dataTransfer.files); + } }); }); } function openChatModal(ticket) { state.chatModalOpen = true; - chatModalTitle.textContent = `Чат #${ticket.ticket_number} — ${ticket.subject}`; + const subj = ticket.subject != null ? String(ticket.subject).trim() : ""; + chatModalTitle.textContent = subj ? `Чат : ${subj}` : `Чат : Запрос ${ticket.ticket_number}`; setModalVisible(chatModal, true); } @@ -930,7 +1064,7 @@ messageList.innerHTML = ""; const msgInput = document.getElementById("message-input"); if (msgInput) msgInput.value = ""; - clearPendingFile(); + clearChatAttachments(); setModalVisible(chatModal, false); showError(""); document.querySelectorAll(".f7-ticket-card--active").forEach((n) => n.classList.remove("f7-ticket-card--active")); @@ -976,8 +1110,10 @@ } function renderTicketCardHtml(ticket) { - const previewRaw = ticket.preview_text != null ? ticket.preview_text : ""; - const preview = escapeHtml(previewRaw); + const previewRaw = ticket.preview_text != null ? String(ticket.preview_text).trim() : ""; + const preview = previewRaw + ? escapeHtml(previewRaw) + : 'Текст последнего сообщения…'; const activity = ticket.activity_at || ticket.created_at; const time = escapeHtml(formatTicketCardTime(activity)); const hasUnread = ticketHasUnread(ticket); @@ -985,7 +1121,8 @@ ? '' : ""; const tn = escapeHtml(ticket.ticket_number); - const subj = escapeHtml(ticket.subject || "—"); + const subjRaw = ticket.subject != null ? String(ticket.subject).trim() : ""; + const subj = escapeHtml(subjRaw || "—"); const st = escapeHtml(ticket.status); const stCls = clientTicketStatusClass(ticket.status); return `
  • @@ -994,7 +1131,7 @@ ${st}
    -

    ${preview || "—"}

    +

    ${preview}

    ${unread}
    @@ -1034,6 +1171,18 @@ } } + function syncHomeLayout() { + const home = document.getElementById("f7support-home"); + const board = document.getElementById("f7-ticket-board"); + const iconWrap = document.querySelector(".f7s-home-icon-wrap"); + if (!home) return; + const hasTickets = state.tickets.length > 0; + home.classList.toggle("f7s-home--empty", !hasTickets); + home.classList.toggle("f7s-home--has-tickets", hasTickets); + if (board) board.hidden = !hasTickets; + if (iconWrap) iconWrap.hidden = hasTickets; + } + function renderTickets() { const { newT, progress, closed } = partitionClientTickets(state.tickets); const cols = [ @@ -1049,6 +1198,7 @@ li.onclick = () => tryOpenTicket(li.getAttribute("data-ticket-number")); }); }); + syncHomeLayout(); } async function fetchMessages() { @@ -1057,9 +1207,9 @@ headers: clientIdentityHeaders(), }); if (!response.ok) throw new Error("Не удалось загрузить сообщения"); - state.messages = await response.json(); + state.messages = dedupeMessagesById(await response.json()); syncCurrentTicketLastMsgSigFromState(); - renderMessages(); + await renderMessages(); } async function fetchClientAttachmentBlob(attachmentId) { @@ -1069,61 +1219,184 @@ return res.blob(); } - function hydrateClientAttachments(scope) { - const root = scope || messageList; - if (!root || !state.currentTicket) return; - root.querySelectorAll("[data-f7-att-preview]").forEach((wrap) => { - const id = wrap.getAttribute("data-f7-att-preview"); - (async () => { - try { - const blob = await fetchClientAttachmentBlob(id); - const objUrl = URL.createObjectURL(blob); - state.blobUrls.push(objUrl); - const frame = document.createElement("div"); - frame.className = "chat-inline-img-wrap"; - const img = document.createElement("img"); - img.className = "chat-inline-img"; - img.src = objUrl; - img.alt = ""; - img.loading = "lazy"; - img.addEventListener("load", () => scrollChatToBottom(), { once: true }); - frame.appendChild(img); - wrap.replaceChildren(frame); - } catch (_) { - wrap.textContent = "Не удалось показать файл"; - } - })(); - }); + function appendAttachCard(bubble, att) { + const mime = typeof att.mime_type === "string" ? att.mime_type : ""; + const filename = att.filename || "file"; + const sizePart = att.size_bytes != null ? formatBytes(att.size_bytes) : ""; + const displayName = formatMsgAttachDisplayName(filename); - root.querySelectorAll("[data-f7-att-file]").forEach((row) => { - const handler = async () => { - const id = row.getAttribute("data-f7-att-file"); - const fn = row.getAttribute("data-f7-filename") || "file"; - const mime = row.getAttribute("data-f7-mime") || ""; - try { - const blob = await fetchClientAttachmentBlob(id); - openOrDownloadBlob(blob, fn, mime); - } catch (_) { - showError("Не удалось открыть файл"); - } - }; - row.onclick = handler; - row.onkeydown = (e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handler(); - } - }; + const card = document.createElement("div"); + card.className = "f7s-msg-attach-card"; + card.setAttribute("role", "button"); + card.tabIndex = 0; + + const iconEl = document.createElement("div"); + iconEl.className = "f7s-msg-attach-card__icon"; + const iconImg = document.createElement("img"); + iconImg.className = "f7s-msg-attach-card__icon-img"; + iconImg.src = f7sAppImgUrl(attachmentIconFile(att)); + iconImg.alt = ""; + iconImg.width = 22; + iconImg.height = 22; + iconEl.appendChild(iconImg); + + const body = document.createElement("div"); + body.className = "f7s-msg-attach-card__body"; + const fn = document.createElement("div"); + fn.className = "f7s-msg-attach-card__name"; + fn.textContent = displayName; + if (filename.length > MSG_ATTACH_NAME_MAX_LEN) fn.title = filename; + const metaLine = document.createElement("div"); + metaLine.className = "f7s-msg-attach-card__size"; + metaLine.textContent = sizePart || " "; + body.append(fn, metaLine); + + const openFile = async () => { + try { + const blob = await fetchClientAttachmentBlob(att.id); + openOrDownloadBlob(blob, filename, mime); + } catch { + showError("Не удалось открыть файл"); + } + }; + card.addEventListener("click", () => void openFile()); + card.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + void openFile(); + } }); + card.append(iconEl, body); + bubble.appendChild(card); } - function renderMessages() { + async function appendImageAttach(bubble, att) { + const filename = att.filename || "file"; + const sizePart = att.size_bytes != null ? formatBytes(att.size_bytes) : ""; + const displayName = formatMsgAttachDisplayName(filename); + + const head = document.createElement("div"); + head.className = "f7s-msg-img-head"; + const fn = document.createElement("div"); + fn.className = "f7s-msg-img-name"; + fn.textContent = displayName; + if (filename.length > MSG_ATTACH_NAME_MAX_LEN) fn.title = filename; + const sz = document.createElement("div"); + sz.className = "f7s-msg-img-size"; + sz.textContent = sizePart; + head.append(fn, sz); + bubble.appendChild(head); + + const wrap = document.createElement("div"); + wrap.className = "f7s-msg-img-wrap"; + const img = document.createElement("img"); + img.className = "f7s-msg-img"; + img.alt = filename; + wrap.appendChild(img); + bubble.appendChild(wrap); + + try { + const blob = await fetchClientAttachmentBlob(att.id); + const u = URL.createObjectURL(blob); + state.blobUrls.push(u); + img.src = u; + img.addEventListener("load", () => scrollChatToBottom(), { once: true }); + img.addEventListener("click", () => { + try { + window.open(u, "_blank", "noopener,noreferrer"); + } catch (_) {} + }); + } catch { + const err = document.createElement("div"); + err.className = "f7s-img-err"; + err.textContent = `Не удалось показать изображение: ${displayName}`; + wrap.appendChild(err); + } + } + + async function renderMessages() { revokeAttachmentUrls(); - messageList.innerHTML = state.messages.map((m) => messageBlockHtml(m)).join(""); - hydrateClientAttachments(messageList); + messageList.innerHTML = ""; + const ticketNo = state.currentTicket; + if (!ticketNo) return; + + const rows = buildRenderableMessageRows(state.messages); + for (const { m, groupPos, showSender, outgoing } of rows) { + const text = String(m.text ?? "").trim(); + const attachments = messageAttachmentsFrom(m); + const showText = text && !isMessageBodyPlaceholder(text); + + const article = document.createElement("article"); + article.className = `f7s-msg f7s-msg--${outgoing ? "out" : "in"} f7s-msg--${groupPos}`; + if (m.id != null) article.dataset.f7MsgId = String(m.id); + + if (!outgoing && showSender) { + const sender = document.createElement("div"); + sender.className = "f7s-msg-sender"; + const avatar = document.createElement("span"); + avatar.className = "f7s-msg-avatar"; + avatar.textContent = "F7"; + const name = document.createElement("span"); + name.className = "f7s-msg-sender-name"; + name.textContent = supportSenderLabel(m); + sender.append(avatar, name); + article.appendChild(sender); + } + + const bubble = document.createElement("div"); + bubble.className = `f7s-msg-bubble f7s-msg-bubble--${groupPos}`; + if (attachments.length > 0) { + bubble.classList.add("f7s-msg-bubble--has-attach"); + const onlyImages = attachments.every( + (a) => + String(a.mime_type || "") + .toLowerCase() + .startsWith("image/") || isRasterImageAtt(a) + ); + if (onlyImages && !showText) bubble.classList.add("f7s-msg-bubble--image"); + } + + if (showText) { + const p = document.createElement("p"); + p.className = "f7s-msg-text"; + p.textContent = text; + bubble.appendChild(p); + } + + for (const att of attachments) { + const mime = typeof att.mime_type === "string" ? att.mime_type : ""; + const isImg = mime.startsWith("image/") || isRasterImageAtt(att); + if (isImg) { + await appendImageAttach(bubble, att); + } else { + appendAttachCard(bubble, att); + } + } + + const meta = document.createElement("div"); + meta.className = "f7s-msg-meta"; + const timeEl = document.createElement("time"); + timeEl.className = "f7s-msg-time"; + if (m.created_at) timeEl.dateTime = String(m.created_at); + timeEl.textContent = formatMsgTime(m.created_at); + meta.appendChild(timeEl); + if (outgoing) { + const read = document.createElement("span"); + read.className = "f7s-msg-read"; + const isRead = clientMessageIsRead(m); + read.setAttribute("aria-label", isRead ? "Прочитано" : "Доставлено"); + read.innerHTML = isRead ? CHAT_MSG_READ_SVG : CHAT_MSG_UNREAD_SVG; + meta.appendChild(read); + } + bubble.appendChild(meta); + + article.appendChild(bubble); + messageList.appendChild(article); + } scrollChatToBottom(); } + async function submitTicketWithRetry(payload) { const maxAttempts = 3; for (let attempt = 1; attempt <= maxAttempts; attempt++) { @@ -1161,6 +1434,7 @@ document.getElementById("close-modal-btn").onclick = () => { document.getElementById("subject-input").value = ""; document.getElementById("body-input").value = ""; + clearCreateTicketAttachments(); setModalVisible(createModal, false); }; @@ -1168,25 +1442,24 @@ void closeChatModal(); }; - document.getElementById("attachment-clip-btn").onclick = () => { - document.getElementById("attachment-input")?.click(); - }; + document.getElementById("create-ticket-attachment-input")?.addEventListener("change", onCreateTicketAttachmentChange); + document.getElementById("attachment-input")?.addEventListener("change", onChatAttachmentChange); - document.getElementById("attachment-input").onchange = (e) => { - const f = e.target.files?.[0]; - if (f) setPendingFile(f); - }; - - document.getElementById("pending-attachment-clear").onclick = () => { - clearPendingFile(); - }; + const bodyInputEl = document.getElementById("body-input"); + bodyInputEl?.addEventListener("paste", (e) => { + const files = e.clipboardData?.files; + if (files && files.length) { + e.preventDefault(); + addFilesToZone("create", files); + } + }); const messageInputEl = document.getElementById("message-input"); - messageInputEl.addEventListener("paste", (e) => { + messageInputEl?.addEventListener("paste", (e) => { const files = e.clipboardData?.files; - if (files && files[0]) { + if (files && files.length) { e.preventDefault(); - setPendingFile(files[0]); + addFilesToZone("chat", files); } }); @@ -1202,13 +1475,54 @@ server_address: serverAddress, username, subject, - body, + body: TICKET_CREATE_BODY_PLACEHOLDER, }); + const ticketNo = result.ticket_number; + let createdMsg; + try { + createdMsg = await postMessageToTicket(ticketNo, body); + } catch (e) { + showError( + e.message || + `Токен ${ticketNo} создан, но текст обращения не попал в чат. Откройте тикет и напишите сообщение вручную.` + ); + setModalVisible(createModal, false); + document.getElementById("subject-input").value = ""; + document.getElementById("body-input").value = ""; + clearCreateTicketAttachments(); + await fetchTickets(); + return; + } + const firstMessageId = createdMsg.id ?? createdMsg.message_id; + const createFiles = [...state.createTicketAttachmentFiles]; + state.chatOutboundBusy = true; + try { + if (createFiles.length > 0) { + if (firstMessageId == null) { + showError(`Токен ${ticketNo} создан, но сервер не вернул id сообщения — вложения не отправлены.`); + } else { + try { + for (const f of createFiles) { + await uploadAttachmentForMessage(ticketNo, firstMessageId, f); + } + } catch (attErr) { + showError( + attErr.message || + `Токен ${ticketNo} создан, текст в чате есть, но не все вложения удалось отправить.` + ); + } + } + } + } finally { + state.chatOutboundBusy = false; + } setModalVisible(createModal, false); - showError(`Токен создан: ${result.ticket_number}`); document.getElementById("subject-input").value = ""; document.getElementById("body-input").value = ""; + clearCreateTicketAttachments(); + showError(`Токен создан: ${ticketNo}`); await fetchTickets(); + void tryOpenTicket(ticketNo); } catch (e) { showError(e.message || "Ошибка отправки"); } @@ -1220,46 +1534,47 @@ return; } let text = document.getElementById("message-input").value.trim(); - const file = state.pendingFile; - if (!text && !file) { + const chatFiles = [...state.chatAttachmentFiles]; + if (!text && !chatFiles.length) { showError("Введите сообщение или прикрепите файл"); return; } - if (!text && file) text = `📎 ${file.name}`; - - const response = await fetch(`${apiBase}/api/client/tickets/${state.currentTicket}/messages`, { - method: "POST", - headers: { - "Content-Type": "application/json", - ...clientIdentityHeaders(), - }, - body: JSON.stringify({ text }), - }); - if (!response.ok) { - showError("Не удалось отправить сообщение"); - return; + if (!text && chatFiles.length) { + text = MESSAGE_BODY_PLACEHOLDER; } - const created = await response.json(); - if (file) { - const form = new FormData(); - form.append("message_id", String(created.id)); - form.append("file", file); - const attRes = await fetch(`${apiBase}/api/client/tickets/${state.currentTicket}/attachments`, { - method: "POST", - headers: clientIdentityHeaders(), - body: form, - }); - if (!attRes.ok) { - const errText = await attRes.text().catch(() => ""); - showError(errText || "Не удалось отправить файл"); - return; + showError(""); + state.chatOutboundBusy = true; + let created; + try { + created = await postMessageToTicket(state.currentTicket, text); + const messageId = created.id ?? created.message_id; + if (chatFiles.length > 0) { + if (messageId == null) { + showError("Сообщение создано, но сервер не вернул id — вложения не отправлены"); + clearChatAttachments(); + } else { + try { + for (const f of chatFiles) { + await uploadAttachmentForMessage(state.currentTicket, messageId, f); + } + } catch (e) { + showError(e.message || "Сообщение отправлено, но не все вложения приняты"); + } + clearChatAttachments(); + } } - clearPendingFile(); + document.getElementById("message-input").value = ""; + await fetchMessages(); + } catch (e) { + showError(e.message || "Не удалось отправить сообщение"); + } finally { + state.chatOutboundBusy = false; } - document.getElementById("message-input").value = ""; - await fetchMessages(); }; + syncCreateTicketAttachmentsUI(); + syncChatAttachmentsUI(); + queueMicrotask(() => { fetchTickets() .catch((e) => showError(e.message)) diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index e600d7e..ff70819 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -68,6 +68,7 @@ class PageController extends Controller { } } + Util::addStyle('f7support', 'fonts'); Util::addStyle('f7support', 'f7support'); Util::addScript('f7support', 'main');