Files
WhatIDo/public/settings.html
T
dev b931c0a760 feat(lesson-ai): проверка отчёта о занятии по шаблону + история версий
Галочка «Проверить по шаблону» в окне отчёта отправляет текст модели:
совпал с шаблоном — остаётся как есть (skipped), не совпал — переписывается
в деловом виде (done). Обработка идёт в фоне, HTTP-запрос не ждёт модель,
оригинал тьютора сохраняется в text_original.

- схема: text_original/text_ai/ai_status/ai_checked_at/ai_error в
  lesson_reports, таблица lesson_report_versions, ensureLessonReportsTable()
- настройки lesson_ai_enabled и lesson_ai_prompt (раздел sec-lesson-ai),
  значения только 'true'/'false'
- worker.js: createLessonReportChecker (FOR UPDATE OF lr SKIP LOCKED,
  до 3 попыток), хук назовён notifyEvent — notify в createPhotoEnhanceWorker
  уже занят будильником
- server.js: wakeLessonAiWorker, onLessonAiDone (версия, аудит с diff,
  уведомление lesson.ai.formatted, SSE lesson_report_status), маршруты
  /versions, /versions/:id/restore и /ai/revert
- aiComplete вместо aiCorrectText: общий вызов модели с таймаутом
- бэкап/восстановление: lesson_reports и lesson_report_versions в payload
- фронтенд: openLessonVersions/restoreLessonVersion в admin.js, бейджи
  статусов в lessons.js, лейблы аудита, renderAuditPager
- docs: раздел 3d в AGENTS.md и Agent Workflow, пункт в README
- тесты: контракт lesson-report и настройки уведомления в api.smoketest.js
2026-10-04 00:12:24 +03:00

418 lines
27 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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="settings">
<div class="layout">
<div class="sidebar" id="sidebar"></div>
<div class="main">
<div class="page-head">
<h2>Настройки</h2>
<p class="page-sub">Параметры формы, публичных страниц и данные системы</p>
<span class="page-status" id="authStatus"><span class="spinner dark"></span> Загрузка…</span>
<button class="head-btn" id="reloadBtn" type="button" title="Перезагрузить настройки"><i data-lucide="rotate-ccw"></i> Сбросить</button>
</div>
<div class="settings-layout">
<aside class="settings-nav" id="settingsNav">
<a href="#sec-system" data-nav="sec-system" class="active"><i data-lucide="bar-chart-3"></i> Система</a>
<a href="#sec-stack" data-nav="sec-stack"><i data-lucide="server"></i> Стек</a>
<a href="#sec-brand" data-nav="sec-brand"><i data-lucide="palette"></i> Брендинг</a>
<a href="#sec-antispam" data-nav="sec-antispam"><i data-lucide="shield"></i> Антиспам</a>
<a href="#sec-share" data-nav="sec-share"><i data-lucide="link"></i> Публичные ссылки</a>
<a href="#sec-form" data-nav="sec-form"><i data-lucide="file-text"></i> Форма ответа</a>
<a href="#sec-photo" data-nav="sec-photo"><i data-lucide="camera"></i> Фото</a>
<a href="#sec-photo-ai" data-nav="sec-photo-ai"><i data-lucide="sparkles"></i> Фото-ИИ</a>
<a href="#sec-notify" data-nav="sec-notify"><i data-lucide="bell"></i> Уведомления</a>
<a href="#sec-ai" data-nav="sec-ai"><i data-lucide="bot"></i> ИИ</a>
<a href="#sec-lesson-ai" data-nav="sec-lesson-ai"><i data-lucide="notebook-pen"></i> Шаблон отчёта</a>
<a href="#sec-backup" data-nav="sec-backup"><i data-lucide="database"></i> Бэкапы</a>
<a href="#sec-trash" data-nav="sec-trash"><i data-lucide="trash-2"></i> Корзина</a>
<a href="#sec-bans" data-nav="sec-bans"><i data-lucide="shield-off"></i> Блокировки</a>
</aside>
<div class="settings-stack settings-stack-grid">
<div class="settings-card wide" id="sec-system">
<div class="card-head">
<div class="ic"><i data-lucide="bar-chart-3"></i></div>
<h3>Системная информация</h3>
<button class="icon-btn" id="systemRefreshBtn" type="button" title="Обновить" aria-label="Обновить"><i data-lucide="refresh-cw"></i></button>
<span class="head-live" id="systemLive" title="Данные кэшируются на сервере"></span>
</div>
<div id="systemInfo" class="sys-grid">
<div class="sys-card loading">Загрузка…</div>
</div>
</div>
<div class="settings-card wide" id="sec-stack">
<div class="card-head">
<div class="ic"><i data-lucide="server"></i></div>
<h3>Статус стека</h3>
<button class="icon-btn" id="stackRefreshBtn" type="button" title="Обновить" aria-label="Обновить стек"><i data-lucide="refresh-cw"></i></button>
<span class="head-live" id="stackLive" title="Версии и состояние сервисов"></span>
</div>
<p class="hint">На чём работает приложение прямо сейчас: версии компонентов, состояние сервисов и нагрузка. Обновление перезагружает данные и этого блока, и системной информации выше.</p>
<div id="stackInfo" class="sys-grid">
<div class="sys-card loading">Загрузка…</div>
</div>
</div>
<div class="settings-card" id="sec-brand">
<div class="card-head">
<div class="ic"><i data-lucide="palette"></i></div>
<h3>Брендинг</h3>
</div>
<p class="hint">Название и логотип отображаются в админ-панели, на форме ответа ученика, странице входа и в отчёте.</p>
<div class="settings-field">
<label for="systemName">Название системы</label>
<input type="text" id="systemName" class="settings-input" maxlength="60" placeholder="WhatIDo">
</div>
<div class="settings-field">
<label>Логотип</label>
<div class="logo-picker">
<div class="logo-preview" id="logoPreview">
<span id="logoPreviewFallback">—</span>
<img id="logoPreviewImg" alt="Логотип">
</div>
<div class="logo-picker-actions">
<div class="logo-picker-btns">
<button class="btn-primary ghost" id="logoUploadBtn" type="button"><i data-lucide="upload"></i> Загрузить логотип</button>
<button class="btn-primary danger-ghost" id="logoRemoveBtn" type="button" hidden><i data-lucide="trash-2"></i> Удалить</button>
</div>
<input type="file" id="logoInput" accept="image/*" style="display:none">
<div class="logo-upload-status" id="logoUploadStatus"></div>
<span class="hint">Рекомендуется широкое изображение с прозрачным фоном (PNG/WebP), не более 1 МБ</span>
</div>
</div>
</div>
</div>
<div class="settings-card" id="sec-antispam">
<div class="card-head">
<div class="ic"><i data-lucide="shield"></i></div>
<h3>Защита от повторов</h3>
</div>
<p class="hint">Если ученик уже отправил ответ, следующая отправка от него будет отклонена в течение заданного времени. Минимальное значение — 1 минута.</p>
<div class="settings-field">
<label for="spamInterval">Интервал ожидания</label>
<div class="unit-row">
<input type="number" id="spamInterval" class="settings-input" min="1" max="1440" step="1">
<span class="unit">минут</span>
</div>
<span class="field-error" id="spamIntervalError"></span>
<div class="preset-btns" id="spamPresets">
<button class="preset-btn" type="button" data-min="1">1 мин</button>
<button class="preset-btn" type="button" data-min="15">15 мин</button>
<button class="preset-btn" type="button" data-min="30">30 мин</button>
<button class="preset-btn" type="button" data-min="60">1 час</button>
<button class="preset-btn" type="button" data-min="1440">Сутки</button>
</div>
<span class="hint" id="spamIntervalHint"></span>
</div>
</div>
<div class="settings-card" id="sec-share">
<div class="card-head">
<div class="ic"><i data-lucide="link"></i></div>
<h3>Страница публичной ссылки /s/&lt;hash&gt;</h3>
</div>
<p class="hint">Управление видимостью элементов на публичных страницах записей.</p>
<label class="toggle">
<input type="checkbox" id="shareShowMessage">
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Сообщение ученика</b><small>Текст «Что делала(а)» на странице по ссылке</small></span>
</label>
<label class="toggle">
<input type="checkbox" id="shareShowDate">
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Дата записи</b><small>Дата в карточке записи</small></span>
</label>
<label class="toggle">
<input type="checkbox" id="shareShowNames">
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Фамилии и имена</b><small>Имена учеников в галерее работ</small></span>
</label>
<label class="toggle">
<input type="checkbox" id="shareShowPhotos">
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Фото группы</b><small>Групповое фото в шапке страницы</small></span>
</label>
<div class="settings-field">
<label for="cookieNoticeText">Уведомление о cookie</label>
<textarea id="cookieNoticeText" class="settings-input" rows="3" style="min-height:80px"></textarea>
<span class="hint">Пусто — баннер не показывается</span>
</div>
</div>
<div class="settings-card" id="sec-ai">
<div class="card-head">
<div class="ic"><i data-lucide="bot"></i></div>
<h3>Промпт ИИ для исправления текста</h3>
</div>
<p class="hint">Инструкция для нейросети при исправлении ошибок в записи «Что делала(а)».</p>
<label class="toggle">
<input type="checkbox" id="aiAutoCheck">
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Автопроверка новых записей</b><small>Без участия человека — воркер сам обрабатывает очередь</small></span>
</label>
<div class="settings-field">
<label for="aiPrompt">Промпт</label>
<textarea id="aiPrompt" class="settings-input mono" rows="5" spellcheck="false"></textarea>
</div>
<div class="ai-queue" id="aiQueueInfo"></div>
</div>
<div class="settings-card" id="sec-lesson-ai">
<div class="card-head">
<div class="ic"><i data-lucide="notebook-pen"></i></div>
<h3>Шаблон отчёта о занятии</h3>
</div>
<p class="hint">Инструкция для нейросети: она сверяет текст отчёта тьютора с шаблоном делового сообщения. Если текст уже соответствует шаблону — остаётся без изменений, иначе приводится к деловому виду. Оригинал тьютора всегда сохраняется, результат можно посмотреть в истории версий.</p>
<label class="toggle">
<input type="checkbox" id="lessonAiEnabled">
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Проверять отчёты по шаблону</b><small>Применяется, когда тьютор включил галочку в окне отчёта о занятии</small></span>
</label>
<div class="settings-field">
<label for="lessonAiPrompt">Шаблон и правила</label>
<textarea id="lessonAiPrompt" class="settings-input mono" rows="12" spellcheck="false"></textarea>
<span class="hint">Пусто — будет подставлен шаблон по умолчанию</span>
</div>
</div>
<div class="settings-card">
<div class="card-head">
<div class="ic"><i data-lucide="boxes"></i></div>
<h3>Профили моделей ИИ</h3>
</div>
<p class="hint">Модель, выбранная по умолчанию, обрабатывает записи учеников. «Нативная» — llama.cpp в Docker. Можно добавить любого провайдера с OpenAI-совместимым API (OpenRouter, OpenAI, Ollama и др.).</p>
<div class="ai-queue" id="aiProfileActive"></div>
<div class="card-foot" style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:8px;font-size:.88rem;min-width:0;max-width:100%">
Модель по умолчанию:
<select id="aiActiveProfile" class="settings-input" style="width:auto;min-width:0;max-width:100%;flex:1 1 200px">
<option value="native">Нативная (llama.cpp)</option>
</select>
</label>
<button class="btn-primary ghost" id="aiProfileAddBtn" type="button"><i data-lucide="plus"></i> Добавить профиль</button>
</div>
<div id="aiProfilesList" class="ai-profiles-list"></div>
</div>
<div class="settings-card" id="sec-backup">
<div class="card-head">
<div class="ic"><i data-lucide="database"></i></div>
<h3>Резервное копирование</h3>
<button class="icon-btn" id="statsRefreshBtn" type="button" title="Обновить статистику" aria-label="Обновить статистику"><i data-lucide="refresh-cw"></i></button>
</div>
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Архив формируется на сервере, скачивание выполняет браузер — при обрыве связи загрузку можно продолжить. Восстановление полностью заменит текущие данные.</p>
<div class="data-stats" id="dataStats"><span class="chip">Загрузка статистики…</span></div>
<div class="card-foot">
<button class="btn-primary ghost" id="backupBtn" type="button"><i data-lucide="download"></i> Скачать бэкап</button>
<button class="btn-primary ghost danger" id="restoreBtn" type="button"><i data-lucide="upload"></i> Восстановить из файла</button>
<input type="file" id="restoreFile" accept=".tar.gz,.gz,application/gzip" style="display:none">
</div>
<div class="restore-status" id="restoreStatus"></div>
<div class="restore-status" id="backupStatus"></div>
</div>
<div class="settings-card" id="sec-form">
<div class="card-head">
<div class="ic"><i data-lucide="file-text"></i></div>
<h3>Футер на форме</h3>
</div>
<p class="hint">Тексты показываются внизу формы ответа ученика.</p>
<div class="settings-row">
<div class="settings-field">
<label for="footerLeft">Текст слева</label>
<input type="text" id="footerLeft" class="settings-input" placeholder="Например: Поле, руководитель">
</div>
<div class="settings-field">
<label for="footerRight">Текст справа</label>
<input type="text" id="footerRight" class="settings-input" placeholder="Например: Год, курс">
</div>
</div>
</div>
<div class="settings-card" id="sec-photo">
<div class="card-head">
<div class="ic"><i data-lucide="camera"></i></div>
<h3>Фотография с веб-камеры</h3>
</div>
<p class="hint">Разрешение и качество снимка на форме ответа ученика. Камера может выдать разрешение, ближайшее к выбранному.</p>
<label class="toggle">
<input type="checkbox" id="camEnabled">
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Кнопка «Камера»</b><small>Показывать кнопку «Камера» на форме ответа. Если выключено, фото делается кликом по превью «Нажми чтобы сделать фото»</small></span>
</label>
<div class="settings-row">
<div class="settings-field">
<label for="photoResolution">Разрешение</label>
<select id="photoResolution" class="settings-input">
<option value="640x480">640 × 480 (по умолчанию)</option>
<option value="1280x720">1280 × 720 (HD)</option>
<option value="1920x1080">1920 × 1080 (Full HD)</option>
<option value="2560x1440">2560 × 1440 (QHD)</option>
</select>
</div>
<div class="settings-field">
<label for="photoQuality">Качество JPEG</label>
<div class="unit-row">
<input type="number" id="photoQuality" class="settings-input" min="0.5" max="1" step="0.01" value="0.92">
<span class="unit">0.5–1.0</span>
</div>
<span class="hint">Больше значение — лучше качество и крупнее файл</span>
</div>
<div class="settings-field">
<label for="photoEngine">Обработка улучшения фото</label>
<select id="photoEngine" class="settings-input">
<option value="auto">Авто (сервер, иначе клиент)</option>
<option value="server">Сервер (sharp)</option>
<option value="client">Клиент (браузер)</option>
</select>
<span class="hint">Серверный режим обрабатывает оригинал без пережатия и быстрее на больших фото</span>
</div>
</div>
</div>
<div class="settings-card" id="sec-photo-ai">
<div class="card-head">
<div class="ic"><i data-lucide="sparkles"></i></div>
<h3>Фото-ИИ</h3>
<button class="icon-btn" id="photoAiRefreshBtn" type="button" title="Обновить статус фото-ИИ" aria-label="Обновить статус фото-ИИ"><i data-lucide="refresh-cw"></i></button>
</div>
<p class="hint">Нейросетевая обработка фотографий: Real-ESRGAN увеличивает разрешение, GFPGAN восстанавливает лица. Лицевой режим включается только явно — модель дорисовывает лицо по приору и может изменить черты. Без `PHOTO_AI_URL` кнопка «🤖 ИИ» в журнале скрыта.</p>
<div class="settings-row">
<div class="settings-field">
<label for="photoAiFaceMode">Режим по умолчанию</label>
<select id="photoAiFaceMode" class="settings-input">
<option value="off">Без лиц — только увеличение</option>
<option value="face">Лица — восстановить лица, фон увеличить</option>
<option value="all">Лица + фон — восстановить лица и снять шум фона</option>
</select>
<span class="hint">Предлагается в окне улучшения фото, режим можно переключить там же</span>
</div>
<div class="settings-field">
<label for="photoAiFaceModel">Модель лиц</label>
<select id="photoAiFaceModel" class="settings-input">
<option value="gfpgan">GFPGAN v1.4</option>
<option value="codeformer">CodeFormer</option>
</select>
<span class="hint">CodeFormer работает, только если модуль установлен в образ photo-ai</span>
</div>
<div class="settings-field">
<label for="photoAiDevicePref">Желаемое устройство</label>
<select id="photoAiDevicePref" class="settings-input">
<option value="auto">Авто (GPU, если доступен)</option>
<option value="cuda">GPU (CUDA)</option>
<option value="cpu">CPU</option>
</select>
<span class="hint">Документирующее значение: фактическое устройство задаёт переменная PHOTO_AI_DEVICE в контейнере</span>
</div>
</div>
<div class="data-stats" id="photoAiStatus"><span class="hint">Загрузка статуса фото-ИИ…</span></div>
</div>
<div class="settings-card" id="sec-trash">
<div class="card-head">
<div class="ic"><i data-lucide="trash-2"></i></div>
<h3>Корзина</h3>
</div>
<p class="hint">При очистке корзины или удалении пункта данные не удаляются сразу, а помечаются на удаление и исчезают из корзины. Через указанное количество дней они удаляются автоматически и безвозвратно вместе с файлами. До этого помеченные данные можно вернуть в корзину и восстановить (страница «Корзина»).</p>
<div class="settings-field">
<label for="trashPurgeDays">Время до безвозвратного удаления</label>
<div class="unit-row">
<input type="number" id="trashPurgeDays" class="settings-input" min="1" max="365" step="1">
<span class="unit">дней</span>
</div>
<span class="hint">От 1 до 365 дней</span>
</div>
</div>
<div class="settings-card wide" id="sec-notify">
<div class="card-head">
<div class="ic"><i data-lucide="bell"></i></div>
<h3>Уведомления</h3>
<button class="icon-btn" id="notifyTestBtn" type="button" title="Отправить тестовое уведомление" aria-label="Тестовое уведомление"><i data-lucide="send"></i></button>
</div>
<p class="hint">Уведомления собираются в «колокольчике» админ-панели и на странице «Уведомления». Здесь выбирается, о каких событиях сообщать: новые записи журнала, обработка фото нейросетью, блокировки IP, бэкапы и другое.</p>
<label class="toggle">
<input type="checkbox" id="notifyEnabled">
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Уведомления включены</b><small>Общий выключатель: при снятии галочки не создаётся ни одно уведомление</small></span>
</label>
<div class="settings-field">
<label for="notifyRetentionDays">Хранение уведомлений</label>
<div class="unit-row">
<input type="number" id="notifyRetentionDays" class="settings-input" min="1" max="365" step="1">
<span class="unit">дней</span>
</div>
<span class="hint">От 1 до 365 дней, старые уведомления удаляются автоматически</span>
</div>
<div class="notify-types" id="notifyTypes"><span class="hint">Загрузка…</span></div>
</div>
<div class="settings-card" id="sec-bans">
<div class="card-head">
<div class="ic"><i data-lucide="shield-off"></i></div>
<h3>Блокировки IP</h3>
</div>
<p class="hint">IP автоматически блокируется на 24 часа: за заполнение скрытого антиспам-поля, за 10 неудачных попыток входа или 10 неверных паролей на защищённой ссылке.</p>
<div class="data-stats" id="bansList"><span class="chip">Загрузка…</span></div>
</div>
</div>
</div>
</div>
</div>
<div class="save-bar" id="saveBar">
<span class="hint" id="saveHint"><i data-lucide="triangle-alert"></i> Есть несохранённые изменения</span>
<span class="save-bar-actions">
<button class="btn-primary ghost" id="resetBtn" type="button"><i data-lucide="rotate-ccw"></i> Отменить</button>
<button class="btn-primary" id="saveBtn" type="button"><i data-lucide="check"></i> Сохранить</button>
</span>
</div>
<div class="modal-overlay" id="aiProfileModal">
<div class="edit-modal" style="max-width:560px">
<h3 id="aiProfileModalTitle">Добавить профиль ИИ</h3>
<div class="settings-field">
<label for="aiProfileName">Название профиля <span style="color:#ef4444">*</span></label>
<input type="text" id="aiProfileName" class="settings-input" placeholder="Например: OpenRouter GPT-4o-mini">
</div>
<div class="settings-field">
<label for="aiProfileModel">Модель <span style="color:#ef4444">*</span></label>
<input type="text" id="aiProfileModel" class="settings-input" placeholder="openai/gpt-4o-mini">
</div>
<div class="settings-field">
<label for="aiProfileBaseUrl">Base URL (OpenAI-совместимый API) <span style="color:#ef4444">*</span></label>
<input type="text" id="aiProfileBaseUrl" class="settings-input" placeholder="https://openrouter.ai/api/v1">
</div>
<div class="settings-field">
<label for="aiProfileApiKey">API-ключ (если требуется)</label>
<input type="password" id="aiProfileApiKey" class="settings-input" placeholder="sk-...">
</div>
<div class="settings-field">
<label for="aiProfileMaxTokens">Max tokens (лимит ответа модели)</label>
<input type="number" id="aiProfileMaxTokens" class="settings-input" min="16" max="32768" placeholder="Авто (1024–4096)">
<span class="hint">Оставьте пустым для авто. Для reasoning-моделей (gpt-oss, deepseek-r1) нужно 1024+ — иначе рассуждения не уместятся и ответ придёт пустым.</span>
</div>
<div class="ai-test-status" id="aiProfileTestStatus"></div>
<div class="actions" style="flex-wrap:wrap">
<button class="btn-primary" id="aiProfileSaveBtn" type="button">Сохранить</button>
<button class="btn-primary ghost" id="aiProfileTestBtn" type="button"><i data-lucide="plug-zap"></i> Проверить</button>
<button class="btn-primary ghost" id="aiProfileCancelBtn" type="button">Отмена</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script>
<script src="js/settings.js"></script>
</body>
</html>