From 1a58baebb294c807b09c2efde714d47b4fa34571 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 5 May 2026 11:01:54 +0300 Subject: [PATCH] Initial import of f7support application. --- appinfo/info.xml | 18 + appinfo/routes.php | 8 + css/f7support.css | 201 ++++++++++ js/main.js | 616 ++++++++++++++++++++++++++++++ lib/Application.php | 16 + lib/Controller/PageController.php | 48 +++ templates/main.php | 8 + 7 files changed, 915 insertions(+) create mode 100644 appinfo/info.xml create mode 100644 appinfo/routes.php create mode 100644 css/f7support.css create mode 100644 js/main.js create mode 100644 lib/Application.php create mode 100644 lib/Controller/PageController.php create mode 100644 templates/main.php diff --git a/appinfo/info.xml b/appinfo/info.xml new file mode 100644 index 0000000..40927af --- /dev/null +++ b/appinfo/info.xml @@ -0,0 +1,18 @@ + + + f7support + f7support + Support ticket client for F7cloud (F7cloud-compatible) + f7support client app for creating and viewing support tickets. + 0.1.0 + AGPL + f7support team + F7Support + + filesystem + + + + + + diff --git a/appinfo/routes.php b/appinfo/routes.php new file mode 100644 index 0000000..e210b44 --- /dev/null +++ b/appinfo/routes.php @@ -0,0 +1,8 @@ + [ + ['name' => 'page#index', 'url' => '/', 'verb' => 'GET'], + ], +]; + diff --git a/css/f7support.css b/css/f7support.css new file mode 100644 index 0000000..d08734c --- /dev/null +++ b/css/f7support.css @@ -0,0 +1,201 @@ +/* f7support UI — вынесено из innerHTML для меньшего парсинга JS и кэширования CSS ядром NC */ + +#f7support-app { + box-sizing: border-box; + font-family: sans-serif; +} + +#f7support-app *, +#f7support-app *::before, +#f7support-app *::after { + box-sizing: inherit; +} + +#f7support-app .f7s-main { + padding: 16px; + max-width: 720px; +} + +#f7support-app .f7s-error { + color: #b00020; +} + +#f7support-app .f7s-section-title { + margin-top: 16px; +} + +#f7support-app .f7s-hint { + color: #666; + font-size: 14px; + margin: 4px 0 8px; +} + +#f7support-app .f7s-ticket-list { + padding-left: 20px; + margin: 0; +} + +#f7support-app .f7s-ticket-item { + cursor: pointer; + margin-bottom: 6px; +} + +#f7support-app .f7s-modal { + display: none; + position: fixed; + inset: 0; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.45); +} + +#f7support-app .f7s-modal.f7s-open { + display: flex; +} + +#f7support-app .f7s-modal--create { + z-index: 1000; +} + +#f7support-app .f7s-modal--chat { + z-index: 1100; +} + +#f7support-app .f7s-modal-panel { + background: #fff; + border: 1px solid #333; + padding: 16px; + width: min(560px, 92vw); + max-height: 85vh; + overflow: auto; + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); +} + +#f7support-app .f7s-modal-head { + display: flex; + justify-content: space-between; + align-items: center; +} + +#f7support-app .f7s-modal-head h3 { + margin: 0; +} + +#f7support-app .f7s-btn-icon { + font-size: 20px; + line-height: 1; + border: none; + background: transparent; + cursor: pointer; +} + +#f7support-app .f7s-btn-icon--lg { + font-size: 22px; + padding: 4px 8px; +} + +#f7support-app .f7s-input { + width: 100%; + margin-top: 8px; +} + +#f7support-app .f7s-textarea { + width: 100%; + height: 140px; + margin-top: 8px; +} + +#f7support-app .f7s-textarea--message { + min-height: 72px; + height: auto; +} + +#f7support-app .f7s-mt-8 { + margin-top: 8px; +} + +#f7support-app .f7s-chat-shell { + background: #fff; + width: min(720px, 94vw); + max-height: 88vh; + display: flex; + flex-direction: column; + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25); + overflow: hidden; +} + +#f7support-app .f7s-chat-head { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid #e0e0e0; + flex-shrink: 0; +} + +#f7support-app .f7s-chat-title { + margin: 0; + font-size: 1.1rem; +} + +#f7support-app .f7s-chat-error { + margin: 0; + padding: 8px 16px; + min-height: 1.2em; +} + +#f7support-app .f7s-message-list { + flex: 1; + min-height: 220px; + max-height: 45vh; + overflow: auto; + border-top: 1px solid #eee; + border-bottom: 1px solid #eee; + padding: 12px 16px; +} + +#f7support-app .f7s-chat-footer { + padding: 12px 16px 16px; + flex-shrink: 0; +} + +#f7support-app .f7s-chat-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-top: 8px; +} + +#f7support-app .f7s-msg-row { + margin-bottom: 12px; + border-bottom: 1px solid #eee; + padding-bottom: 10px; +} + +#f7support-app .f7s-img-wrap { + margin-top: 8px; +} + +#f7support-app .f7s-msg-img { + max-width: 100%; + max-height: 260px; + object-fit: contain; + border: 1px solid #ddd; + border-radius: 4px; +} + +#f7support-app .f7s-img-err { + color: #888; + font-size: 13px; +} + +#f7support-app .f7s-attach-row { + margin-top: 6px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..92aac77 --- /dev/null +++ b/js/main.js @@ -0,0 +1,616 @@ +/** + * Производительность: список тикетов — после первого кадра (requestIdleCallback / queueMicrotask); + * кэш списка в sessionStorage на время вкладки; обновление при возврате фокуса. + * Скрипт подключается через Util::addScript — ядро NC уже даёт defer и ?v=… (версия приложения / хэш). + * «Живой» чат без опроса — только WebSocket/SSE на apiBase (отдельная задача бэкенда). + */ +(function () { + const root = document.getElementById("f7support-app"); + if (!root) return; + + const username = root.dataset.username; + const serverAddress = root.dataset.serverAddress; + const apiBase = root.dataset.supportApiBase; + const messagesPollMs = Math.max(2000, parseInt(root.dataset.messagesPollMs, 10) || 5000); + + /** @type {ReturnType | null} */ + let chatMessagesPollTimer = null; + + const state = { + tickets: [], + currentTicket: null, + messages: [], + /** Preview images in the message list (revoked on re-render / close chat). */ + /** @type {string[]} */ + previewBlobUrls: [], + }; + + function clientHeaders(extra) { + const h = { + "X-F7cloud-User": username, + "X-F7cloud-Server": serverAddress, + }; + if (extra && typeof extra === "object") { + Object.assign(h, extra); + } + return h; + } + + function revokePreviewBlobs() { + for (const u of state.previewBlobUrls) { + try { + URL.revokeObjectURL(u); + } catch { + /* ignore */ + } + } + state.previewBlobUrls = []; + } + + async function formatApiError(response, fallback) { + try { + const data = await response.clone().json(); + const d = data?.detail; + if (Array.isArray(d)) { + return d.map((e) => e.msg || JSON.stringify(e)).join("; ") || fallback; + } + if (typeof d === "string") return d; + } catch { + /* ignore */ + } + return fallback; + } + + function formatBytes(n) { + if (n == null || Number.isNaN(n)) return ""; + const u = ["байт", "КБ", "МБ", "ГБ"]; + let v = n; + let i = 0; + while (v >= 1024 && i < u.length - 1) { + v /= 1024; + i++; + } + return `${v < 10 && i > 0 ? v.toFixed(1) : Math.round(v)} ${u[i]}`; + } + + root.innerHTML = ` +
+

f7support

+ +

+

Мои токены

+

Нажмите на токен, чтобы открыть чат. Закройте окно чата, чтобы выбрать другой токен.

+
    +
    + +
    +
    +
    +

    Создание токена

    + +
    + + +
    + +
    + +
    +
    + +
    + +
    + `; + + const ticketList = document.getElementById("ticket-list"); + const messageList = document.getElementById("message-list"); + const chatTitle = document.getElementById("chat-title"); + const errorBox = document.getElementById("error-box"); + const chatErrorBox = document.getElementById("chat-error-box"); + const createModal = document.getElementById("create-ticket-modal"); + const chatModal = document.getElementById("chat-modal"); + + function showError(message) { + errorBox.textContent = message || ""; + } + + function showChatError(message) { + chatErrorBox.textContent = message || ""; + } + + function isChatOpen() { + return chatModal.classList.contains("f7s-open"); + } + + function openChatModal() { + chatModal.classList.add("f7s-open"); + } + + function stopChatMessagesPoll() { + if (chatMessagesPollTimer !== null) { + clearInterval(chatMessagesPollTimer); + chatMessagesPollTimer = null; + } + } + + function startChatMessagesPoll() { + stopChatMessagesPoll(); + chatMessagesPollTimer = setInterval(() => { + if (document.hidden) return; + if (!state.currentTicket || !isChatOpen()) return; + void loadMessages(false, false); + }, messagesPollMs); + } + + function messagesFingerprint(list) { + if (!Array.isArray(list)) return ""; + return list + .map((m) => { + const attIds = (Array.isArray(m.attachments) ? m.attachments : []) + .map((a) => a.id) + .join(","); + return `${m.id}|${m.created_at ?? ""}|${(m.text || "").length}|${attIds}`; + }) + .join("\n"); + } + + /** + * @param {boolean} force — всегда перерисовать (после отправки / первое открытие) + * @param {boolean} showErrors — показывать ошибку в UI (только при явной загрузке) + */ + async function loadMessages(force, showErrors) { + if (!state.currentTicket) return; + try { + const response = await fetch( + `${apiBase}/api/client/tickets/${encodeURIComponent(state.currentTicket)}/messages`, + { headers: clientHeaders() } + ); + if (!response.ok) { + if (showErrors) throw new Error("Не удалось загрузить сообщения"); + return; + } + const next = await response.json(); + if (!force && messagesFingerprint(next) === messagesFingerprint(state.messages)) { + return; + } + state.messages = next; + await renderMessages(); + } catch (e) { + if (showErrors) throw e; + } + } + + function closeChatModal() { + stopChatMessagesPoll(); + revokePreviewBlobs(); + chatModal.classList.remove("f7s-open"); + state.currentTicket = null; + state.messages = []; + messageList.innerHTML = ""; + const msgInput = document.getElementById("message-input"); + const fileInput = document.getElementById("attachment-input"); + if (msgInput) msgInput.value = ""; + if (fileInput) fileInput.value = ""; + showChatError(""); + chatTitle.textContent = "Чат"; + } + + function ticketsStorageKey() { + return `f7support.tickets.v1|${username}|${serverAddress}`; + } + + function readTicketsCache() { + try { + const raw = sessionStorage.getItem(ticketsStorageKey()); + if (!raw) return null; + const j = JSON.parse(raw); + return Array.isArray(j) ? j : null; + } catch { + return null; + } + } + + function writeTicketsCache(list) { + try { + sessionStorage.setItem(ticketsStorageKey(), JSON.stringify(list)); + } catch { + /* quota / private mode */ + } + } + + function applyTickets(list) { + state.tickets = list; + renderTickets(); + } + + async function fetchTicketsFromNetwork() { + const response = await fetch(`${apiBase}/api/client/tickets`, { + headers: clientHeaders(), + }); + if (!response.ok) throw new Error("Не удалось получить токены"); + const list = await response.json(); + writeTicketsCache(list); + applyTickets(list); + } + + function scheduleTicketsBootstrap() { + const cached = readTicketsCache(); + if (cached) { + applyTickets(cached); + } + const run = () => { + fetchTicketsFromNetwork().catch((e) => { + if (!cached) { + showError(e.message); + } + }); + }; + if (typeof requestIdleCallback !== "undefined") { + requestIdleCallback(run, { timeout: 2500 }); + } else { + queueMicrotask(() => setTimeout(run, 0)); + } + } + + function renderTickets() { + ticketList.innerHTML = ""; + for (const ticket of state.tickets) { + const li = document.createElement("li"); + li.className = "f7s-ticket-item"; + li.textContent = `${ticket.ticket_number} — ${ticket.subject} [${ticket.status}]`; + li.onclick = () => { + if (isChatOpen()) { + return; + } + void openTicketChat(ticket.ticket_number); + }; + ticketList.appendChild(li); + } + } + + async function openTicketChat(ticketNumber) { + showError(""); + state.currentTicket = ticketNumber; + chatTitle.textContent = `Чат #${ticketNumber}`; + showChatError(""); + openChatModal(); + try { + await loadMessages(true, true); + startChatMessagesPoll(); + } catch (e) { + showChatError(e.message || "Ошибка загрузки"); + startChatMessagesPoll(); + } + } + + function attachmentDownloadUrl(ticketNumber, attachmentId) { + return `${apiBase}/api/client/tickets/${encodeURIComponent(ticketNumber)}/attachments/${encodeURIComponent(String(attachmentId))}`; + } + + async function fetchAttachmentBlob(ticketNumber, attachmentId) { + const res = await fetch(attachmentDownloadUrl(ticketNumber, attachmentId), { + headers: clientHeaders(), + }); + if (!res.ok) throw new Error("Не удалось загрузить вложение"); + return res.blob(); + } + + async function renderMessages() { + revokePreviewBlobs(); + messageList.innerHTML = ""; + const ticketNo = state.currentTicket; + if (!ticketNo) return; + + for (const m of state.messages) { + const row = document.createElement("div"); + row.className = "f7s-msg-row"; + + const line = document.createElement("div"); + const b = document.createElement("b"); + b.textContent = m.author ?? ""; + line.appendChild(b); + line.appendChild(document.createTextNode(` (${m.author_role ?? ""}): `)); + const textPart = document.createElement("span"); + textPart.textContent = m.text ?? ""; + line.appendChild(textPart); + row.appendChild(line); + + const attachments = Array.isArray(m.attachments) ? m.attachments : []; + for (const att of attachments) { + 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)}` : ""; + + if (mime.startsWith("image/")) { + const wrap = document.createElement("div"); + wrap.className = "f7s-img-wrap"; + const img = document.createElement("img"); + img.className = "f7s-msg-img"; + img.alt = filename; + wrap.appendChild(img); + row.appendChild(wrap); + try { + const blob = await fetchAttachmentBlob(ticketNo, att.id); + const u = URL.createObjectURL(blob); + state.previewBlobUrls.push(u); + img.src = u; + } catch { + const err = document.createElement("div"); + err.className = "f7s-img-err"; + err.textContent = `Не удалось показать изображение: ${filename}`; + wrap.appendChild(err); + } + } else { + const wrap = document.createElement("div"); + wrap.className = "f7s-attach-row"; + const label = document.createElement("span"); + label.textContent = `${filename}${sizePart}`; + const btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = "Скачать"; + btn.onclick = async () => { + let objectUrl = null; + try { + const blob = await fetchAttachmentBlob(ticketNo, att.id); + objectUrl = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = objectUrl; + a.download = filename; + a.rel = "noopener"; + a.style.display = "none"; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => { + try { + URL.revokeObjectURL(objectUrl); + } catch { + /* ignore */ + } + }, 60_000); + } catch { + showChatError("Не удалось скачать вложение"); + if (objectUrl) { + try { + URL.revokeObjectURL(objectUrl); + } catch { + /* ignore */ + } + } + } + }; + wrap.appendChild(label); + wrap.appendChild(btn); + row.appendChild(wrap); + } + } + + messageList.appendChild(row); + } + messageList.scrollTop = messageList.scrollHeight; + } + + /** OpenAPI TicketCreate требует body minLength:1; реальный текст уходит первым сообщением в чат. */ + const TICKET_CREATE_BODY_PLACEHOLDER = "."; + + /** + * POST текстового сообщения в тикет (как в форме чата). + * @returns {Promise} тело ответа API + */ + async function postMessageToTicket(ticketNumber, text) { + const response = await fetch( + `${apiBase}/api/client/tickets/${encodeURIComponent(ticketNumber)}/messages`, + { + method: "POST", + headers: clientHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ text }), + } + ); + if (!response.ok) { + const msg = await formatApiError(response, "Не удалось отправить сообщение"); + throw new Error(msg); + } + return response.json(); + } + + /** + * POST одного файла к сообщению (message_id в query). + * @param {string|number} messageId + * @param {File} file + */ + async function uploadAttachmentForMessage(ticketNumber, messageId, file) { + const form = new FormData(); + form.append("file", file); + const q = new URLSearchParams({ message_id: String(messageId) }); + const attRes = await fetch( + `${apiBase}/api/client/tickets/${encodeURIComponent(ticketNumber)}/attachments?${q.toString()}`, + { + method: "POST", + headers: clientHeaders(), + body: form, + } + ); + if (!attRes.ok) { + const msg = await formatApiError(attRes, "Вложение не принято"); + throw new Error(msg); + } + return attRes; + } + + async function submitTicketWithRetry(payload) { + const maxAttempts = 3; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const duplicate = attempt > 1 ? 1 : 0; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 180000); + try { + const response = await fetch(`${apiBase}/api/client/tickets`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...payload, duplicate }), + signal: controller.signal, + }); + clearTimeout(timeout); + if (!response.ok) throw new Error("Send failed"); + return await response.json(); + } catch (e) { + clearTimeout(timeout); + if (attempt === maxAttempts) { + throw new Error("Сервер поддержки недоступен, пожалуйста обратитесь к администратору."); + } + } + } + throw new Error("Unknown error"); + } + + document.getElementById("close-chat-btn").onclick = () => { + closeChatModal(); + }; + + document.getElementById("create-ticket-btn").onclick = () => { + createModal.classList.add("f7s-open"); + showError(""); + }; + + document.getElementById("close-modal-btn").onclick = () => { + document.getElementById("subject-input").value = ""; + document.getElementById("body-input").value = ""; + const catt = document.getElementById("create-ticket-attachment-input"); + if (catt) catt.value = ""; + createModal.classList.remove("f7s-open"); + }; + + createModal.addEventListener("click", (ev) => { + if (ev.target === createModal) { + document.getElementById("close-modal-btn").click(); + } + }); + + chatModal.addEventListener("click", (ev) => { + if (ev.target === chatModal) { + closeChatModal(); + } + }); + + document.getElementById("submit-ticket-btn").onclick = async () => { + try { + const subject = document.getElementById("subject-input").value.trim(); + const body = document.getElementById("body-input").value.trim(); + if (!subject || !body) { + showError("Тема и обращение обязательны"); + return; + } + const result = await submitTicketWithRetry({ + server_address: serverAddress, + username, + subject, + body: TICKET_CREATE_BODY_PLACEHOLDER, + }); + const ticketNo = result.ticket_number; + let createdMsg; + try { + createdMsg = await postMessageToTicket(ticketNo, body); + } catch (e) { + showError( + e.message || + `Токен ${ticketNo} создан, но текст обращения не попал в чат. Откройте тикет и напишите сообщение вручную.` + ); + createModal.classList.remove("f7s-open"); + document.getElementById("subject-input").value = ""; + document.getElementById("body-input").value = ""; + const cattFail = document.getElementById("create-ticket-attachment-input"); + if (cattFail) cattFail.value = ""; + await fetchTicketsFromNetwork(); + return; + } + const firstMessageId = createdMsg.id ?? createdMsg.message_id; + const createAtt = document.getElementById("create-ticket-attachment-input"); + const createFiles = createAtt && createAtt.files ? Array.from(createAtt.files) : []; + if (createFiles.length > 0) { + if (firstMessageId == null) { + showError(`Токен ${ticketNo} создан, но сервер не вернул id сообщения — вложения не отправлены.`); + createAtt.value = ""; + } else { + try { + for (const f of createFiles) { + await uploadAttachmentForMessage(ticketNo, firstMessageId, f); + } + } catch (attErr) { + showError( + attErr.message || + `Токен ${ticketNo} создан, текст в чате есть, но не все вложения удалось отправить.` + ); + } + } + createAtt.value = ""; + } + createModal.classList.remove("f7s-open"); + document.getElementById("subject-input").value = ""; + document.getElementById("body-input").value = ""; + showError(`Токен создан: ${ticketNo}`); + await fetchTicketsFromNetwork(); + void openTicketChat(ticketNo); + } catch (e) { + showError(e.message || "Ошибка отправки"); + } + }; + + document.getElementById("send-message-btn").onclick = async () => { + if (!state.currentTicket) { + showChatError("Сначала выберите токен"); + return; + } + const text = document.getElementById("message-input").value.trim(); + if (!text) return; + showChatError(""); + let created; + try { + created = await postMessageToTicket(state.currentTicket, text); + } catch (e) { + showChatError(e.message || "Не удалось отправить сообщение"); + return; + } + const messageId = created.id ?? created.message_id; + const fileInput = document.getElementById("attachment-input"); + if (fileInput.files && fileInput.files[0]) { + if (messageId == null) { + showChatError("Сообщение создано, но сервер не вернул id — вложение не отправлено"); + fileInput.value = ""; + } else { + try { + await uploadAttachmentForMessage(state.currentTicket, messageId, fileInput.files[0]); + } catch (e) { + showChatError(e.message || "Сообщение отправлено, но вложение не принято"); + } + fileInput.value = ""; + } + } + document.getElementById("message-input").value = ""; + await loadMessages(true, false); + }; + + document.addEventListener("visibilitychange", () => { + if (document.hidden) return; + void fetchTicketsFromNetwork().catch(() => {}); + if (isChatOpen() && state.currentTicket) { + void loadMessages(false, false); + } + }); + + scheduleTicketsBootstrap(); +})(); diff --git a/lib/Application.php b/lib/Application.php new file mode 100644 index 0000000..4d534a4 --- /dev/null +++ b/lib/Application.php @@ -0,0 +1,16 @@ +userSession->getUser(); + $baseUrl = $this->urlGenerator->getBaseUrl(); + $serverHost = parse_url($baseUrl, PHP_URL_HOST) ?: 'localhost'; + + $supportApiBase = 'https://support.f7cloud.ru'; + $supportParts = parse_url($supportApiBase); + $supportApiOrigin = ($supportParts['scheme'] ?? 'https') . '://' . ($supportParts['host'] ?? ''); + + Util::addStyle('f7support', 'f7support'); + Util::addScript('f7support', 'main'); + + return new TemplateResponse('f7support', 'main', [ + 'username' => $user ? $user->getUID() : '', + 'serverAddress' => $serverHost, + 'supportApiBase' => $supportApiBase, + 'supportApiOrigin' => $supportApiOrigin, + ]); + } +} + diff --git a/templates/main.php b/templates/main.php new file mode 100644 index 0000000..9a6d09d --- /dev/null +++ b/templates/main.php @@ -0,0 +1,8 @@ + + +
    +