feat(chat): правка и удаление сообщений
- PUT/DELETE /api/chat/threads/:id/messages/:messageId под requireAuth+chatLimiter, доступ через chatMessageFor (тред + принадлежность сообщения) - править может только автор (админу чужое сообщение 403), удалять — автор или админ - мягкое удаление: body='', вложения каскадом из БД + safeUnlink по path, счётчик непрочитанных уменьшается на 1 у своей стороны (GREATEST(col-1,0)) - refreshChatThreadPreview() пересчитывает превью треда по последнему живому сообщению - SSE: publishChat с type, список типов валидируется через CHAT_EVENTS; новые message_update/message_delete подписаны в connectChatStream (admin.js) - аудит chat.message.update с textDiff и chat.message.delete без текста - колонки edited_at/deleted_at в db/init.sql, db/migration.sql, ensureChatTables() и в normalizeRestoreData + restore-INSERT — удалённые сообщения не воскресают - фронтенд: кнопки правки/удаления по наведению, модалка правки (Ctrl+Enter), метка «изменено», плейсхолдер «Сообщение удалено», обновление по SSE - покрытие в api.smoketest.js (правка/удаление/403/400/404) и backup.selftest.js
This commit is contained in:
@@ -956,6 +956,17 @@ body.report-card-open .report-fields{display:flex}
|
||||
.msg-who{font-weight:600;color:var(--text);font-size:.78rem}
|
||||
.msg-role{border:1px solid var(--border);border-radius:5px;padding:0 5px;font-size:.65rem}
|
||||
.msg-time{white-space:nowrap}
|
||||
.msg-edited{font-style:italic}
|
||||
.msg-actions{display:inline-flex;align-items:center;gap:2px;opacity:0;transition:opacity .15s}
|
||||
.msg:hover .msg-actions,.msg-actions:focus-within{opacity:1}
|
||||
.msg-act{background:none;border:none;padding:2px;border-radius:6px;color:var(--muted);cursor:pointer;display:inline-flex}
|
||||
.msg-act:hover{color:var(--accent);background:var(--bg)}
|
||||
.msg-act-del:hover{color:#ef4444}
|
||||
.msg-act svg{width:13px;height:13px}
|
||||
.msg-deleted-body{font-style:italic;color:var(--muted)}
|
||||
.chat-edit-modal{max-width:560px}
|
||||
.chat-edit-modal textarea{min-height:110px;max-height:40dvh}
|
||||
.chat-edit-modal textarea:focus{border-color:var(--accent)}
|
||||
.msg-body{font-size:.88rem;line-height:1.45;white-space:pre-wrap;word-break:break-word}
|
||||
.msg-files{display:flex;flex-wrap:wrap;gap:8px}
|
||||
.msg-file{display:flex;align-items:center;gap:8px;background:var(--bg);border:1px solid var(--border);border-radius:9px;padding:7px 10px;font-size:.78rem;color:var(--text);text-decoration:none;max-width:230px;min-width:0;transition:border-color .15s}
|
||||
@@ -987,6 +998,10 @@ body.report-card-open .report-fields{display:flex}
|
||||
.chat-hint{font-size:.75rem;margin:0}
|
||||
.chat-new-modal{max-width:420px}
|
||||
|
||||
@media(hover:none){
|
||||
.msg-actions{opacity:1}
|
||||
}
|
||||
|
||||
@media(max-width:900px){
|
||||
.chat-layout{grid-template-columns:1fr}
|
||||
.chat-side{max-height:230px}
|
||||
|
||||
@@ -264,6 +264,16 @@ function connectChatStream() {
|
||||
}
|
||||
if (typeof onChatMessage === 'function') onChatMessage(p);
|
||||
});
|
||||
chatStream.addEventListener('message_update', ev => {
|
||||
let p = null;
|
||||
try { p = JSON.parse(ev.data); } catch { return; }
|
||||
if (p && typeof onChatMessageUpdate === 'function') onChatMessageUpdate(p);
|
||||
});
|
||||
chatStream.addEventListener('message_delete', ev => {
|
||||
let p = null;
|
||||
try { p = JSON.parse(ev.data); } catch { return; }
|
||||
if (p && typeof onChatMessageDelete === 'function') onChatMessageDelete(p);
|
||||
});
|
||||
chatStream.onerror = () => {
|
||||
if (chatStream) { chatStream.close(); chatStream = null; }
|
||||
clearTimeout(chatReconnectTimer);
|
||||
|
||||
@@ -76,6 +76,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="chatEditModal">
|
||||
<div class="edit-modal chat-edit-modal">
|
||||
<h3>Редактировать сообщение</h3>
|
||||
<textarea id="chatEditText" class="settings-input" maxlength="5000"></textarea>
|
||||
<div class="hint">Ctrl+Enter — сохранить. Вложения сообщения останутся как есть.</div>
|
||||
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||
<button type="button" class="btn-primary ghost" id="chatEditCancel">Отмена</button>
|
||||
<button type="submit" class="btn-primary" id="chatEditSave">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="imgModal">
|
||||
<img id="imgModalSrc">
|
||||
</div>
|
||||
|
||||
+155
-2
@@ -16,6 +16,7 @@ let chatMessages = [];
|
||||
let chatPendingFiles = [];
|
||||
let chatLoading = false;
|
||||
let chatSending = false;
|
||||
let chatEditId = 0;
|
||||
|
||||
function chatHumanSize(b) {
|
||||
const n = Number(b) || 0;
|
||||
@@ -110,12 +111,29 @@ function chatMessageHtml(m) {
|
||||
const mine = Number(m.sender_id) === Number(currentUser?.id);
|
||||
const who = mine ? 'Вы' : (m.sender_name || 'Тьютор');
|
||||
const role = mine ? '' : (m.sender_role === 'admin' ? 'администратор' : 'тьютор');
|
||||
if (m.deleted_at) {
|
||||
return `<div class="msg msg-deleted ${mine ? 'msg-mine' : ''}" data-msg="${m.id}">
|
||||
<div class="msg-head">
|
||||
<span class="msg-who">${esc(who)}</span>
|
||||
${role ? `<span class="msg-role">${esc(role)}</span>` : ''}
|
||||
<span class="grow"></span>
|
||||
<span class="msg-time">${esc(chatMessageTime(m.created_at))}</span>
|
||||
</div>
|
||||
<div class="msg-body msg-deleted-body">Сообщение удалено</div>
|
||||
</div>`;
|
||||
}
|
||||
const canDelete = mine || isAdmin();
|
||||
return `<div class="msg ${mine ? 'msg-mine' : ''}" data-msg="${m.id}">
|
||||
<div class="msg-head">
|
||||
<span class="msg-who">${esc(who)}</span>
|
||||
${role ? `<span class="msg-role">${esc(role)}</span>` : ''}
|
||||
${m.edited_at ? `<span class="msg-edited" title="Изменено ${esc(chatMessageTime(m.edited_at))}">изменено</span>` : ''}
|
||||
<span class="grow"></span>
|
||||
<span class="msg-time">${esc(chatMessageTime(m.created_at))}</span>
|
||||
${canDelete ? `<span class="msg-actions">
|
||||
${mine ? `<button type="button" class="msg-act" data-msg-edit="${m.id}" title="Редактировать"><i data-lucide="pencil"></i></button>` : ''}
|
||||
<button type="button" class="msg-act msg-act-del" data-msg-del="${m.id}" title="Удалить"><i data-lucide="trash-2"></i></button>
|
||||
</span>` : ''}
|
||||
</div>
|
||||
${m.body ? `<div class="msg-body">${esc(m.body)}</div>` : ''}
|
||||
${chatFilesHtml(m.files)}
|
||||
@@ -292,8 +310,8 @@ function removeChatFile(i) {
|
||||
renderChatAttach();
|
||||
}
|
||||
|
||||
function autoGrowChatInput() {
|
||||
const input = document.getElementById('chatInput');
|
||||
function autoGrowChatInput(el) {
|
||||
const input = el || document.getElementById('chatInput');
|
||||
if (!input) return;
|
||||
input.style.height = 'auto';
|
||||
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
|
||||
@@ -325,6 +343,109 @@ function onChatMessage(p) {
|
||||
markChatRead();
|
||||
}
|
||||
|
||||
function applyChatThreadPreview(p) {
|
||||
if (!p || !p.preview) return;
|
||||
const idx = chatThreads.findIndex(t => t.id === Number(p.thread_id));
|
||||
if (idx < 0) return;
|
||||
chatThreads[idx].last_message_text = p.preview.last_message_text || '';
|
||||
chatThreads[idx].last_message_at = p.preview.last_message_at || chatThreads[idx].last_message_at;
|
||||
renderChatThreads();
|
||||
}
|
||||
|
||||
function onChatMessageUpdate(p) {
|
||||
if (!p || !p.message) return;
|
||||
applyChatThreadPreview(p);
|
||||
if (Number(p.thread_id) !== chatActiveId) return;
|
||||
const m = p.message;
|
||||
const idx = chatMessages.findIndex(x => Number(x.id) === Number(m.id));
|
||||
if (idx < 0) {
|
||||
addChatMessage(m);
|
||||
} else {
|
||||
chatMessages[idx] = { ...chatMessages[idx], ...m };
|
||||
}
|
||||
renderChatMessages();
|
||||
}
|
||||
|
||||
function onChatMessageDelete(p) {
|
||||
if (!p || !p.message) return;
|
||||
applyChatThreadPreview(p);
|
||||
if (Number(p.thread_id) !== chatActiveId) return;
|
||||
const m = p.message;
|
||||
const idx = chatMessages.findIndex(x => Number(x.id) === Number(m.id));
|
||||
if (idx >= 0) chatMessages[idx] = { ...chatMessages[idx], ...m, body: '', files: [], edited_at: null };
|
||||
renderChatMessages();
|
||||
if (Number(m.sender_id) !== Number(currentUser?.id)) markChatRead();
|
||||
}
|
||||
|
||||
function openChatEditModal(messageId) {
|
||||
const modal = document.getElementById('chatEditModal');
|
||||
const input = document.getElementById('chatEditText');
|
||||
if (!modal || !input) return;
|
||||
const msg = chatMessages.find(m => Number(m.id) === Number(messageId));
|
||||
if (!msg || msg.deleted_at) return;
|
||||
chatEditId = Number(messageId);
|
||||
input.value = String(msg.body || '');
|
||||
modal.classList.add('open');
|
||||
input.focus();
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
autoGrowChatInput(input);
|
||||
}
|
||||
|
||||
function closeChatEditModal() {
|
||||
const modal = document.getElementById('chatEditModal');
|
||||
if (modal) modal.classList.remove('open');
|
||||
chatEditId = 0;
|
||||
}
|
||||
|
||||
async function submitChatEdit() {
|
||||
const input = document.getElementById('chatEditText');
|
||||
const saveBtn = document.getElementById('chatEditSave');
|
||||
if (!input || !chatEditId) return;
|
||||
const body = String(input.value || '').trim();
|
||||
if (!body) {
|
||||
showToast('Сообщение не может быть пустым', true);
|
||||
return;
|
||||
}
|
||||
if (saveBtn) saveBtn.disabled = true;
|
||||
try {
|
||||
const data = await chatApi(`/api/chat/threads/${chatActiveId}/messages/${chatEditId}`, {
|
||||
method: 'PUT',
|
||||
headers: hdrJson(),
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
if (data.message) {
|
||||
const idx = chatMessages.findIndex(m => Number(m.id) === Number(data.message.id));
|
||||
if (idx >= 0) chatMessages[idx] = { ...chatMessages[idx], ...data.message };
|
||||
renderChatMessages();
|
||||
}
|
||||
closeChatEditModal();
|
||||
} catch (e) {
|
||||
showToast(e.message || 'Не удалось изменить сообщение', true);
|
||||
} finally {
|
||||
if (saveBtn) saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteChatMessage(messageId) {
|
||||
const msg = chatMessages.find(m => Number(m.id) === Number(messageId));
|
||||
if (!msg) return;
|
||||
const mine = Number(msg.sender_id) === Number(currentUser?.id);
|
||||
if (!confirm(mine
|
||||
? 'Удалить сообщение? Вложения будут удалены безвозвратно.'
|
||||
: 'Удалить сообщение собеседника? Вложения будут удалены безвозвратно.')) return;
|
||||
try {
|
||||
await chatApi(`/api/chat/threads/${chatActiveId}/messages/${messageId}`, { method: 'DELETE', headers: hdr() });
|
||||
const idx = chatMessages.findIndex(m => Number(m.id) === Number(messageId));
|
||||
if (idx >= 0) {
|
||||
chatMessages[idx] = { ...chatMessages[idx], body: '', files: [], edited_at: null, deleted_at: new Date().toISOString() };
|
||||
}
|
||||
renderChatMessages();
|
||||
loadChatThreads();
|
||||
} catch (e) {
|
||||
showToast(e.message || 'Не удалось удалить сообщение', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function openChatNewModal() {
|
||||
const modal = document.getElementById('chatNewModal');
|
||||
const select = document.getElementById('chatNewUser');
|
||||
@@ -429,6 +550,37 @@ function initChatPage() {
|
||||
const newOk = document.getElementById('chatNewOk');
|
||||
const attachBox = document.getElementById('chatAttachList');
|
||||
const imgModal = document.getElementById('imgModal');
|
||||
const box = document.getElementById('chatMessages');
|
||||
const editModal = document.getElementById('chatEditModal');
|
||||
const editInput = document.getElementById('chatEditText');
|
||||
const editCancel = document.getElementById('chatEditCancel');
|
||||
const editSave = document.getElementById('chatEditSave');
|
||||
|
||||
if (box) {
|
||||
box.addEventListener('click', e => {
|
||||
const editBtn = e.target.closest('[data-msg-edit]');
|
||||
if (editBtn) {
|
||||
openChatEditModal(Number(editBtn.dataset.msgEdit));
|
||||
return;
|
||||
}
|
||||
const delBtn = e.target.closest('[data-msg-del]');
|
||||
if (delBtn) deleteChatMessage(Number(delBtn.dataset.msgDel));
|
||||
});
|
||||
}
|
||||
if (editCancel) editCancel.addEventListener('click', closeChatEditModal);
|
||||
if (editSave) editSave.addEventListener('click', submitChatEdit);
|
||||
if (editModal) {
|
||||
editModal.addEventListener('click', e => { if (e.target === editModal) closeChatEditModal(); });
|
||||
}
|
||||
if (editInput) {
|
||||
editInput.addEventListener('input', () => autoGrowChatInput(editInput));
|
||||
editInput.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
submitChatEdit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (list) {
|
||||
list.addEventListener('click', e => {
|
||||
@@ -493,6 +645,7 @@ function initChatPage() {
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (editModal && editModal.classList.contains('open')) { closeChatEditModal(); return; }
|
||||
if (imgModal && imgModal.classList.contains('open')) { imgModal.classList.remove('open'); return; }
|
||||
if (newModal && newModal.classList.contains('open')) newModal.classList.remove('open');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user