/** * Производительность: список тикетов — после первого кадра (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(); })();