Files
f7support/js/main.js
T

1270 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(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 supportWsBaseOverride = (root.dataset.supportWsBase || "").trim();
/** Включается в NC: `occ config:app:set f7support client_read_receipts --value=1` после выката API `POST .../read`. */
const clientReadReceiptsEnabled = root.dataset.clientReadReceipts === "1";
const ticketsPollMsRaw = parseInt(
String(root.dataset.ticketsPollMs || root.dataset.messagesPollMs || "3000").trim(),
10
);
const ticketsPollMsParsed = Number.isFinite(ticketsPollMsRaw) && ticketsPollMsRaw >= 0 ? ticketsPollMsRaw : 3000;
/** Интервал опроса GET /api/client/tickets (мс). По умолчанию 3000 (3 с), если атрибут не задан. 0 — выключить. Иначе 3000…120000. */
const ticketsPollIntervalMs =
ticketsPollMsParsed === 0 ? 0 : Math.min(120000, Math.max(3000, ticketsPollMsParsed));
const RASTER_IMAGE_EXT = new Set(["jpg", "jpeg", "png", "gif", "webp", "bmp", "tif", "tiff", "heic", "heif"]);
const RASTER_IMAGE_MIME = new Set([
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/bmp",
"image/tiff",
"image/x-tiff",
"image/heic",
"image/heif",
]);
function isRasterImageAtt(a) {
const m = (a.mime_type || "").toLowerCase().split(";")[0].trim();
if (RASTER_IMAGE_MIME.has(m)) return true;
const parts = (a.filename || "").split(".");
const ext = parts.length > 1 ? parts.pop().toLowerCase() : "";
return RASTER_IMAGE_EXT.has(ext);
}
function openOrDownloadBlob(blob, filename, mime) {
const m = (mime || blob.type || "").toLowerCase().split(";")[0].trim();
const url = URL.createObjectURL(blob);
const tryInline =
RASTER_IMAGE_MIME.has(m) ||
m === "application/pdf" ||
(m.startsWith("text/") && m !== "text/html");
if (tryInline) {
const w = window.open(url, "_blank", "noopener,noreferrer");
if (w) {
setTimeout(() => URL.revokeObjectURL(url), 180000);
return;
}
}
const a = document.createElement("a");
a.href = url;
a.download = filename || "file";
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 120000);
}
/** Сервер без маршрута `POST .../read` (404) — не дёргать повторно, меньше шума в консоли. */
let skipClientReadReceiptPost = false;
const state = {
tickets: [],
currentTicket: null,
messages: [],
chatModalOpen: false,
blobUrls: [],
ticketSocket: null,
wsReconnectTimer: null,
ticketsPollTimer: null,
pendingFile: null,
/** @type {Record<string, string>} последний известный «отпечаток» карточки по номеру тикета (для точки без has_unread с API) */
ticketSeenFingerprint: {},
/** @type {Record<string, string>} сигнатура последнего сообщения (id+время) по тикету — список тикетов часто не меняет preview/activity */
ticketLastMsgSig: Object.create(null),
/** @type {Record<string, boolean>} новое сообщение по сравнению с последним опросом /messages */
ticketUnreadByLastMsg: Object.create(null),
/** Смещение пачки опросов /messages (не грузим все тикеты за один раз) */
msgSigPollIndex: 0,
};
function clientIdentityHeaders() {
return {
"X-F7cloud-User": username,
"X-F7cloud-Server": serverAddress,
};
}
function revokeAttachmentUrls() {
for (const u of state.blobUrls) {
try {
URL.revokeObjectURL(u);
} catch (_) {}
}
state.blobUrls = [];
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function formatBytes(n) {
const v = Number(n) || 0;
if (v < 1024) return `${v} Б`;
if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)} КБ`;
return `${(v / (1024 * 1024)).toFixed(1)} МБ`;
}
function formatChatTime(iso) {
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", hour12: false });
} catch (_) {
return "";
}
}
function formatTicketCardTime(iso) {
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const day = d.getDate();
const month = d.toLocaleString("ru-RU", { month: "long" });
const hm = d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", hour12: false });
return `${day} ${month}, ${hm}`;
} catch (_) {
return "";
}
}
function fileExtBadge(filename) {
let ext = (filename || "").split(".").pop() || "";
ext = ext.toLowerCase().replace(/[^a-z0-9]/g, "");
if (!ext) ext = "file";
const label = ext.length <= 5 ? ext : ext.slice(0, 4) + "…";
const slug = ext.replace(/[^a-z0-9]/g, "").slice(0, 12) || "file";
return { label, slug };
}
function nonImageAttachmentHtml(m, a) {
const outgoing = m.author_role === "client";
const wrapCls = outgoing ? "chat-file-bubble-wrap chat-file-bubble-wrap--out" : "chat-file-bubble-wrap chat-file-bubble-wrap--in";
const bubbleCls = outgoing ? "chat-file-bubble chat-file-bubble--out" : "chat-file-bubble chat-file-bubble--in";
const { label, slug } = fileExtBadge(a.filename);
const time = formatChatTime(m.created_at);
const ticks = outgoing ? '<span class="chat-file-bubble__ticks" aria-hidden="true">✓✓</span>' : "";
return `<div class="${wrapCls}">
<div class="${bubbleCls}" role="button" tabindex="0" data-f7-att-file="${a.id}" data-f7-filename="${escapeHtml(a.filename)}" data-f7-mime="${escapeHtml(a.mime_type || "")}">
<div class="chat-file-bubble__row">
<div class="chat-file-bubble__icon chat-file-bubble__icon--${escapeHtml(slug)}"><span class="chat-file-bubble__icon-text">${escapeHtml(label)}</span></div>
<div class="chat-file-bubble__body">
<div class="chat-file-bubble__name">${escapeHtml(a.filename)}</div>
<div class="chat-file-bubble__size">${formatBytes(a.size_bytes)}</div>
</div>
</div>
<div class="chat-file-bubble__foot">
<span class="chat-file-bubble__time">${escapeHtml(time)}</span>
${ticks}
</div>
</div>
</div>`;
}
function wsUrlForTicket(ticketNumber) {
const tail = `/tickets/${encodeURIComponent(ticketNumber)}`;
if (supportWsBaseOverride) {
try {
const raw = supportWsBaseOverride.replace(/\/$/, "");
const u = new URL(raw.includes("://") ? raw : `wss://${raw}`);
const wsProto = u.protocol === "https:" ? "wss:" : u.protocol === "http:" ? "ws:" : u.protocol;
let path = (u.pathname || "").replace(/\/$/, "");
if (path === "" || path === "/") {
path = "/ws";
}
return `${wsProto}//${u.host}${path}${tail}`;
} catch (_) {
return null;
}
}
try {
const u = new URL(apiBase);
const wsProto = u.protocol === "https:" ? "wss:" : "ws:";
return `${wsProto}//${u.host}/ws${tail}`;
} catch (_) {
return null;
}
}
function disconnectTicketSocket() {
if (state.wsReconnectTimer) {
clearTimeout(state.wsReconnectTimer);
state.wsReconnectTimer = null;
}
if (state.ticketSocket) {
try {
state.ticketSocket.close();
} catch (_) {}
state.ticketSocket = null;
}
}
function connectTicketSocket(ticketNumber) {
disconnectTicketSocket();
const url = wsUrlForTicket(ticketNumber);
if (!url) return;
let socket;
try {
socket = new WebSocket(url);
} catch (_) {
return;
}
state.ticketSocket = socket;
socket.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
if (!payload || !payload.data) return;
if (!state.chatModalOpen || state.currentTicket !== ticketNumber) return;
if (payload.event === "new_message" || payload.event === "message_updated") {
applyIncomingMessage(payload.data);
fetchTickets().catch(() => {});
}
} catch (_) {}
};
socket.onclose = () => {
state.ticketSocket = null;
if (state.chatModalOpen && state.currentTicket === ticketNumber) {
state.wsReconnectTimer = setTimeout(() => connectTicketSocket(ticketNumber), 2500);
}
};
socket.onerror = () => {
try {
socket.close();
} catch (_) {}
};
}
function messageBlockHtml(m) {
const atts = m.attachments || [];
const attHtml = atts
.map((a) => {
if (isRasterImageAtt(a)) {
return `<div class="f7support-att" data-f7-att-preview="${a.id}"><span style="color:#666;font-size:0.9em;">Загрузка…</span></div>`;
}
return nonImageAttachmentHtml(m, a);
})
.join("");
return `<div class="f7support-msg" data-f7-msg-id="${m.id}" style="margin-bottom:10px;padding-bottom:8px;border-bottom:1px solid #eee;">
<div><b>${escapeHtml(m.author)}</b> (${escapeHtml(m.author_role)}): ${escapeHtml(m.text)}</div>
${attHtml ? `<div style="margin-top:6px;">${attHtml}</div>` : ""}
</div>`;
}
function applyIncomingMessage(msg) {
const id = msg.id;
const idx = state.messages.findIndex((m) => m.id === id);
if (idx === -1) state.messages.push(msg);
else state.messages[idx] = msg;
state.messages.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));
const el = messageList.querySelector(`[data-f7-msg-id="${id}"]`);
const html = messageBlockHtml(msg);
if (el) {
el.outerHTML = html;
} else {
messageList.insertAdjacentHTML("beforeend", html);
}
const node = messageList.querySelector(`[data-f7-msg-id="${id}"]`);
if (node) hydrateClientAttachments(node);
scrollChatToBottom();
syncCurrentTicketLastMsgSigFromState();
if (clientReadReceiptsEnabled && msg.author_role === "support" && state.currentTicket) {
markClientTicketRead(state.currentTicket).catch(() => {});
}
}
async function markClientTicketRead(ticketNumber) {
if (!clientReadReceiptsEnabled || skipClientReadReceiptPost || !ticketNumber) return;
let res;
try {
res = await fetch(`${apiBase}/api/client/tickets/${encodeURIComponent(ticketNumber)}/read`, {
method: "POST",
headers: clientIdentityHeaders(),
});
} catch {
return;
}
if (res.status === 404) {
skipClientReadReceiptPost = true;
return;
}
if (!res.ok) return;
const row = state.tickets.find((x) => x.ticket_number === ticketNumber);
if (row) row.has_unread = false;
delete state.ticketUnreadByLastMsg[String(ticketNumber)];
renderTickets();
reapplyActiveTicketCard(ticketNumber);
}
function reapplyActiveTicketCard(ticketNumber) {
const tnSel = String(ticketNumber).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const card = document.querySelector(`.f7-ticket-card[data-ticket-number="${tnSel}"]`);
if (card) card.classList.add("f7-ticket-card--active");
}
root.innerHTML = `
<div class="f7support-wrap">
<h2>f7support</h2>
<button type="button" id="create-ticket-btn">Создать токен</button>
<p id="error-box" class="f7support-error"></p>
<div class="f7support-main">
<h3>Мои обращения</h3>
<div class="f7-board" id="f7-ticket-board">
<div class="f7-column">
<h4 class="f7-column-title">Новые</h4>
<ul id="ticket-col-new" class="f7-column-list"></ul>
</div>
<div class="f7-column">
<h4 class="f7-column-title">В работе</h4>
<ul id="ticket-col-progress" class="f7-column-list"></ul>
</div>
<div class="f7-column">
<h4 class="f7-column-title">Архив</h4>
<ul id="ticket-col-closed" class="f7-column-list"></ul>
</div>
</div>
</div>
</div>
<div id="create-ticket-modal" class="f7support-modal" aria-hidden="true">
<div class="f7support-modal-panel">
<div class="f7support-modal-head">
<h3>Создание токена</h3>
<button type="button" id="close-modal-btn" class="f7support-icon-btn" aria-label="Закрыть">✕</button>
</div>
<input id="subject-input" maxlength="255" placeholder="Тема" class="f7support-input" />
<textarea id="body-input" maxlength="4000" placeholder="Опишите проблему (до 4000 символов)" class="f7support-textarea"></textarea>
<button type="button" id="submit-ticket-btn">Отправить</button>
</div>
</div>
<div id="chat-modal" class="f7support-modal" aria-hidden="true">
<div class="f7support-modal-panel f7support-chat-panel">
<div class="f7support-modal-head">
<h3 id="chat-modal-title">Чат</h3>
<button type="button" id="chat-modal-close-btn" class="f7support-icon-btn" aria-label="Закрыть чат">✕</button>
</div>
<div id="message-list" class="f7support-message-list f7support-dnd-target" title="Перетащите файл сюда"></div>
<div class="f7support-composer">
<button type="button" id="attachment-clip-btn" class="f7support-clip-btn" title="Прикрепить файл" aria-label="Прикрепить файл">📎</button>
<input type="file" id="attachment-input" class="f7support-hidden-file" />
<textarea id="message-input" maxlength="4000" placeholder="Сообщение (до 4000 символов). Файл — скрепка или перетаскивание." class="f7support-textarea f7support-dnd-target"></textarea>
</div>
<div id="pending-attachment-row" class="f7support-pending-row" hidden>
<span id="pending-attachment-label"></span>
<button type="button" id="pending-attachment-clear" class="f7support-icon-btn" aria-label="Убрать файл">×</button>
</div>
<div class="f7support-chat-actions">
<button type="button" id="send-message-btn">Отправить</button>
</div>
</div>
</div>
<style>
.f7support-wrap { padding: 16px; font-family: sans-serif; max-width: min(1680px, 100%); width: 100%; box-sizing: border-box; }
.f7support-error { color: #b00020; min-height: 1.25em; margin: 8px 0; }
.f7support-main { margin-top: 12px; width: 100%; }
.f7-board {
display: grid;
grid-template-columns: repeat(3, minmax(300px, 1fr));
gap: 16px;
margin-top: 12px;
align-items: stretch;
width: 100%;
}
@media (max-width: 1100px) {
.f7-board { grid-template-columns: 1fr; }
}
.f7-column {
border: 1px solid #ddd;
border-radius: 8px;
background: #fafafa;
padding: 12px;
min-height: min(52vh, 560px);
display: flex;
flex-direction: column;
}
.f7-column-title { margin: 0 0 8px 0; font-size: 1rem; font-weight: 600; }
.f7-column-list {
list-style: none;
margin: 0;
padding: 0;
flex: 1;
min-height: min(42vh, 420px);
max-height: min(72vh, 820px);
overflow-y: auto;
}
.f7-ticket-card {
border: 1px solid #e0e0e0;
border-radius: 8px;
margin-bottom: 10px;
background: #fff;
cursor: pointer;
overflow: hidden;
}
.f7-ticket-card:hover { filter: brightness(0.985); border-color: #c8c8c8; }
.f7-ticket-card--active { outline: 2px solid #1a4fa3; }
.f7-ticket-card__head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
background: #f5f5f5;
margin: 8px;
padding: 8px 10px;
border-radius: 6px;
}
.f7-ticket-card__title { font-size: 0.92rem; color: #333; }
.f7-ticket-card__title b { font-weight: 700; }
.f7-ticket-card__num { font-weight: 400; color: #777; margin-left: 4px; }
.f7-ticket-card__status {
flex-shrink: 0;
font-size: 11px;
padding: 3px 10px;
border-radius: 999px;
font-weight: 600;
color: #fff;
}
.f7-ticket-card__status--new { background: #2d9cdb; }
.f7-ticket-card__status--progress { background: #f2994a; }
.f7-ticket-card__status--closed { background: #9e9e9e; }
.f7-ticket-card__body {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 4px 14px 6px;
}
.f7-ticket-card__preview {
flex: 1;
margin: 0;
color: #666;
font-size: 0.88rem;
line-height: 1.4;
min-height: 2.8em;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.f7-unread-dot {
flex-shrink: 0;
width: 10px;
height: 10px;
border-radius: 50%;
background: #6fcf97;
margin-top: 5px;
}
.f7-ticket-card__foot {
display: flex;
justify-content: flex-end;
padding: 0 14px 10px;
}
.f7-ticket-card__time {
font-size: 0.75rem;
color: #333;
background: #f0f0f0;
padding: 2px 10px;
border-radius: 999px;
}
.f7support-modal {
display: none;
position: fixed;
inset: 0;
z-index: 2000;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.45);
padding: 16px;
box-sizing: border-box;
}
.f7support-modal.f7support-modal-visible { display: flex; }
.f7support-modal-panel {
background: #fff;
border-radius: 8px;
padding: 16px;
max-width: 640px;
width: 100%;
max-height: 90vh;
overflow: auto;
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
}
.f7support-chat-panel {
max-width: 720px;
display: flex;
flex-direction: column;
max-height: 88vh;
}
.f7support-modal-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-shrink: 0;
}
.f7support-modal-head h3 { margin: 0; font-size: 1.1rem; }
.f7support-icon-btn {
border: none;
background: transparent;
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
padding: 4px 8px;
color: #444;
}
.f7support-icon-btn:hover { color: #000; background: #eee; border-radius: 4px; }
.f7support-input, .f7support-textarea {
width: 100%;
box-sizing: border-box;
margin-top: 8px;
}
.f7support-textarea { min-height: 72px; resize: vertical; }
.f7support-message-list {
flex: 1;
min-height: 200px;
max-height: 45vh;
overflow: auto;
border: 1px solid #ccc;
border-radius: 6px;
padding: 8px;
margin-bottom: 8px;
background: #fff;
}
.f7support-message-list.f7support-dnd-dragover { outline: 2px dashed #1a4fa3; background: #f4f8ff; }
.f7support-composer {
display: flex;
align-items: flex-end;
gap: 8px;
margin-bottom: 6px;
}
.f7support-composer .f7support-textarea { flex: 1; margin-top: 0; min-height: 72px; }
.f7support-clip-btn {
flex-shrink: 0;
width: 40px;
height: 40px;
border: 1px solid #ccc;
border-radius: 8px;
background: #fafafa;
cursor: pointer;
font-size: 1.2rem;
line-height: 1;
padding: 0;
}
.f7support-clip-btn:hover { background: #eee; }
.f7support-hidden-file { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.f7support-pending-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: #f5f5f5;
border-radius: 6px;
margin-bottom: 8px;
font-size: 0.9rem;
}
.chat-file-bubble-wrap {
display: flex;
width: 100%;
margin-top: 8px;
}
.chat-file-bubble-wrap--out { justify-content: flex-end; }
.chat-file-bubble-wrap--in { justify-content: flex-start; }
.chat-file-bubble {
max-width: min(100%, 340px);
padding: 10px 12px 8px;
color: #fff;
cursor: pointer;
text-align: left;
box-shadow: 0 2px 10px rgba(0,0,0,0.12);
position: relative;
transition: filter 0.15s ease, transform 0.15s ease;
}
.chat-file-bubble:hover { filter: brightness(1.05); }
.chat-file-bubble:active { transform: scale(0.99); }
.chat-file-bubble--out {
background: linear-gradient(165deg, #c4b5e8 0%, #9b86d8 45%, #8b7cc8 100%);
border-radius: 14px 14px 6px 14px;
}
.chat-file-bubble--out::after {
content: "";
position: absolute;
right: -4px;
bottom: 2px;
width: 14px;
height: 14px;
background: #8b7cc8;
border-radius: 0 0 10px 0;
clip-path: polygon(0 100%, 100% 0, 100% 100%);
}
.chat-file-bubble--in {
background: linear-gradient(165deg, #6b7280 0%, #4b5563 100%);
border-radius: 14px 14px 14px 6px;
}
.chat-file-bubble--in::after {
content: "";
position: absolute;
left: -4px;
bottom: 2px;
width: 14px;
height: 14px;
background: #4b5563;
border-radius: 0 0 0 10px;
clip-path: polygon(0 0, 0 100%, 100% 100%);
}
.chat-file-bubble__row { display: flex; gap: 10px; align-items: flex-start; }
.chat-file-bubble__icon {
flex-shrink: 0;
width: 44px;
height: 52px;
border-radius: 8px;
background: linear-gradient(180deg, #3b9eff 0%, #1e6fd9 100%);
display: flex;
align-items: center;
justify-content: center;
position: relative;
box-shadow: inset 0 -2px 0 rgba(0,0,0,0.12);
}
.chat-file-bubble__icon::before {
content: "";
position: absolute;
top: 0;
right: 0;
width: 0;
height: 0;
border-style: solid;
border-width: 0 14px 14px 0;
border-color: transparent rgba(255,255,255,0.35) transparent transparent;
}
.chat-file-bubble__icon-text {
font-size: 10px;
font-weight: 700;
text-transform: lowercase;
color: #fff;
letter-spacing: 0.02em;
max-width: 36px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-file-bubble__body { flex: 1; min-width: 0; }
.chat-file-bubble__name {
font-weight: 600;
font-size: 0.92rem;
line-height: 1.25;
word-break: break-word;
}
.chat-file-bubble__size {
font-size: 0.8rem;
opacity: 0.92;
margin-top: 4px;
}
.chat-file-bubble__foot {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 6px;
margin-top: 8px;
font-size: 0.72rem;
opacity: 0.92;
}
.chat-file-bubble__ticks { font-size: 0.78rem; letter-spacing: -3px; opacity: 0.95; }
.f7support-chat-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 4px; }
.f7support-dnd-target.f7support-dnd-dragover { outline: 2px dashed #1a4fa3; }
.chat-inline-img-wrap {
display: flex;
justify-content: center;
align-items: center;
max-width: 100%;
margin: 4px 0;
}
.chat-inline-img {
display: block;
max-width: min(100%, min(520px, 92vw));
max-height: min(280px, 42vh);
width: auto;
height: auto;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
}
</style>
`;
const messageList = document.getElementById("message-list");
const chatModalTitle = document.getElementById("chat-modal-title");
const errorBox = document.getElementById("error-box");
const createModal = document.getElementById("create-ticket-modal");
const chatModal = document.getElementById("chat-modal");
let clientChatDndBound = false;
bindClientChatDnDOnce();
function scrollChatToBottom() {
const ml = document.getElementById("message-list");
if (!ml) return;
const run = () => {
try {
ml.scrollTop = ml.scrollHeight;
} catch (_) {}
};
run();
requestAnimationFrame(() => {
run();
requestAnimationFrame(run);
});
window.setTimeout(run, 80);
window.setTimeout(run, 320);
}
function lastMessageSignatureFromList(msgs) {
if (!Array.isArray(msgs) || msgs.length === 0) return null;
const last = msgs[msgs.length - 1];
return `${last.id ?? ""}\u0002${last.created_at ?? ""}`;
}
function syncCurrentTicketLastMsgSigFromState() {
if (!state.currentTicket) return;
const tn = String(state.currentTicket);
const sig = lastMessageSignatureFromList(state.messages);
if (sig == null) return;
state.ticketLastMsgSig[tn] = sig;
delete state.ticketUnreadByLastMsg[tn];
}
async function refreshLastMsgSigOne(tn, opts) {
const forceBaseline = opts && opts.forceBaseline === true;
const key = String(tn);
try {
const r = await fetch(`${apiBase}/api/client/tickets/${encodeURIComponent(key)}/messages`, {
headers: clientIdentityHeaders(),
});
if (!r.ok) return;
const msgs = await r.json();
const sig = lastMessageSignatureFromList(msgs);
if (sig == null) return;
const prev = state.ticketLastMsgSig[key];
state.ticketLastMsgSig[key] = sig;
if (!forceBaseline && prev != null && sig !== prev) {
state.ticketUnreadByLastMsg[key] = true;
}
} catch (_) {}
}
async function refreshLastMessageSignaturesStaggered() {
const cur = state.chatModalOpen && state.currentTicket != null ? String(state.currentTicket) : null;
const openTickets = state.tickets.filter((t) => {
const tn = String(t.ticket_number);
if (cur && tn === cur) return false;
if (String(t.status || "") === "Закрыт") return false;
return true;
});
if (!openTickets.length) return;
const BATCH = 6;
const n = openTickets.length;
const start = state.msgSigPollIndex % n;
const chunk = [];
for (let i = 0; i < Math.min(BATCH, n); i++) {
chunk.push(String(openTickets[(start + i) % n].ticket_number));
}
state.msgSigPollIndex = (state.msgSigPollIndex + chunk.length) % n;
await Promise.all(chunk.map((tn) => refreshLastMsgSigOne(tn, null)));
}
function ticketBoardFingerprint(t) {
const act = String(t.activity_at || t.updated_at || t.last_activity_at || t.created_at || "");
const prev = t.preview_text != null ? String(t.preview_text) : "";
const cnt =
t.messages_count ??
t.message_count ??
t.msg_count ??
t.replies_count ??
t.reply_count ??
"";
return `${act}\u0001${prev}\u0001${cnt}`;
}
/** Для открытого чата учитываем последнее сообщение, чтобы отпечаток не отставал от превью в списке. */
function effectiveBoardFingerprint(t) {
const base = ticketBoardFingerprint(t);
const tn = String(t.ticket_number);
if (state.chatModalOpen && state.currentTicket != null && String(state.currentTicket) === tn) {
const msgs = state.messages;
if (msgs && msgs.length) {
const last = msgs[msgs.length - 1];
return `${base}\u0002${last.id ?? ""}\u0003${last.created_at ?? ""}`;
}
}
return base;
}
function updateBoardUnreadFingerprints() {
for (const t of state.tickets) {
const tn = String(t.ticket_number);
const fp = effectiveBoardFingerprint(t);
const seenKey = tn;
if (!Object.prototype.hasOwnProperty.call(state.ticketSeenFingerprint, seenKey)) {
state.ticketSeenFingerprint[seenKey] = fp;
t._f7_board_unread = false;
continue;
}
if (state.chatModalOpen && state.currentTicket != null && String(state.currentTicket) === tn) {
state.ticketSeenFingerprint[seenKey] = fp;
t._f7_board_unread = false;
continue;
}
t._f7_board_unread = fp !== state.ticketSeenFingerprint[seenKey];
}
}
function ticketHasUnread(ticket) {
const tn = String(ticket.ticket_number);
if (ticket && ticket._f7_board_unread) return true;
if (state.ticketUnreadByLastMsg[tn]) return true;
const raw =
ticket?.has_unread ??
ticket?.hasUnread ??
ticket?.unread ??
ticket?.is_unread ??
ticket?.unread_count;
if (typeof raw === "number" && raw > 0) return true;
return raw === true || raw === 1 || raw === "1" || raw === "true" || raw === "yes";
}
function scheduleTicketsBoardPolling() {
if (state.ticketsPollTimer) {
clearInterval(state.ticketsPollTimer);
state.ticketsPollTimer = null;
}
if (ticketsPollIntervalMs === 0) return;
state.ticketsPollTimer = window.setInterval(() => {
if (document.visibilityState === "hidden") return;
fetchTickets().catch(() => {});
}, ticketsPollIntervalMs);
}
function showError(message) {
errorBox.textContent = message || "";
}
function setModalVisible(el, visible) {
el.classList.toggle("f7support-modal-visible", visible);
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;
document.querySelectorAll(".f7support-dnd-target").forEach((el) => {
el.addEventListener("dragover", (e) => {
e.preventDefault();
e.stopPropagation();
el.classList.add("f7support-dnd-dragover");
});
el.addEventListener("dragleave", (e) => {
e.preventDefault();
el.classList.remove("f7support-dnd-dragover");
});
el.addEventListener("drop", (e) => {
e.preventDefault();
e.stopPropagation();
el.classList.remove("f7support-dnd-dragover");
const f = e.dataTransfer?.files?.[0];
if (f) setPendingFile(f);
});
});
}
function openChatModal(ticket) {
state.chatModalOpen = true;
chatModalTitle.textContent = `Чат #${ticket.ticket_number}${ticket.subject}`;
setModalVisible(chatModal, true);
}
async function closeChatModal() {
const closingTn = state.currentTicket;
if (closingTn != null) {
const row = state.tickets.find((x) => String(x.ticket_number) === String(closingTn));
if (row) state.ticketSeenFingerprint[String(closingTn)] = ticketBoardFingerprint(row);
delete state.ticketUnreadByLastMsg[String(closingTn)];
await refreshLastMsgSigOne(String(closingTn), { forceBaseline: true });
}
disconnectTicketSocket();
revokeAttachmentUrls();
state.chatModalOpen = false;
state.currentTicket = null;
state.messages = [];
messageList.innerHTML = "";
const msgInput = document.getElementById("message-input");
if (msgInput) msgInput.value = "";
clearPendingFile();
setModalVisible(chatModal, false);
showError("");
document.querySelectorAll(".f7-ticket-card--active").forEach((n) => n.classList.remove("f7-ticket-card--active"));
await fetchTickets().catch(() => {});
}
async function fetchTickets() {
const response = await fetch(`${apiBase}/api/client/tickets`, {
headers: {
"X-F7cloud-User": username,
"X-F7cloud-Server": serverAddress,
},
});
if (!response.ok) throw new Error("Не удалось загрузить список обращений");
state.tickets = await response.json();
await refreshLastMessageSignaturesStaggered();
updateBoardUnreadFingerprints();
renderTickets();
}
function partitionClientTickets(list) {
const buckets = { newT: [], progress: [], closed: [] };
for (const t of list) {
const s = String(t.status || "");
if (s === "Закрыт") buckets.closed.push(t);
else if (s === "В работе") buckets.progress.push(t);
else if (s === "Новый") buckets.newT.push(t);
else buckets.newT.push(t);
}
const byDate = (a, b) =>
String(b.activity_at || b.created_at || "").localeCompare(String(a.activity_at || a.created_at || ""));
buckets.newT.sort(byDate);
buckets.progress.sort(byDate);
buckets.closed.sort(byDate);
return buckets;
}
function clientTicketStatusClass(status) {
const s = String(status || "");
if (s === "Новый") return "f7-ticket-card__status--new";
if (s === "В работе") return "f7-ticket-card__status--progress";
return "f7-ticket-card__status--closed";
}
function renderTicketCardHtml(ticket) {
const previewRaw = ticket.preview_text != null ? ticket.preview_text : "";
const preview = escapeHtml(previewRaw);
const activity = ticket.activity_at || ticket.created_at;
const time = escapeHtml(formatTicketCardTime(activity));
const hasUnread = ticketHasUnread(ticket);
const unread = hasUnread
? '<span class="f7-unread-dot" title="Новое сообщение" aria-label="Новое сообщение"></span>'
: "";
const tn = escapeHtml(ticket.ticket_number);
const st = escapeHtml(ticket.status);
const stCls = clientTicketStatusClass(ticket.status);
return `<li class="f7-ticket-card" data-ticket-number="${escapeHtml(ticket.ticket_number)}">
<div class="f7-ticket-card__head">
<span class="f7-ticket-card__title"><b>Тема</b><span class="f7-ticket-card__num">#${tn}</span></span>
<span class="f7-ticket-card__status ${stCls}">${st}</span>
</div>
<div class="f7-ticket-card__body">
<p class="f7-ticket-card__preview">${preview || "—"}</p>
${unread}
</div>
<div class="f7-ticket-card__foot">
<span class="f7-ticket-card__time">${time}</span>
</div>
</li>`;
}
async function tryOpenTicket(ticketNumber) {
const ticket = state.tickets.find((t) => t.ticket_number === ticketNumber);
if (!ticket) return;
if (state.chatModalOpen) {
if (state.currentTicket === ticketNumber) return;
showError("Чтобы открыть другое обращение, сначала закройте окно чата (крестик справа сверху).");
return;
}
state.currentTicket = ticketNumber;
document.querySelectorAll(".f7-ticket-card--active").forEach((n) => n.classList.remove("f7-ticket-card--active"));
const tnSel = String(ticketNumber).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const card = document.querySelector(`.f7-ticket-card[data-ticket-number="${tnSel}"]`);
if (card) card.classList.add("f7-ticket-card--active");
openChatModal(ticket);
delete state.ticketUnreadByLastMsg[String(ticketNumber)];
try {
await fetchMessages();
await fetchTickets();
const row = state.tickets.find((x) => x.ticket_number === ticketNumber);
if (row) row.has_unread = false;
renderTickets();
reapplyActiveTicketCard(ticketNumber);
void markClientTicketRead(ticket.ticket_number);
connectTicketSocket(ticket.ticket_number);
} catch (e) {
showError(e.message || "Не удалось загрузить сообщения");
disconnectTicketSocket();
await closeChatModal();
}
}
function renderTickets() {
const { newT, progress, closed } = partitionClientTickets(state.tickets);
const cols = [
document.getElementById("ticket-col-new"),
document.getElementById("ticket-col-progress"),
document.getElementById("ticket-col-closed"),
];
const data = [newT, progress, closed];
cols.forEach((ul, i) => {
if (!ul) return;
ul.innerHTML = data[i].map(renderTicketCardHtml).join("");
ul.querySelectorAll(".f7-ticket-card").forEach((li) => {
li.onclick = () => tryOpenTicket(li.getAttribute("data-ticket-number"));
});
});
}
async function fetchMessages() {
if (!state.currentTicket) return;
const response = await fetch(`${apiBase}/api/client/tickets/${state.currentTicket}/messages`, {
headers: clientIdentityHeaders(),
});
if (!response.ok) throw new Error("Не удалось загрузить сообщения");
state.messages = await response.json();
syncCurrentTicketLastMsgSigFromState();
renderMessages();
}
async function fetchClientAttachmentBlob(attachmentId) {
const url = `${apiBase}/api/client/tickets/${state.currentTicket}/attachments/${attachmentId}`;
const res = await fetch(url, { headers: clientIdentityHeaders() });
if (!res.ok) throw new Error("attachment");
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 = "Не удалось показать файл";
}
})();
});
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();
}
};
});
}
function renderMessages() {
revokeAttachmentUrls();
messageList.innerHTML = state.messages.map((m) => messageBlockHtml(m)).join("");
hydrateClientAttachments(messageList);
scrollChatToBottom();
}
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",
...clientIdentityHeaders(),
},
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("create-ticket-btn").onclick = () => {
setModalVisible(createModal, true);
showError("");
};
document.getElementById("close-modal-btn").onclick = () => {
document.getElementById("subject-input").value = "";
document.getElementById("body-input").value = "";
setModalVisible(createModal, false);
};
document.getElementById("chat-modal-close-btn").onclick = () => {
void closeChatModal();
};
document.getElementById("attachment-clip-btn").onclick = () => {
document.getElementById("attachment-input")?.click();
};
document.getElementById("attachment-input").onchange = (e) => {
const f = e.target.files?.[0];
if (f) setPendingFile(f);
};
document.getElementById("pending-attachment-clear").onclick = () => {
clearPendingFile();
};
const messageInputEl = document.getElementById("message-input");
messageInputEl.addEventListener("paste", (e) => {
const files = e.clipboardData?.files;
if (files && files[0]) {
e.preventDefault();
setPendingFile(files[0]);
}
});
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,
});
setModalVisible(createModal, false);
showError(`Токен создан: ${result.ticket_number}`);
document.getElementById("subject-input").value = "";
document.getElementById("body-input").value = "";
await fetchTickets();
} catch (e) {
showError(e.message || "Ошибка отправки");
}
};
document.getElementById("send-message-btn").onclick = async () => {
if (!state.currentTicket) {
showError("Сначала выберите токен");
return;
}
let text = document.getElementById("message-input").value.trim();
const file = state.pendingFile;
if (!text && !file) {
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;
}
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;
}
clearPendingFile();
}
document.getElementById("message-input").value = "";
await fetchMessages();
};
queueMicrotask(() => {
fetchTickets()
.catch((e) => showError(e.message))
.finally(() => {
scheduleTicketsBoardPolling();
});
});
})();