Add AI auto-check worker, share link message/link fields, and update journal/settings UI
This commit is contained in:
@@ -141,6 +141,7 @@ curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `server.js` | Entire backend (Express, routes, DB, uploads, backup) |
|
||||
| `worker.js` | Background AI auto-check worker for entry messages |
|
||||
| `db/init.sql` | Initial schema (runs on fresh DB) |
|
||||
| `db/migration.sql` | Idempotent migrations for existing DBs |
|
||||
| `docker-compose.yml` | Service definitions (app, db, tailscale) |
|
||||
|
||||
+30
@@ -29,11 +29,24 @@ CREATE TABLE IF NOT EXISTS entries (
|
||||
student_name VARCHAR(150) NOT NULL,
|
||||
group_id INT NOT NULL REFERENCES groups(id),
|
||||
description TEXT NOT NULL,
|
||||
description_original TEXT,
|
||||
description_ai TEXT,
|
||||
ai_status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
ai_checked_at TIMESTAMPTZ,
|
||||
ai_error TEXT,
|
||||
photo_path VARCHAR(255),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_original TEXT;
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_ai TEXT;
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'pending';
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ;
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_error TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_ai_pending ON entries(id) WHERE ai_status = 'pending' AND deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
@@ -41,6 +54,10 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('spam_interval_min', '30')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('ai_prompt', 'Ты — редактор текстов для педагогического журнала. Исправь грамматические, орфографические и пунктуационные ошибки. Сохрани смысл и стиль. Верни ТОЛЬКО исправленный текст, без пояснений.')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('ai_autocheck_enabled', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS share_links (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -53,6 +70,8 @@ CREATE TABLE IF NOT EXISTS share_links (
|
||||
anonymize_names BOOLEAN DEFAULT false,
|
||||
expires_at TIMESTAMPTZ,
|
||||
access_password_hash VARCHAR(255),
|
||||
message TEXT,
|
||||
link_url VARCHAR(500),
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
@@ -86,6 +105,17 @@ CREATE TABLE IF NOT EXISTS project_files (
|
||||
detached_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entry_photos (
|
||||
id SERIAL PRIMARY KEY,
|
||||
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
photo_path VARCHAR(255) NOT NULL,
|
||||
caption TEXT,
|
||||
sort_order INT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_entry_photos_entry_id ON entry_photos(entry_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(100) NOT NULL UNIQUE,
|
||||
|
||||
@@ -13,6 +13,8 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('spam_interval_min', '30')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('ai_prompt', 'Ты — редактор текстов для педагогического журнала. Исправь грамматические, орфографические и пунктуационные ошибки. Сохрани смысл и стиль. Верни ТОЛЬКО исправленный текст, без пояснений.')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS share_links (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -25,6 +27,9 @@ CREATE TABLE IF NOT EXISTS share_links (
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS message TEXT;
|
||||
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS link_url VARCHAR(500);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
INSERT INTO students (name)
|
||||
@@ -48,7 +53,37 @@ CREATE UNIQUE INDEX IF NOT EXISTS project_files_token_key ON project_files(token
|
||||
ALTER TABLE project_files ALTER COLUMN entry_id DROP NOT NULL;
|
||||
ALTER TABLE project_files ADD COLUMN IF NOT EXISTS detached_at TIMESTAMPTZ;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entry_photos (
|
||||
id SERIAL PRIMARY KEY,
|
||||
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
photo_path VARCHAR(255) NOT NULL,
|
||||
caption TEXT,
|
||||
sort_order INT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_entry_photos_entry_id ON entry_photos(entry_id);
|
||||
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_original TEXT;
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_ai TEXT;
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'pending';
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ;
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_error TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_ai_pending ON entries(id) WHERE ai_status = 'pending' AND deleted_at IS NULL;
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('ai_autocheck_enabled', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM settings WHERE key = 'ai_autocheck_migrated') THEN
|
||||
UPDATE entries SET description_original = description WHERE description_original IS NULL;
|
||||
UPDATE entries SET ai_status = 'skipped' WHERE ai_status = 'pending';
|
||||
INSERT INTO settings (key, value) VALUES ('ai_autocheck_migrated', '1');
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS branches (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -105,3 +140,10 @@ CREATE TABLE IF NOT EXISTS group_photos (
|
||||
ALTER TABLE group_photos ADD COLUMN IF NOT EXISTS sort_order INT DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_group_photos_group_id ON group_photos(group_id);
|
||||
|
||||
UPDATE entries e SET photo_path = p.photo_path
|
||||
FROM (
|
||||
SELECT DISTINCT ON (entry_id) entry_id, photo_path
|
||||
FROM entry_photos ORDER BY entry_id, sort_order, id
|
||||
) p
|
||||
WHERE e.photo_path IS NULL AND p.entry_id = e.id;
|
||||
|
||||
@@ -15,3 +15,7 @@ ALTER TABLE share_links ADD COLUMN IF NOT EXISTS access_password_hash VARCHAR(25
|
||||
|
||||
-- 5. Add index for cleanup of expired links
|
||||
CREATE INDEX IF NOT EXISTS idx_share_links_expires_at ON share_links(expires_at);
|
||||
|
||||
-- 6. Add public message and external link shown on the share page
|
||||
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS message TEXT;
|
||||
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS link_url VARCHAR(500);
|
||||
@@ -232,6 +232,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
||||
.page-head h2{font-size:1.3rem;font-weight:600;margin:0}
|
||||
.page-sub{color:var(--muted);font-size:.85rem}
|
||||
.settings-stack{display:flex;flex-direction:column;gap:20px;max-width:680px}
|
||||
.link-fields{display:grid;grid-template-columns:1fr 1fr;gap:12px;align-items:start}
|
||||
.link-fields > .full{grid-column:1/-1}
|
||||
@media(max-width:600px){.link-fields{grid-template-columns:1fr}}
|
||||
.settings-stack-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:20px;max-width:1200px}
|
||||
.settings-stack-grid > .settings-card{max-width:none;height:100%}
|
||||
.settings-stack-grid > .settings-card.wide{grid-column:1/-1}
|
||||
@@ -352,3 +355,29 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
||||
.ai-btn:hover{color:var(--accent);background:rgba(37,99,235,.1)}
|
||||
.ai-btn:disabled{opacity:.5;cursor:not-allowed}
|
||||
#aiSuggestion{font-size:.9rem;line-height:1.5}
|
||||
|
||||
/* --- AI auto-check --- */
|
||||
.ai-badge{display:inline-block;font-size:.68rem;font-weight:600;padding:2px 7px;border-radius:999px;margin-left:6px;vertical-align:middle;background:rgba(37,99,235,.1);color:var(--accent);white-space:nowrap}
|
||||
.ai-badge-done{background:rgba(22,163,74,.12);color:#16a34a}
|
||||
.ai-badge-error{background:rgba(239,68,68,.12);color:#ef4444}
|
||||
.ai-badge-skipped,.ai-badge-reverted{background:rgba(148,163,184,.18);color:var(--muted)}
|
||||
.ai-status{margin-top:6px;font-size:.78rem;color:var(--muted)}
|
||||
.ai-queue{margin-top:8px;font-size:.78rem;color:var(--muted)}
|
||||
|
||||
/* --- AI worker monitor --- */
|
||||
.worker-dot{width:9px;height:9px;border-radius:50%;display:inline-block;flex-shrink:0;background:#94a3b8}
|
||||
.worker-dot.on{background:#16a34a;animation:wpulse 1.6s infinite}
|
||||
.worker-dot.busy{background:#f59e0b;animation:wpulse 1.2s infinite}
|
||||
.worker-dot.err{background:#ef4444}
|
||||
@keyframes wpulse{0%{box-shadow:0 0 0 0 rgba(22,163,74,.45)}70%{box-shadow:0 0 0 7px rgba(22,163,74,0)}100%{box-shadow:0 0 0 0 rgba(22,163,74,0)}}
|
||||
.worker-state{display:inline-flex;align-items:center;gap:8px;font-size:.85rem;font-weight:600}
|
||||
.worker-counts{display:flex;flex-wrap:wrap;gap:10px;margin-top:16px}
|
||||
.worker-counts .chip{background:var(--card);border:1px solid var(--border);border-radius:999px;padding:7px 14px;font-size:.8rem;color:var(--text);display:inline-flex;align-items:center;gap:6px}
|
||||
.worker-counts .chip b{font-weight:700}
|
||||
.worker-counts .chip.pending b{color:#f59e0b}
|
||||
.worker-counts .chip.done b{color:#16a34a}
|
||||
.worker-counts .chip.error b{color:#ef4444}
|
||||
.worker-counts .chip.skipped b{color:var(--muted)}
|
||||
.worker-counts .chip.reverted b{color:var(--accent)}
|
||||
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ function buildSidebar(active) {
|
||||
{ page: 'branches', label: 'Филиалы' },
|
||||
{ page: 'users', label: 'Пользователи' },
|
||||
{ page: 'audit', label: 'Аудит' },
|
||||
{ page: 'worker', label: 'Воркер ИИ' },
|
||||
{ page: 'settings', label: 'Настройки' },
|
||||
];
|
||||
const nav = [...base, ...(isAdmin() ? adminNav : [])];
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
<a class="action-btn" href="groups.html"><span class="aic">👥</span>Группы</a>
|
||||
<a class="action-btn" href="links.html"><span class="aic">🔗</span>Ссылки</a>
|
||||
<a class="action-btn" href="trash.html"><span class="aic">🗑️</span>Корзина</a>
|
||||
<a class="action-btn" href="worker.html"><span class="aic">🤖</span>Воркер ИИ</a>
|
||||
<a class="action-btn" href="settings.html"><span class="aic">🛡️</span>Бэкап</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+39
-35
@@ -58,6 +58,14 @@
|
||||
<button type="button" id="aiCorrectBtn" class="ai-btn" title="Исправить ошибки ИИ">✨</button>
|
||||
</label>
|
||||
<textarea id="editDesc" class="settings-input" required></textarea>
|
||||
<div id="editAiStatus" class="ai-status" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label>Фотография</label>
|
||||
<input type="file" id="editPhoto" accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.avif,.ico,.heic,.heif,.jfif">
|
||||
<div class="files-preview" id="editPhotoPreview"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
@@ -88,50 +96,46 @@
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="linkModal">
|
||||
<div class="edit-modal" style="max-width:480px">
|
||||
<div class="edit-modal" style="max-width:640px">
|
||||
<div id="linkCreateView">
|
||||
<h3>Создать ссылку</h3>
|
||||
<div style="font-size:.85rem;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:10px 12px;line-height:1.5" id="linkPreview"></div>
|
||||
<div class="settings-stack" style="gap:12px">
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label>Название ссылки <span style="color:#ef4444">*</span></label>
|
||||
<input type="text" id="linkNameInput" class="settings-input" required placeholder="Например: Апрельский журнал">
|
||||
</div>
|
||||
<div class="link-fields">
|
||||
<div class="settings-field">
|
||||
<label>Название ссылки <span style="color:#ef4444">*</span></label>
|
||||
<input type="text" id="linkNameInput" class="settings-input" required placeholder="Например: Апрельский журнал">
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label>Группа (если выбрана)</label>
|
||||
<input type="text" id="linkGroupPreview" class="settings-input" readonly style="background:var(--bg);color:var(--muted);cursor:not-allowed">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Группа (если выбрана)</label>
|
||||
<input type="text" id="linkGroupPreview" class="settings-input" readonly style="background:var(--bg);color:var(--muted);cursor:not-allowed">
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label>Ученик (необязательно)</label>
|
||||
<input type="text" id="linkStudent" class="settings-input" list="linkStudentList" placeholder="Все / введите имя..."><datalist id="linkStudentList"></datalist>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Ученик (необязательно)</label>
|
||||
<input type="text" id="linkStudent" class="settings-input" list="linkStudentList" placeholder="Все / введите имя..."><datalist id="linkStudentList"></datalist>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label>Истекает через (дни)</label>
|
||||
<input type="number" id="linkExpiresDays" class="settings-input" min="1" max="365" value="7" placeholder="7">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Ссылка</label>
|
||||
<input type="url" id="linkUrl" class="settings-input" placeholder="https://...">
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="linkAnonymizeNames">
|
||||
Показывать только инициалы учеников (анонимизировать)
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-field full">
|
||||
<label>Сообщение</label>
|
||||
<textarea id="linkMessage" class="settings-input" rows="3" placeholder="Текст, который увидят на публичной странице"></textarea>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label>Пароль для доступа (необязательно)</label>
|
||||
<input type="password" id="linkPassword" class="settings-input" placeholder="Оставьте пустым для открытого доступа">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Истекает через (дни)</label>
|
||||
<input type="number" id="linkExpiresDays" class="settings-input" min="1" max="365" value="7" placeholder="7">
|
||||
</div>
|
||||
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||
<div class="settings-field">
|
||||
<label>Пароль для доступа (необязательно)</label>
|
||||
<input type="password" id="linkPassword" class="settings-input" placeholder="Оставьте пустым для открытого доступа">
|
||||
</div>
|
||||
<div class="settings-field full">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="linkAnonymizeNames">
|
||||
Показывать только инициалы учеников (анонимизировать)
|
||||
</label>
|
||||
</div>
|
||||
<div class="card-foot full" style="justify-content:flex-end;gap:8px">
|
||||
<button type="button" class="btn-primary ghost" id="linkCreateCloseBtn">Отмена</button>
|
||||
<button type="button" class="btn-primary" id="linkCreateSaveBtn">Создать</button>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,14 @@ const ACTION_LABELS = {
|
||||
'link.delete': 'Удалена ссылка',
|
||||
'backup.download': 'Скачан бэкап',
|
||||
'backup.restore': 'Выполнено восстановление',
|
||||
'entry.ai.auto-check': 'ИИ-автопроверка записи',
|
||||
'entry.ai.retry': 'Повтор ИИ-проверки записи',
|
||||
'entry.ai.recheck': 'Запись отправлена на ИИ-проверку',
|
||||
'entry.ai.revert': 'Возвращён оригинал текста записи',
|
||||
'entry.ai.error': 'Ошибка ИИ-проверки записи',
|
||||
'ai.wake': 'Воркер ИИ разбужен вручную',
|
||||
'ai.enabled': 'Переключена автопроверка ИИ',
|
||||
'ai.requeue-failed': 'Ошибочные записи возвращены в очередь ИИ',
|
||||
};
|
||||
|
||||
const AUDIT_PAGE_SIZE = 50;
|
||||
|
||||
+187
-11
@@ -9,11 +9,18 @@ let totalEntries = 0;
|
||||
let searchTimer = null;
|
||||
let view = localStorage.getItem('journalView') === 'cards' ? 'cards' : 'list';
|
||||
let aiCorrecting = false;
|
||||
let editDeletePhoto = false;
|
||||
|
||||
function isImageFile(name) {
|
||||
return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
|
||||
}
|
||||
|
||||
function mainPhotoPath(e) {
|
||||
if (e.photo_path) return e.photo_path;
|
||||
if (e.photos && e.photos.length) return e.photos[0].photo_path;
|
||||
return null;
|
||||
}
|
||||
|
||||
function filesHTML(files) {
|
||||
if (!files || !files.length) return '';
|
||||
return `<div class="entry-files">${files.map(f => {
|
||||
@@ -24,6 +31,21 @@ function filesHTML(files) {
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
|
||||
const AI_STATUS_LABELS = {
|
||||
pending: 'ИИ: в очереди',
|
||||
processing: 'ИИ: проверяет…',
|
||||
done: 'ИИ: проверено',
|
||||
skipped: 'ИИ: пропущено',
|
||||
error: 'ИИ: ошибка',
|
||||
reverted: 'ИИ: возвращён оригинал',
|
||||
};
|
||||
|
||||
function aiBadge(e) {
|
||||
if (!e || !e.ai_status) return '';
|
||||
const label = AI_STATUS_LABELS[e.ai_status] || `ИИ: ${e.ai_status}`;
|
||||
return `<span class="ai-badge ai-badge-${esc(e.ai_status)}" title="${esc(label)}">${esc(label)}</span>`;
|
||||
}
|
||||
|
||||
function findFile(id) {
|
||||
for (const e of currentEntries) {
|
||||
const f = (e.files || []).find(x => x.id === id);
|
||||
@@ -47,11 +69,12 @@ async function detachFile(id) {
|
||||
}
|
||||
|
||||
function gridEntryHTML(e) {
|
||||
const mp = mainPhotoPath(e);
|
||||
return `
|
||||
<div class="card">
|
||||
${e.photo_path ? `<img src="${API}${e.photo_path}" data-img="${API}${e.photo_path}" alt="">` : ''}
|
||||
${mp ? `<img src="${API}${mp}" data-img="${API}${mp}" alt="">` : ''}
|
||||
<div class="info">
|
||||
<div class="name">${esc(e.student_name)}</div>
|
||||
<div class="name">${esc(e.student_name)}</div>${aiBadge(e)}
|
||||
<div class="group">${esc(e.group_name)}</div>
|
||||
<div class="desc">${esc(e.description)}</div>
|
||||
${filesHTML(e.files)}
|
||||
@@ -64,13 +87,14 @@ function gridEntryHTML(e) {
|
||||
}
|
||||
|
||||
function cardEntryHTML(e) {
|
||||
const mp = mainPhotoPath(e);
|
||||
return `
|
||||
<div class="journal-card">
|
||||
${e.photo_path
|
||||
? `<img src="${API}${e.photo_path}" data-img="${API}${e.photo_path}" alt="">`
|
||||
${mp
|
||||
? `<img src="${API}${mp}" data-img="${API}${mp}" alt="">`
|
||||
: `<div class="no-photo"><svg viewBox="0 0 240 160" preserveAspectRatio="xMidYMid meet" aria-hidden="true"><circle cx="105" cy="48" r="15"/><path d="M74 140 C74 106 88 92 105 92 C122 92 136 106 136 140 Z"/><circle cx="145" cy="58" r="18"/><path d="M108 144 C108 104 124 88 145 88 C166 88 182 104 182 144 Z"/></svg></div>`}
|
||||
<div class="body">
|
||||
<div class="name">${esc(e.student_name)}</div>
|
||||
<div class="name">${esc(e.student_name)}</div>${aiBadge(e)}
|
||||
<div class="group">${esc(e.group_name)}</div>
|
||||
<div class="desc">${esc(e.description)}</div>
|
||||
${filesHTML(e.files)}
|
||||
@@ -171,9 +195,11 @@ async function openEdit(id) {
|
||||
const e = currentEntries.find(x => x.id === id);
|
||||
if (!e) return;
|
||||
editId = id;
|
||||
editDeletePhoto = false;
|
||||
fillAutocomplete();
|
||||
document.getElementById('editName').value = e.student_name;
|
||||
document.getElementById('editDesc').value = e.description;
|
||||
renderEditAiStatus(e);
|
||||
const sel = document.getElementById('editGroup');
|
||||
if (!editGroups.length) {
|
||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||
@@ -181,14 +207,85 @@ async function openEdit(id) {
|
||||
sel.innerHTML = editGroups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
|
||||
}
|
||||
sel.value = e.group_id;
|
||||
renderEditPhotos(e.photos || [], e.photo_path);
|
||||
document.getElementById('editModal').classList.add('open');
|
||||
}
|
||||
|
||||
function renderEditPhotos(photos, mainPath) {
|
||||
const preview = document.getElementById('editPhotoPreview');
|
||||
preview.innerHTML = '';
|
||||
if (!photos.length) return;
|
||||
photos.forEach((p) => {
|
||||
const isMain = p.photo_path === mainPath;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'file-preview-item';
|
||||
div.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:.8rem';
|
||||
div.dataset.photoId = p.id;
|
||||
const applyStyle = isMain
|
||||
? 'background:#16a34a;border:none;color:#fff;border-radius:6px;padding:4px 10px;font-size:.75rem;cursor:default;white-space:nowrap'
|
||||
: 'background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:4px 10px;font-size:.75rem;cursor:pointer;white-space:nowrap';
|
||||
div.innerHTML = `<img src="${API}${esc(p.photo_path)}" style="width:48px;height:48px;object-fit:cover;border-radius:4px"> <span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(p.photo_path.split('/').pop())}</span> <button type="button" class="apply-photo-btn" style="${applyStyle}" title="${isMain ? 'Главное фото записи' : 'Сделать главным фото записи'}" ${isMain ? 'disabled' : ''}>${isMain ? 'Применено ✓' : 'Применить'}</button> <button type="button" class="delete-entry-photo" style="background:none;border:none;color:#ef4444;cursor:pointer;font-size:1.1rem;line-height:1" title="Удалить фото">✕</button>`;
|
||||
preview.appendChild(div);
|
||||
const thumb = div.querySelector('img');
|
||||
const showMissing = () => {
|
||||
const ph = document.createElement('span');
|
||||
ph.textContent = 'нет файла';
|
||||
ph.style.cssText = 'width:48px;height:48px;display:flex;align-items:center;justify-content:center;background:var(--bg);border:1px dashed var(--border);border-radius:4px;font-size:.6rem;color:var(--muted);text-align:center;line-height:1.1;flex:0 0 auto';
|
||||
thumb.replaceWith(ph);
|
||||
};
|
||||
thumb.addEventListener('error', showMissing);
|
||||
if (thumb.complete && thumb.naturalWidth === 0) showMissing();
|
||||
div.querySelector('.delete-entry-photo').addEventListener('click', async () => {
|
||||
if (!confirm('Удалить это фото?')) return;
|
||||
const res = await fetch(`${API}/api/entries/${editId}/photos/${p.id}`, { method: 'DELETE', headers: hdr() });
|
||||
if (res.ok) {
|
||||
div.remove();
|
||||
showToast('Фото удалено');
|
||||
loadEntries();
|
||||
} else {
|
||||
alert('Ошибка удаления');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function applyMainPhoto(btn) {
|
||||
const div = btn.closest('.file-preview-item');
|
||||
const photoId = div?.dataset.photoId;
|
||||
if (!photoId) return;
|
||||
btn.disabled = true;
|
||||
const res = await fetch(`${API}/api/entries/${editId}/photos/${photoId}/main`, { method: 'PUT', headers: hdr() });
|
||||
if (!res.ok) {
|
||||
btn.disabled = false;
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Ошибка');
|
||||
return;
|
||||
}
|
||||
document.querySelectorAll('#editPhotoPreview .apply-photo-btn').forEach(b => {
|
||||
b.disabled = false;
|
||||
b.textContent = 'Применить';
|
||||
b.title = 'Сделать главным фото записи';
|
||||
b.style.background = 'var(--card)';
|
||||
b.style.color = 'var(--text)';
|
||||
});
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Применено ✓';
|
||||
btn.title = 'Главное фото записи';
|
||||
btn.style.background = '#16a34a';
|
||||
btn.style.color = '#fff';
|
||||
showToast('Главное фото обновлено');
|
||||
loadEntries();
|
||||
}
|
||||
|
||||
function closeEdit() {
|
||||
document.getElementById('editModal').classList.remove('open');
|
||||
document.getElementById('editFiles').value = '';
|
||||
document.getElementById('editFilesPreview').innerHTML = '';
|
||||
document.getElementById('editPhoto').value = '';
|
||||
document.getElementById('editPhotoPreview').innerHTML = '';
|
||||
document.getElementById('editAiStatus').style.display = 'none';
|
||||
editId = null;
|
||||
editDeletePhoto = false;
|
||||
}
|
||||
|
||||
async function aiCorrect() {
|
||||
@@ -238,27 +335,70 @@ function acceptAiCorrect() {
|
||||
closeAiCorrect();
|
||||
}
|
||||
|
||||
function renderEditAiStatus(e) {
|
||||
const el = document.getElementById('editAiStatus');
|
||||
if (!e || !e.ai_status) { el.style.display = 'none'; el.innerHTML = ''; return; }
|
||||
el.style.display = 'block';
|
||||
const changed = e.description_ai && e.description_original && e.description_ai !== e.description_original;
|
||||
const parts = [AI_STATUS_LABELS[e.ai_status] || `ИИ: ${e.ai_status}`];
|
||||
if (e.ai_checked_at) parts.push(new Date(e.ai_checked_at).toLocaleString('ru'));
|
||||
let html = `<span>${esc(parts.join(' · '))}</span>`;
|
||||
if (changed) html += ` <button type="button" id="aiRevertBtn" class="ai-btn" style="font-size:.75rem" title="Вернуть исходный текст (до ИИ)">↩ Оригинал</button>`;
|
||||
html += ` <button type="button" id="aiRecheckBtn" class="ai-btn" style="font-size:.75rem" title="Проверить заново">⟳ Проверить</button>`;
|
||||
el.innerHTML = html;
|
||||
const rb = document.getElementById('aiRevertBtn');
|
||||
if (rb) rb.addEventListener('click', () => revertAi(e.id));
|
||||
const cb = document.getElementById('aiRecheckBtn');
|
||||
if (cb) cb.addEventListener('click', () => recheckAi(e.id));
|
||||
}
|
||||
|
||||
async function revertAi(id) {
|
||||
if (!confirm('Вернуть исходный текст записи (до ИИ-проверки)?')) return;
|
||||
const res = await fetch(`${API}/api/entries/${id}/ai/revert`, { method: 'POST', headers: hdr() });
|
||||
if (!res.ok) { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); return; }
|
||||
showToast('Оригинал восстановлен');
|
||||
closeEdit();
|
||||
loadEntries();
|
||||
}
|
||||
|
||||
async function recheckAi(id) {
|
||||
const res = await fetch(`${API}/api/entries/${id}/ai/recheck`, { method: 'POST', headers: hdr() });
|
||||
if (!res.ok) { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); return; }
|
||||
showToast('Отправлено на ИИ-проверку');
|
||||
closeEdit();
|
||||
loadEntries();
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editId) return;
|
||||
const name = document.getElementById('editName').value.trim();
|
||||
const group_id = document.getElementById('editGroup').value;
|
||||
const description = document.getElementById('editDesc').value.trim();
|
||||
if (!name || !group_id || !description) { alert('Заполните все поля'); return; }
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('student_name', name);
|
||||
formData.append('group_id', group_id);
|
||||
formData.append('description', description);
|
||||
|
||||
const photoFiles = document.getElementById('editPhoto').files;
|
||||
for (const f of photoFiles) formData.append('photo', f);
|
||||
|
||||
const res = await fetch(`${API}/api/entries/${editId}`, {
|
||||
method: 'PUT',
|
||||
headers: hdrJson(),
|
||||
body: JSON.stringify({ student_name: name, group_id, description })
|
||||
headers: hdr(),
|
||||
body: formData
|
||||
});
|
||||
if (!res.ok) { const err = await res.json(); alert(err.error); return; }
|
||||
|
||||
const files = document.getElementById('editFiles').files;
|
||||
if (files.length) {
|
||||
const formData = new FormData();
|
||||
for (const f of files) formData.append('files', f);
|
||||
const filesFormData = new FormData();
|
||||
for (const f of files) filesFormData.append('files', f);
|
||||
const uploadRes = await fetch(`${API}/api/entries/${editId}/files`, {
|
||||
method: 'POST',
|
||||
headers: hdr(),
|
||||
body: formData
|
||||
body: filesFormData
|
||||
});
|
||||
if (!uploadRes.ok) {
|
||||
const err = await uploadRes.json().catch(() => ({}));
|
||||
@@ -303,6 +443,18 @@ document.getElementById('editFiles').addEventListener('change', (e) => {
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('editPhoto').addEventListener('change', (e) => {
|
||||
const preview = document.getElementById('editPhotoPreview');
|
||||
Array.from(e.target.files).forEach(f => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'file-preview-item';
|
||||
div.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:.8rem';
|
||||
const url = URL.createObjectURL(f);
|
||||
div.innerHTML = `<img src="${url}" style="width:48px;height:48px;object-fit:cover;border-radius:4px"> <span>${esc(f.name)}</span> <span style="color:var(--muted)">${(f.size/1024).toFixed(1)} KB</span> <button type="button" data-remove-new-photo="${f.name}" style="background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;line-height:1">✕</button>`;
|
||||
preview.appendChild(div);
|
||||
});
|
||||
});
|
||||
|
||||
function removeEditFile(index) {
|
||||
const input = document.getElementById('editFiles');
|
||||
const dt = new DataTransfer();
|
||||
@@ -381,6 +533,8 @@ async function openLinkCreate() {
|
||||
document.getElementById('linkExpiresDays').value = '7';
|
||||
document.getElementById('linkAnonymizeNames').checked = false;
|
||||
document.getElementById('linkPassword').value = '';
|
||||
document.getElementById('linkMessage').value = '';
|
||||
document.getElementById('linkUrl').value = '';
|
||||
document.getElementById('linkCreateView').style.display = '';
|
||||
document.getElementById('linkResultView').style.display = 'none';
|
||||
document.getElementById('linkModal').classList.add('open');
|
||||
@@ -399,13 +553,15 @@ async function createLink() {
|
||||
const expires_days = parseInt(document.getElementById('linkExpiresDays').value, 10);
|
||||
const anonymize_names = document.getElementById('linkAnonymizeNames').checked;
|
||||
const access_password = document.getElementById('linkPassword').value;
|
||||
const message = document.getElementById('linkMessage').value.trim();
|
||||
const link_url = document.getElementById('linkUrl').value.trim();
|
||||
if (!name) { alert('Введите название'); return; }
|
||||
if (expires_days < 1 || expires_days > 365) { alert('Дней истечения: 1-365'); return; }
|
||||
const expires_at = new Date(Date.now() + expires_days * 24 * 60 * 60 * 1000).toISOString();
|
||||
const res = await fetch(`${API}/api/links`, {
|
||||
method: 'POST',
|
||||
headers: hdrJson(),
|
||||
body: JSON.stringify({ name, group_id, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null, anonymize_names, expires_at, access_password: access_password || null })
|
||||
body: JSON.stringify({ name, group_id, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null, anonymize_names, expires_at, access_password: access_password || null, message: message || null, link_url: link_url || null })
|
||||
});
|
||||
if (!res.ok) { const err = await res.json(); alert(err.error || 'Ошибка'); return; }
|
||||
const link = await res.json();
|
||||
@@ -436,6 +592,26 @@ document.getElementById('editFilesPreview').addEventListener('click', e => {
|
||||
if (btn) removeEditFile(parseInt(btn.dataset.removeFile, 10));
|
||||
});
|
||||
|
||||
document.getElementById('editPhotoPreview').addEventListener('click', e => {
|
||||
const removeBtn = e.target.closest('[data-remove-photo]');
|
||||
if (removeBtn) {
|
||||
document.getElementById('editPhoto').value = '';
|
||||
document.getElementById('editPhotoPreview').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const applyBtn = e.target.closest('.apply-photo-btn');
|
||||
if (applyBtn) { applyMainPhoto(applyBtn); return; }
|
||||
const removeNewBtn = e.target.closest('[data-remove-new-photo]');
|
||||
if (removeNewBtn) {
|
||||
const fileName = removeNewBtn.dataset.removeNewPhoto;
|
||||
const input = document.getElementById('editPhoto');
|
||||
const dt = new DataTransfer();
|
||||
Array.from(input.files).forEach(f => { if (f.name !== fileName) dt.items.add(f); });
|
||||
input.files = dt.files;
|
||||
removeNewBtn.closest('.file-preview-item').remove();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('aiCorrectBtn').addEventListener('click', aiCorrect);
|
||||
document.getElementById('aiAcceptBtn').addEventListener('click', acceptAiCorrect);
|
||||
document.getElementById('aiRejectBtn').addEventListener('click', closeAiCorrect);
|
||||
|
||||
+7
-1
@@ -45,6 +45,8 @@ async function openLinkModal() {
|
||||
document.getElementById('linkExpiresDays').value = '7';
|
||||
document.getElementById('linkAnonymizeNames').checked = false;
|
||||
document.getElementById('linkPassword').value = '';
|
||||
document.getElementById('linkMessage').value = '';
|
||||
document.getElementById('linkUrl').value = '';
|
||||
if (!groups.length) {
|
||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||
groups = await res.json();
|
||||
@@ -72,6 +74,8 @@ async function openLinkEdit(id) {
|
||||
document.getElementById('linkExpiresDays').value = l.expires_at ? Math.ceil((new Date(l.expires_at) - new Date()) / (1000 * 60 * 60 * 24)) : '7';
|
||||
document.getElementById('linkAnonymizeNames').checked = l.anonymize_names || false;
|
||||
document.getElementById('linkPassword').value = '';
|
||||
document.getElementById('linkMessage').value = l.message || '';
|
||||
document.getElementById('linkUrl').value = l.link_url || '';
|
||||
if (!groups.length) {
|
||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||
groups = await res.json();
|
||||
@@ -104,10 +108,12 @@ async function saveLink() {
|
||||
const expires_days = parseInt(document.getElementById('linkExpiresDays').value, 10);
|
||||
const anonymize_names = document.getElementById('linkAnonymizeNames').checked;
|
||||
const access_password = document.getElementById('linkPassword').value;
|
||||
const message = document.getElementById('linkMessage').value.trim();
|
||||
const link_url = document.getElementById('linkUrl').value.trim();
|
||||
if (!name) { alert('Введите название'); return; }
|
||||
if (expires_days < 1 || expires_days > 365) { alert('Дней истечения: 1-365'); return; }
|
||||
const expires_at = new Date(Date.now() + expires_days * 24 * 60 * 60 * 1000).toISOString();
|
||||
const body = JSON.stringify({ name, group_id: group_id || null, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null, anonymize_names, expires_at, access_password: access_password || null });
|
||||
const body = JSON.stringify({ name, group_id: group_id || null, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null, anonymize_names, expires_at, access_password: access_password || null, message: message || null, link_url: link_url || null });
|
||||
const res = await fetch(editingId ? `${API}/api/links/${editingId}` : `${API}/api/links`, {
|
||||
method: editingId ? 'PUT' : 'POST',
|
||||
headers: hdrJson(),
|
||||
|
||||
+25
-3
@@ -1,4 +1,8 @@
|
||||
const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight'];
|
||||
const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck'];
|
||||
|
||||
function fieldValue(el) {
|
||||
return el.type === 'checkbox' ? String(el.checked) : el.value;
|
||||
}
|
||||
|
||||
function setBtnLoading(btn, on, spinnerClass) {
|
||||
if (on) {
|
||||
@@ -14,7 +18,7 @@ function setBtnLoading(btn, on, spinnerClass) {
|
||||
function updateDirty() {
|
||||
const dirty = DIRTY_FIELDS.some(id => {
|
||||
const el = document.getElementById(id);
|
||||
return String(el.dataset.original ?? '') !== el.value;
|
||||
return String(el.dataset.original ?? '') !== fieldValue(el);
|
||||
});
|
||||
document.getElementById('saveBar').classList.toggle('show', dirty);
|
||||
}
|
||||
@@ -27,7 +31,7 @@ function bindDirty() {
|
||||
|
||||
function snapshotDirty() {
|
||||
DIRTY_FIELDS.forEach(id => {
|
||||
document.getElementById(id).dataset.original = document.getElementById(id).value;
|
||||
document.getElementById(id).dataset.original = fieldValue(document.getElementById(id));
|
||||
});
|
||||
updateDirty();
|
||||
}
|
||||
@@ -41,7 +45,10 @@ async function loadSettings() {
|
||||
if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min;
|
||||
document.getElementById('footerLeft').value = s.footer_left || '';
|
||||
document.getElementById('footerRight').value = s.footer_right || '';
|
||||
document.getElementById('aiPrompt').value = s.ai_prompt || '';
|
||||
document.getElementById('aiAutoCheck').checked = s.ai_autocheck_enabled !== 'false';
|
||||
snapshotDirty();
|
||||
loadAiQueue();
|
||||
if (stRes.ok) {
|
||||
const st = await stRes.json();
|
||||
document.getElementById('dataStats').innerHTML = `
|
||||
@@ -122,6 +129,19 @@ function renderSystemInfo(si) {
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadAiQueue() {
|
||||
const el = document.getElementById('aiQueueInfo');
|
||||
if (!el) return;
|
||||
try {
|
||||
const res = await fetch(`${API}/api/ai/queue`, { headers: hdr() });
|
||||
if (!res.ok) { el.textContent = ''; return; }
|
||||
const q = await res.json();
|
||||
const c = q.counts || {};
|
||||
const state = q.enabled ? 'включена' : 'выключена';
|
||||
el.innerHTML = `Автопроверка: <b>${state}</b> · Модель: <b>${esc(q.model)}</b> · В очереди: <b>${c.pending || 0}</b> · Проверено: <b>${c.done || 0}</b> · Ошибок: <b>${c.error || 0}</b> · Пропущено: <b>${c.skipped || 0}</b> · <a href="worker.html">Монитор воркера →</a>`;
|
||||
} catch { el.textContent = ''; }
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const v = document.getElementById('spamInterval').value;
|
||||
if (v === '' || Number(v) < 1) { alert('Введите число от 1'); return; }
|
||||
@@ -136,6 +156,8 @@ async function saveSettings() {
|
||||
spam_interval_min: String(Math.round(Number(v))),
|
||||
footer_left: document.getElementById('footerLeft').value.trim(),
|
||||
footer_right: document.getElementById('footerRight').value.trim(),
|
||||
ai_prompt: document.getElementById('aiPrompt').value.trim(),
|
||||
ai_autocheck_enabled: document.getElementById('aiAutoCheck').checked ? 'true' : 'false',
|
||||
} })
|
||||
});
|
||||
if (res.ok) {
|
||||
|
||||
@@ -96,6 +96,8 @@ function showError(msg) {
|
||||
document.getElementById('title').textContent = msg;
|
||||
document.getElementById('grid').innerHTML = `<div class="empty"><div class="big">🤔</div>${esc(msg)}</div>`;
|
||||
document.getElementById('photosBlock').style.display = 'none';
|
||||
document.getElementById('shareMessage').style.display = 'none';
|
||||
document.getElementById('shareLinkBlock').style.display = 'none';
|
||||
}
|
||||
|
||||
function renderShare(data, password) {
|
||||
@@ -107,6 +109,29 @@ function renderShare(data, password) {
|
||||
if (data.date_from || data.date_to) chips.push(`Период: <b>${fmt(data.date_from)}</b> — <b>${fmt(data.date_to)}</b>`);
|
||||
document.getElementById('chips').innerHTML = chips.map(c => `<span class="chip">${c}</span>`).join('');
|
||||
|
||||
const msgEl = document.getElementById('shareMessage');
|
||||
if (data.message) {
|
||||
msgEl.textContent = data.message;
|
||||
msgEl.style.display = 'block';
|
||||
} else {
|
||||
msgEl.style.display = 'none';
|
||||
}
|
||||
|
||||
const linkBlock = document.getElementById('shareLinkBlock');
|
||||
linkBlock.innerHTML = '';
|
||||
if (data.link_url && /^https?:\/\//i.test(data.link_url)) {
|
||||
const a = document.createElement('a');
|
||||
a.className = 'share-link-btn';
|
||||
a.href = data.link_url;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener noreferrer';
|
||||
a.textContent = '🔗 Перейти по ссылке';
|
||||
linkBlock.appendChild(a);
|
||||
linkBlock.style.display = 'block';
|
||||
} else {
|
||||
linkBlock.style.display = 'none';
|
||||
}
|
||||
|
||||
const pb = document.getElementById('photosBlock');
|
||||
if (data.photos && data.photos.length) {
|
||||
document.getElementById('photosGrid').innerHTML = data.photos.map(p => `
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
const AUTO_REFRESH_MS = 5000;
|
||||
const STATUS_LABELS = {
|
||||
pending: 'В очереди',
|
||||
processing: 'Проверяется',
|
||||
done: 'Проверено',
|
||||
skipped: 'Пропущено',
|
||||
error: 'Ошибка',
|
||||
reverted: 'Оригинал',
|
||||
};
|
||||
const STATUS_BADGE = {
|
||||
done: 'ai-badge-done',
|
||||
error: 'ai-badge-error',
|
||||
skipped: 'ai-badge-skipped',
|
||||
reverted: 'ai-badge-reverted',
|
||||
};
|
||||
|
||||
let state = null;
|
||||
let refreshTimer = null;
|
||||
let busy = false;
|
||||
|
||||
function fmtDur(ms) {
|
||||
if (ms === null || ms === undefined || ms < 0) return '—';
|
||||
const s = Math.floor(ms / 1000);
|
||||
const d = Math.floor(s / 86400);
|
||||
const h = Math.floor((s % 86400) / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
if (d) return `${d} д ${h} ч`;
|
||||
if (h) return `${h} ч ${m} мин`;
|
||||
if (m) return `${m} мин ${sec} с`;
|
||||
return `${sec} с`;
|
||||
}
|
||||
|
||||
function fmtTime(t) {
|
||||
if (!t) return '—';
|
||||
try { return new Date(t).toLocaleString('ru'); } catch { return String(t); }
|
||||
}
|
||||
|
||||
function ago(t) {
|
||||
if (!t) return '—';
|
||||
const diff = Date.now() - new Date(t).getTime();
|
||||
if (Number.isNaN(diff)) return '—';
|
||||
return fmtDur(diff) + ' назад';
|
||||
}
|
||||
|
||||
function statusBadge(s) {
|
||||
return `<span class="ai-badge ${STATUS_BADGE[s] || ''}">${esc(STATUS_LABELS[s] || s)}</span>`;
|
||||
}
|
||||
|
||||
function renderState() {
|
||||
const w = state.worker || {};
|
||||
const svc = state.service || {};
|
||||
const enabled = state.enabled;
|
||||
const processing = !!w.processing;
|
||||
const dotClass = !enabled ? '' : (svc.reachable ? (processing ? 'busy' : 'on') : 'err');
|
||||
const label = !enabled
|
||||
? 'Автопроверка выключена'
|
||||
: (!svc.reachable ? 'Сервис модели недоступен' : (processing ? `Обрабатывается запись #${w.current_id ?? ''}` : 'Ожидание очереди'));
|
||||
document.getElementById('stateLine').innerHTML = `<span class="worker-dot ${dotClass}"></span> ${esc(label)}`;
|
||||
document.getElementById('toggleBtn').textContent = enabled ? 'Выключить автопроверку' : 'Включить автопроверку';
|
||||
}
|
||||
|
||||
function renderCards() {
|
||||
const w = state.worker || {};
|
||||
const svc = state.service || {};
|
||||
const cfg = w.config || {};
|
||||
const pct = w.checks ? Math.round(((w.corrected || 0) / w.checks) * 100) : 0;
|
||||
document.getElementById('workerCards').innerHTML = `
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">Состояние воркера</div>
|
||||
<div class="sys-item"><span class="sys-label">Режим</span><span class="sys-value">${w.processing ? 'Обработка' : 'Ожидание'}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Текущая запись</span><span class="sys-value">${w.current_id ? '#' + w.current_id : '—'}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Работает</span><span class="sys-value">${fmtDur(w.uptime_ms)}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Запущен</span><span class="sys-value">${fmtTime(w.started_at)}</span></div>
|
||||
</div>
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">Модель</div>
|
||||
<div class="sys-item"><span class="sys-label">Модель</span><span class="sys-value">${esc(state.model)}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Сервис</span><span class="sys-value">${svc.reachable ? '✅ доступен' : '❌ недоступен'}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Отклик</span><span class="sys-value">${svc.latency_ms !== undefined ? svc.latency_ms + ' мс' : '—'}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Адрес</span><span class="sys-value" style="font-size:.75rem">${esc(state.ai_url || '')}</span></div>
|
||||
${svc.error ? `<div class="sys-meta" style="color:#ef4444">${esc(svc.error)}</div>` : ''}
|
||||
</div>
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">Итоги работы</div>
|
||||
<div class="sys-item"><span class="sys-label">Проверок</span><span class="sys-value">${w.checks || 0}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Исправлено</span><span class="sys-value">${w.corrected || 0} (${pct}%)</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Без изменений</span><span class="sys-value">${w.unchanged || 0}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Ошибок</span><span class="sys-value">${w.errors || 0}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Активность</span><span class="sys-value">${ago(w.last_at)}</span></div>
|
||||
${w.last_error ? `<div class="sys-meta" style="color:#ef4444">${esc(w.last_error)}</div>` : ''}
|
||||
</div>
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">Параметры (мин. нагрузка)</div>
|
||||
<div class="sys-item"><span class="sys-label">Пауза простоя</span><span class="sys-value">${fmtDur(cfg.idle_min_ms)} → ${fmtDur(cfg.idle_max_ms)}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Таймаут запроса</span><span class="sys-value">${fmtDur(cfg.request_timeout_ms)}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Макс. текст</span><span class="sys-value">${cfg.max_input_chars || 0} симв.</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Мин. текст</span><span class="sys-value">${cfg.min_text_chars || 0} симв.</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Повторы при сбое</span><span class="sys-value">${cfg.max_attempts || 0}</span></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderCounts() {
|
||||
const c = state.counts || {};
|
||||
const chips = [
|
||||
['pending', 'В очереди', c.pending || 0],
|
||||
['processing', 'Проверяется', c.processing || 0],
|
||||
['done', 'Проверено', c.done || 0],
|
||||
['skipped', 'Пропущено', c.skipped || 0],
|
||||
['error', 'Ошибка', c.error || 0],
|
||||
['reverted', 'Оригинал', c.reverted || 0],
|
||||
];
|
||||
document.getElementById('workerCounts').innerHTML = chips
|
||||
.map(([cls, label, n]) => `<span class="chip ${cls}">${label}: <b>${n}</b></span>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function renderPending() {
|
||||
const rows = state.pending || [];
|
||||
document.getElementById('pendingCount').textContent = rows.length;
|
||||
const tbody = document.querySelector('#pendingTable tbody');
|
||||
const empty = document.getElementById('pendingEmpty');
|
||||
if (!rows.length) { tbody.innerHTML = ''; empty.style.display = 'block'; return; }
|
||||
empty.style.display = 'none';
|
||||
tbody.innerHTML = rows.map(r => `
|
||||
<tr>
|
||||
<td>#${r.id}</td>
|
||||
<td>${esc(r.student_name)}</td>
|
||||
<td style="color:var(--muted)">${esc(r.group_name || '')}</td>
|
||||
<td style="color:var(--muted);white-space:nowrap">${fmtTime(r.created_at)}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
function renderRecent() {
|
||||
const filter = document.getElementById('recentFilter').value;
|
||||
const all = state.recent || [];
|
||||
const rows = filter ? all.filter(r => r.ai_status === filter) : all;
|
||||
document.getElementById('recentCount').textContent = `${rows.length} из ${all.length}`;
|
||||
const tbody = document.querySelector('#recentTable tbody');
|
||||
const empty = document.getElementById('recentEmpty');
|
||||
if (!rows.length) { tbody.innerHTML = ''; empty.style.display = 'block'; return; }
|
||||
empty.style.display = 'none';
|
||||
tbody.innerHTML = rows.map(r => `
|
||||
<tr>
|
||||
<td style="white-space:nowrap">${fmtTime(r.ai_checked_at)}</td>
|
||||
<td>${esc(r.student_name)}</td>
|
||||
<td style="color:var(--muted)">${esc(r.group_name || '')}</td>
|
||||
<td>${statusBadge(r.ai_status)}</td>
|
||||
<td>${r.changed ? '✏️ исправлен' : '—'}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
function renderErrors() {
|
||||
const rows = state.errors || [];
|
||||
document.getElementById('errorsCount').textContent = rows.length;
|
||||
document.getElementById('errorsCard').style.display = rows.length ? 'flex' : 'none';
|
||||
document.querySelector('#errorsTable tbody').innerHTML = rows.map(r => `
|
||||
<tr>
|
||||
<td style="white-space:nowrap">${fmtTime(r.ai_checked_at)}</td>
|
||||
<td>#${r.id}</td>
|
||||
<td>${esc(r.student_name)}</td>
|
||||
<td style="color:#ef4444">${esc(r.ai_error || '')}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!state) return;
|
||||
renderState();
|
||||
renderCards();
|
||||
renderCounts();
|
||||
renderPending();
|
||||
renderRecent();
|
||||
renderErrors();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
const res = await fetch(`${API}/api/ai/status`, { headers: hdr() });
|
||||
if (!res.ok) return;
|
||||
state = await res.json();
|
||||
render();
|
||||
} catch {
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function post(path, body) {
|
||||
const opts = { method: 'POST', headers: body ? hdrJson() : hdr() };
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
const res = await fetch(`${API}${path}`, opts);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) { showToast(data.error || 'Ошибка'); return null; }
|
||||
return data;
|
||||
}
|
||||
|
||||
async function wake() {
|
||||
if (await post('/api/ai/wake')) { showToast('Воркер разбужен'); load(); }
|
||||
}
|
||||
|
||||
async function toggleEnabled() {
|
||||
const next = !(state && state.enabled);
|
||||
const data = await post('/api/ai/enabled', { enabled: next });
|
||||
if (data) { showToast(next ? 'Автопроверка включена' : 'Автопроверка выключена'); load(); }
|
||||
}
|
||||
|
||||
async function requeueFailed() {
|
||||
if (!confirm('Вернуть все записи с ошибкой в очередь на повторную проверку?')) return;
|
||||
const data = await post('/api/ai/requeue-failed');
|
||||
if (data) { showToast(`В очередь возвращено: ${data.count}`); load(); }
|
||||
}
|
||||
|
||||
function setAutoRefresh(on) {
|
||||
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
|
||||
if (on) refreshTimer = setInterval(load, AUTO_REFRESH_MS);
|
||||
}
|
||||
|
||||
document.getElementById('refreshBtn').addEventListener('click', load);
|
||||
document.getElementById('wakeBtn').addEventListener('click', wake);
|
||||
document.getElementById('toggleBtn').addEventListener('click', toggleEnabled);
|
||||
document.getElementById('requeueBtn').addEventListener('click', requeueFailed);
|
||||
document.getElementById('recentFilter').addEventListener('change', renderRecent);
|
||||
document.getElementById('autoRefresh').addEventListener('change', e => setAutoRefresh(e.target.checked));
|
||||
|
||||
(async () => {
|
||||
if (await requireAdminPage()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
load();
|
||||
}
|
||||
})();
|
||||
|
||||
+38
-44
@@ -17,58 +17,52 @@
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="linkModal">
|
||||
<div class="edit-modal" style="max-width:480px">
|
||||
<div class="edit-modal" style="max-width:640px">
|
||||
<h3 id="linkModalTitle">Новая ссылка</h3>
|
||||
<div class="settings-stack" style="gap:12px">
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label>Название <span style="color:#ef4444">*</span></label>
|
||||
<input type="text" id="linkName" class="settings-input" required placeholder="Например: СБ1400 05.09">
|
||||
</div>
|
||||
<div class="link-fields">
|
||||
<div class="settings-field">
|
||||
<label>Название <span style="color:#ef4444">*</span></label>
|
||||
<input type="text" id="linkName" class="settings-input" required placeholder="Например: СБ1400 05.09">
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label>Группа</label>
|
||||
<select id="linkGroup" class="settings-input"><option value="">— без группы —</option></select>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Группа</label>
|
||||
<select id="linkGroup" class="settings-input"><option value="">— без группы —</option></select>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label>Ученик (необязательно)</label>
|
||||
<input type="text" id="linkStudent" class="settings-input" list="linkStudentList" placeholder="Все / введите имя..."><datalist id="linkStudentList"></datalist>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Ученик (необязательно)</label>
|
||||
<input type="text" id="linkStudent" class="settings-input" list="linkStudentList" placeholder="Все / введите имя..."><datalist id="linkStudentList"></datalist>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label>С</label>
|
||||
<input type="date" id="linkDateFrom" class="settings-input">
|
||||
</div>
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label>По</label>
|
||||
<input type="date" id="linkDateTo" class="settings-input">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Ссылка</label>
|
||||
<input type="url" id="linkUrl" class="settings-input" placeholder="https://...">
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label>Истекает через (дни)</label>
|
||||
<input type="number" id="linkExpiresDays" class="settings-input" min="1" max="365" value="7" placeholder="7">
|
||||
</div>
|
||||
<div class="settings-field full">
|
||||
<label>Сообщение</label>
|
||||
<textarea id="linkMessage" class="settings-input" rows="3" placeholder="Текст, который увидят на публичной странице"></textarea>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="linkAnonymizeNames">
|
||||
Показывать только инициалы учеников (анонимизировать)
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>С</label>
|
||||
<input type="date" id="linkDateFrom" class="settings-input">
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label>Пароль для доступа (необязательно)</label>
|
||||
<input type="password" id="linkPassword" class="settings-input" placeholder="Оставьте пустым для открытого доступа">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>По</label>
|
||||
<input type="date" id="linkDateTo" class="settings-input">
|
||||
</div>
|
||||
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||
<div class="settings-field">
|
||||
<label>Истекает через (дни)</label>
|
||||
<input type="number" id="linkExpiresDays" class="settings-input" min="1" max="365" value="7" placeholder="7">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Пароль для доступа (необязательно)</label>
|
||||
<input type="password" id="linkPassword" class="settings-input" placeholder="Оставьте пустым для открытого доступа">
|
||||
</div>
|
||||
<div class="settings-field full">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="linkAnonymizeNames">
|
||||
Показывать только инициалы учеников (анонимизировать)
|
||||
</label>
|
||||
</div>
|
||||
<div class="card-foot full" style="justify-content:flex-end;gap:8px">
|
||||
<button type="button" class="btn-primary ghost" id="linkCancelBtn">Отмена</button>
|
||||
<button type="button" class="btn-primary" id="linkSaveBtn">Сохранить</button>
|
||||
</div>
|
||||
|
||||
@@ -61,6 +61,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="card-head">
|
||||
<div class="ic">🤖</div>
|
||||
<h3>Промпт ИИ для исправления текста</h3>
|
||||
</div>
|
||||
<p class="hint">Инструкция для нейросети при исправлении ошибок в записи «Что делала(а)».</p>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label for="aiPrompt">Промпт</label>
|
||||
<textarea id="aiPrompt" class="settings-input" rows="4" style="min-height:100px;font-family:monospace;font-size:.85rem"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="aiAutoCheck">
|
||||
Автоматически проверять новые записи (без участия человека)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ai-queue" id="aiQueueInfo"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="card-head">
|
||||
<div class="ic">🗄️</div>
|
||||
|
||||
@@ -62,6 +62,10 @@ body{font-family:'Segoe UI',system-ui,-apple-system,Roboto,sans-serif;background
|
||||
.password-box input{width:100%;padding:14px 16px;border:1px solid #EBEBEB;border-radius:12px;font-size:1rem;margin-bottom:16px}
|
||||
.password-box button{width:100%;padding:14px;background:var(--yellow);color:var(--dark);border:none;border-radius:12px;font-size:1rem;font-weight:700;cursor:pointer}
|
||||
.password-box button:hover{opacity:.9}
|
||||
.share-message{background:var(--card-bg);border-radius:20px;padding:20px 24px;margin-bottom:32px;box-shadow:0 1px 3px rgba(20,30,40,.06);font-size:.98rem;line-height:1.55;color:#3a3f4a;white-space:pre-wrap;word-break:break-word}
|
||||
.share-link-block{margin-bottom:32px}
|
||||
.share-link-btn{display:inline-flex;align-items:center;gap:8px;background:var(--dark);color:#fff;text-decoration:none;border-radius:999px;padding:13px 24px;font-size:.92rem;font-weight:700;transition:opacity .15s,transform .15s}
|
||||
.share-link-btn:hover{opacity:.9;transform:translateY(-2px)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -70,6 +74,8 @@ body{font-family:'Segoe UI',system-ui,-apple-system,Roboto,sans-serif;background
|
||||
<div class="chips" id="chips"></div>
|
||||
</section>
|
||||
<main class="wrap">
|
||||
<div id="shareMessage" class="share-message" style="display:none"></div>
|
||||
<div id="shareLinkBlock" class="share-link-block" style="display:none"></div>
|
||||
<div id="photosBlock">
|
||||
<div class="photos-label">Фото группы</div>
|
||||
<div class="photos-grid" id="photosGrid"></div>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<!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="worker">
|
||||
<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">
|
||||
<span class="worker-state" id="stateLine"><span class="worker-dot"></span> Загрузка…</span>
|
||||
<span style="flex:1"></span>
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:.85rem">
|
||||
<input type="checkbox" id="autoRefresh"> Автообновление
|
||||
</label>
|
||||
<button class="btn-link" id="refreshBtn" type="button">Обновить</button>
|
||||
<button class="btn-link" id="wakeBtn" type="button" title="Запустить обработку очереди немедленно">Разбудить</button>
|
||||
<button class="btn-link" id="requeueBtn" type="button" title="Вернуть все записи с ошибкой в очередь">Перепроверить ошибки</button>
|
||||
<button class="btn-link" id="toggleBtn" type="button">…</button>
|
||||
</div>
|
||||
|
||||
<div class="sys-grid" id="workerCards" style="margin-top:16px">
|
||||
<div class="sys-card loading">Загрузка…</div>
|
||||
</div>
|
||||
|
||||
<div class="worker-counts" id="workerCounts"></div>
|
||||
|
||||
<div class="settings-stack settings-stack-grid" style="margin-top:20px">
|
||||
<div class="settings-card wide">
|
||||
<div class="card-head">
|
||||
<div class="ic">⏳</div>
|
||||
<h3>Очередь (<span id="pendingCount">0</span>)</h3>
|
||||
</div>
|
||||
<p class="hint">Записи, ожидающие проверки моделью. Обновляются автоматически.</p>
|
||||
<div class="audit-wrap">
|
||||
<table class="audit-table" id="pendingTable">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>Ученик</th><th>Группа</th><th>Создана</th></tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<div class="empty" id="pendingEmpty" style="display:none">Очередь пуста.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card wide">
|
||||
<div class="card-head">
|
||||
<div class="ic">🕒</div>
|
||||
<h3>Последние проверки</h3>
|
||||
</div>
|
||||
<div class="actions-row" style="margin-bottom:10px">
|
||||
<select id="recentFilter" class="settings-input" style="max-width:220px">
|
||||
<option value="">Все статусы</option>
|
||||
<option value="done">Проверено</option>
|
||||
<option value="skipped">Пропущено</option>
|
||||
<option value="error">Ошибка</option>
|
||||
<option value="reverted">Возвращён оригинал</option>
|
||||
</select>
|
||||
<span class="hint" id="recentCount"></span>
|
||||
</div>
|
||||
<div class="audit-wrap">
|
||||
<table class="audit-table" id="recentTable">
|
||||
<thead>
|
||||
<tr><th>Проверено</th><th>Ученик</th><th>Группа</th><th>Статус</th><th>Изменения</th></tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<div class="empty" id="recentEmpty" style="display:none">Проверок пока нет.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card wide" id="errorsCard" style="display:none">
|
||||
<div class="card-head">
|
||||
<div class="ic">⚠️</div>
|
||||
<h3>Ошибки (<span id="errorsCount">0</span>)</h3>
|
||||
</div>
|
||||
<p class="hint">Записи, которые не удалось обработать после всех попыток. Нажмите «Перепроверить ошибки», чтобы запустить их снова.</p>
|
||||
<div class="audit-wrap">
|
||||
<table class="audit-table" id="errorsTable">
|
||||
<thead>
|
||||
<tr><th>Время</th><th>ID</th><th>Ученик</th><th>Ошибка</th></tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="admin.js"></script>
|
||||
<script src="js/worker.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,6 +5,7 @@ const rateLimit = require('express-rate-limit');
|
||||
const helmet = require('helmet');
|
||||
const bcrypt = require('bcrypt');
|
||||
const heicConvert = require('heic-convert');
|
||||
const { createEntryAutoChecker } = require('./worker');
|
||||
|
||||
const https = require('https');
|
||||
const path = require('path');
|
||||
@@ -346,7 +347,9 @@ async function removeEntryFiles(entryId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT photo_path AS p FROM entries WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT path AS p FROM project_files WHERE entry_id = $1`,
|
||||
SELECT path AS p FROM project_files WHERE entry_id = $1
|
||||
UNION ALL
|
||||
SELECT photo_path AS p FROM entry_photos WHERE entry_id = $1`,
|
||||
[entryId]
|
||||
);
|
||||
rows.forEach(r => safeUnlink(r.p));
|
||||
@@ -355,13 +358,14 @@ async function removeEntryFiles(entryId) {
|
||||
async function sweepOrphanedUploads() {
|
||||
const dir = path.join(__dirname, 'uploads');
|
||||
if (!fs.existsSync(dir)) return;
|
||||
const [{ rows: photos }, { rows: files }, { rows: gphotos }] = await Promise.all([
|
||||
const [{ rows: photos }, { rows: files }, { rows: gphotos }, { rows: ephotos }] = await Promise.all([
|
||||
pool.query('SELECT photo_path AS p FROM entries WHERE photo_path IS NOT NULL'),
|
||||
pool.query('SELECT path AS p FROM project_files'),
|
||||
pool.query('SELECT photo_path AS p FROM group_photos'),
|
||||
pool.query('SELECT photo_path AS p FROM entry_photos'),
|
||||
]);
|
||||
const refs = new Set();
|
||||
[...photos, ...files, ...gphotos].forEach(r => refs.add('/' + String(r.p).replace(/^\/+/, '')));
|
||||
[...photos, ...files, ...gphotos, ...ephotos].forEach(r => refs.add('/' + String(r.p).replace(/^\/+/, '')));
|
||||
for (const f of fs.readdirSync(dir)) {
|
||||
const fp = path.join(dir, f);
|
||||
if (!fs.statSync(fp).isFile()) continue;
|
||||
@@ -394,6 +398,34 @@ async function ensureBranchesTable() {
|
||||
await pool.query(`ALTER TABLE groups ADD COLUMN IF NOT EXISTS branch_id INTEGER REFERENCES branches(id) ON DELETE SET NULL`);
|
||||
}
|
||||
|
||||
async function ensureEntryPhotosTable() {
|
||||
await pool.query(`CREATE TABLE IF NOT EXISTS entry_photos (
|
||||
id SERIAL PRIMARY KEY,
|
||||
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
photo_path VARCHAR(255) NOT NULL,
|
||||
caption TEXT,
|
||||
sort_order INT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
)`);
|
||||
await pool.query('CREATE INDEX IF NOT EXISTS idx_entry_photos_entry_id ON entry_photos(entry_id)');
|
||||
}
|
||||
|
||||
async function ensureEntryAiColumns() {
|
||||
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_original TEXT`);
|
||||
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_ai TEXT`);
|
||||
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'pending'`);
|
||||
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ`);
|
||||
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_error TEXT`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_entries_ai_pending ON entries(id) WHERE ai_status = 'pending' AND deleted_at IS NULL`);
|
||||
await pool.query(`INSERT INTO settings (key, value) VALUES ('ai_autocheck_enabled', 'true') ON CONFLICT (key) DO NOTHING`);
|
||||
const marker = await getSetting('ai_autocheck_migrated', '');
|
||||
if (marker !== '1') {
|
||||
await pool.query(`UPDATE entries SET description_original = description WHERE description_original IS NULL`);
|
||||
await pool.query(`UPDATE entries SET ai_status = 'skipped' WHERE ai_status = 'pending'`);
|
||||
await pool.query(`INSERT INTO settings (key, value) VALUES ('ai_autocheck_migrated', '1') ON CONFLICT (key) DO UPDATE SET value = '1'`);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureUserTables() {
|
||||
await pool.query(`CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -683,16 +715,24 @@ const zlib = require('zlib');
|
||||
const tar = require('tar');
|
||||
const os = require('os');
|
||||
const AI_URL = process.env.AI_URL || 'http://text-corrector:8080';
|
||||
const AI_DEFAULT_PROMPT = 'Ты — редактор текстов для педагогического журнала. Исправь грамматические, орфографические и пунктуационные ошибки. Сохрани смысл и стиль. Верни ТОЛЬКО исправленный текст, без пояснений.';
|
||||
let entryAutoChecker = null;
|
||||
|
||||
async function getAiPrompt() {
|
||||
const prompt = await getSetting('ai_prompt', AI_DEFAULT_PROMPT);
|
||||
return prompt;
|
||||
}
|
||||
|
||||
async function aiCorrectText(text) {
|
||||
try {
|
||||
const systemPrompt = await getAiPrompt();
|
||||
const res = await fetch(`${AI_URL}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'qwen2.5-1.5b-instruct',
|
||||
messages: [
|
||||
{ role: 'system', content: 'Ты — редактор текстов для педагогического журнала. Исправь грамматические, орфографические и пунктуационные ошибки. Сохрани смысл и стиль. Верни ТОЛЬКО исправленный текст, без пояснений.' },
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: text }
|
||||
],
|
||||
temperature: 0.1,
|
||||
@@ -783,6 +823,20 @@ function optUploadPath(v, max) {
|
||||
return reqUploadPath(v, max);
|
||||
}
|
||||
|
||||
const AI_STATUSES = new Set(['pending', 'processing', 'done', 'skipped', 'error', 'reverted']);
|
||||
|
||||
function optAiText(v, max) {
|
||||
if (v === null || v === undefined) return null;
|
||||
return reqStr(v, max);
|
||||
}
|
||||
|
||||
function reqAiStatus(v, fallback) {
|
||||
if (v === null || v === undefined) return fallback;
|
||||
const s = String(v);
|
||||
if (s === 'processing') return 'pending';
|
||||
return AI_STATUSES.has(s) ? s : fallback;
|
||||
}
|
||||
|
||||
function normalizeRestoreData(data) {
|
||||
const groups = (data.groups || []).map(x => ({
|
||||
id: reqInt(x.id),
|
||||
@@ -804,6 +858,11 @@ function normalizeRestoreData(data) {
|
||||
student_name: reqStr(x.student_name, 150),
|
||||
group_id: reqInt(x.group_id),
|
||||
description: reqStr(x.description, 100000),
|
||||
description_original: optAiText(x.description_original, 100000) ?? reqStr(x.description, 100000),
|
||||
description_ai: optAiText(x.description_ai, 100000),
|
||||
ai_status: reqAiStatus(x.ai_status, 'skipped'),
|
||||
ai_checked_at: optTs(x.ai_checked_at),
|
||||
ai_error: optAiText(x.ai_error, 500),
|
||||
photo_path: optUploadPath(x.photo_path, 255),
|
||||
deleted_at: optTs(x.deleted_at),
|
||||
created_at: optTs(x.created_at),
|
||||
@@ -978,8 +1037,8 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
|
||||
}
|
||||
for (const x of ndata.entries) {
|
||||
await client.query(
|
||||
'INSERT INTO entries (id, student_name, group_id, description, photo_path, deleted_at, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
||||
[x.id, x.student_name, x.group_id, x.description, x.photo_path, x.deleted_at, x.created_at]
|
||||
'INSERT INTO entries (id, student_name, group_id, description, description_original, description_ai, ai_status, ai_checked_at, ai_error, photo_path, deleted_at, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)',
|
||||
[x.id, x.student_name, x.group_id, x.description, x.description_original, x.description_ai, x.ai_status, x.ai_checked_at, x.ai_error, x.photo_path, x.deleted_at, x.created_at]
|
||||
);
|
||||
}
|
||||
for (const x of ndata.project_files) {
|
||||
@@ -1050,6 +1109,23 @@ function normDates(o) {
|
||||
if (o && o.date_to) o.date_to = o.date_to instanceof Date ? o.date_to.toISOString().slice(0, 10) : String(o.date_to).slice(0, 10);
|
||||
return o;
|
||||
}
|
||||
|
||||
function parseShareMessage(v) {
|
||||
const s = typeof v === 'string' ? v.trim() : '';
|
||||
if (s.length > 2000) throw new Error('Сообщение слишком длинное (макс. 2000 символов)');
|
||||
return s || null;
|
||||
}
|
||||
|
||||
function parseShareLinkUrl(v) {
|
||||
const s = typeof v === 'string' ? v.trim() : '';
|
||||
if (!s) return null;
|
||||
if (s.length > 500) throw new Error('Ссылка слишком длинная (макс. 500 символов)');
|
||||
let u;
|
||||
try { u = new URL(s); } catch { throw new Error('Некорректная ссылка'); }
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('Ссылка должна начинаться с http:// или https://');
|
||||
return s;
|
||||
}
|
||||
|
||||
app.get('/api/links', requireAuth, async (req, res) => {
|
||||
const s = branchScope(req.user);
|
||||
let where = '';
|
||||
@@ -1074,6 +1150,13 @@ app.get('/api/links', requireAuth, async (req, res) => {
|
||||
app.post('/api/links', requireAuth, async (req, res) => {
|
||||
const { name, group_id, student_name, date_from, date_to, anonymize_names, expires_at, access_password } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
|
||||
let message, linkUrl;
|
||||
try {
|
||||
message = parseShareMessage(req.body.message);
|
||||
linkUrl = parseShareLinkUrl(req.body.link_url);
|
||||
} catch (e) {
|
||||
return res.status(400).json({ error: e.message });
|
||||
}
|
||||
if (req.user.role !== 'admin' && group_id && !(await groupBelongsToBranches(req.user, group_id))) {
|
||||
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||||
}
|
||||
@@ -1095,9 +1178,9 @@ app.post('/api/links', requireAuth, async (req, res) => {
|
||||
expiresAt = defaultExp.toISOString();
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO share_links (token, name, group_id, student_name, date_from, date_to, anonymize_names, expires_at, access_password_hash)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`,
|
||||
[token, name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, anonymize_names || false, expiresAt, passwordHash]
|
||||
`INSERT INTO share_links (token, name, group_id, student_name, date_from, date_to, anonymize_names, expires_at, access_password_hash, message, link_url)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
|
||||
[token, name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, anonymize_names || false, expiresAt, passwordHash, message, linkUrl]
|
||||
);
|
||||
await logAudit(req, 'link.create', { id: rows[0].id, name: name.trim() });
|
||||
res.status(201).json(normDates(rows[0]));
|
||||
@@ -1106,6 +1189,13 @@ app.post('/api/links', requireAuth, async (req, res) => {
|
||||
app.put('/api/links/:id', requireAuth, async (req, res) => {
|
||||
const { name, group_id, student_name, date_from, date_to, anonymize_names, expires_at, access_password } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
|
||||
let message, linkUrl;
|
||||
try {
|
||||
message = parseShareMessage(req.body.message);
|
||||
linkUrl = parseShareLinkUrl(req.body.link_url);
|
||||
} catch (e) {
|
||||
return res.status(400).json({ error: e.message });
|
||||
}
|
||||
if (req.user.role !== 'admin') {
|
||||
const { rows: lr } = await pool.query('SELECT group_id FROM share_links WHERE id = $1', [req.params.id]);
|
||||
if (!lr.length) return res.status(404).json({ error: 'Не найдено' });
|
||||
@@ -1137,9 +1227,9 @@ app.put('/api/links/:id', requireAuth, async (req, res) => {
|
||||
expiresAt = null; // Clear expiry if null sent
|
||||
}
|
||||
}
|
||||
const fields = ['name = $1', 'group_id = $2', 'student_name = $3', 'date_from = $4', 'date_to = $5', 'anonymize_names = $6'];
|
||||
const values = [name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, anonymize_names || false];
|
||||
let paramIdx = 7;
|
||||
const fields = ['name = $1', 'group_id = $2', 'student_name = $3', 'date_from = $4', 'date_to = $5', 'anonymize_names = $6', 'message = $7', 'link_url = $8'];
|
||||
const values = [name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, anonymize_names || false, message, linkUrl];
|
||||
let paramIdx = 9;
|
||||
if (passwordHash !== undefined) {
|
||||
fields.push(`access_password_hash = $${paramIdx++}`);
|
||||
values.push(passwordHash);
|
||||
@@ -1249,6 +1339,8 @@ app.get('/api/share/:token', fileLimiter, async (req, res) => {
|
||||
group_id: l.group_id,
|
||||
date_from: l.date_from,
|
||||
date_to: l.date_to,
|
||||
message: l.message,
|
||||
link_url: l.link_url,
|
||||
created_at: l.created_at,
|
||||
anonymize_names: l.anonymize_names,
|
||||
entries,
|
||||
@@ -1785,6 +1877,20 @@ app.get('/api/entries', requireAuth, async (req, res) => {
|
||||
files = {};
|
||||
}
|
||||
rows.forEach(r => { r.files = files[r.id] || []; });
|
||||
|
||||
if (rows.length) {
|
||||
const ids = rows.map(r => r.id);
|
||||
const pRes = await pool.query(
|
||||
'SELECT id, entry_id, photo_path, caption, sort_order FROM entry_photos WHERE entry_id = ANY($1) ORDER BY sort_order, id',
|
||||
[ids]
|
||||
);
|
||||
const photos = {};
|
||||
pRes.rows.forEach(p => { (photos[p.entry_id] = photos[p.entry_id] || []).push(p); });
|
||||
rows.forEach(r => { r.photos = photos[r.id] || []; });
|
||||
} else {
|
||||
rows.forEach(r => { r.photos = []; });
|
||||
}
|
||||
|
||||
res.json({ entries: rows, total });
|
||||
});
|
||||
|
||||
@@ -2202,7 +2308,7 @@ app.get('/api/dashboard', requireAuth, async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
const entryFields = upload.fields([{ name: 'photo', maxCount: 1 }, { name: 'files', maxCount: 10 }]);
|
||||
const entryFields = upload.fields([{ name: 'photo', maxCount: 10 }, { name: 'files', maxCount: 10 }]);
|
||||
app.post('/api/entries', entryLimiter, (req, res, next) => {
|
||||
entryFields(req, res, (err) => {
|
||||
if (!err) return next();
|
||||
@@ -2213,33 +2319,33 @@ app.post('/api/entries', entryLimiter, (req, res, next) => {
|
||||
});
|
||||
}, async (req, res) => {
|
||||
const { student_name, group_id, description, website } = req.body;
|
||||
const photo = req.files?.photo?.[0] || null;
|
||||
const photos = req.files?.photo || [];
|
||||
const projectFiles = req.files?.files || [];
|
||||
if (website) {
|
||||
removeUpload(photo);
|
||||
photos.forEach(removeUpload);
|
||||
projectFiles.forEach(removeUpload);
|
||||
return res.status(400).json({ error: 'Spam detected' });
|
||||
}
|
||||
if (!student_name?.trim() || !group_id || !description?.trim()) {
|
||||
removeUpload(photo);
|
||||
photos.forEach(removeUpload);
|
||||
projectFiles.forEach(removeUpload);
|
||||
return res.status(400).json({ error: 'All fields required' });
|
||||
}
|
||||
const totalBytes = (photo?.size || 0) + projectFiles.reduce((s, f) => s + (f.size || 0), 0);
|
||||
const totalBytes = photos.reduce((s, f) => s + (f.size || 0), 0) + projectFiles.reduce((s, f) => s + (f.size || 0), 0);
|
||||
if (totalBytes > MAX_TOTAL_UPLOAD_BYTES) {
|
||||
removeUpload(photo);
|
||||
photos.forEach(removeUpload);
|
||||
projectFiles.forEach(removeUpload);
|
||||
return res.status(400).json({ error: 'Суммарный размер файлов слишком велик (макс. 30 МБ)' });
|
||||
}
|
||||
const gid = Number.parseInt(group_id, 10);
|
||||
if (!Number.isInteger(gid)) {
|
||||
removeUpload(photo);
|
||||
photos.forEach(removeUpload);
|
||||
projectFiles.forEach(removeUpload);
|
||||
return res.status(400).json({ error: 'Группа не найдена' });
|
||||
}
|
||||
const grpCheck = await pool.query('SELECT id FROM groups WHERE id = $1', [gid]);
|
||||
if (!grpCheck.rows.length) {
|
||||
removeUpload(photo);
|
||||
photos.forEach(removeUpload);
|
||||
projectFiles.forEach(removeUpload);
|
||||
return res.status(400).json({ error: 'Группа не найдена' });
|
||||
}
|
||||
@@ -2250,13 +2356,14 @@ app.post('/api/entries', entryLimiter, (req, res, next) => {
|
||||
[student_name.trim(), intervalMin]
|
||||
);
|
||||
if (dup.rows[0].n > 0) {
|
||||
removeUpload(photo);
|
||||
photos.forEach(removeUpload);
|
||||
projectFiles.forEach(removeUpload);
|
||||
return res.status(429).json({ error: `Уже ответили: подождите ${intervalMin} минут` });
|
||||
}
|
||||
}
|
||||
await convertPhoto(photo);
|
||||
const photo_path = photo ? `/uploads/${photo.filename}` : null;
|
||||
for (const p of photos) {
|
||||
await convertPhoto(p);
|
||||
}
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
@@ -2264,11 +2371,19 @@ app.post('/api/entries', entryLimiter, (req, res, next) => {
|
||||
'INSERT INTO students (name) VALUES ($1) ON CONFLICT (name) DO NOTHING',
|
||||
[student_name.trim()]
|
||||
);
|
||||
const mainPhotoPath = photos.length ? `/uploads/${photos[0].filename}` : null;
|
||||
const { rows } = await client.query(
|
||||
`INSERT INTO entries (student_name, group_id, description, photo_path)
|
||||
VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[student_name.trim(), gid, description.trim(), photo_path]
|
||||
`INSERT INTO entries (student_name, group_id, description, description_original, photo_path)
|
||||
VALUES ($1, $2, $3, $3, $4) RETURNING *`,
|
||||
[student_name.trim(), gid, description.trim(), mainPhotoPath]
|
||||
);
|
||||
for (let i = 0; i < photos.length; i++) {
|
||||
const p = photos[i];
|
||||
await client.query(
|
||||
'INSERT INTO entry_photos (entry_id, photo_path, sort_order) VALUES ($1, $2, $3)',
|
||||
[rows[0].id, `/uploads/${p.filename}`, i]
|
||||
);
|
||||
}
|
||||
for (const f of projectFiles) {
|
||||
const token = crypto.randomBytes(16).toString('hex');
|
||||
await client.query(
|
||||
@@ -2277,10 +2392,11 @@ app.post('/api/entries', entryLimiter, (req, res, next) => {
|
||||
);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
res.status(201).json({ ...rows[0], files: projectFiles.length });
|
||||
if (entryAutoChecker) entryAutoChecker.notify();
|
||||
res.status(201).json({ ...rows[0], files: projectFiles.length, photos: photos.length });
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
removeUpload(photo);
|
||||
photos.forEach(removeUpload);
|
||||
projectFiles.forEach(removeUpload);
|
||||
console.error('POST /api/entries:', e);
|
||||
res.status(500).json({ error: e.message });
|
||||
@@ -2289,18 +2405,28 @@ app.post('/api/entries', entryLimiter, (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/entries/:id', requireAuth, async (req, res) => {
|
||||
app.put('/api/entries/:id', requireAuth, upload.array('photo', 10), async (req, res) => {
|
||||
if (req.user.role !== 'admin') {
|
||||
const acc = await entryAccessible(req.user, req.params.id);
|
||||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||||
}
|
||||
const { student_name, group_id, description } = req.body;
|
||||
const newPhotos = req.files || [];
|
||||
for (const p of newPhotos) {
|
||||
await convertPhoto(p);
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE entries SET
|
||||
student_name = COALESCE($1, student_name),
|
||||
group_id = COALESCE($2, group_id),
|
||||
description = COALESCE($3, description)
|
||||
description = COALESCE($3, description),
|
||||
description_original = COALESCE($3, description_original),
|
||||
description_ai = CASE WHEN $3 IS NULL THEN description_ai ELSE NULL END,
|
||||
ai_status = CASE WHEN $3 IS NULL THEN ai_status ELSE 'pending' END,
|
||||
ai_error = CASE WHEN $3 IS NULL THEN ai_error ELSE NULL END,
|
||||
ai_checked_at = CASE WHEN $3 IS NULL THEN ai_checked_at ELSE NULL END
|
||||
WHERE id = $4 RETURNING *`,
|
||||
[
|
||||
student_name ? student_name.trim() : null,
|
||||
@@ -2309,11 +2435,94 @@ app.put('/api/entries/:id', requireAuth, async (req, res) => {
|
||||
req.params.id,
|
||||
]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
||||
if (!rows.length) {
|
||||
newPhotos.forEach(p => safeUnlink(p.path));
|
||||
return res.status(404).json({ error: 'Not found' });
|
||||
}
|
||||
if (description && entryAutoChecker) entryAutoChecker.notify();
|
||||
|
||||
const { rows: existingPhotos } = await pool.query('SELECT id, photo_path FROM entry_photos WHERE entry_id = $1 ORDER BY sort_order, id', [req.params.id]);
|
||||
const nextSortOrder = existingPhotos.length;
|
||||
|
||||
for (let i = 0; i < newPhotos.length; i++) {
|
||||
const p = newPhotos[i];
|
||||
await pool.query(
|
||||
'INSERT INTO entry_photos (entry_id, photo_path, sort_order) VALUES ($1, $2, $3)',
|
||||
[req.params.id, `/uploads/${p.filename}`, nextSortOrder + i]
|
||||
);
|
||||
}
|
||||
if (!rows[0].photo_path && newPhotos.length) {
|
||||
rows[0].photo_path = `/uploads/${newPhotos[0].filename}`;
|
||||
await pool.query('UPDATE entries SET photo_path = $1 WHERE id = $2', [rows[0].photo_path, req.params.id]);
|
||||
}
|
||||
|
||||
await logAudit(req, 'entry.update', { id: req.params.id });
|
||||
res.json(rows[0]);
|
||||
});
|
||||
|
||||
app.get('/api/entries/:id/photos', requireAuth, async (req, res) => {
|
||||
if (req.user.role !== 'admin') {
|
||||
const acc = await entryAccessible(req.user, req.params.id);
|
||||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
'SELECT id, photo_path, caption, sort_order, created_at FROM entry_photos WHERE entry_id = $1 ORDER BY sort_order, id',
|
||||
[req.params.id]
|
||||
);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
app.delete('/api/entries/:id/photos/:photoId', requireAuth, async (req, res) => {
|
||||
if (req.user.role !== 'admin') {
|
||||
const acc = await entryAccessible(req.user, req.params.id);
|
||||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||||
}
|
||||
const { rows } = await pool.query('SELECT photo_path, sort_order FROM entry_photos WHERE id = $1 AND entry_id = $2', [req.params.photoId, req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Фото не найдено' });
|
||||
const { photo_path: photoPath, sort_order: photoSort } = rows[0];
|
||||
await pool.query('DELETE FROM entry_photos WHERE id = $1 AND entry_id = $2', [req.params.photoId, req.params.id]);
|
||||
safeUnlink(photoPath);
|
||||
await pool.query('UPDATE entry_photos SET sort_order = sort_order - 1 WHERE entry_id = $1 AND sort_order > $2', [req.params.id, photoSort]);
|
||||
const { rows: mainRows } = await pool.query('SELECT id FROM entries WHERE id = $1 AND photo_path = $2', [req.params.id, photoPath]);
|
||||
if (mainRows.length) {
|
||||
const { rows: nextRows } = await pool.query('SELECT photo_path FROM entry_photos WHERE entry_id = $1 ORDER BY sort_order, id LIMIT 1', [req.params.id]);
|
||||
await pool.query('UPDATE entries SET photo_path = $1 WHERE id = $2', [nextRows.length ? nextRows[0].photo_path : null, req.params.id]);
|
||||
}
|
||||
await logAudit(req, 'entry.photo.delete', { entry_id: req.params.id, photo_id: req.params.photoId });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.put('/api/entries/:id/photos/:photoId', requireAuth, async (req, res) => {
|
||||
if (req.user.role !== 'admin') {
|
||||
const acc = await entryAccessible(req.user, req.params.id);
|
||||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||||
}
|
||||
const { caption, sort_order } = req.body;
|
||||
const { rows } = await pool.query(
|
||||
'UPDATE entry_photos SET caption = COALESCE($1, caption), sort_order = COALESCE($2, sort_order) WHERE id = $3 AND entry_id = $4 RETURNING *',
|
||||
[caption ?? null, sort_order !== undefined ? sort_order : null, req.params.photoId, req.params.id]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Фото не найдено' });
|
||||
await logAudit(req, 'entry.photo.update', { entry_id: req.params.id, photo_id: req.params.photoId });
|
||||
res.json(rows[0]);
|
||||
});
|
||||
|
||||
app.put('/api/entries/:id/photos/:photoId/main', requireAuth, async (req, res) => {
|
||||
if (req.user.role !== 'admin') {
|
||||
const acc = await entryAccessible(req.user, req.params.id);
|
||||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||||
}
|
||||
const { rows } = await pool.query('SELECT photo_path FROM entry_photos WHERE id = $1 AND entry_id = $2', [req.params.photoId, req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Фото не найдено' });
|
||||
await pool.query('UPDATE entries SET photo_path = $1 WHERE id = $2', [rows[0].photo_path, req.params.id]);
|
||||
await logAudit(req, 'entry.photo.set_main', { entry_id: req.params.id, photo_id: req.params.photoId });
|
||||
res.json({ ok: true, photo_path: rows[0].photo_path });
|
||||
});
|
||||
|
||||
app.delete('/api/entries/:id', requireAuth, async (req, res) => {
|
||||
if (req.user.role !== 'admin') {
|
||||
const acc = await entryAccessible(req.user, req.params.id);
|
||||
@@ -2369,6 +2578,142 @@ app.post('/api/ai/correct', requireAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/ai/queue', requireAdmin, async (_, res) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ai_status, count(*)::int AS n FROM entries WHERE deleted_at IS NULL GROUP BY ai_status`
|
||||
);
|
||||
const counts = { pending: 0, processing: 0, done: 0, skipped: 0, error: 0, reverted: 0 };
|
||||
rows.forEach(r => { counts[r.ai_status] = r.n; });
|
||||
const enabled = String(await getSetting('ai_autocheck_enabled', 'true')) !== 'false';
|
||||
res.json({
|
||||
enabled,
|
||||
counts,
|
||||
worker: entryAutoChecker ? entryAutoChecker.getStats() : null,
|
||||
model: process.env.AI_MODEL || 'qwen2.5-1.5b-instruct',
|
||||
});
|
||||
});
|
||||
|
||||
async function aiHealthCheck() {
|
||||
const startedAt = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 1500);
|
||||
try {
|
||||
const r = await fetch(`${AI_URL}/health`, { signal: controller.signal });
|
||||
const latency_ms = Date.now() - startedAt;
|
||||
if (!r.ok) return { reachable: false, latency_ms, error: `HTTP ${r.status}` };
|
||||
return { reachable: true, latency_ms, error: null };
|
||||
} catch (e) {
|
||||
const latency_ms = Date.now() - startedAt;
|
||||
const error = e && e.name === 'AbortError' ? 'timeout' : (e && e.message ? e.message : 'unreachable');
|
||||
return { reachable: false, latency_ms, error };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
app.get('/api/ai/status', requireAdmin, async (_, res) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ai_status, count(*)::int AS n FROM entries WHERE deleted_at IS NULL GROUP BY ai_status`
|
||||
);
|
||||
const counts = { pending: 0, processing: 0, done: 0, skipped: 0, error: 0, reverted: 0 };
|
||||
rows.forEach(r => { counts[r.ai_status] = r.n; });
|
||||
const [pending, recent, errors] = await Promise.all([
|
||||
pool.query(
|
||||
`SELECT e.id, e.student_name, e.created_at, g.name AS group_name
|
||||
FROM entries e JOIN groups g ON g.id = e.group_id
|
||||
WHERE e.ai_status IN ('pending', 'processing') AND e.deleted_at IS NULL
|
||||
ORDER BY e.id ASC LIMIT 20`
|
||||
),
|
||||
pool.query(
|
||||
`SELECT e.id, e.student_name, e.ai_status, e.ai_checked_at, e.ai_error, g.name AS group_name,
|
||||
(e.description_ai IS NOT NULL AND e.description_original IS NOT NULL AND e.description_ai <> e.description_original) AS changed
|
||||
FROM entries e JOIN groups g ON g.id = e.group_id
|
||||
WHERE e.ai_checked_at IS NOT NULL AND e.deleted_at IS NULL
|
||||
ORDER BY e.ai_checked_at DESC LIMIT 30`
|
||||
),
|
||||
pool.query(
|
||||
`SELECT e.id, e.student_name, e.ai_error, e.ai_checked_at, g.name AS group_name
|
||||
FROM entries e JOIN groups g ON g.id = e.group_id
|
||||
WHERE e.ai_status = 'error' AND e.deleted_at IS NULL
|
||||
ORDER BY e.ai_checked_at DESC NULLS LAST, e.id DESC LIMIT 20`
|
||||
),
|
||||
]);
|
||||
const enabled = String(await getSetting('ai_autocheck_enabled', 'true')) !== 'false';
|
||||
const service = await aiHealthCheck();
|
||||
res.json({
|
||||
enabled,
|
||||
model: process.env.AI_MODEL || 'qwen2.5-1.5b-instruct',
|
||||
ai_url: AI_URL,
|
||||
service,
|
||||
worker: entryAutoChecker ? entryAutoChecker.getInfo() : null,
|
||||
counts,
|
||||
pending: pending.rows,
|
||||
recent: recent.rows,
|
||||
errors: errors.rows,
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/ai/wake', requireAdmin, async (req, res) => {
|
||||
if (entryAutoChecker) entryAutoChecker.notify();
|
||||
await logAudit(req, 'ai.wake', {});
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/ai/enabled', requireAdmin, async (req, res) => {
|
||||
const enabled = !!req.body?.enabled;
|
||||
await pool.query(
|
||||
`INSERT INTO settings (key, value) VALUES ('ai_autocheck_enabled', $1)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
||||
[enabled ? 'true' : 'false']
|
||||
);
|
||||
if (enabled && entryAutoChecker) entryAutoChecker.notify();
|
||||
await logAudit(req, 'ai.enabled', { enabled });
|
||||
res.json({ ok: true, enabled });
|
||||
});
|
||||
|
||||
app.post('/api/ai/requeue-failed', requireAdmin, async (req, res) => {
|
||||
const { rowCount } = await pool.query(
|
||||
`UPDATE entries SET ai_status = 'pending', ai_error = NULL, ai_checked_at = NULL
|
||||
WHERE ai_status = 'error' AND deleted_at IS NULL`
|
||||
);
|
||||
if (entryAutoChecker) entryAutoChecker.notify();
|
||||
await logAudit(req, 'ai.requeue-failed', { count: rowCount });
|
||||
res.json({ ok: true, count: rowCount });
|
||||
});
|
||||
|
||||
app.post('/api/entries/:id/ai/recheck', requireAuth, async (req, res) => {
|
||||
if (req.user.role !== 'admin') {
|
||||
const acc = await entryAccessible(req.user, req.params.id);
|
||||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE entries SET ai_status = 'pending', ai_error = NULL, ai_checked_at = NULL WHERE id = $1 RETURNING id`,
|
||||
[req.params.id]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Запись не найдена' });
|
||||
if (entryAutoChecker) entryAutoChecker.notify();
|
||||
await logAudit(req, 'entry.ai.recheck', { id: req.params.id });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/entries/:id/ai/revert', requireAuth, async (req, res) => {
|
||||
if (req.user.role !== 'admin') {
|
||||
const acc = await entryAccessible(req.user, req.params.id);
|
||||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE entries SET description = description_original, description_ai = NULL,
|
||||
ai_status = 'reverted', ai_error = NULL, ai_checked_at = now()
|
||||
WHERE id = $1 AND description_original IS NOT NULL RETURNING id`,
|
||||
[req.params.id]
|
||||
);
|
||||
if (!rows.length) return res.status(400).json({ error: 'Оригинал текста недоступен' });
|
||||
await logAudit(req, 'entry.ai.revert', { id: req.params.id });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/trash', requireAuth, async (req, res) => {
|
||||
const { limit, offset } = req.query;
|
||||
const bw = branchScope(req.user);
|
||||
@@ -2476,9 +2821,14 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
||||
app.listen(PORT, '0.0.0.0', () => console.log(`HTTP : ${PORT} (no TLS certs)`));
|
||||
}
|
||||
|
||||
sweepOrphanedUploads().catch(err => console.error('Upload sweep:', err));
|
||||
(async () => {
|
||||
try { await ensureBranchesTable(); } catch (err) { console.error('Branches table:', err); }
|
||||
try { await ensureUsersAndFirstAdmin(); } catch (err) { console.error('Users table:', err); }
|
||||
try { await ensureAuditTable(); } catch (err) { console.error('Audit table:', err); }
|
||||
try { await ensureEntryPhotosTable(); } catch (err) { console.error('Entry photos table:', err); }
|
||||
try { await ensureEntryAiColumns(); } catch (err) { console.error('Entry AI columns:', err); }
|
||||
try { await sweepOrphanedUploads(); } catch (err) { console.error('Upload sweep:', err); }
|
||||
entryAutoChecker = createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl: AI_URL, defaultPrompt: AI_DEFAULT_PROMPT });
|
||||
entryAutoChecker.start();
|
||||
console.log('AI auto-check worker started');
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
const IDLE_MIN_MS = 2000;
|
||||
const IDLE_MAX_MS = 60000;
|
||||
const REQUEST_TIMEOUT_MS = 30000;
|
||||
const MAX_INPUT_CHARS = 2000;
|
||||
const MIN_TEXT_CHARS = 4;
|
||||
|
||||
function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultPrompt, model }) {
|
||||
const AI_URL = aiUrl || process.env.AI_URL || 'http://text-corrector:8080';
|
||||
const MODEL = model || process.env.AI_MODEL || 'qwen2.5-1.5b-instruct';
|
||||
const DEFAULT_PROMPT = defaultPrompt || 'Ты — редактор текстов. Исправь ошибки. Верни ТОЛЬКО исправленный текст.';
|
||||
|
||||
let started = false;
|
||||
let stopped = false;
|
||||
let idleMs = IDLE_MIN_MS;
|
||||
let wake = null;
|
||||
let processing = false;
|
||||
let currentId = null;
|
||||
let startedAt = null;
|
||||
const stats = { checks: 0, corrected: 0, unchanged: 0, errors: 0, last_at: null, last_error: null };
|
||||
const attempts = new Map();
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const CONFIG = {
|
||||
idle_min_ms: IDLE_MIN_MS,
|
||||
idle_max_ms: IDLE_MAX_MS,
|
||||
request_timeout_ms: REQUEST_TIMEOUT_MS,
|
||||
max_input_chars: MAX_INPUT_CHARS,
|
||||
min_text_chars: MIN_TEXT_CHARS,
|
||||
max_attempts: MAX_ATTEMPTS,
|
||||
model: MODEL,
|
||||
ai_url: AI_URL,
|
||||
};
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => {
|
||||
const t = setTimeout(() => { wake = null; resolve(); }, ms);
|
||||
wake = () => { clearTimeout(t); wake = null; resolve(); };
|
||||
});
|
||||
}
|
||||
|
||||
function notify() {
|
||||
if (wake) wake();
|
||||
}
|
||||
|
||||
async function isEnabled() {
|
||||
const v = await getSetting('ai_autocheck_enabled', 'true');
|
||||
return String(v) !== 'false';
|
||||
}
|
||||
|
||||
async function claimNext() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { rows } = await client.query(
|
||||
`SELECT id, description, description_original FROM entries
|
||||
WHERE ai_status = 'pending' AND deleted_at IS NULL
|
||||
ORDER BY id ASC LIMIT 1 FOR UPDATE SKIP LOCKED`
|
||||
);
|
||||
if (!rows.length) {
|
||||
await client.query('COMMIT');
|
||||
return null;
|
||||
}
|
||||
await client.query(`UPDATE entries SET ai_status = 'processing' WHERE id = $1`, [rows[0].id]);
|
||||
await client.query('COMMIT');
|
||||
return rows[0];
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function callModel(text, systemPrompt) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(`${AI_URL}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: text },
|
||||
],
|
||||
temperature: 0.1,
|
||||
max_tokens: Math.min(512, Math.max(64, text.length + 32)),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`AI service error: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return (data.choices?.[0]?.message?.content || '').trim();
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function sanitize(original, out) {
|
||||
if (!out) return original;
|
||||
const o = out.trim();
|
||||
if (!o) return original;
|
||||
if (o.length > original.length * 4 + 80) return original;
|
||||
return o;
|
||||
}
|
||||
|
||||
async function processOne(row) {
|
||||
const original = String(row.description_original ?? row.description ?? '').trim();
|
||||
if (original.length < MIN_TEXT_CHARS) {
|
||||
await pool.query(`UPDATE entries SET ai_status = 'skipped', ai_checked_at = now() WHERE id = $1`, [row.id]);
|
||||
return true;
|
||||
}
|
||||
const prompt = (await getSetting('ai_prompt', DEFAULT_PROMPT)) || DEFAULT_PROMPT;
|
||||
const input = original.length > MAX_INPUT_CHARS ? original.slice(0, MAX_INPUT_CHARS) : original;
|
||||
try {
|
||||
const out = await callModel(input, prompt);
|
||||
const checked = sanitize(input, out);
|
||||
const changed = checked !== input;
|
||||
await pool.query(
|
||||
`UPDATE entries SET description_ai = $1, description = $1, ai_status = 'done', ai_error = NULL, ai_checked_at = now() WHERE id = $2`,
|
||||
[changed ? checked : original, row.id]
|
||||
);
|
||||
attempts.delete(row.id);
|
||||
stats.checks++;
|
||||
if (changed) stats.corrected++; else stats.unchanged++;
|
||||
stats.last_at = new Date().toISOString();
|
||||
stats.last_error = null;
|
||||
if (logAudit) await logAudit(null, 'entry.ai.auto-check', { id: row.id, changed });
|
||||
return true;
|
||||
} catch (e) {
|
||||
const message = (e && e.message ? e.message : 'error').slice(0, 500);
|
||||
const tries = (attempts.get(row.id) || 0) + 1;
|
||||
stats.last_at = new Date().toISOString();
|
||||
stats.last_error = message;
|
||||
if (tries < MAX_ATTEMPTS) {
|
||||
attempts.set(row.id, tries);
|
||||
await pool.query(`UPDATE entries SET ai_status = 'pending', ai_error = $1 WHERE id = $2`, [message, row.id]);
|
||||
if (logAudit) await logAudit(null, 'entry.ai.retry', { id: row.id, attempt: tries, error: message });
|
||||
return false;
|
||||
}
|
||||
attempts.delete(row.id);
|
||||
stats.errors++;
|
||||
await pool.query(
|
||||
`UPDATE entries SET ai_status = 'error', ai_error = $1, ai_checked_at = now() WHERE id = $2`,
|
||||
[message, row.id]
|
||||
);
|
||||
if (logAudit) await logAudit(null, 'entry.ai.error', { id: row.id, error: message });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async function loop() {
|
||||
while (!stopped) {
|
||||
let row = null;
|
||||
try {
|
||||
if (!(await isEnabled())) {
|
||||
await sleep(IDLE_MAX_MS);
|
||||
continue;
|
||||
}
|
||||
row = await claimNext();
|
||||
} catch (e) {
|
||||
console.error('AI auto-check claim error:', e);
|
||||
await sleep(IDLE_MAX_MS);
|
||||
continue;
|
||||
}
|
||||
if (!row) {
|
||||
await sleep(idleMs);
|
||||
idleMs = Math.min(idleMs * 2, IDLE_MAX_MS);
|
||||
continue;
|
||||
}
|
||||
idleMs = IDLE_MIN_MS;
|
||||
processing = true;
|
||||
currentId = row.id;
|
||||
let ok = true;
|
||||
try {
|
||||
ok = await processOne(row);
|
||||
} catch (e) {
|
||||
console.error('AI auto-check process error:', e);
|
||||
} finally {
|
||||
processing = false;
|
||||
currentId = null;
|
||||
}
|
||||
if (!ok) await sleep(IDLE_MAX_MS);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetStale() {
|
||||
await pool.query(`UPDATE entries SET ai_status = 'pending' WHERE ai_status = 'processing'`);
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
startedAt = Date.now();
|
||||
resetStale()
|
||||
.catch((e) => console.error('AI auto-check reset error:', e))
|
||||
.finally(() => { loop().catch((e) => console.error('AI auto-check loop error:', e)); });
|
||||
}
|
||||
|
||||
function getStats() {
|
||||
return { ...stats, processing };
|
||||
}
|
||||
|
||||
function getInfo() {
|
||||
return {
|
||||
started_at: startedAt ? new Date(startedAt).toISOString() : null,
|
||||
uptime_ms: startedAt ? Date.now() - startedAt : 0,
|
||||
processing,
|
||||
current_id: currentId,
|
||||
config: CONFIG,
|
||||
...stats,
|
||||
};
|
||||
}
|
||||
|
||||
return { start, notify, getStats, getInfo };
|
||||
}
|
||||
|
||||
module.exports = { createEntryAutoChecker };
|
||||
Reference in New Issue
Block a user