feat(shorts): короткие сообщения резидентов с ИИ-коррекцией
Таблица short_messages (резидент, дата занятия, тема, текст, отметка об использовании), CRUD API /api/shorts с мягким удалением и плановой очисткой через purge_at, ИИ-коррекция текста по профилю, страница shorts.html с массовым добавлением разбором строк «ФИО - текст».
This commit is contained in:
+18
@@ -458,3 +458,21 @@ INSERT INTO settings (key, value) VALUES ('lesson_ai_prompt', 'Ты — реда
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('notify_lesson_ai_formatted', 'false')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS short_messages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
resident VARCHAR(200) NOT NULL,
|
||||
lesson_date DATE NOT NULL,
|
||||
topic VARCHAR(500),
|
||||
message TEXT NOT NULL,
|
||||
used BOOLEAN NOT NULL DEFAULT false,
|
||||
used_at DATE,
|
||||
author_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
purge_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_short_messages_date ON short_messages(lesson_date DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_short_messages_deleted ON short_messages(deleted_at);
|
||||
|
||||
@@ -559,3 +559,30 @@ WHERE key = 'lesson_ai_prompt' AND value IN (
|
||||
INSERT INTO settings (key, value) VALUES ('notify_lesson_ai_formatted', 'false') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('timezone', 'Europe/Moscow') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('time_format', '24h') ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- Short messages table migration
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'short_messages') THEN
|
||||
CREATE TABLE short_messages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
resident VARCHAR(200) NOT NULL,
|
||||
lesson_date DATE NOT NULL,
|
||||
topic VARCHAR(500),
|
||||
message TEXT NOT NULL,
|
||||
used BOOLEAN NOT NULL DEFAULT false,
|
||||
used_at DATE,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
purge_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_short_messages_date ON short_messages(lesson_date DESC);
|
||||
CREATE INDEX idx_short_messages_deleted ON short_messages(deleted_at);
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
ALTER TABLE short_messages ADD COLUMN IF NOT EXISTS used_at DATE;
|
||||
ALTER TABLE short_messages ADD COLUMN IF NOT EXISTS author_id INT REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('short_messages_retention_days', '365') ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
@@ -58,6 +58,7 @@ function buildSidebar(active) {
|
||||
{ page: 'photos', label: 'Фото', icon: 'Image' },
|
||||
{ page: 'files', label: 'Файлы', icon: 'folder' },
|
||||
{ page: 'links', label: 'Ссылки', icon: 'link' },
|
||||
{ page: 'shorts', label: 'Короткие сообщения', icon: 'zap' },
|
||||
{ page: 'trash', label: 'Корзина', icon: 'trash-2' },
|
||||
];
|
||||
const adminNav = [
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
async function init() {
|
||||
if (!(await checkAuth())) return;
|
||||
buildSidebar('shorts');
|
||||
document.getElementById('addShortsBtn').addEventListener('click', () => openShortsModal(null));
|
||||
document.getElementById('shortsCancel').addEventListener('click', closeModal);
|
||||
document.getElementById('shortsUsed').addEventListener('change', e => {
|
||||
const at = document.getElementById('shortsUsedAt');
|
||||
if (e.target.checked && !at.value) at.value = todayIso();
|
||||
if (!e.target.checked) at.value = '';
|
||||
});
|
||||
document.getElementById('shortsUsedAt').addEventListener('change', e => {
|
||||
document.getElementById('shortsUsed').checked = !!e.target.value;
|
||||
});
|
||||
document.getElementById('shortsAiBtn').addEventListener('click', openShortsAi);
|
||||
document.getElementById('aiImproveBtn').addEventListener('click', improveShortsAi);
|
||||
document.getElementById('aiAcceptBtn').addEventListener('click', acceptShortsAi);
|
||||
document.getElementById('aiRejectBtn').addEventListener('click', closeShortsAi);
|
||||
document.getElementById('shortsSave').addEventListener('click', saveShorts);
|
||||
document.getElementById('shortsSearchBtn').addEventListener('click', () => { shortsPage = 1; loadShorts(); });
|
||||
document.getElementById('shortsClearBtn').addEventListener('click', () => {
|
||||
document.getElementById('shortsSearch').value = '';
|
||||
shortsPage = 1;
|
||||
loadShorts();
|
||||
});
|
||||
document.getElementById('shortsSearch').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') { shortsPage = 1; loadShorts(); }
|
||||
});
|
||||
await loadShorts();
|
||||
}
|
||||
|
||||
let shortsPage = 1;
|
||||
const SHORTS_PAGE_SIZE = 50;
|
||||
let shortsTotal = 0;
|
||||
let editingId = null;
|
||||
|
||||
async function loadShorts() {
|
||||
const tbody = document.querySelector('#shortsTable tbody');
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="color:var(--muted)">…</td></tr>';
|
||||
const p = new URLSearchParams();
|
||||
p.set('limit', SHORTS_PAGE_SIZE);
|
||||
p.set('offset', (shortsPage - 1) * SHORTS_PAGE_SIZE);
|
||||
const search = document.getElementById('shortsSearch').value.trim();
|
||||
if (search) p.set('search', search);
|
||||
try {
|
||||
const res = await fetch(`${API}/api/shorts?${p}`, { headers: hdr() });
|
||||
if (!res.ok) { tbody.innerHTML = '<tr><td colspan="7" style="color:var(--muted)">Ошибка загрузки</td></tr>'; return; }
|
||||
const data = await res.json();
|
||||
shortsTotal = data.total || 0;
|
||||
const rows = data.items || [];
|
||||
const totalPages = Math.max(1, Math.ceil(shortsTotal / SHORTS_PAGE_SIZE));
|
||||
const empty = document.getElementById('shortsEmpty');
|
||||
if (!rows.length) {
|
||||
empty.style.display = 'block';
|
||||
tbody.innerHTML = '';
|
||||
} else {
|
||||
empty.style.display = 'none';
|
||||
tbody.innerHTML = rows.map(r => {
|
||||
const dateStr = r.lesson_date ? fmtDateOnlyIso(r.lesson_date) : '';
|
||||
const usedDateStr = r.used_at ? fmtDateOnlyIso(r.used_at) : '—';
|
||||
const author = r.author_name || r.author_username || '—';
|
||||
const canEdit = true;
|
||||
return `
|
||||
<tr data-edit-row data-message="${escInline(r.message || '')}">
|
||||
<td>${esc(r.resident)}</td>
|
||||
<td style="white-space:nowrap">${dateStr}</td>
|
||||
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(r.topic || '')}">${esc(r.topic || '')}</td>
|
||||
<td style="max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(r.message || '')}">${esc(r.message || '')}</td>
|
||||
<td style="white-space:nowrap">${esc(author)}</td>
|
||||
<td style="white-space:nowrap">
|
||||
<input type="checkbox" class="used-check" data-id="${r.id}" ${r.used ? 'checked' : ''} title="Использовано" ${canEdit ? '' : 'disabled'}>
|
||||
<div style="font-size:.72rem;color:var(--muted)">${r.used ? esc(usedDateStr) : '—'}</div>
|
||||
</td>
|
||||
<td>
|
||||
${canEdit ? `<button class="row-btn" data-edit="${r.id}" title="Редактировать"><i data-lucide="pencil"></i></button>
|
||||
<button class="row-btn del" data-del="${r.id}" title="Удалить (мягко)"><i data-lucide="trash-2"></i></button>` : '—'}
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
renderShortsPager(document.getElementById('shortsPager'), shortsPage, totalPages, goShortsPage);
|
||||
attachRowHandlers();
|
||||
renderIcons();
|
||||
} catch (e) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="color:var(--muted)">Ошибка: ' + esc(e.message) + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function attachRowHandlers() {
|
||||
const tbody = document.querySelector('#shortsTable tbody');
|
||||
tbody.querySelectorAll('.used-check').forEach(cb => {
|
||||
cb.addEventListener('change', () => toggleUsed(Number(cb.dataset.id), cb.checked));
|
||||
});
|
||||
tbody.querySelectorAll('[data-edit]').forEach(b => {
|
||||
b.addEventListener('click', () => loadShortsRow(Number(b.dataset.edit)));
|
||||
});
|
||||
tbody.querySelectorAll('[data-del]').forEach(b => {
|
||||
b.addEventListener('click', () => delShorts(Number(b.dataset.del)));
|
||||
});
|
||||
}
|
||||
function renderShortsPager(container, page, totalPages, onPageChange) {
|
||||
if (totalPages <= 1) { container.innerHTML = ''; return; }
|
||||
const pages = [];
|
||||
const maxVisible = 5;
|
||||
let start = Math.max(1, page - Math.floor(maxVisible / 2));
|
||||
let end = Math.min(totalPages, start + maxVisible - 1);
|
||||
if (end - start + 1 < maxVisible) start = Math.max(1, end - maxVisible + 1);
|
||||
for (let i = start; i <= end; i++) pages.push(i);
|
||||
let html = '';
|
||||
if (page > 1) html += `<button data-page="${page - 1}">«</button>`;
|
||||
if (start > 1) {
|
||||
html += `<button data-page="1">1</button>`;
|
||||
if (start > 2) html += `<span class="pager-ellipsis">…</span>`;
|
||||
}
|
||||
for (const p of pages) html += `<button data-page="${p}"${p === page ? ' class="active"' : ''}>${p}</button>`;
|
||||
if (end < totalPages) {
|
||||
if (end < totalPages - 1) html += `<span class="pager-ellipsis">…</span>`;
|
||||
html += `<button data-page="${totalPages}">${totalPages}</button>`;
|
||||
}
|
||||
if (page < totalPages) html += `<button data-page="${page + 1}">»</button>`;
|
||||
container.innerHTML = html;
|
||||
container.querySelectorAll('button').forEach(b => {
|
||||
b.addEventListener('click', () => onPageChange(parseInt(b.dataset.page, 10)));
|
||||
});
|
||||
}
|
||||
|
||||
function goShortsPage(p) { shortsPage = p; loadShorts(); }
|
||||
|
||||
function openShortsModal(row) {
|
||||
editingId = row ? row.id : null;
|
||||
document.getElementById('shortsModalTitle').textContent = row ? `Сообщение #${row.id}` : 'Новое сообщение';
|
||||
document.getElementById('shortsLinesHint').textContent = row ? ' (только текст сообщения)' : ' (формат: ФИО - текст или ФИО — текст)';
|
||||
document.getElementById('shortsDate').value = row ? row.lesson_date : '';
|
||||
document.getElementById('shortsTopic').value = row ? (row.topic || '') : '';
|
||||
document.getElementById('shortsResident').value = row ? (row.resident || '') : '';
|
||||
document.getElementById('shortsUsed').checked = row ? !!row.used : false;
|
||||
document.getElementById('shortsUsedAt').value = row && row.used_at ? String(row.used_at).slice(0, 10) : '';
|
||||
if (row) {
|
||||
document.getElementById('shortsLines').value = row.message || '';
|
||||
} else {
|
||||
document.getElementById('shortsLines').value = '';
|
||||
}
|
||||
document.getElementById('shortsModal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('shortsModal').classList.remove('open');
|
||||
}
|
||||
|
||||
async function saveShorts() {
|
||||
const date = document.getElementById('shortsDate').value.trim();
|
||||
const topic = document.getElementById('shortsTopic').value.trim() || null;
|
||||
const linesText = document.getElementById('shortsLines').value.trim();
|
||||
const residentOverride = document.getElementById('shortsResident').value.trim() || null;
|
||||
const used = document.getElementById('shortsUsed').checked;
|
||||
let usedAt = document.getElementById('shortsUsedAt').value.trim() || null;
|
||||
if (used && !usedAt) usedAt = todayIso();
|
||||
if (!used) usedAt = null;
|
||||
|
||||
if (!date) { showToast('Укажите дату занятия'); return; }
|
||||
if (!linesText) { showToast('Укажите текст сообщения(я)'); return; }
|
||||
|
||||
let residentFromLine = residentOverride || '';
|
||||
const lines = linesText.split('\n');
|
||||
const parsed = [];
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const match = trimmed.match(/^(.+?)\s*[-—]\s*(.+)$/);
|
||||
if (match) {
|
||||
parsed.push({ resident: match[1].trim(), message: match[2].trim() });
|
||||
if (!residentFromLine) residentFromLine = match[1].trim();
|
||||
} else {
|
||||
parsed.push({ resident: residentOverride || 'Не указан', message: trimmed });
|
||||
if (!residentFromLine) residentFromLine = residentOverride || 'Не указан';
|
||||
}
|
||||
}
|
||||
if (!parsed.length) { showToast('Нет сообщений для добавления'); return; }
|
||||
|
||||
try {
|
||||
let res;
|
||||
if (editingId) {
|
||||
const resident = residentOverride || parsed[0].resident || 'Не указан';
|
||||
const message = parsed.length === 1 && !parsed[0].resident
|
||||
? parsed[0].message
|
||||
: parsed.map(p => p.message).join('\n');
|
||||
res = await fetch(`${API}/api/shorts/${editingId}`, {
|
||||
method: 'PUT',
|
||||
headers: hdrJson(),
|
||||
body: JSON.stringify({ resident, lesson_date: date, topic, message, used, used_at: usedAt })
|
||||
});
|
||||
} else {
|
||||
res = await fetch(`${API}/api/shorts`, {
|
||||
method: 'POST',
|
||||
headers: hdrJson(),
|
||||
body: JSON.stringify({ lesson_date: date, topic, lines: linesText, resident: residentOverride, used })
|
||||
});
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
showToast(err.error || 'Ошибка');
|
||||
return;
|
||||
}
|
||||
closeModal();
|
||||
shortsPage = 1;
|
||||
await loadShorts();
|
||||
showToast(editingId ? 'Сообщение обновлено' : 'Сообщения добавлены');
|
||||
} catch (e) {
|
||||
showToast('Ошибка: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function delShorts(id) {
|
||||
if (!confirm('Переместить сообщение в корзину?')) return;
|
||||
try {
|
||||
const res = await fetch(`${API}/api/shorts/${id}`, { method: 'DELETE', headers: hdr() });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
showToast(err.error || 'Ошибка');
|
||||
return;
|
||||
}
|
||||
await loadShorts();
|
||||
showToast('Сообщение перемещено в корзину');
|
||||
} catch (e) {
|
||||
showToast('Ошибка: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleUsed(id, checked) {
|
||||
try {
|
||||
const usedAt = checked ? todayIso() : null;
|
||||
const res = await fetch(`${API}/api/shorts/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: hdrJson(),
|
||||
body: JSON.stringify({ used: checked, used_at: usedAt })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
showToast(err.error || 'Ошибка');
|
||||
return;
|
||||
}
|
||||
await loadShorts();
|
||||
} catch (e) {
|
||||
showToast('Ошибка: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadShortsRow(id) {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/shorts/${id}`, { headers: hdr() });
|
||||
if (!res.ok) { showToast('Не найдено'); return; }
|
||||
openShortsModal(await res.json());
|
||||
} catch (e) {
|
||||
showToast('Ошибка: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
let shortsAiBusy = false;
|
||||
|
||||
async function openShortsAi() {
|
||||
const src = document.getElementById('aiSourceText');
|
||||
src.value = document.getElementById('shortsLines').value;
|
||||
document.getElementById('aiSuggestion').textContent = '';
|
||||
document.getElementById('aiCorrectModal').classList.add('open');
|
||||
const sel = document.getElementById('aiModelSelect');
|
||||
try {
|
||||
const res = await fetch(`${API}/api/ai/profiles`, { headers: hdr() });
|
||||
if (!res.ok) throw new Error('Профили недоступны');
|
||||
const data = await res.json();
|
||||
const profiles = data.profiles || [];
|
||||
sel.innerHTML = `<option value="native">Нативная — ${esc(data.native?.model || '')}</option>` +
|
||||
profiles.map(p => `<option value="${esc(p.id)}">${esc(p.name)} — ${esc(p.model)}</option>`).join('');
|
||||
sel.value = data.active || 'native';
|
||||
} catch (e) {
|
||||
sel.innerHTML = '<option value="native">Нативная</option>';
|
||||
}
|
||||
}
|
||||
|
||||
function closeShortsAi() {
|
||||
document.getElementById('aiCorrectModal').classList.remove('open');
|
||||
}
|
||||
|
||||
async function improveShortsAi() {
|
||||
if (shortsAiBusy) return;
|
||||
const text = document.getElementById('aiSourceText').value.trim();
|
||||
if (!text) { showToast('Нет текста для улучшения'); return; }
|
||||
const suggestionEl = document.getElementById('aiSuggestion');
|
||||
const btn = document.getElementById('aiImproveBtn');
|
||||
shortsAiBusy = true;
|
||||
btn.disabled = true;
|
||||
suggestionEl.textContent = 'Обработка...';
|
||||
try {
|
||||
const res = await fetch(`${API}/api/shorts/ai-correct`, {
|
||||
method: 'POST',
|
||||
headers: hdrJson(),
|
||||
body: JSON.stringify({ text, profile: document.getElementById('aiModelSelect').value })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Ошибка ИИ');
|
||||
}
|
||||
const data = await res.json();
|
||||
suggestionEl.textContent = data.suggestion || text;
|
||||
} catch (e) {
|
||||
suggestionEl.textContent = 'Ошибка: ' + e.message;
|
||||
showToast('ИИ недоступен: ' + e.message);
|
||||
} finally {
|
||||
shortsAiBusy = false;
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function acceptShortsAi() {
|
||||
const suggestion = document.getElementById('aiSuggestion').textContent;
|
||||
if (suggestion && suggestion !== 'Обработка...' && !suggestion.startsWith('Ошибка:')) {
|
||||
document.getElementById('shortsLines').value = suggestion;
|
||||
}
|
||||
closeShortsAi();
|
||||
}
|
||||
|
||||
function escInline(s) {
|
||||
return String(s || '').replace(/'/g, "'").replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,135 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Короткие сообщения — Админ-панель</title>
|
||||
<link rel="stylesheet" href="admin.css">
|
||||
</head>
|
||||
<body data-page="shorts">
|
||||
<div class="layout">
|
||||
<div class="sidebar" id="sidebar"></div>
|
||||
<div class="main">
|
||||
<div class="page-head">
|
||||
<h2>Короткие сообщения</h2>
|
||||
<p class="page-sub">Создание и редактирование сообщений</p>
|
||||
</div>
|
||||
|
||||
<div class="actions-row" style="margin-bottom:16px">
|
||||
<button class="btn-primary" id="addShortsBtn" type="button">
|
||||
<span style="font-size:1.2rem">+</span> Новое сообщение
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="actions-row" style="margin-bottom:12px">
|
||||
<input type="text" id="shortsSearch" class="settings-input" placeholder="Поиск по резиденту, теме, сообщению..." style="max-width:360px">
|
||||
<button class="btn-primary ghost" id="shortsSearchBtn">Найти</button>
|
||||
<button class="btn-primary ghost" id="shortsClearBtn">Сброс</button>
|
||||
</div>
|
||||
|
||||
<div class="audit-wrap">
|
||||
<table class="audit-table" id="shortsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Резидент</th>
|
||||
<th>Дата занятия</th>
|
||||
<th>Тема</th>
|
||||
<th>Сообщение</th>
|
||||
<th>Автор</th>
|
||||
<th>Использовано</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<div class="empty" id="shortsEmpty" style="display:none">Сообщения не найдены</div>
|
||||
</div>
|
||||
|
||||
<div id="shortsPager" class="pager"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="shortsModal">
|
||||
<div class="edit-modal" style="max-width:620px">
|
||||
<h3 id="shortsModalTitle">Новое сообщение</h3>
|
||||
<div class="settings-stack" style="gap:12px;max-width:none">
|
||||
<div class="settings-field">
|
||||
<label>Дата занятия <span style="color:#ef4444">*</span></label>
|
||||
<input type="date" id="shortsDate" class="settings-input">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Тема занятия</label>
|
||||
<input type="text" id="shortsTopic" class="settings-input" placeholder="Например: Photoshop 3/5">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Список строк <span style="color:#ef4444">*</span>
|
||||
<span id="shortsLinesHint" style="font-size:.7rem;color:var(--muted);font-weight:400"> (формат: ФИО - текст или ФИО — текст)</span>
|
||||
<button type="button" id="shortsAiBtn" class="ai-btn" title="Улучшить текст ИИ"><i data-lucide="sparkles" style="width:14px;height:14px"></i></button>
|
||||
</label>
|
||||
<textarea id="shortsLines" class="settings-input" rows="5" placeholder="Иванов И.И. - Отличная работа на занятии Петров П.П. — Продолжили изучение Photoshop"></textarea>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Резидент (для добавления без разбора строк)</label>
|
||||
<input type="text" id="shortsResident" class="settings-input" placeholder="Иванов И.И.">
|
||||
</div>
|
||||
<div class="settings-field" style="flex-direction:row;align-items:center;gap:8px">
|
||||
<input type="checkbox" id="shortsUsed">
|
||||
<label style="margin:0">Использовано</label>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Дата использования</label>
|
||||
<input type="date" id="shortsUsedAt" class="settings-input">
|
||||
</div>
|
||||
<div class="card-foot" style="justify-content:flex-end;border-top:none;padding-top:0">
|
||||
<button type="button" class="btn-primary ghost" id="shortsCancel">Отмена</button>
|
||||
<button type="button" class="btn-primary" id="shortsSave">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="aiCorrectModal">
|
||||
<div class="edit-modal" style="max-width:560px">
|
||||
<h3><i data-lucide="sparkles" style="width:18px;height:18px;vertical-align:-3px"></i> Улучшение текста ИИ</h3>
|
||||
<div style="font-size:.85rem;color:var(--muted);margin-bottom:8px">Выберите профиль и нажмите «Улучшить»</div>
|
||||
<div class="settings-field">
|
||||
<label>Профиль</label>
|
||||
<select id="aiModelSelect" class="settings-input">
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Текст</label>
|
||||
<textarea id="aiSourceText" class="settings-input" rows="4" placeholder="Текст для улучшения..."></textarea>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<button type="button" class="btn-primary ghost" id="aiImproveBtn">Улучшить</button>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Результат</label>
|
||||
<div id="aiSuggestion" style="background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px;min-height:60px;max-height:250px;overflow:auto;white-space:pre-wrap;font-family:inherit"></div>
|
||||
</div>
|
||||
<div class="card-foot" style="justify-content:flex-end;gap:8px;margin-top:12px">
|
||||
<button type="button" class="btn-primary ghost" id="aiRejectBtn">Отклонить</button>
|
||||
<button type="button" class="btn-primary" id="aiAcceptBtn">Применить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="vendor/lucide.min.js"></script>
|
||||
<script src="js/datetime.js"></script>
|
||||
<script src="admin.js"></script>
|
||||
<script src="js/shorts.js"></script>
|
||||
<style>
|
||||
#shortsModal .settings-field label { font-weight:600 }
|
||||
.used-check { width:18px;height:18px;accent-color:var(--accent);cursor:pointer }
|
||||
.ai-btn { background:none;border:1px solid var(--border);border-radius:6px;padding:4px 10px;cursor:pointer;font-size:.8rem;color:var(--muted) }
|
||||
.ai-btn:hover { border-color:var(--accent);color:var(--accent) }
|
||||
.ai-btn:disabled { opacity:.5;cursor:not-allowed }
|
||||
.ai-status { font-size:.72rem;color:var(--muted);margin-top:4px }
|
||||
.row-btn { background:none;border:none;color:var(--muted);cursor:pointer;font-size:.95rem;padding:4px 8px;border-radius:6px;margin-left:4px }
|
||||
.row-btn:hover { background:var(--bg);color:var(--accent) }
|
||||
.row-btn.del:hover { color:#ef4444 }
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2516,6 +2516,246 @@ app.get('/api/chat/files/:token', fileLimiter, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Short messages (admin only) ---
|
||||
const SHORTS_PAGE_SIZE = 50;
|
||||
|
||||
async function ensureShortMessagesTable() {
|
||||
await pool.query(`CREATE TABLE IF NOT EXISTS short_messages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
resident VARCHAR(200) NOT NULL,
|
||||
lesson_date DATE NOT NULL,
|
||||
topic VARCHAR(500),
|
||||
message TEXT NOT NULL,
|
||||
used BOOLEAN NOT NULL DEFAULT false,
|
||||
used_at DATE,
|
||||
author_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
purge_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
)`);
|
||||
await pool.query(`ALTER TABLE short_messages ADD COLUMN IF NOT EXISTS used_at DATE`);
|
||||
await pool.query(`ALTER TABLE short_messages ADD COLUMN IF NOT EXISTS author_id INT REFERENCES users(id) ON DELETE SET NULL`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_short_messages_date ON short_messages(lesson_date DESC)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_short_messages_deleted ON short_messages(deleted_at)`);
|
||||
}
|
||||
|
||||
function invalidateShorts() {
|
||||
cache.dropPrefix('shorts:');
|
||||
cache.publish(EVENTS_CHANNEL, { type: 'shorts_changed' }).catch(() => {});
|
||||
}
|
||||
|
||||
async function parseShortMessagesFromLines(text) {
|
||||
const lines = String(text || '').split('\n');
|
||||
const messages = [];
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const match = trimmed.match(/^(.+?)\s*[-—]\s*(.+)$/);
|
||||
if (match) {
|
||||
messages.push({ resident: match[1].trim(), message: match[2].trim() });
|
||||
} else {
|
||||
messages.push({ resident: '', message: trimmed });
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
app.get('/api/shorts/:id', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
|
||||
const { rows } = await pool.query(
|
||||
`SELECT sm.*, u.name AS author_name, u.username AS author_username FROM short_messages sm
|
||||
LEFT JOIN users u ON u.id = sm.author_id WHERE sm.id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
||||
res.json(rows[0]);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/shorts', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || SHORTS_PAGE_SIZE, 1), 200);
|
||||
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
|
||||
const showDeleted = req.query.deleted === '1' || req.query.deleted === 'true';
|
||||
const search = req.query.search ? String(req.query.search).trim() : '';
|
||||
const params = [];
|
||||
let where = showDeleted ? 'WHERE sm.deleted_at IS NOT NULL' : 'WHERE sm.deleted_at IS NULL';
|
||||
if (search) {
|
||||
params.push(`%${search}%`);
|
||||
where += ` AND (sm.resident ILIKE $${params.length} OR sm.topic ILIKE $${params.length} OR sm.message ILIKE $${params.length})`;
|
||||
}
|
||||
params.push(limit, offset);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT sm.id, sm.resident, sm.lesson_date, sm.topic, sm.message, sm.used, sm.used_at, sm.author_id,
|
||||
u.name AS author_name, u.username AS author_username, sm.deleted_at, sm.created_at
|
||||
FROM short_messages sm LEFT JOIN users u ON u.id = sm.author_id
|
||||
${where} ORDER BY sm.lesson_date DESC, sm.id DESC LIMIT $${params.length - 1} OFFSET $${params.length}`,
|
||||
params
|
||||
);
|
||||
let total = 0;
|
||||
if (offset === 0) {
|
||||
const { rows: cnt } = await pool.query(
|
||||
`SELECT COUNT(*)::int AS n FROM short_messages sm ${where}`,
|
||||
params.slice(0, -2)
|
||||
);
|
||||
total = cnt[0]?.n || 0;
|
||||
}
|
||||
res.json({ items: rows, total });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/shorts', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const lessonDate = req.body?.lesson_date ? String(req.body.lesson_date).trim() : null;
|
||||
const topic = req.body?.topic ? String(req.body.topic).trim() : null;
|
||||
const linesText = req.body?.lines ? String(req.body.lines) : '';
|
||||
if (!lessonDate || !/^\d{4}-\d{2}-\d{2}$/.test(lessonDate)) {
|
||||
return res.status(400).json({ error: 'lesson_date обязателен в формате YYYY-MM-DD' });
|
||||
}
|
||||
const messages = await parseShortMessagesFromLines(linesText);
|
||||
if (!messages.length) return res.status(400).json({ error: 'Нет сообщений для добавления' });
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const created = [];
|
||||
for (const m of messages) {
|
||||
const resident = m.resident || req.body?.resident ? String(m.resident || req.body.resident).trim() : 'Не указан';
|
||||
const { rows } = await client.query(
|
||||
`INSERT INTO short_messages (resident, lesson_date, topic, message, author_id)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
[resident, lessonDate, topic, m.message, req.user.id || null]
|
||||
);
|
||||
created.push(rows[0]);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
await logAudit(req, 'shorts.create', { count: created.length, lesson_date: lessonDate, topic });
|
||||
invalidateShorts();
|
||||
res.status(201).json({ items: created });
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/shorts/:id', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
|
||||
const current = await pool.query('SELECT * FROM short_messages WHERE id = $1', [id]);
|
||||
if (!current.rows.length) return res.status(404).json({ error: 'Not found' });
|
||||
const target = current.rows[0];
|
||||
const resident = req.body?.resident !== undefined ? String(req.body.resident).trim() : target.resident;
|
||||
const lessonDate = req.body?.lesson_date !== undefined ? String(req.body.lesson_date).trim() : target.lesson_date;
|
||||
const topic = req.body?.topic !== undefined ? String(req.body.topic).trim() : target.topic;
|
||||
const message = req.body?.message !== undefined ? String(req.body.message).trim() : target.message;
|
||||
let used = req.body?.used !== undefined ? Boolean(req.body.used) : Boolean(target.used);
|
||||
let usedAt = req.body?.used_at !== undefined
|
||||
? (req.body.used_at ? String(req.body.used_at).slice(0, 10) : null)
|
||||
: (target.used_at ? String(target.used_at).slice(0, 10) : null);
|
||||
if (usedAt && !/^\d{4}-\d{2}-\d{2}$/.test(usedAt)) {
|
||||
return res.status(400).json({ error: 'used_at должен быть в формате YYYY-MM-DD' });
|
||||
}
|
||||
if (used && !usedAt) usedAt = new Date().toISOString().slice(0, 10);
|
||||
if (usedAt) used = true;
|
||||
if (!used) usedAt = null;
|
||||
if (lessonDate && !/^\d{4}-\d{2}-\d{2}$/.test(lessonDate)) {
|
||||
return res.status(400).json({ error: 'lesson_date должен быть в формате YYYY-MM-DD' });
|
||||
}
|
||||
if (!resident) return res.status(400).json({ error: 'Резидент обязателен' });
|
||||
if (!message) return res.status(400).json({ error: 'Сообщение обязательно' });
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE short_messages SET resident = $1, lesson_date = $2, topic = $3, message = $4, used = $5, used_at = $6, updated_at = now() WHERE id = $7 RETURNING *`,
|
||||
[resident, lessonDate, topic, message, used, usedAt, id]
|
||||
);
|
||||
await logAudit(req, 'shorts.update', { id, resident, lesson_date: lessonDate, topic, used, used_at: usedAt });
|
||||
invalidateShorts();
|
||||
res.json(rows[0]);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/shorts/:id', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
|
||||
const { rowCount } = await pool.query(
|
||||
`UPDATE short_messages SET deleted_at = now(), purge_at = now() + (COALESCE((SELECT value FROM settings WHERE key='short_messages_retention_days'), '365') || ' days')::interval WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[id]
|
||||
);
|
||||
if (!rowCount) return res.status(404).json({ error: 'Not found or already deleted' });
|
||||
await logAudit(req, 'shorts.delete', { id });
|
||||
invalidateShorts();
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/shorts/:id/restore', requireAuth, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
|
||||
const { rowCount } = await pool.query(
|
||||
'UPDATE short_messages SET deleted_at = NULL, purge_at = NULL WHERE id = $1 AND deleted_at IS NOT NULL',
|
||||
[id]
|
||||
);
|
||||
if (!rowCount) return res.status(404).json({ error: 'Not found or not deleted' });
|
||||
await logAudit(req, 'shorts.restore', { id });
|
||||
invalidateShorts();
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/shorts/:id/permanent', requireAuth, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
|
||||
const { rowCount } = await pool.query(
|
||||
'DELETE FROM short_messages WHERE id = $1 AND deleted_at IS NOT NULL',
|
||||
[id]
|
||||
);
|
||||
if (!rowCount) return res.status(404).json({ error: 'Not found or not deleted' });
|
||||
await logAudit(req, 'shorts.permanent-delete', { id });
|
||||
invalidateShorts();
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/shorts/ai-correct', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const text = reqStr(req.body?.text, 5000);
|
||||
if (!text) return res.status(400).json({ error: 'Текст не указан' });
|
||||
const profileId = req.body?.profile ? String(req.body.profile) : (req.body?.model ? String(req.body.model) : null);
|
||||
let profile = null;
|
||||
if (profileId && profileId !== 'native') {
|
||||
const profiles = await getAiProfiles();
|
||||
profile = profiles.find(p => p.id === profileId) || null;
|
||||
if (!profile) return res.status(400).json({ error: 'Профиль ИИ не найден' });
|
||||
}
|
||||
const corrected = await aiCorrectText(text, profile);
|
||||
res.json({ suggestion: corrected });
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: 'Сервис ИИ недоступен: ' + e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Users (admin only) ---
|
||||
app.get('/api/users', requireAuth, requireAdmin, async (_, res) => {
|
||||
const { rows } = await pool.query(
|
||||
@@ -3089,8 +3329,8 @@ function normalizeOpenAiBase(base) {
|
||||
return b;
|
||||
}
|
||||
|
||||
async function aiComplete(systemPrompt, userText, maxTokensOverride) {
|
||||
const profile = await getActiveAiProfile();
|
||||
async function aiComplete(systemPrompt, userText, maxTokensOverride, profileOverride) {
|
||||
const profile = profileOverride || await getActiveAiProfile();
|
||||
let url = `${AI_URL.replace(/\/+$/, '')}/v1/chat/completions`;
|
||||
let model = AI_MODEL;
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
@@ -3129,10 +3369,10 @@ async function aiComplete(systemPrompt, userText, maxTokensOverride) {
|
||||
}
|
||||
}
|
||||
|
||||
async function aiCorrectText(text) {
|
||||
async function aiCorrectText(text, profileOverride) {
|
||||
try {
|
||||
const systemPrompt = await getAiPrompt();
|
||||
const out = await aiComplete(systemPrompt, text);
|
||||
const out = await aiComplete(systemPrompt, text, undefined, profileOverride);
|
||||
return out || text;
|
||||
} catch (e) {
|
||||
console.error('AI correct error:', e);
|
||||
@@ -8397,6 +8637,7 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
||||
try { await ensureLessonReportsTable(); } catch (err) { console.error('Lesson reports table:', err); }
|
||||
try { await ensureNotificationsTable(); } catch (err) { console.error('Notifications table:', err); }
|
||||
try { await ensureChatTables(); } catch (err) { console.error('Chat tables:', err); }
|
||||
try { await ensureShortMessagesTable(); } catch (err) { console.error('Short messages table:', err); }
|
||||
try { await purgeOldNotifications(); } catch (err) { console.error('Notifications purge:', err); }
|
||||
setInterval(() => { purgeOldNotifications().catch(err => console.error('Notifications purge:', err)); }, 60 * 60 * 1000).unref();
|
||||
try { await purgeOldChat(); } catch (err) { console.error('Chat purge:', err); }
|
||||
|
||||
Reference in New Issue
Block a user