Initial import of f7support application.
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0"?>
|
||||
<info>
|
||||
<id>f7support</id>
|
||||
<name>f7support</name>
|
||||
<summary>Support ticket client for F7cloud (F7cloud-compatible)</summary>
|
||||
<description>f7support client app for creating and viewing support tickets.</description>
|
||||
<version>0.1.0</version>
|
||||
<licence>AGPL</licence>
|
||||
<author>f7support team</author>
|
||||
<namespace>F7Support</namespace>
|
||||
<types>
|
||||
<type>filesystem</type>
|
||||
</types>
|
||||
<dependencies>
|
||||
<f7cloud min-version="32" max-version="32"/>
|
||||
</dependencies>
|
||||
</info>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'routes' => [
|
||||
['name' => 'page#index', 'url' => '/', 'verb' => 'GET'],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+616
@@ -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<typeof setInterval> | 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 = `
|
||||
<div class="f7s-main">
|
||||
<h2>f7support</h2>
|
||||
<button id="create-ticket-btn" type="button">Создать токен</button>
|
||||
<p id="error-box" class="f7s-error"></p>
|
||||
<h3 class="f7s-section-title">Мои токены</h3>
|
||||
<p id="ticket-hint" class="f7s-hint">Нажмите на токен, чтобы открыть чат. Закройте окно чата, чтобы выбрать другой токен.</p>
|
||||
<ul id="ticket-list" class="f7s-ticket-list"></ul>
|
||||
</div>
|
||||
|
||||
<div id="create-ticket-modal" class="f7s-modal f7s-modal--create">
|
||||
<div class="f7s-modal-panel">
|
||||
<div class="f7s-modal-head">
|
||||
<h3>Создание токена</h3>
|
||||
<button type="button" id="close-modal-btn" class="f7s-btn-icon" aria-label="Закрыть">✕</button>
|
||||
</div>
|
||||
<input id="subject-input" class="f7s-input" maxlength="255" placeholder="Тема" />
|
||||
<textarea id="body-input" class="f7s-input f7s-textarea" maxlength="4000" placeholder="Опишите проблему (до 4000 символов)"></textarea>
|
||||
<div class="f7s-chat-actions f7s-mt-8">
|
||||
<input id="create-ticket-attachment-input" type="file" multiple />
|
||||
</div>
|
||||
<button type="button" id="submit-ticket-btn" class="f7s-mt-8">Отправить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="chat-modal" class="f7s-modal f7s-modal--chat">
|
||||
<div class="f7s-chat-shell" role="dialog" aria-modal="true" aria-labelledby="chat-title">
|
||||
<div class="f7s-chat-head">
|
||||
<h3 id="chat-title" class="f7s-chat-title">Чат</h3>
|
||||
<button type="button" id="close-chat-btn" class="f7s-btn-icon f7s-btn-icon--lg" aria-label="Закрыть чат">✕</button>
|
||||
</div>
|
||||
<p id="chat-error-box" class="f7s-error f7s-chat-error"></p>
|
||||
<div id="message-list" class="f7s-message-list"></div>
|
||||
<div class="f7s-chat-footer">
|
||||
<textarea id="message-input" class="f7s-input f7s-textarea f7s-textarea--message" maxlength="4000" placeholder="Сообщение (до 4000 символов)"></textarea>
|
||||
<div class="f7s-chat-actions">
|
||||
<input id="attachment-input" type="file" />
|
||||
<button type="button" id="send-message-btn">Отправить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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<object>} тело ответа 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();
|
||||
})();
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\F7Support;
|
||||
|
||||
use OCP\AppFramework\App;
|
||||
|
||||
class Application extends App {
|
||||
public const APP_ID = 'f7support';
|
||||
|
||||
public function __construct(array $urlParams = []) {
|
||||
parent::__construct(self::APP_ID, $urlParams);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\F7Support\Controller;
|
||||
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http\TemplateResponse;
|
||||
use OCP\IRequest;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\IUserSession;
|
||||
use OCP\Util;
|
||||
|
||||
class PageController extends Controller {
|
||||
public function __construct(
|
||||
string $AppName,
|
||||
IRequest $request,
|
||||
private IUserSession $userSession,
|
||||
private IURLGenerator $urlGenerator
|
||||
) {
|
||||
parent::__construct($AppName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
* @NoCSRFRequired
|
||||
*/
|
||||
public function index(): TemplateResponse {
|
||||
$user = $this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<link rel="dns-prefetch" href="<?php p($_['supportApiOrigin']); ?>">
|
||||
<link rel="preconnect" href="<?php p($_['supportApiOrigin']); ?>" crossorigin>
|
||||
<div id="f7support-app"
|
||||
data-username="<?php p($_['username']); ?>"
|
||||
data-server-address="<?php p($_['serverAddress']); ?>"
|
||||
data-support-api-base="<?php p($_['supportApiBase']); ?>"
|
||||
data-messages-poll-ms="5000">
|
||||
</div>
|
||||
Reference in New Issue
Block a user