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