Таблица short_messages (резидент, дата занятия, тема, текст, отметка об использовании), CRUD API /api/shorts с мягким удалением и плановой очисткой через purge_at, ИИ-коррекция текста по профилю, страница shorts.html с массовым добавлением разбором строк «ФИО - текст».
325 lines
14 KiB
JavaScript
325 lines
14 KiB
JavaScript
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();
|