feat(chat): скрытие и плановое удаление диалогов
Админ может скрыть диалог тьютора (мягкое удаление), снять скрытие, отменить плановое удаление или удалить диалог физически через TTL общей настройки корзины. - chat_threads: deleted_at, purge_at (init.sql, migration.sql, ensureChatTables) - маршруты hide / restore / unschedule / permanent под requireAdmin + аудит - purgeScheduledDeletions() + hardDeleteChatThread() с safeUnlink по вложениям - скрытые диалоги видны тьютору как обычно; новое сообщение тьютора авто-возвращает диалог в список (thread_update по SSE) - UI: кнопка «Скрытые» с бейджем и действия в карточке треда - бэкап: deleted_at/purge_at в normalizeRestoreData
This commit is contained in:
+20
-1
@@ -930,13 +930,32 @@ body.report-card-open .report-fields{display:flex}
|
||||
.chat-thread-preview{font-size:.78rem;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.chat-off{font-size:.65rem;color:var(--muted);border:1px solid var(--border);border-radius:5px;padding:1px 5px;flex-shrink:0}
|
||||
.chat-thread-badge{min-width:18px;height:18px;padding:0 6px;border-radius:999px;background:#ef4444;color:#fff;font-size:.65rem;font-weight:700;line-height:18px;text-align:center;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;align-self:center}
|
||||
.chat-side-toggle{padding:7px 12px;font-size:.8rem;display:inline-flex;align-items:center;gap:6px}
|
||||
.chat-side-toggle svg{width:14px;height:14px}
|
||||
.chat-side-toggle.on{background:rgba(37,99,235,.1);border-color:var(--accent);color:var(--accent)}
|
||||
.chat-hidden-count{min-width:16px;height:16px;padding:0 5px;border-radius:999px;background:var(--muted);color:var(--card);font-size:.62rem;font-weight:700;line-height:16px;text-align:center}
|
||||
.chat-side-toggle.on .chat-hidden-count{background:var(--accent);color:#fff}
|
||||
.chat-side-hint{padding:9px 14px;border-bottom:1px solid var(--border);background:var(--bg);color:var(--muted);font-size:.72rem;line-height:1.4}
|
||||
.chat-side-hint[hidden]{display:none}
|
||||
.chat-thread-acts{display:flex;align-items:center;gap:4px;margin-left:auto;align-self:center;opacity:0;transition:opacity .15s}
|
||||
.chat-thread:hover .chat-thread-acts,.chat-thread:focus-visible .chat-thread-acts,.chat-thread.active .chat-thread-acts{opacity:1}
|
||||
.chat-act{width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--muted);cursor:pointer;padding:0;flex-shrink:0}
|
||||
.chat-act:hover{color:var(--accent);border-color:var(--accent);background:rgba(37,99,235,.07)}
|
||||
.chat-act:hover svg{stroke:var(--accent)}
|
||||
.chat-act-danger:hover{color:#ef4444;border-color:#ef4444;background:rgba(239,68,68,.08)}
|
||||
.chat-act-danger:hover svg{stroke:#ef4444}
|
||||
.chat-act svg{width:14px;height:14px}
|
||||
.chat-thread.is-hidden .chat-thread-avatar{opacity:.5}
|
||||
.chat-thread.is-hidden .chat-thread-name,.chat-thread.is-hidden .chat-thread-preview{opacity:.65}
|
||||
.chat-hidden-tag{border-color:rgba(239,68,68,.35);color:#ef4444}
|
||||
@media(hover:none){.chat-thread-acts{opacity:1}}
|
||||
.chat-pane{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);min-height:420px;display:flex;flex-direction:column;overflow:hidden}
|
||||
.chat-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:70px 20px;text-align:center;color:var(--muted)}
|
||||
.chat-empty svg{width:34px;height:34px;opacity:.4}
|
||||
.chat-empty-title{font-size:.95rem;font-weight:600;color:var(--text)}
|
||||
.chat-empty-sub{font-size:.82rem}
|
||||
.chat-main{display:flex;flex-direction:column;min-height:520px}
|
||||
.chat-side[hidden],.chat-empty[hidden],.chat-main[hidden],.chat-attach-list[hidden],.chat-new-btn[hidden]{display:none}
|
||||
.chat-side[hidden],.chat-empty[hidden],.chat-main[hidden],.chat-attach-list[hidden],.chat-new-btn[hidden],.chat-side-toggle[hidden],.chat-hidden-count[hidden]{display:none}
|
||||
.chat-head{display:flex;align-items:center;gap:11px;padding:12px 16px;border-bottom:1px solid var(--border)}
|
||||
.chat-head .grow{flex:1}
|
||||
.chat-avatar{width:36px;height:36px;border-radius:10px;background:rgba(37,99,235,.1);color:var(--accent);display:flex;align-items:center;justify-content:center;font-weight:700;flex-shrink:0}
|
||||
|
||||
@@ -274,6 +274,11 @@ function connectChatStream() {
|
||||
try { p = JSON.parse(ev.data); } catch { return; }
|
||||
if (p && typeof onChatMessageDelete === 'function') onChatMessageDelete(p);
|
||||
});
|
||||
chatStream.addEventListener('thread_update', ev => {
|
||||
let p = null;
|
||||
try { p = JSON.parse(ev.data); } catch { return; }
|
||||
if (p && typeof onChatThreadUpdate === 'function') onChatThreadUpdate(p);
|
||||
});
|
||||
chatStream.onerror = () => {
|
||||
if (chatStream) { chatStream.close(); chatStream = null; }
|
||||
clearTimeout(chatReconnectTimer);
|
||||
|
||||
@@ -22,8 +22,10 @@
|
||||
<div class="chat-side-head">
|
||||
<span class="chat-side-title">Диалоги</span>
|
||||
<span class="grow"></span>
|
||||
<button class="btn-primary ghost chat-side-toggle" id="chatHiddenBtn" type="button" hidden title="Показать скрытые диалоги"><i data-lucide="archive"></i> Скрытые <span class="chat-hidden-count" id="chatHiddenCount" hidden></span></button>
|
||||
<button class="btn-primary ghost chat-new-btn" id="chatNewBtn" type="button" hidden title="Начать диалог с тьютором"><i data-lucide="user-plus"></i> Начать диалог</button>
|
||||
</div>
|
||||
<div class="chat-side-hint" id="chatHiddenHint" hidden>Скрытые диалоги. Новое сообщение от тьютора вернёт диалог в список.</div>
|
||||
<div class="chat-thread-list" id="chatThreadList"></div>
|
||||
</div>
|
||||
|
||||
|
||||
+129
-3
@@ -17,6 +17,8 @@ let chatPendingFiles = [];
|
||||
let chatLoading = false;
|
||||
let chatSending = false;
|
||||
let chatEditId = 0;
|
||||
let chatHiddenMode = false;
|
||||
let chatHiddenCount = 0;
|
||||
|
||||
function chatHumanSize(b) {
|
||||
const n = Number(b) || 0;
|
||||
@@ -65,18 +67,29 @@ function chatThreadCardHtml(t) {
|
||||
const active = t.id === chatActiveId ? ' active' : '';
|
||||
const unread = Number(t.unread) || 0;
|
||||
const preview = (t.last_message_text || '').trim() || 'Нет сообщений';
|
||||
return `<button type="button" class="chat-thread${active}${unread ? ' unread' : ''}" data-thread="${t.id}">
|
||||
const hidden = t.deleted_at ? ' is-hidden' : '';
|
||||
const acts = isAdmin() ? (t.deleted_at
|
||||
? `<span class="chat-thread-acts">
|
||||
<button type="button" class="chat-act" data-thread-restore="${t.id}" title="Вернуть диалог в список"><i data-lucide="archive-restore"></i></button>
|
||||
<button type="button" class="chat-act chat-act-danger" data-thread-purge="${t.id}" title="Удалить диалог безвозвратно"><i data-lucide="trash-2"></i></button>
|
||||
</span>`
|
||||
: `<span class="chat-thread-acts">
|
||||
<button type="button" class="chat-act" data-thread-hide="${t.id}" title="Скрыть диалог"><i data-lucide="archive"></i></button>
|
||||
</span>`) : '';
|
||||
return `<button type="button" class="chat-thread${active}${hidden}${unread ? ' unread' : ''}" data-thread="${t.id}">
|
||||
<span class="chat-thread-avatar">${esc((t.tutor_name || '?').slice(0, 1).toUpperCase())}</span>
|
||||
<span class="chat-thread-body">
|
||||
<span class="chat-thread-top">
|
||||
<span class="chat-thread-name">${esc(t.tutor_name || t.tutor_username)}</span>
|
||||
${t.tutor_active === false ? '<span class="chat-off" title="Пользователь отключён">отключён</span>' : ''}
|
||||
${t.deleted_at ? '<span class="chat-off chat-hidden-tag" title="Диалог скрыт">скрыт</span>' : ''}
|
||||
<span class="grow"></span>
|
||||
<span class="chat-thread-time">${esc(chatThreadTime(t.last_message_at || t.created_at))}</span>
|
||||
</span>
|
||||
<span class="chat-thread-preview">${esc(preview)}</span>
|
||||
</span>
|
||||
${unread ? `<span class="chat-thread-badge">${unread > 99 ? '99+' : unread}</span>` : ''}
|
||||
${acts}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
@@ -84,10 +97,11 @@ function renderChatThreads() {
|
||||
const list = document.getElementById('chatThreadList');
|
||||
if (!list) return;
|
||||
if (!chatThreads.length) {
|
||||
list.innerHTML = '<div class="chat-side-empty">Диалогов пока нет</div>';
|
||||
list.innerHTML = `<div class="chat-side-empty">${chatHiddenMode ? 'Скрытых диалогов нет' : 'Диалогов пока нет'}</div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = chatThreads.map(chatThreadCardHtml).join('');
|
||||
renderIcons();
|
||||
}
|
||||
|
||||
function chatFilesHtml(files) {
|
||||
@@ -184,15 +198,100 @@ async function chatApi(path, options) {
|
||||
|
||||
async function loadChatThreads() {
|
||||
try {
|
||||
const data = await chatApi('/api/chat/threads', { headers: hdr() });
|
||||
const data = await chatApi(`/api/chat/threads${chatHiddenMode ? '?deleted=1' : ''}`, { headers: hdr() });
|
||||
chatThreads = data.items || [];
|
||||
chatHiddenCount = Number(data.hidden) || 0;
|
||||
setChatUnread(data.unread);
|
||||
renderChatHiddenBtn();
|
||||
renderChatThreads();
|
||||
} catch {
|
||||
showToast('Не удалось загрузить диалоги', true);
|
||||
}
|
||||
}
|
||||
|
||||
function renderChatHiddenBtn() {
|
||||
const btn = document.getElementById('chatHiddenBtn');
|
||||
const hint = document.getElementById('chatHiddenHint');
|
||||
const count = document.getElementById('chatHiddenCount');
|
||||
if (!btn) return;
|
||||
btn.hidden = !isAdmin();
|
||||
btn.classList.toggle('on', chatHiddenMode);
|
||||
if (hint) hint.hidden = !chatHiddenMode;
|
||||
if (count) {
|
||||
count.hidden = !chatHiddenMode || !chatHiddenCount;
|
||||
count.textContent = chatHiddenCount > 99 ? '99+' : String(chatHiddenCount);
|
||||
}
|
||||
btn.title = chatHiddenMode ? 'Показать активные диалоги' : 'Показать скрытые диалоги';
|
||||
}
|
||||
|
||||
function resetChatHiddenMode() {
|
||||
if (!chatHiddenMode) return;
|
||||
chatHiddenMode = false;
|
||||
chatActiveId = 0;
|
||||
renderChatHiddenBtn();
|
||||
}
|
||||
|
||||
function afterChatThreadListChanged() {
|
||||
const stillThere = chatThreads.some(t => t.id === chatActiveId);
|
||||
if (!stillThere) chatActiveId = 0;
|
||||
renderChatThreads();
|
||||
if (chatActiveId) loadChatMessages();
|
||||
else {
|
||||
renderChatHeader();
|
||||
renderChatMessages();
|
||||
}
|
||||
}
|
||||
|
||||
async function hideChatThread(id) {
|
||||
const thread = chatThreads.find(t => t.id === Number(id));
|
||||
if (!thread) return;
|
||||
const name = thread.tutor_name || thread.tutor_username || 'диалог';
|
||||
if (!confirm(`Скрыть диалог с «${name}»? Он уйдёт из списка активных, сообщения сохранятся и будут удалены автоматически через срок из настроек корзины.`)) return;
|
||||
try {
|
||||
await chatApi(`/api/chat/threads/${id}`, { method: 'DELETE', headers: hdr() });
|
||||
chatHiddenCount += 1;
|
||||
showToast('Диалог скрыт');
|
||||
if (chatActiveId === Number(id)) chatActiveId = 0;
|
||||
chatThreads = chatThreads.filter(t => t.id !== Number(id));
|
||||
renderChatHiddenBtn();
|
||||
afterChatThreadListChanged();
|
||||
loadChatUnread();
|
||||
} catch (e) {
|
||||
showToast(e.message || 'Не удалось скрыть диалог', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreChatThread(id) {
|
||||
try {
|
||||
await chatApi(`/api/chat/threads/${id}/restore`, { method: 'PUT', headers: hdr() });
|
||||
chatHiddenCount = Math.max(0, chatHiddenCount - 1);
|
||||
showToast('Диалог восстановлен');
|
||||
if (chatActiveId === Number(id)) chatActiveId = 0;
|
||||
chatThreads = chatThreads.filter(t => t.id !== Number(id));
|
||||
renderChatHiddenBtn();
|
||||
afterChatThreadListChanged();
|
||||
loadChatUnread();
|
||||
} catch (e) {
|
||||
showToast(e.message || 'Не удалось восстановить диалог', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function purgeChatThread(id) {
|
||||
const thread = chatThreads.find(t => t.id === Number(id));
|
||||
if (!thread) return;
|
||||
const name = thread.tutor_name || thread.tutor_username || 'диалог';
|
||||
if (!confirm(`Удалить диалог с «${name}» безвозвратно? Сообщения и вложения будут удалены после срока из настроек корзины.`)) return;
|
||||
try {
|
||||
await chatApi(`/api/chat/threads/${id}/permanent`, { method: 'DELETE', headers: hdr() });
|
||||
chatThreads = chatThreads.filter(t => t.id !== Number(id));
|
||||
if (chatActiveId === Number(id)) chatActiveId = 0;
|
||||
afterChatThreadListChanged();
|
||||
showToast('Удаление запланировано');
|
||||
} catch (e) {
|
||||
showToast(e.message || 'Не удалось запланировать удаление', true);
|
||||
}
|
||||
}
|
||||
|
||||
function renderChatHeader() {
|
||||
const thread = chatThreads.find(t => t.id === chatActiveId);
|
||||
const empty = document.getElementById('chatEmpty');
|
||||
@@ -330,6 +429,8 @@ function onChatMessage(p) {
|
||||
const m = p.message;
|
||||
if (idx < 0) {
|
||||
loadChatThreads();
|
||||
} else if (chatHiddenMode) {
|
||||
renderChatThreads();
|
||||
} else {
|
||||
chatThreads[idx].last_message_text = m.body || 'Вложение';
|
||||
chatThreads[idx].last_message_at = m.created_at;
|
||||
@@ -377,6 +478,18 @@ function onChatMessageDelete(p) {
|
||||
if (Number(m.sender_id) !== Number(currentUser?.id)) markChatRead();
|
||||
}
|
||||
|
||||
function onChatThreadUpdate(p) {
|
||||
if (!p || !isAdmin()) return;
|
||||
if (p.deleted_at) {
|
||||
if (chatHiddenMode) return;
|
||||
chatHiddenCount += 1;
|
||||
} else {
|
||||
if (!chatHiddenMode) return;
|
||||
chatHiddenCount = Math.max(0, chatHiddenCount - 1);
|
||||
}
|
||||
loadChatThreads();
|
||||
}
|
||||
|
||||
function openChatEditModal(messageId) {
|
||||
const modal = document.getElementById('chatEditModal');
|
||||
const input = document.getElementById('chatEditText');
|
||||
@@ -545,6 +658,7 @@ function initChatPage() {
|
||||
const sendBtn = document.getElementById('chatSendBtn');
|
||||
const reloadBtn = document.getElementById('chatReloadBtn');
|
||||
const newBtn = document.getElementById('chatNewBtn');
|
||||
const hiddenBtn = document.getElementById('chatHiddenBtn');
|
||||
const newModal = document.getElementById('chatNewModal');
|
||||
const newCancel = document.getElementById('chatNewCancel');
|
||||
const newOk = document.getElementById('chatNewOk');
|
||||
@@ -584,6 +698,12 @@ function initChatPage() {
|
||||
|
||||
if (list) {
|
||||
list.addEventListener('click', e => {
|
||||
const hideBtn = e.target.closest('[data-thread-hide]');
|
||||
if (hideBtn) { e.stopPropagation(); hideChatThread(Number(hideBtn.dataset.threadHide)); return; }
|
||||
const restoreBtn = e.target.closest('[data-thread-restore]');
|
||||
if (restoreBtn) { e.stopPropagation(); restoreChatThread(Number(restoreBtn.dataset.threadRestore)); return; }
|
||||
const purgeBtn = e.target.closest('[data-thread-purge]');
|
||||
if (purgeBtn) { e.stopPropagation(); purgeChatThread(Number(purgeBtn.dataset.threadPurge)); return; }
|
||||
const btn = e.target.closest('[data-thread]');
|
||||
if (!btn) return;
|
||||
selectChatThread(Number(btn.dataset.thread));
|
||||
@@ -614,6 +734,12 @@ function initChatPage() {
|
||||
});
|
||||
}
|
||||
if (newBtn) newBtn.addEventListener('click', openChatNewModal);
|
||||
if (hiddenBtn) hiddenBtn.addEventListener('click', () => {
|
||||
chatHiddenMode = !chatHiddenMode;
|
||||
chatActiveId = 0;
|
||||
renderChatHiddenBtn();
|
||||
loadChatThreads();
|
||||
});
|
||||
if (newCancel) newCancel.addEventListener('click', () => newModal && newModal.classList.remove('open'));
|
||||
if (newModal) {
|
||||
newModal.addEventListener('click', e => { if (e.target === newModal) newModal.classList.remove('open'); });
|
||||
|
||||
Reference in New Issue
Block a user