14 Commits
Author SHA1 Message Date
dev 19b2365b4c fix(backup): ссылка на архив переживает F5 и повторное скачивание
HEAD-проба в downloadBackup() сжигала одноразовый тикет: Express 4
прогоняет HEAD через GET-хендлер /api/backup/:token, который удалял
тикет и файл до отдачи архива, поэтому настоящий GET всегда получал
404 «Ссылка на бэкап устарела».

- server.js: GET /api/backup/:token больше не удаляет тикет и файл —
  ссылка живёт BACKUP_TTL_MS (30 мин), Range-докачка работает
- вынес dropBackupTicket(), fs.rmSync обёрнут в try/catch
- BACKUP_TICKETS_MAX = 3: лишние тикеты вычищаются по возрасту
- sweepBackupStorage() режет с запасом 5 минут сверх TTL
- settings.js: убрана HEAD-проба, в #backupStatus рендерится реальная
  кликабельная ссылка вместо невидимого синтетического <a>
- AGENTS.md: раздел 8 — новое поведение + предупреждение про HEAD
2026-10-04 09:07:16 +03:00
dev 76d36e5c35 feat(backup): формат бэкапа v2 — audit/уведомления/баны, counts и одноразовый тикет
Валидация и нормализация restore вынесены из server.js в backup-restore.js,
покрыты юнит-тестами backup.selftest.js (30+ проверок, без стенда).

В бэкап добавлены audit_log, notifications, notification_reads, banned_ips,
счётчики counts, метаданные приложения и версия формата (принимаются 1..2).
Тикет бэкапа стал одноразовым: файл удаляется сразу после отдачи,
uploadBackup фильтрует расширения. sessions в бэкап не входит намеренно.

sweepOrphanedUploads учитывает аватары, обложки, оригиналы фото и photo_jobs —
иначе restore сносил файлы сразу после восстановления.
storage.getStream при STORAGE_LOCAL_FALLBACK читает локальный файл, а не S3.
2026-10-04 01:36:58 +03:00
dev 32aabe9a80 fix(lesson-ai): обновление промпта у существующих БД + правило в AGENTS.md
INSERT ... ON CONFLICT (key) DO NOTHING вставляет значение только если
ключа ещё нет, поэтому новый шаблон промпта доезжал лишь до свежих
установок — у всех, кто уже пользовался разделом sec-lesson-ai, в settings
оставался старый промпт.

- в db/migration.sql добавлен идемпотентный
  UPDATE settings SET value = '<новый промпт>'
  WHERE key = 'lesson_ai_prompt' AND value IN ('<старый дефолт>')
  по образцу уже существующей миграции для ai_prompt
- условие по value IN (...) обязательно: без него миграция затёрла бы
  промпт, отредактированный админом в UI
- в db/init.sql UPDATE не добавляется: файл выполняется только на пустой БД
- AGENTS.md, раздел 2: правило «Сид настроек» (DO NOTHING не обновляет
  существующие значения) и «Проверка сида» (прогон в транзакции с откатом)
- AGENTS.md, раздел 3d: смена шаблона промпта — четыре правки, а не три;
  все текстовые копии должны быть побайтово идентичны LESSON_AI_DEFAULT_PROMPT

Проверено на postgres:16 в транзакции с откатом, ON_ERROR_STOP=1
- старый дефолт (935 симв.) -> миграция -> новый (2348 симв.)
- повторный прогон ничего не меняет (идемпотентно)
- кастомный промпт админа миграцией не затрагивается
2026-10-04 00:58:12 +03:00
dev 2363d099d2 feat(lesson-ai): новый шаблон промпта + предупреждение о дубле отчёта
- заменён дефолтный промпт редактуры отчёта о занятии на формат
  «сообщение тьютора»: связный текст от 3-го лица, 1 абзац 2–4
  предложения, начало «На занятии ребята …», практика «В конце занятия …»,
  без группы/даты/времени и markdown, обращение на «вы» убрано, «они» → «каждый»
- синхронизированы три копии промпта: LESSON_AI_DEFAULT_PROMPT в server.js
  и сид lesson_ai_prompt в db/init.sql и db/migration.sql; добавлен пример
  «плохо → хорошо» с запретом переносить факты примера
- дубль отчёта в модалке создания больше не автозаполняет форму: показывается
  баннер #lessonDup с кнопкой перехода к существующему отчёту
- проверка на дубль срабатывает и при смене даты (#lessonDate), защищена
  счётчиком поколений lessonDupSeq от гонки при быстрой смене группы/даты
- стили .lesson-dup в public/admin.css, уточнена подсказка в карточке
  шаблона отчёта на public/settings.html
- DDL не менялся: ни таблиц, ни колонок, ни индексов
2026-10-04 00:54:23 +03:00
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
dev a84f33307e feat(lessons): отчёты о занятиях — таблица, CRUD, страница и блок на дашборде
Добавлена сущность «что прошли на занятии»:
- lesson_reports (init.sql + migration.sql + ensureLessonReportsTable)
- GET/POST /api/lesson-reports, PUT/DELETE /api/lesson-reports/:id
  с branchScope, уникальностью (group_id, lesson_date) и лимитом текста
- уведомление lesson.report (NOTIFY_TYPES + настройка + иконка)
- кэш-префикс lessons: + инвалидация stats:/dashboard:
- восстановление lesson_reports в normalizeRestoreData
- блок recent_lessons в /api/dashboard

Фронтенд:
- public/lessons.html + public/js/lessons.js — список с фильтрами и правкой
- openLessonModal в admin.js — общая модалка из журнала и дашборда
- кнопки в журнале и быстрые действия дашборда

Также исправлен сдвиг индексов параметров в notificationsScope —
$1 уходил повторно в список филиалов из-за params.push без смещения.
2026-10-03 23:33:45 +03:00
dev 0dc8ffdb87 feat(export): фото ученика и логотип в экспорте профиля
- /api/export/student: выборка student_photos, отдельный блок photos/ и
  метаданные student_photos в data.json, счётчик в totals
- логотип из system_logo кладётся в корень архива и подставляется в шапку
- student-report.js: секция «Фотографии ученика», логотип в топбаре,
  навигация ограничена 5 пунктами, пустые секции (Опыт работы) не рендерятся
- главное фото профиля/обложки приоритетно берётся из личных фото ученика
2026-10-03 22:57:35 +03:00
dev 2377707297 feat(students): аватары в списке студентов с превью и лайтбоксом
Круглый аватар слева от поля имени: миниатюра главного фото через thumbSrc, иначе инициалы. Клик по фото открывает imgModal с полным размером, клик по пустому — карточку профиля для загрузки. Аватар синхронизируется после загрузки, смены и удаления фото.
2026-10-03 21:33:25 +03:00
dev 70b0c7ae9b feat: env-driven upload limits + request timeout, inline video playback, range requests
- Add UPLOAD_FILE_LIMIT_MB/UPLOAD_TOTAL_LIMIT_MB env (defaults 50/200), compute request timeout from total limit or UPLOAD_REQUEST_TIMEOUT_MS
- Expose upload limits via /api/public-settings and sync in frontend (remove hardcoded 50MB assumption)
- Add byte-range support in storage (getRange/streamRangeTo) and serve Content-Range/Accept-Ranges for S3/local
- Implement inline playable video delivery for browser formats (mp4/m4v/webm/ogv) with ?play=1, range requests, proper 206/416
- Add video modal in journal UI with player and download fallback
- Update docs (AGENTS.md/PRD.md/README.md), styles for video modal, add instructions/TODO.md and screenshots
- Extend MIME types for media
2026-10-03 12:00:48 +03:00
dev 104bdc4f49 feat(uploads): лимиты загрузки в env, 50 МБ на файл и 200 МБ на запись
Лимиты были захардкожены в четырёх местах фронтенда и в константах multer,
из-за чего расходились с текстами ошибок на сервере.

- UPLOAD_FILE_LIMIT_MB (50) и UPLOAD_TOTAL_LIMIT_MB (200) читаются из env;
  оба multer-конфига (upload, adminUpload) берут fileSize из них, тексты
  ошибок собираются из тех же констант вместо литералов
- UPLOAD_REQUEST_TIMEOUT_MS снимает дефолт Node в 5 минут: считается как
  UPLOAD_TOTAL_LIMIT_MB * 7500, иначе 200 МБ по мобильной сети не успевают
- GET /api/public-settings отдаёт upload_file_limit_mb / upload_total_limit_mb,
  фронтенд читает их вместо собственных констант

Проверено на живом стеке: 20 МБ и 180 МБ суммарно принимаются, 55 МБ и
225 МБ отклоняются с верными сообщениями, скачивание 45 МБ из S3 совпадает
по sha256 с оригиналом, api.smoketest.js — 57 PASS / 0 FAIL.
2026-10-03 10:38:36 +03:00
dev 449b86955e feat(groups): расширенный экспорт фото — галерея и оригиналы 2026-10-02 13:07:47 +03:00
dev 55c4b281c3 feat(groups): ZIP-архив группы с выбором категорий и режима фото
Форма архива файлов группы на /groups.html получила галочки выгрузки:
- «Работы (файлы проектов)» — включена по умолчанию
- «Фото записи» — выключена по умолчанию
- режим фото «Последние» / «Все», по умолчанию «Последние»;
  радиокнопки блокируются, когда «Фото записи» выключена

Дефолты периода: дата «с» пустая, дата «по» — сегодня.

GET /api/groups/:id/export/files принимает include_files,
include_photos и photos_mode:
- latest — только entries.photo_path, то главное обработанное фото записи
- all — плюс вся галерея записи из entry_photos
- 400, если обе категории выключены

Запросы к БД для отключённых категорий не выполняются; в data.json
и в аудит добавлены options и раздельные счётчики works/photos.

В аудите добавлена метка export.group_files.

Проверено на стенде: группа с многофото-записями даёт 4 фото в режиме
«последние» и 6 в режиме «все» (совпадает с БД: 4 главных + 2 галереи),
категории комбинируются независимо. api.smoketest.js 57/57,
diff.selftest.js 16/16.
2026-10-02 12:48:36 +03:00
dev 45033bffa8 feat(photo-ai): Stage 5 — фронтенд фото-ИИ (режимы, статус, метаданные)
Только фронтенд: server.js, worker.js, photo-ai/, compose и схема БД не тронуты.
Контракт /enhance-ai, /api/photo-ai/health и service в /api/photo-jobs/status
взят из Stage 4 как есть (I6 — новых колонок нет, метаданные из photo_jobs.params
и audit_log.target).

journal.html + journal.js:
- селект режима рядом с «🤖 ИИ»: Универсально (x2) / Быстро (x4) / Лица /
  Лица + фон; PHOTO_AI_MODES — единственная таблица режим→{model, face}
- значение уходит в POST .../enhance-ai телом {model, face, face_model};
  face_model подставляется только когда face !== 'off'
- дефолт селекта из photo_ai_face_mode (face=all → facesbg, face=face → faces)
- подсказка про медленный CPU: loadPhotoAiDevice() читает
  GET /api/photo-ai/health (только для админа)
- текст подтверждения и надпись «ИИ обрабатывает…» различают апскейл и face-режим
- кнопка и селект скрываются вместе при photo_ai_enabled === 'false' (I3)

settings.html + settings.js:
- новый раздел «Фото-ИИ»: режим по умолчанию, модель лиц, желаемое устройство
- read-only статус из /api/photo-jobs/status: фактическое устройство,
  device_name, VRAM, модели/модели лиц/загруженные; жёлтым — расхождение с
  желаемым устройством, красным — недоступность и незаданный PHOTO_AI_URL
- renderStackInfo() рисует карточку «Фото-ИИ» из stack.photo_ai
- новые id в DIRTY_FIELDS и в payload PUT /api/settings
- photo_ai_device_pref документирующий: фактическое устройство задаёт
  PHOTO_AI_DEVICE в контейнере

worker.html + worker.js:
- PHOTO_ACTION_LABELS: ai_face «ИИ + лица», ai_upscale «ИИ-апскейл»
- в модалке сравнения — чипы с моделью, режимом лиц, найденными лицами,
  устройством, временем обработки и предупреждениями
- строка состояния учитывает service.reachable === false («задания ждут»)
  и дописывает «· расчёт на <device>»

audit.js: 10 меток для кодов, которые раньше показывались сырыми (профиль и
фото ученика, главное фото, фото модуля, удаление/очистка уведомлений, логотип).

Проверено: node --check для всех четырёх js. Приёмка на живом стеке в этом
изменении не гонялась — чекбокс приёмки Stage 5 в TODO_PHOTO_FACE_AI.md
оставлен пустым, там же журнал раздела 5 с замечаниями для Stage 6.
2026-09-30 11:15:54 +03:00
dev 884188e9ec feat(photo-ai): Stage 4 — face-режим, апскейл-модели и метаданные в аудите
Воркер и сервер принимают параметры ИИ-обработки фото: модель апскейла
(x2plus / general-x4v3 / animevideo-v3), режим лиц (off / face / all),
модель лиц (gfpgan / codeformer) и strength. Пустое тело запроса ведёт себя
как раньше: action='ai', params=NULL (инвариант I2).

worker.js:
- таймаут выбирается по params.face: PHOTO_AI_TIMEOUT_MS для апскейла,
  PHOTO_AI_FACE_TIMEOUT_MS (600000) для face-режима
- runAiEnhance шлёт model/face/face_model/strength и понимает оба
  контракта: JSON с image_base64 и сырой image/jpeg старого сервиса
- тело не-2xx ответа больше не выбрасывается: readErrorBody() добавляет
  причину к сообщению, иначе оператор видит «ИИ-сервис ответил 400» без
  объяснения
- applyResult пишет в аудит model/face/face_model/device/faces_found/
  elapsed_ms/warnings и выбирает текст уведомления по факту режима;
  warnings видны оператору, если лица не нашлись
- CONFIG: + face_timeout_ms, default_model, face_model

server.js:
- POST /api/entries/:id/photo/enhance-ai принимает и валидирует тело до
  запроса записи — невалидный вход даёт 400, а не 404/500
- PHOTO_JOB_ACTIONS вынесен на уровень модуля, + ai_face и ai_upscale
- GET /api/photo-ai/health (requireAdmin) — прямой прокси /health
- photoAiHealth(timeoutMs), в «Статусе стека» вызывается с 2000 мс
- getStackInfo(): блок photo_ai (engine, host, device, vram, модели)
- настройки photo_ai_face_mode / photo_ai_face_model / photo_ai_device_pref
  с валидацией в PUT /api/settings, дефолты в init.sql, migration.sql,
  public-settings и ensurePhotoJobsTable()

Приёмка (живой стек, CPU + отдельно CUDA) — в TODO_PHOTO_FACE_AI.md,
журнал раздела 4: I1 байт-в-байт 5/5 и совпадение sha256 с raw-путём,
I2, I3 при пустом PHOTO_AI_URL, I5 на обрыве и на 503 с Retry-After,
7 невалидных тел → 400, api.smoketest.js 57 PASS.
2026-09-29 15:08:46 +03:00
99 changed files with 4206 additions and 524 deletions
+12
View File
@@ -4,6 +4,18 @@ DB_PASSWORD=случайная-длинная-строка
# Лимит размера загружаемого на восстановление бэкапа, МБ (по умолчанию 500) # Лимит размера загружаемого на восстановление бэкапа, МБ (по умолчанию 500)
BACKUP_UPLOAD_LIMIT_MB=500 BACKUP_UPLOAD_LIMIT_MB=500
# === Лимиты загрузки файлов ===
# Максимальный размер одного файла, МБ (по умолчанию 50). Применяется ко всем
# загрузкам: файлы проекта и фото учеников, фото групп и учеников, логотип,
# фото модулей. Нельзя делать меньше UPLOAD_TOTAL_LIMIT_MB.
UPLOAD_FILE_LIMIT_MB=50
# Максимальный суммарный размер вложений одной записи, МБ (по умолчанию 200).
UPLOAD_TOTAL_LIMIT_MB=200
# Таймаут приёма запроса, мс. Пусто — считается автоматически
# (UPLOAD_TOTAL_LIMIT_MB * 7500, минимум 5 минут). Увеличьте, если ученики
# жалуются на обрыв загрузки на медленной мобильной связи.
UPLOAD_REQUEST_TIMEOUT_MS=
# === Redis (кэш, rate limit, баны IP, pub/sub) === # === Redis (кэш, rate limit, баны IP, pub/sub) ===
# Пароль Redis. Обязателен, если Redis включён в docker-compose. # Пароль Redis. Обязателен, если Redis включён в docker-compose.
REDIS_PASSWORD=замените-на-длинный-секрет REDIS_PASSWORD=замените-на-длинный-секрет
+74 -8
View File
@@ -2,6 +2,8 @@
This document defines how AI agents should work with the WhatIDo codebase. Follow these rules strictly. This document defines how AI agents should work with the WhatIDo codebase. Follow these rules strictly.
> **Обязательное правило:** разведку, чтение и анализ репозитория делают **субагенты**, а не основной агент — контекст основного агента не должен раздуваться простынями кода. См. [Agent Workflow](#agent-workflow-обязательные-правила-работы).
--- ---
## Project Overview ## Project Overview
@@ -15,6 +17,37 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
--- ---
## Agent Workflow (обязательные правила работы)
**Главное правило: исследование репозитория выполняют субагенты, а не основной агент.** Контекст основного агента — самый дорогой ресурс проекта: он живёт дольше одной задачи и должен содержать план, решения и точные точки правки, а не выгрузку файлов. Любой поиск, чтение и анализ файлов, которые пользователь явно не назвал, отдаются субагенту (`spawn_agent`).
### Что делегировать суагенту (обязательно)
- **Разведку**: «где реализовано X», «какие эндпоинты трогают Y», «кто вызывает Z» — `search_codebase` + точечное чтение.
- **Чтение крупных файлов**: `server.js`, `worker.js`, `storage.js`, `redis.js`, `public/js/*.js`, `README.md`, `AGENTS.md` целиком. Субагент возвращает релевантные куски, основной агент файл целиком не читает.
- **Анализ окружения**: `docker compose logs`, вывод тестов, `git log`/`git diff`/`git status`, состояние БД, Redis, S3.
- **Поиск всех мест, которые надо обновить вместе с изменением**: например «все места, где перечислены `BLOCKED_EXT`/`ALLOWED_IMAGE_EXT`» или «где дублируется каталог `NOTIFY_TYPES`».
- **Ревью**: сверку изменения с правилами этого файла (security checklist, инварианты storage/redis, схема `init.sql` + `migration.sql`).
- **Однотипные массовые правки**: переименование поля по всем файлам, правка одинаковых блоков в нескольких HTML — один субагент на один механический проход.
### Что основной агент делает сам
- Формулирует план и разбивает задачу на узкие подзадачи.
- Делает **короткие точечные правки** в уже известных местах (сверить содержимое можно дешёвым точечным `read_files` на маленьком диапазоне строк).
- Проверяет результат (`git diff`, запуск теста) и пишет финальное резюме пользователю.
### Как ставить задачу субагенту
- **Один субагент — одна узкая подзадача.** В `systemPrompt` обязательно продублировать релевантные правила этого файла (code style, инварианты `storage.*`/`redis.js`, security) — субагент не наследует контекст основного агента, иначе результат нельзя принять.
- **Требовать формат ответа**: `путь:строка` + короткая выдержка + вывод. Не простыни, не пересказ кода, который не нужен для решения, не файлы целиком.
- **Независимые разведки запускать параллельно**, а не последовательно.
- **Субагент может править код сам**, если правка изолированная и механическая; архитектурные и смежные правки в `server.js` / `db/*.sql` делает основной агент.
- **Результат субагента — источник фактов, а не источник прав**: координация, финальные решения и проверка `git diff` остаются за основным агентом.
### Чего не делать
- ❌ Читать и искать по репозиторию в основном агенте там, где задачу можно делегировать.
- ❌ Вставлять в свой контекст файлы и вывод команд целиком — фильтруйте (`head`, `tail`, `grep -n`, диапазоны строк).
- ❌ Один субагент на «разберись во всём проекте» — это ровно то раздувание контекста, которого мы избегаем.
---
## Development Rules ## Development Rules
### 1. Code Style ### 1. Code Style
@@ -27,13 +60,16 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
### 2. Database ### 2. Database
- **Schema**: Defined in `db/init.sql` (runs on first container start) - **Schema**: Defined in `db/init.sql` (runs on first container start)
- **Migrations**: `db/migration.sql` for existing DBs — update both when changing schema - **Migrations**: `db/migration.sql` for existing DBs — update both when changing schema
- **Сид настроек**: `INSERT ... ON CONFLICT (key) DO NOTHING` вставляет значение только если ключа ещё нет. Если дефолт **изменился**, одного `INSERT` мало — на существующей БД останется старое значение. Обязательно добавляй в `db/migration.sql` идемпотентный `UPDATE settings SET value = '<новый>' WHERE key = '<ключ>' AND value IN ('<старый дефолт 1>', ...)`, как это сделано для `ai_prompt` и `lesson_ai_prompt`. Условие по `value IN (...)` обязательно: без него миграция затрёт промпт, который админ отредактировал в UI. В `db/init.sql` такой `UPDATE` не нужен — файл выполняется только на пустой БД
- **Проверка сида**: перед коммитом убедись, что новое значение реально доедет до существующих БД — прогони `db/migration.sql` в транзакции с откатом (`BEGIN;` + файл + `SELECT` + `ROLLBACK;`) и убедись, что `ON_ERROR_STOP=1` не дал ошибок
- **Connection**: Single `Pool` from `pg`, `DATABASE_URL` from env - **Connection**: Single `Pool` from `pg`, `DATABASE_URL` from env
- **Queries**: Parameterized only (`$1`, `$2`...), never string interpolation - **Queries**: Parameterized only (`$1`, `$2`...), never string interpolation
- **Transactions**: Use `client.query('BEGIN')` / `COMMIT` / `ROLLBACK` for multi-statement ops - **Transactions**: Use `client.query('BEGIN')` / `COMMIT` / `ROLLBACK` for multi-statement ops
### 3. File Uploads ### 3. File Uploads
- **Multer configs**: `upload` (images only), `adminUpload` (wider allowed ext), `uploadBackup` (restore) - **Multer configs**: `upload` (images only), `adminUpload` (wider allowed ext), `uploadBackup` (restore)
- **Limits**: 10 MB/file, 30 MB total per entry - **Видео в интерфейсе**: `mp4`/`m4v`/`webm`/`ogv` играются в модалке `#videoModal` в `journal.html` (`data-video` в `filesHTML`); остальные видео (`mov`, `mkv`, `avi`, …) остаются обычными ссылками на скачивание. Отдача — `GET /api/files/:token?play=1` **inline** с `Accept-Ranges`; без `?play=1` файл по-прежнему уходит как `attachment`, чтобы старые ссылки не поменяли поведение
- **Limits**: `UPLOAD_FILE_LIMIT_MB` per file (default 50), `UPLOAD_TOTAL_LIMIT_MB` per entry (default 200) — both env-driven; `UPLOAD_REQUEST_TIMEOUT_MS` overrides the auto-computed request timeout. The frontend reads the two MB values from `GET /api/public-settings` (`upload_file_limit_mb`, `upload_total_limit_mb`) — do not hardcode them again in `public/js/index.js`
- **Staging**: Multer always writes to `uploads/` (`timestamp-random.ext`); a global `res.on('finish')` hook persists each uploaded file through `storage.persist` on successful responses (only when `STORAGE_DRIVER=s3`) - **Staging**: Multer always writes to `uploads/` (`timestamp-random.ext`); a global `res.on('finish')` hook persists each uploaded file through `storage.persist` on successful responses (only when `STORAGE_DRIVER=s3`)
- **HEIC**: Auto-converted to JPEG via `heic-convert` - **HEIC**: Auto-converted to JPEG via `heic-convert`
- **Cleanup**: `safeUnlink` / `sweepOrphanedUploads` — never delete outside `uploads/` or the configured bucket - **Cleanup**: `safeUnlink` / `sweepOrphanedUploads` — never delete outside `uploads/` or the configured bucket
@@ -41,7 +77,8 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
### 3a. Storage (`storage.js`) ### 3a. Storage (`storage.js`)
- **Drivers**: `local` (default, files in `uploads/`) and `s3` (S3-compatible: SeaweedFS by default, MinIO via `docker-compose.minio.yml`) - **Drivers**: `local` (default, files in `uploads/`) and `s3` (S3-compatible: SeaweedFS by default, MinIO via `docker-compose.minio.yml`)
- **Keys are stable**: DB stores `/uploads/<name>`; S3 object keys are the same `<name>` (plus `.originals/<name>`). Never change key format — it would break existing DB rows and URLs - **Keys are stable**: DB stores `/uploads/<name>`; S3 object keys are the same `<name>` (plus `.originals/<name>`). Never change key format — it would break existing DB rows and URLs
- **API**: `put`, `putFile`, `head`, `exists`, `sizeOf`, `getStream`, `getBuffer`, `del`, `copyObject`, `listAll`, `localize`, `persist`, `streamTo`, `downloadAll`, `uploadTree`, `ensureBucket`, `usage`, `pruneCache` - **API**: `put`, `putFile`, `head`, `exists`, `sizeOf`, `getStream`, `getBuffer`, `getRange`, `del`, `copyObject`, `listAll`, `localize`, `persist`, `streamTo`, `streamRangeTo`, `downloadAll`, `uploadTree`, `ensureBucket`, `usage`, `pruneCache`
- **Диапазоны**: `getRange(key, start, end)` и `streamRangeTo(res, key, start, end, opts)` отдают `206` с `Content-Range`/`Accept-Ranges` — только для медиа, разбор `Range` на стороне сервера (`parseByteRange`)
- **Rules**: never call `fs.*` on `uploads/` directly in request/worker code — use `storage.*`. `safeUnlink` is the only deletion helper (local + remote, idempotent) - **Rules**: never call `fs.*` on `uploads/` directly in request/worker code — use `storage.*`. `safeUnlink` is the only deletion helper (local + remote, idempotent)
- **Read path**: `STORAGE_LOCAL_FALLBACK=1` prefers a local file when it still exists (covers in-flight uploads and partial migration); otherwise the app streams the object from S3 - **Read path**: `STORAGE_LOCAL_FALLBACK=1` prefers a local file when it still exists (covers in-flight uploads and partial migration); otherwise the app streams the object from S3
- **Cache**: `.thumbs` (WebP miniatures) and `.cache` (originals localized for sharp/zip) live inside `uploads/` and are pruned hourly (`STORAGE_CACHE_MAX_AGE_HOURS`) - **Cache**: `.thumbs` (WebP miniatures) and `.cache` (originals localized for sharp/zip) live inside `uploads/` and are pruned hourly (`STORAGE_CACHE_MAX_AGE_HOURS`)
@@ -58,7 +95,7 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
- **`resetTime` в `rateLimitStore.increment` обязан быть `Date`** — express-rate-limit v8 вызывает `resetTime.getTime()` - **`resetTime` в `rateLimitStore.increment` обязан быть `Date`** — express-rate-limit v8 вызывает `resetTime.getTime()`
- **Пабликация всегда отдаёт подписчикам строку** (JSON), независимо от бэкенда — иначе fallback и Redis расходятся по формату - **Пабликация всегда отдаёт подписчикам строку** (JSON), независимо от бэкенда — иначе fallback и Redis расходятся по формату
- **Инвалидация — по префиксу** (`SCAN` + `DEL`), точечного удаления по ключу избегайте - **Инвалидация — по префиксу** (`SCAN` + `DEL`), точечного удаления по ключу избегайте
- **Ключевые пространства**: `setting:`, `groups:`, `students:`, `entries:`, `stats:`, `dashboard:`, `share:payload:`, `public-settings`, `system-info`, `session:`, `ban:`, `fail:`, `rl:` - **Ключевые пространства**: `setting:`, `groups:`, `students:`, `entries:`, `lessons:`, `stats:`, `dashboard:`, `share:payload:`, `public-settings`, `system-info`, `session:`, `ban:`, `fail:`, `rl:`
- **Сессии**: `loadUserByToken` кэширует пользователя на 30 с. Любая мутация `users` / `sessions` / `user_branches` обязана вызывать `invalidateSessions()` или удалять `session:<token>`, иначе деактивированный пользователь сохранит доступ - **Сессии**: `loadUserByToken` кэширует пользователя на 30 с. Любая мутация `users` / `sessions` / `user_branches` обязана вызывать `invalidateSessions()` или удалять `session:<token>`, иначе деактивированный пользователь сохранит доступ
- **Секреты**: пароль только в `REDIS_URL` / `REDIS_PASSWORD`, порт 6379 публикуется лишь на `127.0.0.1` - **Секреты**: пароль только в `REDIS_URL` / `REDIS_PASSWORD`, порт 6379 публикуется лишь на `127.0.0.1`
@@ -74,6 +111,17 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
- **Новое событие добавляется вместе с**: записью в `NOTIFY_TYPES`, строками `INSERT INTO settings` в `db/init.sql` + `db/migration.sql`, вызовом `pushNotification`/`notifyEntry` в точке события и парой `icon` из Lucide - **Новое событие добавляется вместе с**: записью в `NOTIFY_TYPES`, строками `INSERT INTO settings` в `db/init.sql` + `db/migration.sql`, вызовом `pushNotification`/`notifyEntry` в точке события и парой `icon` из Lucide
- **Аудит**: удаление/очистка уведомлений логируется (`notifications.delete`, `notifications.clear`) - **Аудит**: удаление/очистка уведомлений логируется (`notifications.delete`, `notifications.clear`)
### 3d. Отчёты о занятии и проверка по шаблону (`server.js`, `worker.js`, `public/`)
- **Таблицы**: `lesson_reports` (+ `text_original`, `text_ai`, `ai_status`, `ai_checked_at`, `ai_error`) и `lesson_report_versions` (история версий: `text`, `source` = `manual` | `ai` | `restore`). Схема — в `db/init.sql`, `db/migration.sql` и `ensureLessonReportsTable()`
- **Настройки**: `lesson_ai_enabled` (`'true'` / `'false'` — общий выклюжатель) и `lesson_ai_prompt` (шаблон делового сообщения + правила, пример вставляется в `db/init.sql`, `db/migration.sql` и в `LESSON_AI_DEFAULT_PROMPT` в `server.js`). Раздел в UI — `sec-lesson-ai` на `public/settings.html`
- **Смена шаблона промпта — это четыре правки, а не три**: новое значение в `LESSON_AI_DEFAULT_PROMPT` (`server.js`), `db/init.sql`, `db/migration.sql` (там же `INSERT` для свежих БД) и **обязательно** `UPDATE settings SET value = '<новый>' WHERE key = 'lesson_ai_prompt' AND value IN ('<старый дефолт>')` в `db/migration.sql` — без него правка в SQL-файлах действует только на свежие установки, а у всех, кто уже пользовался разделом `sec-lesson-ai`, в `settings` останется старый промпт (см. «Сид настроек» в разделе 2). Все три текстовые копии должны быть побайтово идентичны `LESSON_AI_DEFAULT_PROMPT`
- **Флаг из UI**: чекбокс `#lessonAiCheck` в модалке `#lessonModal` — включён при создании, выключен при редактировании (`resetLessonModalFields` / `fillLessonModalFromReport`). Уходит в теле как `ai_check`
- **Роут не ждёт модель**: `POST`/`PUT /api/lesson-reports` при `ai_check: true` сохраняют отчёт как есть и ставят `ai_status = 'pending'`, затем `wakeLessonAiWorker()`. Ответ возвращается сразу — не блокируйте HTTP-запрос вызовом модели
- **Воркер**: `createLessonReportChecker` в `worker.js` забирает `pending` через `FOR UPDATE OF lr SKIP LOCKED`, шлёт в модель текст + контекст (группа, дата, время), результат: без изменений → `skipped`, переписан → `done` (новый текст в `text` и `text_ai`), сбой → до 3 попыток, затем `error`
- **Доставка результата**: `onDone` в `server.js` пишет версию (`saveLessonReportVersion`), аудит с diff (`lesson_report.ai.format`), уведомление `lesson.ai.formatted` и SSE `lesson_report_status` на `EVENTS_CHANNEL`
- **История версий**: `GET /api/lesson-reports/:id/versions`, восстановление — `POST /api/lesson-reports/:id/versions/:versionId/restore`, откат к тексту тьютора — `POST /api/lesson-reports/:id/ai/revert`. Хранится последние `LESSON_AI_VERSION_LIMIT` версий на отчёт
- **Хуки фронтенда**: `openLessonVersions(id)` и `restoreLessonVersion(...)` живут в `public/admin.js` (модалка доступна с журнала, отчётов и дашборда), список и бейджи статусов — в `public/js/lessons.js`
### 4. API Patterns ### 4. API Patterns
- **Middleware**: `requireAuth` — читает `X-Auth-Token`, 401 без валидной активной сессии. `requireAdmin` — самодостаточный (внутри вызывает `requireAuth`, если `req.user` ещё нет), 403 при `role !== 'admin'`. `optionalAuth` — для публичных страниц с персонализацией - **Middleware**: `requireAuth` — читает `X-Auth-Token`, 401 без валидной активной сессии. `requireAdmin` — самодостаточный (внутри вызывает `requireAuth`, если `req.user` ещё нет), 403 при `role !== 'admin'`. `optionalAuth` — для публичных страниц с персонализацией
- **Филиалы**: `branchScope(user)` / `branchWhere(user, alias)` — для не-admin `user.branch_ids` (из `user_branches`) ограничивают выборку; у `admin` `ids = null` и фильтр не добавляется - **Филиалы**: `branchScope(user)` / `branchWhere(user, alias)` — для не-admin `user.branch_ids` (из `user_branches`) ограничивают выборку; у `admin` `ids = null` и фильтр не добавляется
@@ -107,15 +155,23 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
- Cert: app generates self-signed cert at build (`certs/cert.pem`), mounted into tailscale container - Cert: app generates self-signed cert at build (`certs/cert.pem`), mounted into tailscale container
### 8. Backup / Restore ### 8. Backup / Restore
- **Admin UI**: `/api/backup` (download tar.gz), `/api/restore` (upload tar.gz) - **Admin UI**: `POST /api/backup` → тикет + `GET /api/backup/:token` (ссылка живёт `BACKUP_TTL_MS`, 30 мин, **скачивание можно повторять** — в том числе после обрыва связи и F5; не «сжигать» тикет `HEAD`-пробой, Express 4 отдаёт HEAD через GET-хендлер), `POST /api/restore` (upload `.tar.gz`)
- **Хранение тикетов**: in-memory `Map` (`server.js`), поэтому после рестарта приложения ссылка даёт 404 — это ожидаемо. Одновременно живых тикетов не больше `BACKUP_TICKETS_MAX`, лишние и истёкшие вычищаются `pruneBackupTickets()`; файлы доживают `sweepBackupStorage()` с запасом в 5 минут сверх TTL
- **Scripts**: `scripts/backup.sh`, `scripts/restore.sh` (host-level) - **Scripts**: `scripts/backup.sh`, `scripts/restore.sh` (host-level)
- Backup format: `data.json` (all tables) + `uploads/` directory - Формат архива: `tar.gz` с `data.json` + `uploads/`. Версия формата — `BACKUP_FORMAT_VERSION` в `backup-restore.js` (сейчас `2`), принимаются версии `1..2`; версия пишется в `data.json.version` и возвращается в ответе `POST /api/backup` и `POST /api/restore`
- Restore validates all data, resets sequences, sweeps orphans - `data.json` содержит `version`, `created_at`, `app` (версия/коммит), `counts` (строки по таблицам + `files`) и сами данные. Таблицы перечислены в `BACKUP_TABLES` — **при добавлении таблицы править её и в `buildBackupArchive`, и здесь**
- `sessions` в бэкап **не входит** намеренно: после restore все токены должны умереть. `audit_log`, `notifications`, `notification_reads`, `banned_ips` — входят
- Файлы: `storage.downloadAll` кладёт в архив всё, кроме регенерируемых `.thumbs/` и `.cache/`; `.originals/` (оригиналы фото до ИИ-обработки) **входят** и восстанавливаются через `uploadTree`
- Restore: валидация всего через `normalizeRestoreData` (`backup-restore.js`), транзакция с `DELETE` в FK-безопасном порядке → `INSERT` → `setval` по `BACKUP_SEQUENCE_TABLES` → файлы → `sweepOrphanedUploads()` → `loadBans()` → `invalidateAll()`
- **Колонки, которые normalizeRestoreData обязана сохранять**: `groups.deleted_at`/`purge_at`, `entries.purge_at`. Потеря `deleted_at` воскрешает мягко удалённые группы как активные — это не «мелочь», а порча данных
- `sweepOrphanedUploads()` считает ссылками фото из `entries.photo_path`/`photo_original_path`, `project_files.path`, `group_photos`, `entry_photos`, `student_photos`, `modules.photo_path`, `students.photo_path`, `groups.cover_path`, `photo_jobs.before_path`/`after_path`, `settings.system_logo`. Новая колонка с путём к файлу → добавить сюда, иначе sweep снесёт файл сразу после restore
--- ---
## Common Tasks ## Common Tasks
Каждый рецепт ниже начинается с субагента-разведки (см. [Agent Workflow](#agent-workflow-обязательные-правила-работы)): пусть он найдёт нужные места и вернёт `путь:строка`, а правки вносит основной агент.
### Add a new API endpoint ### Add a new API endpoint
1. Add route in `server.js` (group with related routes) 1. Add route in `server.js` (group with related routes)
2. Use `requireAdmin` for admin, `apiLimiter`/`fileLimiter` for public 2. Use `requireAdmin` for admin, `apiLimiter`/`fileLimiter` for public
@@ -185,6 +241,10 @@ node redis.selftest.js # unit + degradation, ne
node api.smoketest.js # e2e, needs running stack node api.smoketest.js # e2e, needs running stack
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning INFO docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning INFO
docker compose stop redis && node api.smoketest.js # app must keep working in-memory docker compose stop redis && node api.smoketest.js # app must keep working in-memory
# Backup checks
node backup.selftest.js # unit, no stack needed
curl -sk -X POST https://127.0.0.1:3443/api/backup -H "X-Auth-Token: $TOKEN" # -> counts по всем таблицам
docker compose start redis # app reconnects on its own docker compose start redis # app reconnects on its own
# Audit diff checks # Audit diff checks
@@ -240,15 +300,17 @@ guards against.
| `redis.js` | Redis abstraction: cache, counters, rate-limit store, pub/sub, in-memory fallback | | `redis.js` | Redis abstraction: cache, counters, rate-limit store, pub/sub, in-memory fallback |
| `redis.selftest.js` | Self-tests for `redis.js`, including behaviour with Redis unavailable | | `redis.selftest.js` | Self-tests for `redis.js`, including behaviour with Redis unavailable |
| `diff.js` / `diff.selftest.js` | Word-level text diff and audit change payload; self-tests | | `diff.js` / `diff.selftest.js` | Word-level text diff and audit change payload; self-tests |
| `backup-restore.js` / `backup.selftest.js` | Backup format version, `normalizeRestoreData` validation of restore payloads, backup table lists; self-tests |
| `api.smoketest.js` | End-to-end API smoke test against a running stack | | `api.smoketest.js` | End-to-end API smoke test against a running stack |
| `worker.js` | Background AI auto-check worker for entry messages + photo enhance worker | | `worker.js` | Background AI auto-check workers: entry messages, lesson-report template check, photo enhance |
| `db/init.sql` | Initial schema (runs on fresh DB) | | `db/init.sql` | Initial schema (runs on fresh DB) |
| `db/migration.sql` | Idempotent migrations for existing DBs | | `db/migration.sql` | Idempotent migrations for existing DBs |
| `docker-compose.yml` | Service definitions (app, db, s3, tailscale) | | `docker-compose.yml` | Service definitions (app, db, s3, tailscale) |
| `docker-compose.minio.yml` | Override: S3 service backed by MinIO instead of SeaweedFS | | `docker-compose.minio.yml` | Override: S3 service backed by MinIO instead of SeaweedFS |
| `Dockerfile` | App image build | | `Dockerfile` | App image build |
| `public/*.html` | Frontend pages | | `public/*.html` | Frontend pages |
| `public/admin.js` | Shared frontend logic | | `public/admin.js` | Shared frontend logic, модалка отчёта о занятии (`openLessonModal`) |
| `public/lessons.html` | Отчёты о занятиях: список, фильтры, редактирование |
| `scripts/backup.sh` | Host-level backup script (DB dump + storage export) | | `scripts/backup.sh` | Host-level backup script (DB dump + storage export) |
| `scripts/restore.sh` | Host-level restore script (DB dump + storage import) | | `scripts/restore.sh` | Host-level restore script (DB dump + storage import) |
| `scripts/storage-sync.js` | Export/import all storage objects (used by backup/restore) | | `scripts/storage-sync.js` | Export/import all storage objects (used by backup/restore) |
@@ -275,6 +337,10 @@ guards against.
- ❌ Expose DB port (5432) outside docker network - ❌ Expose DB port (5432) outside docker network
- ❌ Use `eval`, `Function` constructor, or dynamic code execution - ❌ Use `eval`, `Function` constructor, or dynamic code execution
- ❌ Add comments to code (this file excepted) - ❌ Add comments to code (this file excepted)
- ❌ Read/search the repo in the main agent when the work can be delegated to a subagent
- ❌ Load whole files (`server.js`, `worker.js`, `storage.js`, `redis.js`, `public/js/*.js`, `README.md`) or raw command output into the main context — delegate and filter (`grep -n`, `head`, line ranges)
- ❌ Give a subagent an open-ended "explore the whole project" task, or omit the project rules from its `systemPrompt` — it does not inherit the main context
- ❌ Treat a subagent report as the final word: coordinate, edit and verify the result yourself (`git diff`)
--- ---
+3 -2
View File
@@ -46,7 +46,8 @@
| ENT-5 | Trash view: list deleted entries, restore, permanent delete | Must | | ENT-5 | Trash view: list deleted entries, restore, permanent delete | Must |
| ENT-6 | Pagination (limit/offset) + total count | Must | | ENT-6 | Pagination (limit/offset) + total count | Must |
| ENT-7 | Anti-spam: min interval between entries per student (configurable, default 30 min) | Must | | ENT-7 | Anti-spam: min interval between entries per student (configurable, default 30 min) | Must |
| ENT-8 | Files attached to entry: upload (max 10 files, 10 MB each, 30 MB total), download by token | Must | | ENT-8 | Files attached to entry: upload (max 10 files, per-file and total size limits from `UPLOAD_FILE_LIMIT_MB`/`UPLOAD_TOTAL_LIMIT_MB`), download by token | Must |
| ENT-9 | Video attachments: `mp4`/`m4v`/`webm`/`ogv` playable inline in the journal with seeking (Range), other video formats download-only | Should |
### 2.4 Files Management (Centralized) ### 2.4 Files Management (Centralized)
| ID | Requirement | Priority | | ID | Requirement | Priority |
@@ -175,7 +176,7 @@ branches
| GET | `/api/files` | Admin | All files (filters) | | GET | `/api/files` | Admin | All files (filters) |
| GET | `/api/files/detached` | Admin | Detached files | | GET | `/api/files/detached` | Admin | Detached files |
| POST | `/api/files/:id/detach` | Admin | Detach file | | POST | `/api/files/:id/detach` | Admin | Detach file |
| GET | `/api/files/:token` | Public | Download file by token | | GET | `/api/files/:token` | Public | Download file by token; `?play=1` serves `mp4`/`m4v`/`webm`/`ogv` inline with `Range` support |
| GET | `/api/groups` | Public | List groups | | GET | `/api/groups` | Public | List groups |
| POST/PUT/DELETE | `/api/groups` | Admin | CRUD groups | | POST/PUT/DELETE | `/api/groups` | Admin | CRUD groups |
| GET/POST | `/api/groups/:id/photos` | Admin | Group photo chronicle | | GET/POST | `/api/groups/:id/photos` | Admin | Group photo chronicle |
+3 -1
View File
@@ -11,6 +11,7 @@
- **Воспитанники** — справочник с привязкой к группам - **Воспитанники** — справочник с привязкой к группам
- **Share-ссылки** — публичные страницы-витрины с выбором группы / воспитанника / диапазона дат - **Share-ссылки** — публичные страницы-витрины с выбором группы / воспитанника / диапазона дат
- **Дашборд** — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников - **Дашборд** — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников
- **Отчёты о занятиях** — тьютор описывает, что прошли на занятии; галочка в окне отчёта отправляет текст модели, которая сверяет его с шаблоном делового сообщения (настраивается в «Настройках» → «Шаблон отчёта»): совпал — остаётся как есть, не совпал — переписывается в деловом виде. Обработка идёт в фоне, оригинал тьютора сохраняется, доступна история версий с восстановлением
- **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz` - **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz`
- **Хранилище файлов** — локальный каталог `uploads/` или S3-совместимый сервис (`s3`: SeaweedFS, либо MinIO через оверрайд), перенос файлов скриптом миграции - **Хранилище файлов** — локальный каталог `uploads/` или S3-совместимый сервис (`s3`: SeaweedFS, либо MinIO через оверрайд), перенос файлов скриптом миграции
- **Настройки** — тексты футера, анти-спам интервал, системная информация (объёмы БД и хранилища) и «Статус стека»: версии Node.js/Express/PostgreSQL/Redis, состояние сервисов, ОС, CPU, память и аптаймы (`GET /api/system-info` → `stack`) - **Настройки** — тексты футера, анти-спам интервал, системная информация (объёмы БД и хранилища) и «Статус стека»: версии Node.js/Express/PostgreSQL/Redis, состояние сервисов, ОС, CPU, память и аптаймы (`GET /api/system-info` → `stack`)
@@ -527,7 +528,8 @@ node api.smoketest.js # сквозная проверка API (нужен
- **Пароль администратора** обязателен (`ADMIN_PASSWORD`) — он создаёт первого админа в пустой БД; фолбэка на `admin` нет. Самостоятельной роли в API не даёт: доступ только по сессиям. - **Пароль администратора** обязателен (`ADMIN_PASSWORD`) — он создаёт первого админа в пустой БД; фолбэка на `admin` нет. Самостоятельной роли в API не даёт: доступ только по сессиям.
- **CORS отключён** — кросс-доменные запросы к API запрещены. - **CORS отключён** — кросс-доменные запросы к API запрещены.
- **Rate limiting** по IP на публичные роуты: `POST /api/entries` — 10 запросов / 15 мин, загрузка файлов и share-ссылки — 300 / 15 мин. - **Rate limiting** по IP на публичные роуты: `POST /api/entries` — 10 запросов / 15 мин, загрузка файлов и share-ссылки — 300 / 15 мин.
- **Загрузки** ограничены: 30 МБ суммарно на запись, 10 МБ на файл; заблокированы опасные расширения (`.html`, `.js`, `.svg`, `.xml`, `.exe` и др.); SVG не отдаётся inline. - **Загрузки** ограничены: суммарно на запись и на файл — лимиты из `UPLOAD_TOTAL_LIMIT_MB` / `UPLOAD_FILE_LIMIT_MB` (по умолчанию 200 МБ и 50 МБ); заблокированы опасные расширения (`.html`, `.js`, `.svg`, `.xml`, `.exe` и др.); SVG не отдаётся inline.
- **Видеофайлы** (`.mp4`, `.m4v`, `.webm`, `.ogv`) играются прямо в журнале: `GET /api/files/:token?play=1` отдаёт файл **inline** с `Accept-Ranges: bytes` и поддержкой `Range` (`206`), поэтому перемотка работает без скачивания целиком. Остальные форматы (`.mov`, `.mkv`, `.avi` и пр.) браузер не играет — они остаются ссылками на скачивание.
- **Restore** проходит полную валидацию данных бэкапа; удаление файлов ограничено каталогом `uploads/`. - **Restore** проходит полную валидацию данных бэкапа; удаление файлов ограничено каталогом `uploads/`.
- **Заголовки**: `helmet` — `X-Frame-Options`, `nosniff`, HSTS, `Referrer-Policy`. - **Заголовки**: `helmet` — `X-Frame-Options`, `nosniff`, HSTS, `Referrer-Policy`.
- **Порт БД** 5432 наружу не публикуется (доступ только внутри docker-сети). - **Порт БД** 5432 наружу не публикуется (доступ только внутри docker-сети).
+157 -30
View File
@@ -472,74 +472,199 @@ nvidia.com/gpu=all`) — так демон перезапускать не ну
### 7.1 `worker.js` (`createPhotoEnhanceWorker`) ### 7.1 `worker.js` (`createPhotoEnhanceWorker`)
- [ ] Таймауты из env: `PHOTO_AI_TIMEOUT_MS` (дефолт 300000), `PHOTO_AI_FACE_TIMEOUT_MS` (дефолт 600000) - [x] Таймауты из env: `PHOTO_AI_TIMEOUT_MS` (дефолт 300000), `PHOTO_AI_FACE_TIMEOUT_MS` (дефолт 600000)
для `face != 'off'`. Таймаут выбирается на основе `params`, а не глобально. для `face != 'off'`. Таймаут выбирается на основе `params`, а не глобально.
- [ ] `runAiEnhance(srcKey, params)`: отправляет `image`, `scale`, `model`, `face`, `face_model`, `strength` - [x] `runAiEnhance(srcKey, params)`: отправляет `image`, `scale`, `model`, `face`, `face_model`, `strength`
из `params` (дефолты при пустых `params`); ставит `Accept: application/json`; принимает и JSON из `params` (дефолты при пустых `params`); ставит `Accept: application/json`; принимает и JSON
(base64 → буфер), и сырой `image/jpeg` (старый photo-ai) без ошибки. (base64 → буфер), и сырой `image/jpeg` (старый photo-ai) без ошибки.
- [ ] `503` / `Retry-After` / сетевая недоступность → `sleep` c экспоненциальной задержкой, `return false`, - [x] `503` / `Retry-After` / сетевая недоступность → `sleep` c экспоненциальной задержкой, `return false`,
**`attempts` не инкрементится**; лимит мягких повторов (например 60) → одна честная ошибка **`attempts` не инкрементится**; лимит мягких повторов (например 60) → одна честная ошибка
с понятным текстом (I5). с понятным текстом (I5).
- [ ] `AbortError` от `AbortSignal.timeout` → сообщение «ИИ-сервис не ответил за N с» — это уже - [x] `AbortError` от `AbortSignal.timeout` → сообщение «ИИ-сервис не ответил за N с» — это уже
«жёсткая» ошибка с попытками. «жёсткая» ошибка с попытками.
- [ ] `processOne`: `action` ∈ {`ai`, `ai_face`, `ai_upscale`} → AI-путь; `enhance` → sharp. - [x] `processOne`: `action` ∈ {`ai`, `ai_face`, `ai_upscale`} → AI-путь; `enhance` → sharp.
Пустые `params` при `ai_face` → `face='face'`. Пустые `params` при `ai_face` → `face='face'`.
- [ ] `applyResult`: в `logAudit(..., 'photo.job.preview', {...})` добавить `model`, `face`, `face_model`, - [x] `applyResult`: в `logAudit(..., 'photo.job.preview', {...})` добавить `model`, `face`, `face_model`,
`device`, `elapsed_ms`, `faces_found`, `warnings`. Текст уведомления `photo.job.done` — по факту `device`, `elapsed_ms`, `faces_found`, `warnings`. Текст уведомления `photo.job.done` — по факту
режима (`job.action === 'ai_face'` → «Фото обработано нейросетью с восстановлением лиц»). режима (`job.action === 'ai_face'` → «Фото обработано нейросетью с восстановлением лиц»).
- [ ] `CONFIG` воркера: + `face_model`, `default_model`, `face_timeout_ms` (попадают в - [x] `CONFIG` воркера: + `face_model`, `default_model`, `face_timeout_ms` (попадают в
`GET /api/photo-jobs/status` → `worker.config`). `GET /api/photo-jobs/status` → `worker.config`).
### 7.2 `server.js` ### 7.2 `server.js`
- [ ] `POST /api/entries/:id/photo/enhance-ai` (`server.js:5114`): принять `{model, face, face_model, strength}`; - [x] `POST /api/entries/:id/photo/enhance-ai` (`server.js:5114`): принять `{model, face, face_model, strength}`;
валидация инлайн-хелперами и явными списками (как `PUT /api/settings`), невалидное → `400`. валидация инлайн-хелперами и явными списками (как `PUT /api/settings`), невалидное → `400`.
`params = JSON.stringify({model, face, face_model, strength})`; `action = face === 'off' ? 'ai' : 'ai_face'`. `params = JSON.stringify({model, face, face_model, strength})`; `action = face === 'off' ? 'ai' : 'ai_face'`.
**Пустое тело → сегодняшнее поведение** (`params = NULL`, `action='ai'`) — I2. **Пустое тело → сегодняшнее поведение** (`params = NULL`, `action='ai'`) — I2.
- [ ] `PHOTO_JOB_ACTIONS` (`server.js:2254`): + `'ai_face'`, `'ai_upscale'` (см. `D3`). Схема - Валидация вынесена **перед** запросом записи: невалидное тело → `400` независимо от наличия
записи и фото, дешевле (без похода в БД) и детерминированно.
- [x] `PHOTO_JOB_ACTIONS` (`server.js:2254`): + `'ai_face'`, `'ai_upscale'` (см. `D3`). Схема
`action VARCHAR(20)` вмещает новые значения — миграция не нужна. `action VARCHAR(20)` вмещает новые значения — миграция не нужна.
- Набор вынесен на уровень модуля (`server.js:1884`) рядом с `PHOTO_AI_URL`; единственное
применение — restore-нормализация (`server.js:2301`).
- Сверка списка с тем, что реально пишет код (`INSERT INTO photo_jobs` × 5): `ai` (пустое тело
и `face=off`), `ai_face` (`parsePhotoAiRequest`), `enhance` (`swapEntryPhotoFiles`), `restore`,
`rollback` — все семь значений набора покрыты, лишних нет.
- [x] `photoAiHealth()` (см. `D2`) + проксирование в `GET /api/photo-jobs/status` → `service` - [x] `photoAiHealth()` (см. `D2`) + проксирование в `GET /api/photo-jobs/status` → `service`
(`reachable`, `device`, `device_name`, `ready`, `vram_total_mb`, `vram_free_mb`, `models`, `face_models`, (`reachable`, `device`, `device_name`, `ready`, `vram_total_mb`, `vram_free_mb`, `models`, `face_models`,
`loaded`). Недоступен → `{reachable:false}`, ответ 200. — **сделано 2026-09-28** (контракт и проверка `loaded`). Недоступен → `{reachable:false}`, ответ 200. — **сделано 2026-09-28** (контракт и проверка
в `D2`; поля `device`/`device_name`/`vram_*` появятся вместе с расширенным `/health` в Stage 1) в `D2`; поля `device`/`device_name`/`vram_*` появятся вместе с расширенным `/health` в Stage 1)
- [ ] `GET /api/photo-ai/health` (`requireAdmin`) — прямой прокси `/health` photo-ai для оператора. - [x] `GET /api/photo-ai/health` (`requireAdmin`) — прямой прокси `/health` photo-ai для оператора.
- [ ] `getStackInfo()` (`server.js:769`) — блок `photo_ai` (`engine: 'Real-ESRGAN + GFPGAN'`, `driver`, - [x] `getStackInfo()` (`server.js:769`) — блок `photo_ai` (`engine: 'Real-ESRGAN + GFPGAN'`, `driver`,
`device`, `device_name`, `vram_total_mb`, `models`). Без credentials, только hostname. `device`, `device_name`, `vram_total_mb`, `models`). Без credentials, только hostname.
- [ ] Настройки (`PUT /api/settings`, рядом `server.js:1710`): `photo_ai_face_mode` ∈ `off|face|all` (дефолт `off`), - `photoAiHealth()` получил необязательный параметр таймаута; в `getStackInfo()` вызывается с
`2000` — «Статус стека» на `settings.html` не должен висеть на `/health` фото-сервиса 5 с.
- [x] Настройки (`PUT /api/settings`, рядом `server.js:1710`): `photo_ai_face_mode` ∈ `off|face|all` (дефолт `off`),
`photo_ai_face_model` ∈ `gfpgan|codeformer` (дефолт `gfpgan`), `photo_ai_device_pref` ∈ `auto|cuda|cpu` `photo_ai_face_model` ∈ `gfpgan|codeformer` (дефолт `gfpgan`), `photo_ai_device_pref` ∈ `auto|cuda|cpu`
(дефолт `auto`, только для UI/доков — фактическое устройство задаёт контейнер). (дефолт `auto`, только для UI/доков — фактическое устройство задаёт контейнер).
- [ ] Дефолты новых настроек: `db/init.sql`, `db/migration.sql` (`INSERT ... ON CONFLICT DO NOTHING`), - [x] Дефолты новых настроек: `db/init.sql`, `db/migration.sql` (`INSERT ... ON CONFLICT DO NOTHING`),
`keys`/`defaults` в `/api/public-settings` (`server.js:1663–1664`). `photo_ai_enabled` (строка 1677) `keys`/`defaults` в `/api/public-settings` (`server.js:1663–1664`). `photo_ai_enabled` (строка 1677)
сохранить. сохранить.
- [ ] `createPhotoEnhanceWorker({...})` (`server.js:6045`): + `faceTimeoutMs: PHOTO_AI_FACE_TIMEOUT_MS`, - Строки добавлены и в `ensurePhotoJobsTable()` — существующая БД получает их при старте
приложения, без ручного прогона `migration.sql`.
- Дефолты читаются из env: `photo_ai_face_model` ← `PHOTO_AI_FACE_MODEL`, `photo_ai_face_mode`
← `PHOTO_AI_FACE_MODE` (обе переменные уже есть в §10), чтобы дефолты БД и compose не разошлись.
- [x] `createPhotoEnhanceWorker({...})` (`server.js:6045`): + `faceTimeoutMs: PHOTO_AI_FACE_TIMEOUT_MS`,
`defaultFaceModel: PHOTO_AI_FACE_MODEL`. `defaultFaceModel: PHOTO_AI_FACE_MODEL`.
- [ ] `warnings` от photo-ai сохранять в аудит/уведомление, чтобы оператор видел, что face-режим не - [x] `warnings` от photo-ai сохранять в аудит/уведомление, чтобы оператор видел, что face-режим не
сработал не из-за ошибки. сработал не из-за ошибки.
- [ ] **Приёмка:** задание с `face='face'` проходит `pending → processing → done`; `params` сохранены; - [x] **Приёмка:** задание с `face='face'` проходит `pending → processing → done`; `params` сохранены;
аудит содержит модель/устройство/время; остановка photo-ai на лету → задание дорабатывается после аудит содержит модель/устройство/время; остановка photo-ai на лету → задание дорабатывается после
возврата сервиса **без** `error` (I5); результат применяется и откатывается как раньше. возврата сервиса **без** `error` (I5); результат применяется и откатывается как раньше.
--- ---
### Журнал раздела 4 (проверено 2026-09-29)
Изменён `worker.js` и `server.js`. `photo-ai/app.py`, Dockerfile, compose, схема БД (кроме трёх
`INSERT` в `settings`) и фронтенд не тронуты — они в Stage 5…6. Проверки шли на живом стеке, фото-сервис
в CPU-режиме (`device: cpu`, RTX 3050 проверен отдельно в Stage 3).
| Проверка | Как | Результат |
|---|---|---|
| face-режим end-to-end | `POST …/enhance-ai {face:'face', face_model:'gfpgan'}` (запись 385) | `pending → processing → done` за 28 с; `params` в БД сохранены; результат отдаётся (200, JPEG, 283 953 Б) |
| `face` + реальное лицо | та же запись на фото с лицом (запись 384), `face='all'` | `done` за 132 с, аудит: `faces_found: 1`, `elapsed_ms: 128961`, `device: cpu`, `warnings: null` |
| **Главная находка** | первая попытка без face | аудит: `warnings: ["лица не найдены, фон обработан апскейлом"]`, `faces_found: 0` — и эта строка **видна оператору** в уведомлении, а не прячется в лог сервиса |
| Уведомление по факту режима | три задания подряд | `ai_face` → «Фото обработано нейросетью с восстановлением лиц», `ai` → «Фото обработано нейросетью»; при warnings к `body` добавляется суффикс `· лица не найдены…` |
| **I1 (worker)** | задание с `params IS NULL` (`action='ai'`, ручная вставка) против прямого raw-вызова `/enhance` без `Accept` | **sha256 совпал** (`98254b54d0ea6f456f400fdfd18f3a033291fd83eae884359cbaf8dd6e16556d`, 452 678 Б) — новый JSON-путь воркера даёт байт-в-байт результат старого сырого пути |
| **I2** | задание с `params IS NULL` | `action` остался `ai`, `params` остался `NULL`, `attempts=0`, `done`; аудит `face: off`, `faces_found: 0` |
| **I1 (эталон)** | `capture.js after-stage4` + `verify.js` | **5/5 `MATCH` байт-в-байт** |
| **I3** | `docker compose -f … -f override.yml up -d app` с `PHOTO_AI_URL=""` | `photo_ai_enabled=false`; `enhance-ai` (`{}`, `{face:'face'}`, `{model,face:'all'}`) → **503**; `/api/photo-ai/health` → 200 `{configured:false}`; `status` → 200 `service.configured=false`; `stack.photo_ai.configured=false, host=null`; 8 маршрутов API живы; воркер виден, `ai_url=""` |
| **I5 (обрыв)** | `stop photo-ai` → задание в очередь → 45 с → `start photo-ai` | в простое: `pending`, **`attempts=0`**, `error='ИИ-сервис недоступен (ENOTFOUND)'`; аудит `photo.job.soft_retry` (`soft_attempt: 1`, `soft_limit: 60`); после возврата — `done`, `attempts=0`, `error` очищен; `status` и `system-info` отдавали 200 с `reachable=false` |
| **I5 (503)** | заглушка-прокси перед сервисом: `503` + `Retry-After: 5` | `status` вернулся в `pending`, **`attempts` остался `0`**; текст сохранил и код, и заголовок, и тело ответа: `«ИИ-сервис ответил 503 (Retry-After: 5): модель x2plus ещё загружается, повторите позже»`; после переключения заглушки на реальный сервис оба задания дошли до `done` с `attempts=0` (задержка ~60 с — мягкий backoff 10 с с удвоением) |
| Валидация `enhance-ai` | 7 невалидных тел | `400` на каждое: неизвестные `model`/`face`/`face_model`, `strength` вне `0..1`, `strength` не число, `strength` при `gfpgan`, `model`+`strength` при `gfpgan` |
| Валидация настроек | 3 невалидных + 3 валидных значения | `400` / `200`; после `PUT` новые значения видны в `/api/public-settings` сразу (кэш `public-settings` сбрасывается `invalidateSettings`) |
| `GET /api/photo-ai/health` | админ / без токена | 200 с полями `device`, `device_name`, `models`, `face_models`, `loaded`, `vram_*`, `driver`, `half`, `tile`, `max_pixels`; без токена → 401 |
| `stack.photo_ai` | `/api/system-info` | `engine`, `host` (`photo-ai:8080`, тот же `hostOf`, что у `database`/`cache`/`storage`), `device`, `device_name`, `vram_*`, `models`, `face_models`, `loaded`; кредов и пароля в payload нет |
| `worker.config` | `GET /api/photo-jobs/status` | `+ face_timeout_ms: 600000`, `default_model: x2plus`, `face_model: gfpgan` рядом с `ai_timeout_ms: 300000` |
| Реальная ошибка сервиса | `face_model: 'codeformer'` (модуль не вендорен, `D5`) | `400` от сервиса дошёл до оператора текстом: `«ИИ-сервис ответил 400: модель лиц codeformer не установлена в образ, доступен gfpgan»`; `attempts=3` (жёсткая ошибка), аудит `photo.job.error` |
| I7 | `grep` по диффу | комментариев 0, `require('fs')`/`require('path')`/`uploads` в `worker.js` нет, SQL параметризован (в `settings`-вставках литералы — ключи, как у соседней `photo_worker_enabled`) |
| I6 | дифф `db/` | только `INSERT INTO settings … ON CONFLICT DO NOTHING` × 3, ни `ALTER`, ни `CREATE TABLE`; `action VARCHAR(20)` не менялся |
| Compose | `config -q` для base, `.gpu.yml`, `.minio.yml` | валидны все три |
| `api.smoketest.js` | существующий набор | 57 `PASS`, 0 `FAIL` |
Решения и находки, которые нужно знать дальше:
- **`Accept: application/json` ничего не ломает в сервисе, но меняет контракт воркера.** Ответ приходит
base64 в JSON — это в ~1.33 раза больше трафика на стыке app↔photo-ai. Оставлено осознанно: без
метаданных (`device`, `elapsed_ms`, `faces_found`, `warnings`) в аудите и уведомлении оператор
не видит, отработала ли модель. Сырой `image/jpeg` воркер по-прежнему принимает — на случай отката
сервиса на старый контракт; метаданные тогда берутся из заголовков `x-photo-ai-*`.
- **Валидация тела вынесена перед запросом записи.** Иначе невалидное тело на несуществующей записи
давало `404`, и чекбокс «невалидное → 400» нельзя было бы проверить, не заводя запись с фото.
Побочная выгода: невалидное тело не стоит похода в БД.
- **`strength` при `face_model != 'codeformer'` отвергается на входе в API**, а не на стороне сервиса.
Иначе ошибка приходила бы после трёх попыток воркера и после 2 минут ожидания — валидация в
`PUT /api/settings`-стиле дешевле и сразу видима пользователю.
- **Тело ответа при не-2xx больше не выбрасывается** (найдено на приёмке: `ИИ-сервис ответил 400`
без причины). Теперь `readErrorBody()` разбирает JSON `{error}`/`{detail}` или сырой текст и
добавляет к сообщению — именно так оператор узнал, что CodeFormer не вендорен.
- **Один и тот же таймаут на `ai` и `ai_face` был бы неверной настройкой:** `x2plus face=all` на
4032×2268 на CPU занимает ~2 мин, и при `PHOTO_AI_TIMEOUT_MS=300000` задание с лицом упало бы
в жёсткую ошибку после трёх попыток там, где GPU-оверрайд уложился бы в 28 с. Отсюда
`PHOTO_AI_FACE_TIMEOUT_MS=600000` и выбор таймаута по `params.face`.
- **`503` — не «сервис недоступен»:** `status.service.reachable` при этом `true` (сервис отвечает).
Разводить эти два состояния важно для Stage 5 — иначе UI покажет «сервис лежит» при обычной
прогревочной паузе.
- **Прокси-заглушка вместо гонки за 503.** Первый запрос к холодной модели грузит её **синхронно**
(`ModelPool.get()` → `factory()`), а `503` получают только *конкурентные* запросы к той же модели
(`key in self.loading` → `ModelNotReady`). Воркер обрабатывает одно задание за раз, поэтому в
бою такой 503 почти не воспроизводится — ветку проверили детерминированно, проксируя ответы
сервиса заглушкой с флипом, иначе проверка была бы гонкой.
- **`photo.job.soft_retry` пишется не на каждый повтор, а на 1-й и каждый 10-й** (`n === 1 || n % 10 === 0`)
— это было в коде и до Stage 4. Практическое следствие для проверок: точный текст последнего
мягкого повтора надёжнее читать из `photo_jobs.error`, а не из аудита; в журнале выше 503 виден
именно там.
- **Живой стек используется параллельно.** Во время приёмки оператор применил результат задания 102
через UI и вернул оригинал — это и есть проверка «результат применяется и откатывается как раньше»
на реальных данных (аудит `entry.photo.apply` → `entry.photo.restore_original`). Поэтому рабочие
задания и результаты оператора не тронуты: удалены только созданные тестами строки `photo_jobs`,
их аудит-строки, уведомления и файлы в S3. Одно замечание для Stage 5: `photo_ai_face_mode` и
`photo_ai_face_model` появились в `settings` у уже работающей БД — приложение создало их сам в
`ensurePhotoJobsTable()` при старте, поэтому значения по умолчанию действительны без ручного
прогона `migration.sql`.
---
## 8. Stage 5. Фронтенд ## 8. Stage 5. Фронтенд
- [ ] `public/journal.html` + `public/js/journal.js`: рядом с «🤖 ИИ» селект режима - [x] `public/journal.html` + `public/js/journal.js`: рядом с «🤖 ИИ» селект режима
(«Универсально (x2)», «Быстро (x4)», «Лица (GFPGAN)», «Лица + фон»); значение уходит в («Универсально (x2)», «Быстро (x4)», «Лица (GFPGAN)», «Лица + фон»); значение уходит в
`POST .../enhance-ai` телом `{model, face, face_model}`. Дефолт — из `photo_ai_face_mode` `POST .../enhance-ai` телом `{model, face, face_model}`. Дефолт — из `photo_ai_face_mode`
(`/api/public-settings`, `loadEnhanceEngine`); при `device === 'cpu'` — подсказка «на CPU медленно». (`/api/public-settings`, `loadEnhanceEngine`); при `device === 'cpu'` — подсказка «на CPU медленно».
Кнопка по-прежнему скрыта при `photo_ai_enabled === 'false'` (I3). Кнопка по-прежнему скрыта при `photo_ai_enabled === 'false'` (I3).
- [ ] `public/js/worker.js`: `PHOTO_ACTION_LABELS` + `ai_face: 'ИИ + лица'`, `ai_upscale: 'ИИ-апскейл'`; - `PHOTO_AI_MODES` (`journal.js:609`) — единственная таблица режим→`{model, face}`; значение
селекта → `photoAiMode()`, `face_model` подставляется только при `face !== 'off'`.
- [x] `public/js/worker.js`: `PHOTO_ACTION_LABELS` + `ai_face: 'ИИ + лица'`, `ai_upscale: 'ИИ-апскейл'`;
в модалке сравнения — `model`, `device`, `faces_found`, `elapsed_ms` (данные из `params`/аудита, I6); в модалке сравнения — `model`, `device`, `faces_found`, `elapsed_ms` (данные из `params`/аудита, I6);
строка состояния учитывает `service.reachable === false` и `service.device`. строка состояния учитывает `service.reachable === false` и `service.device`.
- [ ] `public/settings.html` + `public/js/settings.js`: блок «Фото-ИИ» — селект face-режима, селект - [x] `public/settings.html` + `public/js/settings.js`: блок «Фото-ИИ» — селект face-режима, селект
face-модели, селект желаемого устройства + строка «фактическое: …» с подсветкой расхождения; face-модели, селект желаемого устройства + строка «фактическое: …» с подсветкой расхождения;
read-only статус (`device_name`, VRAM, список моделей) из `/api/photo-jobs/status`. read-only статус (`device_name`, VRAM, список моделей) из `/api/photo-jobs/status`.
Новые id внести в `DIRTY_FIELDS` (`settings.js:1`) и в payload (`settings.js:739`). Новые id внести в `DIRTY_FIELDS` (`settings.js:1`) и в payload (`settings.js:739`).
- [ ] `public/js/settings.js:372` `renderStackInfo()` — карточка «Фото-ИИ» из `stack.photo_ai`. - [x] `public/js/settings.js:372` `renderStackInfo()` — карточка «Фото-ИИ» из `stack.photo_ai`.
- [ ] `public/js/audit.js` — метки для новых кодов аудита (иначе в UI будет сырой код). - [x] `public/js/audit.js` — метки для новых кодов аудита (иначе в UI будет сырой код).
- [ ] **Приёмка:** из журнала доступны все 4 режима; после постановки видно «В очереди», затем сравнение - [ ] **Приёмка:** из журнала доступны все 4 режима; после постановки видно «В очереди», затем сравнение
«Было/Стало» с моделью/устройством; в настройках видно фактическое устройство и VRAM. «Было/Стало» с моделью/устройством; в настройках видно фактическое устройство и VRAM.
### Журнал раздела 5 (код сделан 2026-09-30, приёмка на живом стеке не гонялась)
Изменены только файлы фронтенда: `journal.html`, `worker.html`, `settings.html`, `js/journal.js`,
`js/worker.js`, `js/settings.js`, `js/audit.js`. `server.js`, `worker.js` (воркер), `photo-ai/`, compose
и схема БД не тронуты — контракт `/enhance-ai`, `/api/photo-ai/health` и `service` в
`/api/photo-jobs/status` взят из Stage 4 без изменений (I6 соблюдён: новых колонок нет, метаданные
приходят из `photo_jobs.params` и `audit_log.target`).
| Пункт | Реализация |
|---|---|
| Селект режима в журнале | `#enhanceAiMode` рядом с «🤖 ИИ»; `PHOTO_AI_MODES` → `photoAiMode()` → тело `{model, face, face_model}`; селект и кнопка скрываются вместе при `photo_ai_enabled === 'false'` (I3) |
| Дефолт режима | `defaultPhotoAiModeKey()`: `face=all → facesbg`, `face=face → faces`, иначе `x2`; значение берётся из `photo_ai_face_mode` в `loadEnhanceEngine()` |
| Подсказка про CPU | `loadPhotoAiDevice()` (админ) читает `GET /api/photo-ai/health`; при `device` с `cpu` — «Фото-ИИ считает на CPU — лицевые режимы могут занять несколько минут». Не-админу запрос не делается |
| Тексты подтверждения | `runPhotoAi()` различает апскейл («до минуты») и face-режим («несколько минут», с названием модели лиц) — по той же логике выбирается таймаут в воркере |
| Статус фото-воркера | `photoStateHint` учитывает `enabled`, `ai_configured`, `service.reachable === false` («задания ждут») и дописывает `· расчёт на <device>` |
| Метаданные в модалке | `renderPhotoJobMeta()`: `params` (модель/режим) из строки задания, `device`/`faces_found`/`elapsed_ms`/`warnings` — из `audit_log.target` по `job_id` (один запрос `GET /api/audit?action=photo.job.preview&limit=200`, мапа кэшируется) |
| Блок «Фото-ИИ» в настройках | три селекта + read-only чипы: фактическое устройство, `device_name`, VRAM, модели/модели лиц/загруженные; жёлтым подсвечивается расхождение с желаемым устройством, красным — недоступность и незаданный `PHOTO_AI_URL` |
| Карточка в «Статусе стека» | `renderStackInfo()` рисует `stack.photo_ai`: состояние (`не настроен` / `нет связи` / `модели грузятся` / `отвечает`), движок, host, устройство, fp16/fp32, VRAM, модели |
| Метки аудита | 10 новых кодов в `audit.js`: профиль и фото ученика, главное фото ученика, фото модуля, удаление и очистка уведомлений, логотип; плюс `ai_face`/`ai_upscale` в `PHOTO_ACTION_LABELS` (`worker.js`) |
| Синтаксис | `node --check` для `js/journal.js`, `js/settings.js`, `js/worker.js`, `js/audit.js` — чисто |
Замечания, важные для приёмки и для Stage 6:
- **Метаданные в UI берутся из аудита, а не из `photo_jobs`.** В `params` лежат только модель, режим,
модель лиц и `strength`; `device`, `faces_found`, `elapsed_ms` и `warnings` пишет воркер в
`audit_log.target` при `photo.job.preview`. Поэтому `GET /api/audit?action=photo.job.preview&limit=200`
— один запрос на всё открытие модалки; у заданий старше 200 последних метаданных не будет, UI честно
покажет «Метаданные обработки недоступны». Новых колонок не добавляли (I6).
- **`reachable` и «модели грузятся» — разные состояния.** Прогрев даёт `reachable: true` при
`ready: false` (см. замечание Stage 4 про 503), поэтому строка статуса показывает «Ожидание очереди»,
а не «сервис недоступен»; жёлтая подсветка в настройках — только при реальном расхождении устройства.
- **`photo_ai_device_pref` — документирующее значение.** Фактическое устройство задаёт
`PHOTO_AI_DEVICE` в контейнере, поэтому в UI это не переключатель, а сверка «желаемое vs фактическое»
с явной подсказкой, как это исправить.
- **Приёмка на живом стеке не выполнялась** в этом изменении: чекбокс приёмки выше остаётся пустым.
Проверять нужно на стеке с поднятым `photo-ai` (иначе селект и блок статуса скрыты по I3), отдельно —
расхождение `photo_ai_device_pref=cuda` при CPU-сервисе, чтобы увидеть жёлтую подсветку.
--- ---
## 9. Stage 6. Документация и тесты ## 9. Stage 6. Документация и тесты
@@ -568,11 +693,11 @@ nvidia.com/gpu=all`) — так демон перезапускать не ну
| `PHOTO_AI_MAX_PIXELS` | `4000000` | `app.py` (алиас `MAX_INPUT_PIXELS`) | | `PHOTO_AI_MAX_PIXELS` | `4000000` | `app.py` (алиас `MAX_INPUT_PIXELS`) |
| `PHOTO_AI_DEVICE` | `auto` | `app.py` (`auto|cuda|cpu`) | | `PHOTO_AI_DEVICE` | `auto` | `app.py` (`auto|cuda|cpu`) |
| `PHOTO_AI_TILE` | `256` | `app.py` | | `PHOTO_AI_TILE` | `256` | `app.py` |
| `PHOTO_AI_FACE_MODEL` | `gfpgan` | `app.py`, дефолт воркера/UI | | `PHOTO_AI_FACE_MODEL` | `gfpgan` | `app.py`, дефолт воркера, дефолт `photo_ai_face_model` |
| `PHOTO_AI_LOAD_ALL` | `0` | `app.py` | | `PHOTO_AI_LOAD_ALL` | `0` | `app.py` |
| `PHOTO_AI_FACE_MODE` | `off` | дефолт UI | | `PHOTO_AI_FACE_MODE` | `off` | дефолт `photo_ai_face_mode` (UI) |
| `PHOTO_AI_JPEG_QUALITY` | `92` | `app.py` | | `PHOTO_AI_JPEG_QUALITY` | `92` | `app.py` |
| `PHOTO_AI_TIMEOUT_MS` | `300000` | `worker.js` (сейчас хардкод) | | `PHOTO_AI_TIMEOUT_MS` | `300000` | `worker.js` (раньше хардкод — сделан в Stage 4) |
| `PHOTO_AI_FACE_TIMEOUT_MS` | `600000` | `worker.js` | | `PHOTO_AI_FACE_TIMEOUT_MS` | `600000` | `worker.js` |
| `PHOTO_AI_SOFT_MAX_RETRIES` | `60` | `worker.js` — лимит мягких повторов (I5) | | `PHOTO_AI_SOFT_MAX_RETRIES` | `60` | `worker.js` — лимит мягких повторов (I5) |
| `PHOTO_AI_SOFT_BACKOFF_MS` | `10000` | `worker.js` — первая пауза мягкого повтора | | `PHOTO_AI_SOFT_BACKOFF_MS` | `10000` | `worker.js` — первая пауза мягкого повтора |
@@ -583,9 +708,9 @@ nvidia.com/gpu=all`) — так демон перезапускать не ну
## 11. Порядок и правила выполнения ## 11. Порядок и правила выполнения
- [ ] Этапы выполняются строго 0 → 1 → 2 → 3 → 4 → 5 → 6. Каждый этап закрывается своей приёмкой - [x] Этапы выполняются строго 0 → 1 → 2 → 3 → 4 → 5 → 6. Каждый этап закрывается своей приёмкой
**до** начала следующего. **до** начала следующего. (0–4 закрыты, см. журналы разделов)
- [ ] Каждый завершённый чекбокс отмечать (`- [x]`) в этом файле по мере выполнения. - [x] Каждый завершённый чекбокс отмечать (`- [x]`) в этом файле по мере выполнения.
- [ ] Изменения в `AGENTS.md`/`.env.example`/`README.md` делать **в том же** изменении, что и код, - [ ] Изменения в `AGENTS.md`/`.env.example`/`README.md` делать **в том же** изменении, что и код,
который они описывают (правило `AGENTS.md` про рассинхрон документации). который они описывают (правило `AGENTS.md` про рассинхрон документации).
- [ ] Коммит на этап, а не на весь план: `app.py`+`Dockerfile`+compose → воркер/сервер → фронт → доки/тесты. - [ ] Коммит на этап, а не на весь план: `app.py`+`Dockerfile`+compose → воркер/сервер → фронт → доки/тесты.
@@ -595,9 +720,11 @@ nvidia.com/gpu=all`) — так демон перезапускать не ну
## 12. Готово, когда ## 12. Готово, когда
- [ ] `photo-ai` стартует на CPU и на CUDA, устройство выбирается автоматически, `/health` показывает - [x] `photo-ai` стартует на CPU и на CUDA, устройство выбирается автоматически, `/health` показывает
фактическое устройство, имя GPU, VRAM, доступные и загруженные модели. фактическое устройство, имя GPU, VRAM, доступные и загруженные модели. — **проверено на обоих**
- [ ] Сценарий «🤖 ИИ» без параметров даёт результат, эквивалентный текущему. (CPU на текущем стеке, CUDA — журнал GPU-прогона в разделе 2)
- [x] Сценарий «🤖 ИИ» без параметров даёт результат, эквивалентный текущему. — **I1/I2 в Stage 4**:
байт-в-байт с raw-вызовом и 5/5 `MATCH` эталона
- [ ] Доступны режимы: универсальный x2, быстрый x4, только лица (GFPGAN), лица + фон. - [ ] Доступны режимы: универсальный x2, быстрый x4, только лица (GFPGAN), лица + фон.
- [ ] Face-задание проходит полный цикл, результат виден в сравнении, применяется и откатывается. - [ ] Face-задание проходит полный цикл, результат виден в сравнении, применяется и откатывается.
- [ ] Отсутствие GPU, отсутствие `nvidia-ctk` и недоступный `photo-ai` не ломают приложение. - [ ] Отсутствие GPU, отсутствие `nvidia-ctk` и недоступный `photo-ai` не ломают приложение.
+43
View File
@@ -210,6 +210,49 @@ async function main() {
body: enhanceAi.data, body: enhanceAi.data,
}); });
// Отчёты о занятии: контракт проверки по шаблону и истории версий.
// Модель здесь не дёргаем (долго и нужен сервис) — проверяем постановку в очередь
// и то, что при ai_check:false текст остаётся нетронутым.
const lrGroups = await api('/api/groups', { token });
const gid = lrGroups.data[0] && lrGroups.data[0].id;
if (gid) {
const date = '2019-05-17';
await api(`/api/lesson-reports?group_id=${gid}&date_from=${date}&date_to=${date}`, { token })
.then(r => (r.data.items || []).forEach(i => api(`/api/lesson-reports/${i.id}`, { token, method: 'DELETE' })));
const plainText = 'Текст отчёта без проверки ИИ для смоук-теста.';
const plain = await api('/api/lesson-reports', {
token, method: 'POST',
body: { group_id: gid, lesson_date: date, lesson_time: '10:00', text: plainText, ai_check: false },
});
ok('lesson-report: создание без ai_check -> ai_status=none, text_original=null',
plain.status === 201 && plain.data.ai_status === 'none' && plain.data.text_original === null,
{ status: plain.status, ai_status: plain.data.ai_status });
const versions = await api(`/api/lesson-reports/${plain.data.id}/versions`, { token });
ok('lesson-report: история версий содержит исходный текст',
versions.status === 200 && Array.isArray(versions.data.items) && versions.data.items.length >= 1
&& versions.data.items.some(v => v.text === plainText && v.source === 'manual'),
{ status: versions.status, items: (versions.data.items || []).length });
const badRestore = await api(`/api/lesson-reports/${plain.data.id}/versions/99999999/restore`, { token, method: 'POST' });
ok('lesson-report: восстановление несуществующей версии -> 404', badRestore.status === 404, badRestore.status);
const noOrig = await api(`/api/lesson-reports/${plain.data.id}/ai/revert`, { token, method: 'POST' });
ok('lesson-report: откат без оригинала -> 400', noOrig.status === 400, noOrig.status);
const del = await api(`/api/lesson-reports/${plain.data.id}`, { token, method: 'DELETE' });
ok('lesson-report: удаление', del.status === 200, del.status);
} else {
ok('lesson-report: есть группа для проверки', false, 'no groups');
}
const lessonNotifyMeta = await api('/api/notifications/meta', { token });
// /api/notifications/meta отдаёт ключи настроек (notify_<тип>), а не сами типы
ok('notifications: настройка lesson.ai.formatted заведена',
(lessonNotifyMeta.data.types || []).some(t => t.key === 'notify_lesson_ai_formatted'),
(lessonNotifyMeta.data.types || []).map(t => t.key));
const logout = await api('/api/auth/logout', { token, method: 'POST' }); const logout = await api('/api/auth/logout', { token, method: 'POST' });
ok('logout', logout.status === 200, logout.status); ok('logout', logout.status === 200, logout.status);
const afterLogout = await api('/api/auth/me', { token }); const afterLogout = await api('/api/auth/me', { token });
+488
View File
@@ -0,0 +1,488 @@
const PHOTO_JOB_ACTIONS = new Set(['ai', 'ai_face', 'ai_upscale', 'enhance', 'restore', 'rollback']);
const LESSON_REPORT_TEXT_MAX = 5000;
const BACKUP_FORMAT_VERSION = 2;
const BACKUP_MIN_FORMAT_VERSION = 1;
const BACKUP_TABLES = [
'groups', 'students', 'entries', 'project_files', 'branches', 'users', 'user_branches',
'group_photos', 'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs',
'lesson_reports', 'lesson_report_versions', 'audit_log', 'notifications',
'notification_reads', 'banned_ips',
];
const BACKUP_SEQUENCE_TABLES = [
'groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos',
'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs', 'lesson_reports',
'lesson_report_versions', 'audit_log', 'notifications',
];
function isSupportedBackupVersion(data) {
if (!data || typeof data !== 'object' || !Array.isArray(data.groups)) return false;
const v = Number(data.version);
return Number.isInteger(v) && v >= BACKUP_MIN_FORMAT_VERSION && v <= BACKUP_FORMAT_VERSION;
}
function restoredCounts(ndata) {
const out = {};
for (const tbl of BACKUP_TABLES) out[tbl] = Array.isArray(ndata[tbl]) ? ndata[tbl].length : 0;
out.settings = Object.keys(ndata.settings || {}).length;
return out;
}
const SAFE_NAME = /^[\w,.()-]+$/;
function isSafeUploadPath(p) {
if (typeof p !== 'string' || !p.startsWith('/uploads/')) return false;
const name = p.slice('/uploads/'.length);
return name !== '' && !name.includes('/') && !name.includes('..') && SAFE_NAME.test(name);
}
function reqInt(v) {
const n = Number(v);
if (!Number.isInteger(n)) throw new Error('Invalid integer');
return n;
}
function optInt(v, lo = -Infinity, hi = Infinity) {
if (v === null || v === undefined || v === '') return null;
const n = Number(v);
if (!Number.isInteger(n) || n < lo || n > hi) throw new Error('Invalid integer');
return n;
}
function reqStr(v, max) {
if (typeof v !== 'string') throw new Error('Invalid string');
const s = v.trim();
if (!s || s.length > max) throw new Error('Invalid string length');
return s;
}
function optStr(v, max) {
if (v === null || v === undefined) return null;
return reqStr(v, max);
}
function optTs(v) {
if (v === null || v === undefined) return null;
if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(v)) throw new Error('Invalid timestamp');
return v;
}
function reqTs(v) {
const s = optTs(v);
if (!s) throw new Error('Invalid timestamp');
return s;
}
function optJsonText(v, max) {
if (v === null || v === undefined) return null;
if (typeof v === 'object') {
if (Array.isArray(v)) throw new Error('Invalid json');
v = JSON.stringify(v);
}
const s = String(v);
if (!s || s.length > max) throw new Error('Invalid json');
return s;
}
function reqIp(v) {
const s = reqStr(v, 64);
if (!/^[0-9a-fA-F:.]+$/.test(s)) throw new Error('Invalid ip');
return s;
}
function optTime(v) {
if (v === null || v === undefined) return null;
if (typeof v !== 'string' || !/^\d{2}:\d{2}(:\d{2})?$/.test(v)) throw new Error('Invalid time');
return v;
}
function optDate(v) {
if (v === null || v === undefined) return null;
if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(v)) throw new Error('Invalid date');
return v;
}
function reqDate(v) {
const s = optDate(v);
if (!s) throw new Error('Invalid date');
return s;
}
function optBool(v) {
if (v === null || v === undefined) return null;
return !!v;
}
function reqToken(v) {
if (typeof v !== 'string' || !/^[0-9a-f]{16,64}$/.test(v)) throw new Error('Invalid token');
return v;
}
function reqUploadPath(v, max) {
if (typeof v !== 'string' || v.length > max) throw new Error('Invalid path');
if (!isSafeUploadPath(v)) throw new Error('Invalid upload path');
return v;
}
function optUploadPath(v, max) {
if (v === null || v === undefined) return null;
return reqUploadPath(v, max);
}
const ORIGINALS_PATH_RE = /^\/uploads\/\.originals\/[\w.,()-]+$/;
function optOriginalsPath(v, max) {
if (v === null || v === undefined) return null;
if (typeof v !== 'string' || v.length > max || !ORIGINALS_PATH_RE.test(v)) throw new Error('Invalid originals path');
return v;
}
function reqPhotoRefPath(v, max) {
if (typeof v !== 'string' || v.length > max) throw new Error('Invalid photo path');
if (isSafeUploadPath(v) || ORIGINALS_PATH_RE.test(v)) return v;
throw new Error('Invalid photo path');
}
function optPhotoRefPath(v, max) {
if (v === null || v === undefined) return null;
return reqPhotoRefPath(v, max);
}
function photoRefKey(p) {
if (typeof p !== 'string') return null;
if (isSafeUploadPath(p) || ORIGINALS_PATH_RE.test(p)) return p.slice('/uploads/'.length);
return null;
}
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;
}
const PROFILE_HREF_RE = /^(https?:\/\/|mailto:|tel:|\/|#)/i;
const PROFILE_EMAIL_RE = /^[\w.+-]+@[\w-]+\.[\w.-]{2,}$/;
function profText(v, max) {
if (v === null || v === undefined) return null;
if (typeof v !== 'string') throw new Error('Ожидалась строка');
const s = v.trim();
if (!s) return null;
if (s.length > max) throw new Error('Слишком длинное значение');
return s;
}
function profIcon(v) {
const s = String(v || '').trim().toLowerCase();
return /^[a-z0-9-]{1,32}$/.test(s) ? s : 'link';
}
function profHref(v) {
const s = String(v || '').trim();
if (!s || s.length > 500) return null;
return (PROFILE_HREF_RE.test(s) || PROFILE_EMAIL_RE.test(s)) ? s : null;
}
function profList(v, max, fn) {
if (v === null || v === undefined) return [];
if (!Array.isArray(v)) throw new Error('Ожидался список');
const out = [];
for (const item of v.slice(0, max)) {
const row = fn(item);
if (row) out.push(row);
}
return out;
}
function sanitizeStudentProfile(raw) {
if (raw === null || raw === undefined) return null;
if (typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Ожидался объект профиля');
const out = {
role: profText(raw.role, 200),
status: profText(raw.status, 60),
status_note: profText(raw.status_note, 120),
city: profText(raw.city, 120),
mentor: profText(raw.mentor, 150),
joined: profText(raw.joined, 120),
bio: profText(raw.bio, 2000),
quote: profText(raw.quote, 300),
tags: profList(raw.tags, 20, t => profText(t, 40)),
achievements: profList(raw.achievements, 40, a => profText(a, 200)),
contacts: profList(raw.contacts, 20, c => {
if (!c || typeof c !== 'object') return null;
const label = profText(c.label, 120);
if (!label) return null;
return { icon: profIcon(c.icon), label, href: profHref(c.href) };
}),
skills: profList(raw.skills, 80, s => {
if (!s || typeof s !== 'object') return null;
const name = profText(s.name, 120);
if (!name) return null;
const value = (s.value === null || s.value === undefined || s.value === '') ? null : optInt(s.value, 0, 100);
return { group: profText(s.group, 80) || 'Навыки', name, level: profText(s.level, 40), value };
}),
experience: profList(raw.experience, 30, e => {
if (!e || typeof e !== 'object') return null;
const title = profText(e.title, 160);
if (!title) return null;
return {
title,
company: profText(e.company, 160),
period: profText(e.period, 80),
date: profText(e.date, 40),
badge: profText(e.badge, 40),
description: profText(e.description, 800),
tags: profList(e.tags, 10, t => profText(t, 40)),
};
}),
education: profList(raw.education, 60, m => {
if (!m || typeof m !== 'object') return null;
const module = profText(m.module, 200);
if (!module) return null;
const progress = (m.progress === null || m.progress === undefined || m.progress === '') ? null : optInt(m.progress, 0, 100);
return { module, progress, grade: profText(m.grade, 80), teacher: profText(m.teacher, 150) };
}),
stats: profList(raw.stats, 12, s => {
if (!s || typeof s !== 'object') return null;
const label = profText(s.label, 80);
const value = (s.value === null || s.value === undefined) ? null : String(s.value).trim().slice(0, 20);
if (!label || !value) return null;
return {
icon: profIcon(s.icon || 'star'),
value,
suffix: profText(s.suffix, 20),
label,
hint: profText(s.hint, 120),
delta: profText(s.delta, 60),
};
}),
};
const hasData = Object.values(out).some(v => (Array.isArray(v) ? v.length > 0 : v !== null));
return hasData ? out : null;
}
function normalizeRestoreData(data) {
const groups = (data.groups || []).map(x => ({
id: reqInt(x.id),
name: reqStr(x.name, 100),
created_at: optTs(x.created_at),
day_of_week: optInt(x.day_of_week, 0, 6),
time_start: optTime(x.time_start),
time_end: optTime(x.time_end),
branch_id: optInt(x.branch_id, 0, 2147483647),
tutor_id: optInt(x.tutor_id, 0, 2147483647),
cover_path: optUploadPath(x.cover_path, 255),
deleted_at: optTs(x.deleted_at),
purge_at: optTs(x.purge_at),
}));
const students = (data.students || []).map(x => ({
id: reqInt(x.id),
name: reqStr(x.name, 150),
created_at: optTs(x.created_at),
group_id: optInt(x.group_id, 0, 2147483647),
photo_path: optUploadPath(x.photo_path, 255),
profile: sanitizeStudentProfile(x.profile),
}));
const entries = (data.entries || []).map(x => ({
id: reqInt(x.id),
student_name: reqStr(x.student_name, 150),
group_id: reqInt(x.group_id),
module_id: optInt(x.module_id, 0, 2147483647),
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),
photo_original_path: optOriginalsPath(x.photo_original_path, 255),
deleted_at: optTs(x.deleted_at),
purge_at: optTs(x.purge_at),
created_at: optTs(x.created_at),
}));
const project_files = (data.project_files || []).map(x => ({
id: reqInt(x.id),
entry_id: optInt(x.entry_id, 0, 2147483647),
token: reqToken(x.token),
path: reqUploadPath(x.path, 255),
name: reqStr(x.name, 255),
detached_at: optTs(x.detached_at),
created_at: optTs(x.created_at),
}));
const branches = (data.branches || []).map(x => ({
id: reqInt(x.id),
name: reqStr(x.name, 200),
address: optStr(x.address, 1000),
phone: optStr(x.phone, 50),
created_at: optTs(x.created_at),
}));
const users = (data.users || []).map(x => ({
id: reqInt(x.id),
username: reqStr(x.username, 100),
password_hash: reqStr(x.password_hash, 255),
name: optStr(x.name, 150),
role: (x.role === 'admin' || x.role === 'tutor') ? x.role : 'tutor',
is_active: !!x.is_active,
created_at: optTs(x.created_at),
}));
const user_branches = (data.user_branches || []).map(x => ({
user_id: reqInt(x.user_id),
branch_id: reqInt(x.branch_id),
}));
const modules = (data.modules || []).map(x => ({
id: reqInt(x.id),
name: reqStr(x.name, 200),
lessons_count: optInt(x.lessons_count, 0, 10000) ?? 0,
is_active: x.is_active !== false,
photo_path: optUploadPath(x.photo_path, 255),
created_at: optTs(x.created_at),
}));
const entry_photos = (data.entry_photos || []).map(x => ({
id: reqInt(x.id),
entry_id: reqInt(x.entry_id),
photo_path: reqUploadPath(x.photo_path, 255),
caption: optStr(x.caption, 10000),
sort_order: optInt(x.sort_order, -2147483648, 2147483647),
created_at: optTs(x.created_at),
}));
const student_photos = (data.student_photos || []).map(x => ({
id: reqInt(x.id),
student_id: reqInt(x.student_id),
photo_path: reqUploadPath(x.photo_path, 255),
created_at: optTs(x.created_at),
}));
const group_photos = (data.group_photos || []).map(x => ({
id: reqInt(x.id),
group_id: reqInt(x.group_id),
photo_path: reqUploadPath(x.photo_path, 255),
caption: optStr(x.caption, 10000),
taken_at: optDate(x.taken_at),
sort_order: optInt(x.sort_order, -2147483648, 2147483647),
created_at: optTs(x.created_at),
}));
const share_links = (data.share_links || []).map(x => ({
id: reqInt(x.id),
token: optStr(x.token, 40),
name: reqStr(x.name, 200),
group_id: optInt(x.group_id, 0, 2147483647),
student_name: optStr(x.student_name, 150),
date_from: optDate(x.date_from),
date_to: optDate(x.date_to),
show_student_names: optBool(x.show_student_names),
expires_at: optTs(x.expires_at),
access_password_hash: optStr(x.access_password_hash, 255),
message: optStr(x.message, 2000),
link_url: optStr(x.link_url, 500),
show_student_message: optBool(x.show_student_message),
show_entry_date: optBool(x.show_entry_date),
show_group_photos: optBool(x.show_group_photos),
created_at: optTs(x.created_at),
}));
const lesson_reports = (data.lesson_reports || []).map(x => ({
id: reqInt(x.id),
group_id: reqInt(x.group_id),
lesson_date: reqDate(x.lesson_date),
lesson_time: optTime(x.lesson_time),
text: reqStr(x.text, LESSON_REPORT_TEXT_MAX),
text_original: optStr(x.text_original, LESSON_REPORT_TEXT_MAX),
text_ai: optStr(x.text_ai, LESSON_REPORT_TEXT_MAX),
ai_status: optStr(x.ai_status, 20),
ai_checked_at: optTs(x.ai_checked_at),
ai_error: optStr(x.ai_error, 500),
author_id: optInt(x.author_id, 0, 2147483647),
branch_id: optInt(x.branch_id, 0, 2147483647),
created_at: optTs(x.created_at),
updated_at: optTs(x.updated_at),
}));
const lesson_report_versions = (data.lesson_report_versions || []).map(x => ({
id: reqInt(x.id),
lesson_report_id: reqInt(x.lesson_report_id),
text: reqStr(x.text, LESSON_REPORT_TEXT_MAX),
source: optStr(x.source, 20),
author_id: optInt(x.author_id, 0, 2147483647),
created_at: optTs(x.created_at),
}));
const settings = {};
for (const [k, v] of Object.entries(data.settings || {})) {
settings[reqStr(k, 100)] = reqStr(String(v), 10000);
}
const audit_log = (data.audit_log || []).map(x => ({
id: reqInt(x.id),
user_id: optInt(x.user_id, 0, 2147483647),
action: reqStr(x.action, 100),
target: optJsonText(x.target, 200000),
ip: optStr(x.ip, 45),
created_at: optTs(x.created_at),
}));
const NOTIFICATION_LEVELS = new Set(['info', 'success', 'warning', 'critical']);
const notifications = (data.notifications || []).map(x => ({
id: reqInt(x.id),
type: reqStr(x.type, 50),
level: (x.level && NOTIFICATION_LEVELS.has(x.level)) ? x.level : 'info',
title: reqStr(x.title, 200),
body: optStr(x.body, 2000),
link: optStr(x.link, 255),
target: optJsonText(x.target, 20000),
admin_only: !!x.admin_only,
branch_id: optInt(x.branch_id, 0, 2147483647),
created_at: optTs(x.created_at),
}));
const notification_reads = (data.notification_reads || []).map(x => ({
user_id: reqInt(x.user_id),
notification_id: reqInt(x.notification_id),
read_at: optTs(x.read_at),
}));
const banned_ips = (data.banned_ips || []).map(x => ({
ip: reqIp(x.ip),
reason: reqStr(x.reason, 100),
banned_until: reqTs(x.banned_until),
created_at: optTs(x.created_at),
}));
const PHOTO_JOB_STATUSES = new Set(['pending', 'processing', 'done', 'error', 'rejected']);
const photo_jobs = (data.photo_jobs || []).map(x => ({
id: reqInt(x.id),
entry_id: reqInt(x.entry_id),
action: (x.action && PHOTO_JOB_ACTIONS.has(x.action)) ? x.action : 'ai',
params: optJsonText(x.params, 20000),
before_path: optPhotoRefPath(x.before_path, 255),
after_path: optPhotoRefPath(x.after_path, 255),
status: (x.status && PHOTO_JOB_STATUSES.has(x.status)) ? x.status : 'pending',
applied: !!x.applied,
attempts: optInt(x.attempts, 0, 2147483647) ?? 0,
error: optStr(x.error, 4000),
created_at: optTs(x.created_at),
finished_at: optTs(x.finished_at),
}));
return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, student_photos, group_photos, share_links, modules, photo_jobs, lesson_reports, lesson_report_versions, audit_log, notifications, notification_reads, banned_ips };
}
module.exports = {
PHOTO_JOB_ACTIONS,
LESSON_REPORT_TEXT_MAX,
SAFE_NAME,
isSafeUploadPath,
photoRefKey,
sanitizeStudentProfile,
reqInt,
optInt,
reqStr,
optStr,
optTs,
reqTs,
optDate,
optUploadPath,
normalizeRestoreData,
BACKUP_FORMAT_VERSION,
BACKUP_MIN_FORMAT_VERSION,
BACKUP_TABLES,
BACKUP_SEQUENCE_TABLES,
restoredCounts,
isSupportedBackupVersion,
};
+88
View File
@@ -0,0 +1,88 @@
const {
BACKUP_FORMAT_VERSION,
BACKUP_TABLES,
BACKUP_SEQUENCE_TABLES,
isSupportedBackupVersion,
restoredCounts,
isSafeUploadPath,
normalizeRestoreData,
} = require('./backup-restore');
let failed = 0;
function ok(label, cond, extra) {
console.log(`${cond ? 'PASS' : 'FAIL'} ${label}${extra !== undefined && !cond ? ' -> ' + JSON.stringify(extra) : ''}`);
if (!cond) failed++;
}
function throws(label, fn) {
let threw = false;
try { fn(); } catch (e) { threw = true; }
ok(label, threw);
}
ok('формат 1 (старый архив) поддерживается', isSupportedBackupVersion({ version: 1, groups: [] }));
ok(`формат ${BACKUP_FORMAT_VERSION} поддерживается`, isSupportedBackupVersion({ version: BACKUP_FORMAT_VERSION, groups: [] }));
ok('формат 0 отклоняется', !isSupportedBackupVersion({ version: 0, groups: [] }));
ok('формат из будущего отклоняется', !isSupportedBackupVersion({ version: BACKUP_FORMAT_VERSION + 1, groups: [] }));
ok('без groups отклоняется', !isSupportedBackupVersion({ version: BACKUP_FORMAT_VERSION }));
ok('не объект отклоняется', !isSupportedBackupVersion(null));
ok('в бэкап входят audit_log/notifications/banned_ips',
['audit_log', 'notifications', 'notification_reads', 'banned_ips'].every(t => BACKUP_TABLES.includes(t)),
BACKUP_TABLES);
ok('sessions не попадают в бэкап', !BACKUP_TABLES.includes('sessions'));
ok('sequences сбрасываются для audit_log и notifications',
BACKUP_SEQUENCE_TABLES.includes('audit_log') && BACKUP_SEQUENCE_TABLES.includes('notifications'));
const counts = restoredCounts({ groups: [1, 2], settings: { a: '1', b: '2' }, audit_log: [1] });
ok('restoredCounts считает строки и settings', counts.groups === 2 && counts.settings === 2 && counts.audit_log === 1, counts);
ok('restoredCounts для отсутствующей таблицы = 0', restoredCounts({ groups: [] }).notifications === 0);
ok('безопасный путь загрузки принимается', isSafeUploadPath('/uploads/1700000000000-abc123.jpg'));
ok('path traversal отклоняется', !isSafeUploadPath('/uploads/../../etc/passwd'));
ok('вложенный путь отклоняется', !isSafeUploadPath('/uploads/.originals/x.jpg'));
ok('чужой префикс отклоняется', !isSafeUploadPath('/etc/passwd'));
const base = { version: BACKUP_FORMAT_VERSION, groups: [], entries: [], users: [], branches: [] };
const n = normalizeRestoreData({
...base,
groups: [{ id: 1, name: 'G', deleted_at: '2026-01-02T03:04:05.000Z', purge_at: '2026-02-03T04:05:06.000Z' }],
});
ok('groups.deleted_at больше не теряется', n.groups[0].deleted_at === '2026-01-02T03:04:05.000Z', n.groups[0]);
ok('groups.purge_at больше не теряется', n.groups[0].purge_at === '2026-02-03T04:05:06.000Z', n.groups[0]);
const e = normalizeRestoreData({
...base,
entries: [{ id: 1, student_name: 'S', group_id: 1, description: 'd', deleted_at: '2026-01-02T03:04:05.000Z', purge_at: '2026-03-04T05:06:07.000Z' }],
});
ok('entries.purge_at больше не теряется', e.entries[0].purge_at === '2026-03-04T05:06:07.000Z', e.entries[0]);
const nt = normalizeRestoreData({
...base,
notifications: [{ id: 5, type: 'entry.new', level: 'warning', title: 'T', body: 'B', link: 'l', target: { a: 1 }, admin_only: true, branch_id: 2 }],
notification_reads: [{ user_id: 1, notification_id: 5 }],
audit_log: [{ id: 7, user_id: 1, action: 'backup.download', target: { size: 5 }, ip: '1.2.3.4' }],
banned_ips: [{ ip: '203.0.113.7', reason: 'manual', banned_until: '2026-05-05T00:00:00.000Z' }],
});
ok('notifications нормализуются', nt.notifications[0].level === 'warning' && nt.notifications[0].title === 'T', nt.notifications[0]);
ok('notifications.target остаётся JSON-строкой', nt.notifications[0].target === '{"a":1}', nt.notifications[0].target);
ok('notification_reads нормализуются', nt.notification_reads[0].notification_id === 5);
ok('audit_log нормализуется', nt.audit_log[0].action === 'backup.download' && nt.audit_log[0].target === '{"size":5}', nt.audit_log[0]);
ok('banned_ips нормализуются', nt.banned_ips[0].ip === '203.0.113.7');
const badLevel = normalizeRestoreData({ ...base, notifications: [{ id: 1, type: 'x', level: 'drop-table', title: 'T' }] });
ok('неизвестный level уведомления -> info', badLevel.notifications[0].level === 'info', badLevel.notifications[0]);
throws('мусорный ip в banned_ips отклоняется', () => normalizeRestoreData({ ...base, banned_ips: [{ ip: 'не ip', reason: 'r', banned_until: '2026-01-01T00:00:00.000Z' }] }));
throws('пустой banned_until отклоняется', () => normalizeRestoreData({ ...base, banned_ips: [{ ip: '1.2.3.4', reason: 'r' }] }));
throws('пустой action в audit_log отклоняется', () => normalizeRestoreData({ ...base, audit_log: [{ id: 1, action: ' ' }] }));
throws('не-объект в notifications.target отклоняется', () => normalizeRestoreData({ ...base, notifications: [{ id: 1, type: 'x', title: 'T', target: [1, 2, 3] }] }));
throws('группа без id отклоняется', () => normalizeRestoreData({ ...base, groups: [{ name: 'G' }] }));
throws('путь вне uploads отклоняется', () => normalizeRestoreData({ ...base, students: [{ id: 1, name: 'S', photo_path: '/etc/passwd' }] }));
throws('notifications не массив отклоняется', () => normalizeRestoreData({ ...base, notifications: { nope: 1 } }));
const legacy = normalizeRestoreData({ version: 1, groups: [{ id: 1, name: 'G' }] });
ok('старый архив без новых таблиц восстанавливается', Array.isArray(legacy.audit_log) && legacy.audit_log.length === 0 && legacy.groups.length === 1, Object.keys(legacy));
console.log(failed ? `\n${failed} проверок провалено` : '\nBACKUP SELFTEST OK');
process.exit(failed ? 1 : 0);
+79
View File
@@ -221,6 +221,37 @@ CREATE TABLE IF NOT EXISTS sessions (
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
CREATE TABLE IF NOT EXISTS lesson_reports (
id SERIAL PRIMARY KEY,
group_id INT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
lesson_date DATE NOT NULL,
lesson_time TIME,
text TEXT NOT NULL,
text_original TEXT,
text_ai TEXT,
ai_status VARCHAR(20) NOT NULL DEFAULT 'none',
ai_checked_at TIMESTAMPTZ,
ai_error TEXT,
author_id INT REFERENCES users(id) ON DELETE SET NULL,
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_lesson_reports_group_date ON lesson_reports(group_id, lesson_date);
CREATE INDEX IF NOT EXISTS idx_lesson_reports_date ON lesson_reports(lesson_date DESC);
CREATE TABLE IF NOT EXISTS lesson_report_versions (
id SERIAL PRIMARY KEY,
lesson_report_id INT NOT NULL REFERENCES lesson_reports(id) ON DELETE CASCADE,
text TEXT NOT NULL,
source VARCHAR(20) NOT NULL DEFAULT 'manual',
author_id INT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_lesson_report_versions_report ON lesson_report_versions(lesson_report_id, id DESC);
CREATE TABLE IF NOT EXISTS photo_jobs ( CREATE TABLE IF NOT EXISTS photo_jobs (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE, entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
@@ -242,6 +273,15 @@ CREATE INDEX IF NOT EXISTS idx_photo_jobs_entry_id ON photo_jobs(entry_id);
INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true') INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true')
ON CONFLICT (key) DO NOTHING; ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('photo_ai_face_mode', 'off')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('photo_ai_face_model', 'gfpgan')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('photo_ai_device_pref', 'auto')
ON CONFLICT (key) DO NOTHING;
CREATE TABLE IF NOT EXISTS audit_log ( CREATE TABLE IF NOT EXISTS audit_log (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE SET NULL, user_id INT REFERENCES users(id) ON DELETE SET NULL,
@@ -300,3 +340,42 @@ INSERT INTO settings (key, value) VALUES ('notify_backup_create', 'false')
ON CONFLICT (key) DO NOTHING; ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_system_test', 'true') INSERT INTO settings (key, value) VALUES ('notify_system_test', 'true')
ON CONFLICT (key) DO NOTHING; ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('lesson_ai_enabled', 'true')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('lesson_ai_prompt', 'Ты — редактор сообщений тьютора детской IT-школы.
Переработай исходный текст занятия так, чтобы он звучал естественно и грамотно, как будто его написал живой тьютор родителю, а не нейросеть.
ГЛАВНОЕ:
- Не добавляй информацию, которой нет в исходном тексте. Сохрани все факты и смысл.
- Исправь ошибки, повторы и неудачные формулировки. Убери канцелярит, шаблонные фразы и «ИИ-язык».
- Не используй чрезмерную похвалу и не превращай обычное занятие в достижение мирового масштала.
СТИЛЬ И СТРУКТУРА:
- Один цельный абзац, 2–4 предложения, от третьего лица.
- Первое предложение начинай со слов «На занятии ребята …».
- Если в исходном тексте описана практическая часть, начни её со слов «В конце занятия …» или «Затем …». Нет практики в исходнике — не придумывай её.
- Вместо «они» пиши «каждый». Обращение на «вы» не используй.
- Коротко скажи, с чем познакомились или что изучали; затем — что конкретно делали; в конце — что сделал самостоятельно.
- Сократи перечисления, объединяй их через «и», «а также», не повторяй одно и то же разными словами.
- Убери разговорные и оценочные обороты: «было весело», «очень», «классно».
ФОРМАТ:
- Без заголовков, списков, markdown, подписей и пояснений.
- Не начинай со слов «Сегодня», «Вчера», «Дата», «Группа», «Время» и вообще не упоминай группу, дату и время занятия.
- Не начинай с «На данном занятии» или «В рамках занятия».
- Не оборачивай ответ в кавычки.
- Не добавляй лишних предложений: если исходный текст уже написан нормально, не переписывай его ради переписывания.
ПРИМЕР ПРЕОБРАЗОВАНИЯ (бери отсюда только формулировки, тему и факты примера в свой текст не переноси):
Исходный текст:
«Ребята познакомились с программой Scratch Jr, научились выбирать фон, добавлять, изменять и создавать своих персонажей. В завершении занятия они выполнили практическое индивидуальное задание по созданию собственной анимации и небольшой программы».
Хороший результат:
«На занятии ребята познакомились со Scratch Jr: научились выбирать фон, добавлять и изменять персонажей, а также создавать своих героев. В конце занятия каждый самостоятельно выполнил небольшое практическое задание — придумал свою анимацию и собрал простую программу».
ГЛАВНОЕ ПРАВИЛО:
Отрабатывай ровно по этому исходному тексту. Ничего из примера выше в свой текст не переноси: тема, программа, персонажи и детали из примера не твои.')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_lesson_ai_formatted', 'false')
ON CONFLICT (key) DO NOTHING;
+134
View File
@@ -234,6 +234,15 @@ CREATE INDEX IF NOT EXISTS idx_photo_jobs_entry_id ON photo_jobs(entry_id);
INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true') INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true')
ON CONFLICT (key) DO NOTHING; ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('photo_ai_face_mode', 'off')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('photo_ai_face_model', 'gfpgan')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('photo_ai_device_pref', 'auto')
ON CONFLICT (key) DO NOTHING;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255); ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255);
ALTER TABLE photo_jobs ADD COLUMN IF NOT EXISTS applied BOOLEAN NOT NULL DEFAULT false; ALTER TABLE photo_jobs ADD COLUMN IF NOT EXISTS applied BOOLEAN NOT NULL DEFAULT false;
@@ -295,3 +304,128 @@ INSERT INTO settings (key, value) VALUES ('notify_ip_ban', 'true') ON CONFLICT (
INSERT INTO settings (key, value) VALUES ('notify_backup_restore', 'true') ON CONFLICT (key) DO NOTHING; INSERT INTO settings (key, value) VALUES ('notify_backup_restore', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_backup_create', 'false') ON CONFLICT (key) DO NOTHING; INSERT INTO settings (key, value) VALUES ('notify_backup_create', 'false') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_system_test', 'true') ON CONFLICT (key) DO NOTHING; INSERT INTO settings (key, value) VALUES ('notify_system_test', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_lesson_report', 'true') ON CONFLICT (key) DO NOTHING;
CREATE TABLE IF NOT EXISTS lesson_reports (
id SERIAL PRIMARY KEY,
group_id INT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
lesson_date DATE NOT NULL,
lesson_time TIME,
text TEXT NOT NULL,
author_id INT REFERENCES users(id) ON DELETE SET NULL,
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_lesson_reports_group_date ON lesson_reports(group_id, lesson_date);
CREATE INDEX IF NOT EXISTS idx_lesson_reports_date ON lesson_reports(lesson_date DESC);
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS text_original TEXT;
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS text_ai TEXT;
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'none';
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ;
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_error TEXT;
CREATE TABLE IF NOT EXISTS lesson_report_versions (
id SERIAL PRIMARY KEY,
lesson_report_id INT NOT NULL REFERENCES lesson_reports(id) ON DELETE CASCADE,
text TEXT NOT NULL,
source VARCHAR(20) NOT NULL DEFAULT 'manual',
author_id INT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_lesson_report_versions_report ON lesson_report_versions(lesson_report_id, id DESC);
INSERT INTO settings (key, value) VALUES ('lesson_ai_enabled', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('lesson_ai_prompt', 'Ты — редактор сообщений тьютора детской IT-школы.
Переработай исходный текст занятия так, чтобы он звучал естественно и грамотно, как будто его написал живой тьютор родителю, а не нейросеть.
ГЛАВНОЕ:
- Не добавляй информацию, которой нет в исходном тексте. Сохрани все факты и смысл.
- Исправь ошибки, повторы и неудачные формулировки. Убери канцелярит, шаблонные фразы и «ИИ-язык».
- Не используй чрезмерную похвалу и не превращай обычное занятие в достижение мирового масштала.
СТИЛЬ И СТРУКТУРА:
- Один цельный абзац, 2–4 предложения, от третьего лица.
- Первое предложение начинай со слов «На занятии ребята …».
- Если в исходном тексте описана практическая часть, начни её со слов «В конце занятия …» или «Затем …». Нет практики в исходнике — не придумывай её.
- Вместо «они» пиши «каждый». Обращение на «вы» не используй.
- Коротко скажи, с чем познакомились или что изучали; затем — что конкретно делали; в конце — что сделал самостоятельно.
- Сократи перечисления, объединяй их через «и», «а также», не повторяй одно и то же разными словами.
- Убери разговорные и оценочные обороты: «было весело», «очень», «классно».
ФОРМАТ:
- Без заголовков, списков, markdown, подписей и пояснений.
- Не начинай со слов «Сегодня», «Вчера», «Дата», «Группа», «Время» и вообще не упоминай группу, дату и время занятия.
- Не начинай с «На данном занятии» или «В рамках занятия».
- Не оборачивай ответ в кавычки.
- Не добавляй лишних предложений: если исходный текст уже написан нормально, не переписывай его ради переписывания.
ПРИМЕР ПРЕОБРАЗОВАНИЯ (бери отсюда только формулировки, тему и факты примера в свой текст не переноси):
Исходный текст:
«Ребята познакомились с программой Scratch Jr, научились выбирать фон, добавлять, изменять и создавать своих персонажей. В завершении занятия они выполнили практическое индивидуальное задание по созданию собственной анимации и небольшой программы».
Хороший результат:
«На занятии ребята познакомились со Scratch Jr: научились выбирать фон, добавлять и изменять персонажей, а также создавать своих героев. В конце занятия каждый самостоятельно выполнил небольшое практическое задание — придумал свою анимацию и собрал простую программу».
ГЛАВНОЕ ПРАВИЛО:
Отрабатывай ровно по этому исходному тексту. Ничего из примера выше в свой текст не переноси: тема, программа, персонажи и детали из примера не твои.') ON CONFLICT (key) DO NOTHING;
UPDATE settings
SET value = 'Ты — редактор сообщений тьютора детской IT-школы.
Переработай исходный текст занятия так, чтобы он звучал естественно и грамотно, как будто его написал живой тьютор родителю, а не нейросеть.
ГЛАВНОЕ:
- Не добавляй информацию, которой нет в исходном тексте. Сохрани все факты и смысл.
- Исправь ошибки, повторы и неудачные формулировки. Убери канцелярит, шаблонные фразы и «ИИ-язык».
- Не используй чрезмерную похвалу и не превращай обычное занятие в достижение мирового масштала.
СТИЛЬ И СТРУКТУРА:
- Один цельный абзац, 2–4 предложения, от третьего лица.
- Первое предложение начинай со слов «На занятии ребята …».
- Если в исходном тексте описана практическая часть, начни её со слов «В конце занятия …» или «Затем …». Нет практики в исходнике — не придумывай её.
- Вместо «они» пиши «каждый». Обращение на «вы» не используй.
- Коротко скажи, с чем познакомились или что изучали; затем — что конкретно делали; в конце — что сделал самостоятельно.
- Сократи перечисления, объединяй их через «и», «а также», не повторяй одно и то же разными словами.
- Убери разговорные и оценочные обороты: «было весело», «очень», «классно».
ФОРМАТ:
- Без заголовков, списков, markdown, подписей и пояснений.
- Не начинай со слов «Сегодня», «Вчера», «Дата», «Группа», «Время» и вообще не упоминай группу, дату и время занятия.
- Не начинай с «На данном занятии» или «В рамках занятия».
- Не оборачивай ответ в кавычки.
- Не добавляй лишних предложений: если исходный текст уже написан нормально, не переписывай его ради переписывания.
ПРИМЕР ПРЕОБРАЗОВАНИЯ (бери отсюда только формулировки, тему и факты примера в свой текст не переноси):
Исходный текст:
«Ребята познакомились с программой Scratch Jr, научились выбирать фон, добавлять, изменять и создавать своих персонажей. В завершении занятия они выполнили практическое индивидуальное задание по созданию собственной анимации и небольшой программы».
Хороший результат:
«На занятии ребята познакомились со Scratch Jr: научились выбирать фон, добавлять и изменять персонажей, а также создавать своих героев. В конце занятия каждый самостоятельно выполнил небольшое практическое задание — придумал свою анимацию и собрал простую программу».
ГЛАВНОЕ ПРАВИЛО:
Отрабатывай ровно по этому исходному тексту. Ничего из примера выше в свой текст не переноси: тема, программа, персонажи и детали из примера не твои.'
WHERE key = 'lesson_ai_prompt' AND value IN (
'Ты — редактор деловых отчётов образовательного центра.
Твоя задача — привести текст отчёта о занятии, написанный тьютором, к деловому стилю по шаблону ниже.
ШАБЛОН ДЕЛОВОГО СООБЩЕНИЯ:
Отчёт о проведённом занятии
Дата: <дата занятия>
Группа: <название группы>
Темы: <перечень тем>
Практика: <задания>
Домашнее задание: <что задано>
ПРАВИЛА:
1. Сначала сравни исходный текст с шаблоном. Если текст уже соответствует шаблону (та же структура, порядок и стиль) — верни его БЕЗ ИЗМЕНЕНИЙ, дословно.
2. Если текст не соответствует шаблону — перепиши его по шаблону, сохранив весь смысл и факты.
3. НЕ выдумывай тем, дат, заданий и оценок, которых нет в исходном тексте. Если данных нет — не добавляй раздел.
4. Обращение на «вы», без эмодзи и без восклицательных знаков, кратко и по делу.
5. Не добавляй приветствия, подписи и какие-либо пояснения.
6. Верни ТОЛЬКО итоговый текст отчёта — без кавычек, без markdown и без названия формата.'
);
INSERT INTO settings (key, value) VALUES ('notify_lesson_ai_formatted', 'false') ON CONFLICT (key) DO NOTHING;
+3
View File
@@ -73,6 +73,9 @@ services:
ADMIN_PASSWORD: ${ADMIN_PASSWORD} ADMIN_PASSWORD: ${ADMIN_PASSWORD}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
BACKUP_UPLOAD_LIMIT_MB: ${BACKUP_UPLOAD_LIMIT_MB:-500} BACKUP_UPLOAD_LIMIT_MB: ${BACKUP_UPLOAD_LIMIT_MB:-500}
UPLOAD_FILE_LIMIT_MB: ${UPLOAD_FILE_LIMIT_MB:-50}
UPLOAD_TOTAL_LIMIT_MB: ${UPLOAD_TOTAL_LIMIT_MB:-200}
UPLOAD_REQUEST_TIMEOUT_MS: ${UPLOAD_REQUEST_TIMEOUT_MS:-}
AI_MODEL: ${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf} AI_MODEL: ${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf}
AI_PROMPT: ${AI_PROMPT:-} AI_PROMPT: ${AI_PROMPT:-}
AI_REQUEST_TIMEOUT_MS: ${AI_REQUEST_TIMEOUT_MS:-120000} AI_REQUEST_TIMEOUT_MS: ${AI_REQUEST_TIMEOUT_MS:-120000}
+92
View File
@@ -0,0 +1,92 @@
# TODO — Инструкция по системе WhatIDo (со скриншотами)
Статус: `☐` todo · `☑` готово · `◐` в работе
Цель: папка `instructions/` с полной русскоязычной инструкцией для администратора/преподавателя,
**каждый раздел проиллюстрирован реальными скриншотами живого стенда** (стенд поднят,
в БД 358 записей, 151 ученик, 22 группы, 72 модуля, 35 ссылок — данные реальные, не моки).
## Часть 0. Подготовка
- [x] 0.1 Поднять стенд: `docker compose ps` — app на `http://localhost:3003`
- [x] 0.2 Проверить доступность `GET /` → 200, `GET /api/public-settings` → `system_name=KIBERone`
- [x] 0.3 Получить учётку админа (`admin` / `ADMIN_PASSWORD`), `POST /api/auth/login` → токен
- [x] 0.4 Инвентаризация всех страниц `public/*.html` (меню, ID, русские подписи, роли)
- [x] 0.5 Снять инвентарь данных в БД, чтобы скриншоты были непустыми
- [x] 0.6 Создать папку `instructions/` и этот TODO
## Часть 1. Публичная часть — то, что видит ученик
- [x] 1.1 `index.html` — форма «Что мы узнали на занятии»: общий вид, шапка, футер
- [x] 1.2 Блок «Фото»: превью, кнопка «Камера», модалка камеры (снять/отмена)
- [x] 1.3 Блок «Файлы проекта»: выбор, вставка из буфера, список с размерами, очистка
- [x] 1.4 Блок полей: ФИО, Группа, Тема модуля, «Что сделал» (+ автодополнение)
- [x] 1.5 Кнопка «Отправить» + панель успеха `#sentPanel` с таймером антиспама
- [x] 1.6 Ошибки/повторная отправка, тосты
## Часть 2. Вход и оболочка админки
- [x] 2.1 `login.html` — форма входа (логин, пароль, honeypot, ошибки)
- [x] 2.2 `admin.js` — сайдбар: логотип, user-box, две группы меню, версия
- [x] 2.3 Выпадающий список уведомлений в сайдбаре + бейдж
## Часть 3. Разделы по порядку (основное → администрирование)
- [x] 3.1 **Дашборд** — плитки статистики, динамика за 14 дней, последние записи, активные группы, топ учеников, быстрые действия
- [x] 3.2 **Журнал** — фильтры, список/карточки, пагинация, карточка записи
- [x] 3.3 Журнал — модалка «Редактирование записи» (+ `✨` ИИ-исправление, `ИИ предлагает вариант`)
- [x] 3.4 Журнал — «Улучшение фото»: сравнение до/после, слайдеры, режимы ИИ, история версий
- [x] 3.5 Журнал — модалка «Создать ссылку»
- [x] 3.6 **Ученики** — список, фильтры, пагинация, кнопки действий
- [x] 3.7 Ученики — пакетное добавление, прикрепление к группе
- [x] 3.8 Ученики — «Данные профиля» (все поля отчёта)
- [x] 3.9 Ученики — «Экспорт отчёта» (ZIP)
- [x] 3.10 **Группы** — карточки, расписание, филиал/тутор
- [x] 3.11 Группы — галерея фото группы (загрузка, обложка, порядок, правка)
- [x] 3.12 Группы — «Архив файлов группы» (ZIP-выгрузка)
- [x] 3.13 **Фото** — все фото, фильтры по источнику, карточки
- [x] 3.14 **Файлы** — прикреплённые / откреплённые
- [x] 3.15 **Ссылки** — список share-ссылок, бейджи, создание
- [x] 3.16 **Корзина** — восстановление, «помеченные на удаление»
- [x] 3.17 **Темы модулей** — список, модалка модуля, пакетное добавление
- [x] 3.18 **Филиалы** — CRUD
- [x] 3.19 **Пользователи** — таблица, модалка, мультивыбор филиалов
- [x] 3.20 **Воркер ИИ** — статус, очередь, последние проверки, фото-задания, ошибки
- [x] 3.21 **Аудит** — таблица действий, модалка «Детали действия» с диффом
- [x] 3.22 **Блокировки** — таблица IP, модалка ручного бана
- [x] 3.23 **Уведомления** — полная история, фильтр, «прочитать все», «очистить всё»
- [x] 3.24 **Настройки** — 13 секций (система, стек, брендинг, антиспам, ссылки, футер, фото, фото-ИИ, уведомления, ИИ, бэкапы, корзина, блокировки)
## Часть 4. Публичные страницы по ссылкам
- [x] 4.1 `share.html` (`/s/<token>`) — карточки записей, фото группы, cookie-баннер, лайтбокс
- [x] 4.2 `share.html` — защита паролем `#passwordModal`
- [x] 4.3 `report.html` (`/r/<token>`) — публичный отчёт: hero, «Обо мне», хроника, работы, файлы, фото, контакты
- [x] 4.4 `error.html` — страница ошибки 404
## Часть 5. Сборка инструкции
- [x] 5.1 Скриншоты → `instructions/img/` с нумерацией
- [x] 5.2 `instructions/README.md` — оглавление, роли, вход, быстрый старт
- [x] 5.3 Постраничные файлы инструкции `01-…` … `05-…` с вставленными картинками
- [x] 5.4 `instructions/CHEATSHEET.md` — краткая шпаргалка + горячие клавиши/API
- [x] 5.5 Проверка: все ссылки на изображения существуют, нет битых `.md`
## Итог
| Артефакт | Описание |
|---|---|
| `instructions/README.md` | Оглавление + обзор системы + порядок работы |
| `instructions/01-public-form.md` | Публичная форма ученика (пошагово) |
| `instructions/02-login-shell.md` | Вход, сайдбар, уведомления |
| `instructions/03-dashboard.md` | Дашборд |
| `instructions/04-journal.md` | Журнал: фильтры, редактирование, фото-ИИ, ссылки |
| `instructions/05-students.md` | Ученики: профили, группы, экспорт |
| `instructions/06-groups-photos-files.md` | Группы, Фото, Файлы |
| `instructions/07-links-trash.md` | Ссылки, Корзина |
| `instructions/08-admin-sections.md` | Модули, Филиалы, Пользователи, Блокировки |
| `instructions/09-worker-audit-notifications.md` | Воркер ИИ, Аудит, Уведомления |
| `instructions/10-settings.md` | Настройки: все 13 секций |
| `instructions/11-share-report-pages.md` | Публичные `/s/` и `/r/` страницы |
| `instructions/CHEATSHEET.md` | Шпаргалка |
| `instructions/img/*.png` | Скриншоты |
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 681 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 947 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 647 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+61
View File
@@ -161,6 +161,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.item .name-input:focus{border-color:var(--accent)} .item .name-input:focus{border-color:var(--accent)}
.item .group-select{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:4px 8px;font-size:.8rem;outline:none;max-width:200px} .item .group-select{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:4px 8px;font-size:.8rem;outline:none;max-width:200px}
.item .group-select:focus{border-color:var(--accent)} .item .group-select:focus{border-color:var(--accent)}
.student-main{display:flex;align-items:center;gap:10px;flex:1;min-width:0}
.student-main .name-input{flex:1;min-width:0}
.student-avatar{width:40px;height:40px;flex:0 0 auto;border-radius:50%;overflow:hidden;border:1px solid var(--border);background:var(--bg);padding:0;cursor:zoom-in;display:flex;align-items:center;justify-content:center;transition:border-color .15s,opacity .15s}
.student-avatar img{width:100%;height:100%;object-fit:cover;display:block}
.student-avatar:hover{border-color:var(--accent);opacity:.9}
.student-avatar.empty{cursor:pointer;border-style:dashed}
.student-avatar.empty span{font-size:.75rem;font-weight:700;color:var(--muted);letter-spacing:.02em;user-select:none}
.item.expired{opacity:.6;border-color:rgba(239,68,68,.35);background:rgba(239,68,68,.03)} .item.expired{opacity:.6;border-color:rgba(239,68,68,.35);background:rgba(239,68,68,.03)}
.item.expired:hover{opacity:.85} .item.expired:hover{opacity:.85}
.badge-expired{display:inline-block;font-size:.68rem;font-weight:700;line-height:1;padding:3px 8px;border-radius:999px;background:rgba(239,68,68,.12);color:#ef4444;vertical-align:middle;margin-left:6px} .badge-expired{display:inline-block;font-size:.68rem;font-weight:700;line-height:1;padding:3px 8px;border-radius:999px;background:rgba(239,68,68,.12);color:#ef4444;vertical-align:middle;margin-left:6px}
@@ -215,6 +222,15 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:100;display:none;align-items:center;justify-content:center} .modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:100;display:none;align-items:center;justify-content:center}
.modal-overlay.open{display:flex} .modal-overlay.open{display:flex}
.modal-overlay > img{max-width:90%;max-height:90dvh;border-radius:var(--radius)} .modal-overlay > img{max-width:90%;max-height:90dvh;border-radius:var(--radius)}
#videoModal{background:rgba(0,0,0,.85)}
.video-modal{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:16px;width:min(1000px,94vw);max-height:94dvh;display:flex;flex-direction:column;gap:12px}
.video-head{display:flex;align-items:center;gap:12px;font-size:.85rem;color:var(--text)}
.video-head > span:first-child{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.video-dl{background:none;border:1px solid var(--border);border-radius:6px;padding:3px 10px;color:var(--muted);text-decoration:none;font-size:.75rem}
.video-dl:hover{color:var(--accent);border-color:var(--accent)}
.video-close{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.4rem;line-height:1;padding:0 4px}
.video-close:hover{color:var(--text)}
#videoPlayer{width:100%;max-height:78dvh;background:#000;border-radius:var(--radius);display:block}
.edit-modal{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:24px;max-width:420px;width:90%;display:flex;flex-direction:column;gap:16px} .edit-modal{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:24px;max-width:420px;width:90%;display:flex;flex-direction:column;gap:16px}
.edit-modal h3{font-size:1.1rem;font-weight:600} .edit-modal h3{font-size:1.1rem;font-weight:600}
.edit-modal label{font-size:.8rem;color:var(--muted);margin-bottom:4px} .edit-modal label{font-size:.8rem;color:var(--muted);margin-bottom:4px}
@@ -642,4 +658,49 @@ body[data-page=settings] .page-head{align-items:flex-end}
.notify-panel{left:12px;right:12px;width:auto;bottom:12px} .notify-panel{left:12px;right:12px;width:auto;bottom:12px}
} }
.lesson-modal{max-width:560px;width:92vw}
.lesson-modal .lesson-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.lesson-modal .hint{font-size:.78rem;color:var(--muted);text-align:right}
.lesson-modal textarea{min-height:130px}
.lesson-modal .lesson-ai-toggle{background:var(--bg);border:1px solid var(--border);border-radius:10px}
.lesson-ai-status{font-size:.78rem;color:var(--muted);line-height:1.45;margin-top:-4px}
.lesson-ai-status.err{color:#ef4444}
.lesson-dup{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:10px 12px;border-radius:10px;border:1px solid var(--border);background:var(--bg);color:var(--muted);font-size:.8rem;line-height:1.45}
.lesson-dup[hidden]{display:none}
.lesson-dup .btn-link{padding:5px 10px;font-size:.75rem;flex-shrink:0}
.lesson-versions{display:flex;flex-direction:column;gap:8px;max-height:56vh;overflow-y:auto;margin:4px 0}
.lesson-version{border:1px solid var(--border);border-radius:var(--radius);padding:10px 12px;background:var(--bg)}
.lesson-version.current{border-color:var(--accent);background:rgba(37,99,235,.06)}
.lesson-version-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.lesson-version-label{font-size:.78rem;font-weight:600}
.lesson-version-time{font-size:.74rem;color:var(--muted);margin-left:auto}
.lesson-version-text{font-size:.82rem;line-height:1.5;white-space:pre-wrap;word-break:break-word;margin-top:6px}
.lesson-version-foot{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:8px}
.lesson-version-author{font-size:.74rem;color:var(--muted)}
.lesson-version-foot .btn-link{margin-left:auto;padding:4px 10px;font-size:.75rem}
.lesson-item .ai-badge{margin-left:0}
@media(max-width:560px){
.lesson-modal .lesson-row{grid-template-columns:1fr}
}
.lesson-item{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px;display:flex;flex-direction:column;gap:8px}
.lesson-item-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.lesson-group{font-weight:600;font-size:.95rem}
.lesson-when{color:var(--muted);font-size:.78rem;display:inline-flex;align-items:center;gap:5px}
.lesson-when i{width:13px;height:13px}
.lesson-badge{font-size:.7rem;font-weight:700;padding:2px 9px;border-radius:999px;background:rgba(37,99,235,.1);color:var(--accent)}
.lesson-badge.today{background:rgba(16,185,129,.14);color:#059669}
.lesson-text{font-size:.88rem;color:var(--text);white-space:pre-wrap;word-break:break-word;line-height:1.55}
.lesson-author{font-size:.75rem;color:var(--muted)}
.lesson-actions{display:flex;gap:8px;flex-wrap:wrap;margin-left:auto}
.lesson-item .btn-link{padding:6px 12px;font-size:.78rem}
.lesson-item .btn-link.danger{color:#ef4444;border-color:rgba(239,68,68,.35)}
.lesson-item .btn-link.danger:hover{border-color:#ef4444}
.lesson-list{display:flex;flex-direction:column;gap:10px}
.lesson-dash-item{display:flex;flex-direction:column;gap:3px;padding:8px 6px;border-radius:10px;transition:background .15s;cursor:pointer}
.lesson-dash-item:hover{background:var(--bg)}
.lesson-dash-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.lesson-dash-group{font-weight:600;font-size:.86rem}
.lesson-dash-when{color:var(--muted);font-size:.74rem;margin-left:auto;white-space:nowrap}
.lesson-dash-text{font-size:.8rem;color:var(--muted);overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;line-height:1.45}
+404
View File
@@ -49,6 +49,7 @@ function buildSidebar(active) {
const base = [ const base = [
{ page: 'dashboard', label: 'Дашборд', icon: 'layout-dashboard' }, { page: 'dashboard', label: 'Дашборд', icon: 'layout-dashboard' },
{ page: 'journal', label: 'Журнал', icon: 'book-open' }, { page: 'journal', label: 'Журнал', icon: 'book-open' },
{ page: 'lessons', label: 'Отчёты о занятиях', icon: 'notebook-pen' },
{ page: 'notifications', label: 'Уведомления', icon: 'bell', badge: true }, { page: 'notifications', label: 'Уведомления', icon: 'bell', badge: true },
{ page: 'students', label: 'Ученики', icon: 'graduation-cap' }, { page: 'students', label: 'Ученики', icon: 'graduation-cap' },
{ page: 'groups', label: 'Группы', icon: 'users' }, { page: 'groups', label: 'Группы', icon: 'users' },
@@ -176,6 +177,8 @@ const NOTIFY_ICONS = {
'backup.restore': 'database', 'backup.restore': 'database',
'backup.create': 'download', 'backup.create': 'download',
'system.test': 'send', 'system.test': 'send',
'lesson.report': 'notebook-pen',
'lesson.ai.formatted': 'file-check',
}; };
const NOTIFY_LEVELS = { warning: 'Предупреждение', critical: 'Важно', info: '' }; const NOTIFY_LEVELS = { warning: 'Предупреждение', critical: 'Важно', info: '' };
@@ -483,6 +486,407 @@ function copyText(text) {
} }
} }
let lessonModalGroups = null;
let lessonModalGroupsPromise = null;
function lessonTodayIso() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function lessonNowTime() {
const d = new Date();
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
async function loadLessonGroups() {
if (lessonModalGroups) return lessonModalGroups;
if (!lessonModalGroupsPromise) {
lessonModalGroupsPromise = fetch(`${API}/api/groups`, { headers: hdr() })
.then(res => (res.ok ? res.json() : []))
.then(g => { lessonModalGroups = Array.isArray(g) ? g.filter(x => !x.deleted_at) : []; return lessonModalGroups; })
.catch(() => { lessonModalGroups = []; return lessonModalGroups; });
}
return lessonModalGroupsPromise;
}
function ensureLessonModal() {
let modal = document.getElementById('lessonModal');
if (modal) return modal;
modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.id = 'lessonModal';
modal.innerHTML = `
<div class="edit-modal lesson-modal">
<h3 id="lessonModalTitle">Что прошли на занятии</h3>
<form id="lessonForm" class="settings-stack" style="gap:12px" autocomplete="off">
<input type="hidden" id="lessonId">
<div class="settings-field">
<label>Группа <span style="color:#ef4444">*</span></label>
<select id="lessonGroup" class="settings-input" required></select>
</div>
<div class="lesson-row">
<div class="settings-field">
<label>Дата занятия <span style="color:#ef4444">*</span></label>
<input type="date" id="lessonDate" class="settings-input" required>
</div>
<div class="settings-field">
<label>Время</label>
<input type="time" id="lessonTime" class="settings-input">
</div>
</div>
<div class="settings-field">
<label>Что прошли на занятии <span style="color:#ef4444">*</span></label>
<textarea id="lessonText" class="settings-input" rows="6" maxlength="5000" required
placeholder="Например: разобрали циклы for и while, решали задачи на списки, домашнее задание — функции"></textarea>
<span class="hint" id="lessonHint">0 / 5000</span>
</div>
<div class="lesson-dup" id="lessonDup" hidden>
<span>За эту группу и дату отчёт уже есть — создать второй нельзя.</span>
<button type="button" class="btn-link" id="lessonDupGo">Перейти к существующему отчёту</button>
</div>
<label class="toggle lesson-ai-toggle" id="lessonAiRow">
<input type="checkbox" id="lessonAiCheck" checked>
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Проверить по шаблону делового сообщения</b><small>ИИ сверит текст с шаблоном из настроек и, если не совпадёт, оформит его как деловое сообщение</small></span>
</label>
<div class="lesson-ai-status" id="lessonAiStatus" hidden></div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="lessonVersionsBtn" hidden>История версий</button>
<button type="button" class="btn-primary ghost" id="lessonCancel">Отмена</button>
<button type="submit" class="btn-primary" id="lessonSubmit">Сохранить</button>
</div>
</form>
</div>`;
document.body.appendChild(modal);
const groupSel = modal.querySelector('#lessonGroup');
const textEl = modal.querySelector('#lessonText');
const hintEl = modal.querySelector('#lessonHint');
const submitBtn = modal.querySelector('#lessonSubmit');
textEl.addEventListener('input', () => { hintEl.textContent = `${textEl.value.length} / 5000`; });
modal.addEventListener('click', e => { if (e.target === modal) closeLessonModal(); });
modal.querySelector('#lessonCancel').addEventListener('click', closeLessonModal);
modal.querySelector('#lessonVersionsBtn').addEventListener('click', () => {
const id = modal.querySelector('#lessonId').value;
if (id) openLessonVersions(id);
});
groupSel.addEventListener('change', () => prefetchLessonForGroupDate());
modal.querySelector('#lessonDate').addEventListener('change', () => prefetchLessonForGroupDate());
modal.querySelector('#lessonDupGo').addEventListener('click', () => {
const id = modal.querySelector('#lessonDup').dataset.id;
if (!id) return;
closeLessonModal();
openLessonModal({ id });
});
modal.querySelector('#lessonForm').addEventListener('submit', e => submitLessonModal(e));
return modal;
}
async function submitLessonModal(e) {
e.preventDefault();
const modal = document.getElementById('lessonModal');
if (!modal) return;
const groupSel = modal.querySelector('#lessonGroup');
const dateEl = modal.querySelector('#lessonDate');
const timeEl = modal.querySelector('#lessonTime');
const textEl = modal.querySelector('#lessonText');
const submitBtn = modal.querySelector('#lessonSubmit');
const aiCheckEl = modal.querySelector('#lessonAiCheck');
const groupId = groupSel.value;
const date = dateEl.value;
const text = textEl.value.trim();
if (!groupId) { showToast('Выберите группу'); groupSel.focus(); return; }
if (!date) { showToast('Укажите дату занятия'); return; }
if (!text) { showToast('Введите текст отчёта'); textEl.focus(); return; }
const id = modal.querySelector('#lessonId').value;
const aiCheck = !!aiCheckEl && aiCheckEl.checked;
submitBtn.disabled = true;
try {
const res = await fetch(`${API}/api/lesson-reports${id ? '/' + id : ''}`, {
method: id ? 'PUT' : 'POST',
headers: hdrJson(),
body: JSON.stringify({ group_id: Number(groupId), lesson_date: date, lesson_time: timeEl.value || null, text, ai_check: aiCheck }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (res.status === 409 && data.id) {
showLessonDuplicate(modal, data.id);
showToast('За эту группу и дату отчёт уже есть — новый создать нельзя');
return;
}
showToast(data.error || 'Не удалось сохранить отчёт');
return;
}
closeLessonModal();
if (data.ai_status === 'pending' || data.ai_status === 'processing') {
showToast('Отчёт сохранён — ИИ проверяет текст по шаблону');
} else if (data.ai_status === 'error') {
showToast(`Отчёт сохранён, проверка по шаблону не удалась: ${data.ai_error || 'ошибка ИИ'}`);
} else {
showToast(id ? 'Отчёт о занятии обновлён' : 'Отчёт о занятии сохранён');
}
if (typeof onLessonReportSaved === 'function') onLessonReportSaved();
} catch {
showToast('Ошибка сети');
} finally {
submitBtn.disabled = false;
}
}
let lessonDupSeq = 0;
async function prefetchLessonForGroupDate() {
const modal = document.getElementById('lessonModal');
if (!modal || !modal.classList.contains('open')) return;
if (modal.querySelector('#lessonId').value) return;
const groupId = modal.querySelector('#lessonGroup').value;
const date = modal.querySelector('#lessonDate').value;
if (!groupId || !date) { showLessonDuplicate(modal, null); return; }
const seq = ++lessonDupSeq;
try {
const q = `group_id=${encodeURIComponent(groupId)}&date_from=${encodeURIComponent(date)}&date_to=${encodeURIComponent(date)}&limit=1`;
const res = await fetch(`${API}/api/lesson-reports?${q}`, { headers: hdr() });
if (seq !== lessonDupSeq) return;
if (!res.ok) { showLessonDuplicate(modal, null); return; }
const data = await res.json();
if (seq !== lessonDupSeq) return;
const item = (data.items || [])[0];
showLessonDuplicate(modal, item ? item.id : null);
} catch {
if (seq === lessonDupSeq) showLessonDuplicate(modal, null);
}
}
function showLessonDuplicate(modal, existingId) {
const row = modal.querySelector('#lessonDup');
if (!row) return;
row.dataset.id = existingId ? String(existingId) : '';
row.hidden = !existingId;
}
function fillLessonModalFromReport(modal, r) {
modal.querySelector('#lessonId').value = r.id;
if (r.lesson_time && !modal.querySelector('#lessonTime').value) {
modal.querySelector('#lessonTime').value = String(r.lesson_time).slice(0, 5);
}
if (!modal.querySelector('#lessonText').value) {
const textEl = modal.querySelector('#lessonText');
textEl.value = r.text || '';
textEl.dispatchEvent(new Event('input'));
}
const aiCheck = modal.querySelector('#lessonAiCheck');
if (aiCheck) aiCheck.checked = false;
updateLessonAiRow(modal, r);
modal.querySelector('#lessonModalTitle').textContent = 'Редактирование отчёта';
modal.querySelector('#lessonSubmit').textContent = 'Обновить';
}
function lessonAiStatusText(r) {
if (!r || !r.ai_status || r.ai_status === 'none') return '';
if (r.ai_status === 'pending') return 'ИИ проверяет текст по шаблону…';
if (r.ai_status === 'processing') return 'ИИ оформляет текст по шаблону…';
if (r.ai_status === 'done' && r.text_ai) return 'ИИ оформил текст по шаблону';
if (r.ai_status === 'skipped') return 'Текст уже соответствует шаблону';
if (r.ai_status === 'reverted') return 'Возвращена исходная версия текста';
if (r.ai_status === 'error') return `Проверка по шаблону не удалась: ${r.ai_error || 'ошибка ИИ'}`;
return `Статус ИИ: ${r.ai_status}`;
}
function updateLessonAiRow(modal, r) {
const row = modal.querySelector('#lessonAiRow');
const status = modal.querySelector('#lessonAiStatus');
const versionsBtn = modal.querySelector('#lessonVersionsBtn');
if (row) row.hidden = false;
const text = lessonAiStatusText(r);
if (status) {
status.textContent = text;
status.hidden = !text;
status.classList.toggle('err', !!r && r.ai_status === 'error');
}
if (versionsBtn) versionsBtn.hidden = !modal.querySelector('#lessonId').value;
}
function resetLessonModalFields(modal) {
lessonDupSeq++;
modal.querySelector('#lessonId').value = '';
showLessonDuplicate(modal, null);
modal.querySelector('#lessonDate').value = lessonTodayIso();
modal.querySelector('#lessonTime').value = lessonNowTime();
const textEl = modal.querySelector('#lessonText');
textEl.value = '';
textEl.dispatchEvent(new Event('input'));
const aiCheck = modal.querySelector('#lessonAiCheck');
if (aiCheck) aiCheck.checked = true;
updateLessonAiRow(modal, null);
modal.querySelector('#lessonModalTitle').textContent = 'Что прошли на занятии';
modal.querySelector('#lessonSubmit').textContent = 'Сохранить';
}
const LESSON_VERSION_LABELS = {
manual: 'Тьютор',
ai: 'ИИ по шаблону',
restore: 'Восстановлено'
};
let currentLessonVersionId = '';
function ensureLessonVersionsModal() {
let modal = document.getElementById('lessonVersionsModal');
if (modal) return modal;
modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.id = 'lessonVersionsModal';
modal.innerHTML = `
<div class="edit-modal lesson-modal">
<h3>История версий отчёта</h3>
<div class="lesson-versions" id="lessonVersionsList"><div class="empty">Загрузка…</div></div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="lessonVersionsClose">Закрыть</button>
</div>
</div>`;
document.body.appendChild(modal);
modal.addEventListener('click', e => { if (e.target === modal) closeLessonVersions(); });
modal.querySelector('#lessonVersionsClose').addEventListener('click', closeLessonVersions);
return modal;
}
function closeLessonVersions() {
const modal = document.getElementById('lessonVersionsModal');
if (modal) modal.classList.remove('open');
}
function lessonVersionItemHtml(v, index) {
const label = LESSON_VERSION_LABELS[v.source] || v.source || 'Версия';
const who = v.author_name || v.author_username || '';
return `
<div class="lesson-version${index === 0 ? ' current' : ''}">
<div class="lesson-version-head">
<span class="lesson-version-label">${esc(label)}</span>
<span class="lesson-version-time">${esc(notifyTime(v.created_at))}</span>
</div>
<div class="lesson-version-text">${esc(v.text || '')}</div>
<div class="lesson-version-foot">
${who ? `<span class="lesson-version-author">${esc(who)}</span>` : ''}
${index === 0 ? '<span class="lesson-badge">текущая</span>' : ''}
${index === 0 ? '' : `<button type="button" class="btn-link" data-restore="${v.id}" data-report="${v.lesson_report_id || currentLessonVersionId || ''}">Восстановить</button>`}
</div>
</div>`;
}
async function openLessonVersions(id) {
currentLessonVersionId = String(id || '');
const modal = ensureLessonVersionsModal();
const list = modal.querySelector('#lessonVersionsList');
list.innerHTML = '<div class="empty">Загрузка…</div>';
modal.classList.add('open');
renderIcons();
try {
const res = await fetch(`${API}/api/lesson-reports/${id}/versions`, { headers: hdr() });
const data = await res.json().catch(() => ({}));
if (!res.ok) { list.innerHTML = `<div class="empty">${esc(data.error || 'Не удалось загрузить историю')}</div>`; return; }
const items = data.items || [];
if (!items.length) { list.innerHTML = '<div class="empty">Версий пока нет</div>'; return; }
list.innerHTML = items.map(lessonVersionItemHtml).join('');
renderIcons();
} catch {
list.innerHTML = '<div class="empty">Ошибка сети</div>';
}
}
async function restoreLessonVersion(id, versionId, btn) {
if (!confirm('Восстановить эту версию отчёта?')) return;
if (btn) btn.disabled = true;
try {
const res = await fetch(`${API}/api/lesson-reports/${id}/versions/${versionId}/restore`, { method: 'POST', headers: hdr() });
const data = await res.json().catch(() => ({}));
if (!res.ok) { showToast(data.error || 'Не удалось восстановить версию'); return; }
closeLessonVersions();
showToast('Версия отчёта восстановлена');
if (typeof onLessonReportSaved === 'function') onLessonReportSaved();
} catch {
showToast('Ошибка сети');
} finally {
if (btn) btn.disabled = false;
}
}
document.addEventListener('click', e => {
const btn = e.target.closest('[data-restore]');
if (!btn) return;
e.preventDefault();
let id = btn.dataset.report || currentLessonVersionId;
if (!id) {
const lessonModal = document.getElementById('lessonModal');
id = (lessonModal && lessonModal.querySelector('#lessonId').value) || '';
}
if (!id) return;
restoreLessonVersion(id, btn.dataset.restore, btn);
});
async function openLessonModal(opts = {}) {
const modal = ensureLessonModal();
const groupSel = modal.querySelector('#lessonGroup');
const previous = groupSel.value;
const groups = await loadLessonGroups();
groupSel.innerHTML = '';
if (!groups.length) {
const o = document.createElement('option');
o.value = '';
o.textContent = 'Нет доступных групп';
groupSel.appendChild(o);
} else {
groups.forEach(g => {
const o = document.createElement('option');
o.value = g.id;
o.textContent = g.branch_name ? `${g.name} (${g.branch_name})` : g.name;
groupSel.appendChild(o);
});
}
resetLessonModalFields(modal);
const wanted = opts.groupId ? String(opts.groupId) : previous;
if (wanted && groups.some(g => String(g.id) === wanted)) groupSel.value = wanted;
if (opts.text) {
const textEl = modal.querySelector('#lessonText');
textEl.value = opts.text;
textEl.dispatchEvent(new Event('input'));
}
modal.classList.add('open');
if (opts.id) {
try {
const res = await fetch(`${API}/api/lesson-reports/${opts.id}`, { headers: hdr() });
if (res.ok) {
const r = await res.json();
groupSel.value = String(r.group_id);
modal.querySelector('#lessonDate').value = String(r.lesson_date).slice(0, 10);
modal.querySelector('#lessonTime').value = r.lesson_time ? String(r.lesson_time).slice(0, 5) : '';
fillLessonModalFromReport(modal, r);
}
} catch {}
} else {
await prefetchLessonForGroupDate();
}
renderIcons();
setTimeout(() => modal.querySelector('#lessonText').focus(), 50);
}
function closeLessonModal() {
const modal = document.getElementById('lessonModal');
if (modal) modal.classList.remove('open');
}
document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
const modal = document.getElementById('lessonModal');
if (modal && modal.classList.contains('open')) closeLessonModal();
});
function renderPager(el, page, totalPages, onPage) { function renderPager(el, page, totalPages, onPage) {
if (!el) return; if (!el) return;
if (totalPages <= 1) { el.innerHTML = ''; return; } if (totalPages <= 1) { el.innerHTML = ''; return; }
+6
View File
@@ -28,6 +28,11 @@
<div class="recent" id="recentList"></div> <div class="recent" id="recentList"></div>
<div class="dash-link-row"><a href="journal.html">Все записи →</a></div> <div class="dash-link-row"><a href="journal.html">Все записи →</a></div>
</div> </div>
<div class="dash-card">
<h3><span class="ic"><i data-lucide="notebook-pen"></i></span> Что прошли на занятиях</h3>
<div class="recent" id="lessonList"></div>
<div class="dash-link-row"><a href="lessons.html">Все отчёты →</a></div>
</div>
<div class="dash-card"> <div class="dash-card">
<h3><span class="ic"><i data-lucide="camera"></i></span> Фото групп</h3> <h3><span class="ic"><i data-lucide="camera"></i></span> Фото групп</h3>
<div class="photos-grid-dash" id="dashPhotos"></div> <div class="photos-grid-dash" id="dashPhotos"></div>
@@ -40,6 +45,7 @@
<h3><span class="ic"><i data-lucide="zap"></i></span> Быстрые действия</h3> <h3><span class="ic"><i data-lucide="zap"></i></span> Быстрые действия</h3>
<div class="actions-grid"> <div class="actions-grid">
<a class="action-btn" href="journal.html"><span class="aic"><i data-lucide="book-open"></i></span>Журнал</a> <a class="action-btn" href="journal.html"><span class="aic"><i data-lucide="book-open"></i></span>Журнал</a>
<button type="button" class="action-btn" id="lessonReportBtn" style="border:1px solid var(--border);cursor:pointer;font-family:inherit"><span class="aic"><i data-lucide="notebook-pen"></i></span>Что прошли на занятии</button>
<a class="action-btn" href="files.html"><span class="aic"><i data-lucide="folder"></i></span>Файлы</a> <a class="action-btn" href="files.html"><span class="aic"><i data-lucide="folder"></i></span>Файлы</a>
<a class="action-btn" href="photos.html"><span class="aic"><i data-lucide="Image"></i></span>Фото</a> <a class="action-btn" href="photos.html"><span class="aic"><i data-lucide="Image"></i></span>Фото</a>
<a class="action-btn" href="groups.html"><span class="aic"><i data-lucide="users"></i></span>Группы</a> <a class="action-btn" href="groups.html"><span class="aic"><i data-lucide="users"></i></span>Группы</a>
+64
View File
@@ -71,6 +71,70 @@
</div> </div>
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<div class="modal-overlay" id="groupFilesModal">
<div class="edit-modal" style="max-width:440px">
<h3 id="groupFilesTitle">Архив файлов группы</h3>
<form id="groupFilesForm" class="settings-stack" style="gap:14px">
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Группа <span style="color:#ef4444">*</span></label>
<select id="groupFilesGroup" class="settings-input" required></select>
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Дата с</label>
<input type="date" id="groupFilesFrom" class="settings-input">
</div>
<div class="settings-field" style="flex:1">
<label>Дата по</label>
<input type="date" id="groupFilesTo" class="settings-input">
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<div class="hint" style="font-size:.78rem;color:var(--muted);margin:0">Файлы всех учеников группы за период. По умолчанию — с самого начала и по сегодня.</div>
</div>
</div>
<div class="settings-field full">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="groupFilesIncludeWorks" checked>
Работы (файлы проектов)
</label>
</div>
<div class="settings-field full">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="groupFilesIncludePhotos">
Фото записи
</label>
</div>
<div class="settings-field full">
<label>Какие фото выгружать</label>
<div style="display:flex;flex-direction:column;gap:8px">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-weight:400">
<input type="checkbox" id="groupFilesMainPhoto" checked>
Главное фото каждой записи
</label>
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-weight:400">
<input type="checkbox" id="groupFilesGallery">
Вся галерея записи
</label>
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-weight:400">
<input type="checkbox" id="groupFilesOriginals">
Оригиналы фото (до обработки)
</label>
</div>
<span class="hint" style="font-size:.78rem">Главное фото — текущая версия снимка записи. Галерея — все фото записи. Оригиналы — версии до обработки, они попадают в подпапку «Оригиналы», если обработка уже применялась.</span>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="closeGroupFilesBtn">Отмена</button>
<button type="submit" class="btn-primary" id="downloadGroupFilesBtn">Скачать ZIP</button>
</div>
</form>
</div>
</div>
<script src="vendor/lucide.min.js"></script> <script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script> <script src="admin.js"></script>
<script src="vendor/jszip.min.js"></script> <script src="vendor/jszip.min.js"></script>
+1 -1
View File
@@ -150,7 +150,7 @@ nav a:hover{color:var(--text)}
<div class="field"> <div class="field">
<label>Что сделал</label> <label>Что сделал</label>
<textarea id="descInput" placeholder="Опиши, что проходил, что делал на занятии..." required></textarea> <textarea id="descInput" maxlength="300" placeholder="Опиши, что проходил, что делал на занятии..." required></textarea>
</div> </div>
<input type="text" name="website" id="hpWebsite" tabindex="-1" autocomplete="off" style="display:none" value=""> <input type="text" name="website" id="hpWebsite" tabindex="-1" autocomplete="off" style="display:none" value="">
+21
View File
@@ -27,6 +27,8 @@
.enhance-sliders { flex: 0 0 240px; display: flex; flex-direction: column; gap: 14px; } .enhance-sliders { flex: 0 0 240px; display: flex; flex-direction: column; gap: 14px; }
.enhance-sliders .settings-field input[type=range] { width: 100%; } .enhance-sliders .settings-field input[type=range] { width: 100%; }
.enhance-foot { justify-content: flex-end; gap: 8px; margin-top: 14px; } .enhance-foot { justify-content: flex-end; gap: 8px; margin-top: 14px; }
.enhance-ai-mode { width: auto; min-width: 200px; padding: 9px 12px; font-size: .85rem; }
.enhance-ai-hint { font-size: .75rem; color: var(--muted); max-width: 340px; }
.photo-thumb { width: 56px; height: 56px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); display: block; } .photo-thumb { width: 56px; height: 56px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); display: block; }
@media (max-width: 800px) { @media (max-width: 800px) {
.enhance-body { flex-direction: column; } .enhance-body { flex-direction: column; }
@@ -48,6 +50,7 @@
<div><label>По</label><input type="date" id="dateTo"></div> <div><label>По</label><input type="date" id="dateTo"></div>
<div><label>&nbsp;</label><button type="button" id="exportCsvBtn" style="background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 16px;font-size:.85rem;cursor:pointer;white-space:nowrap">Экспорт CSV</button></div> <div><label>&nbsp;</label><button type="button" id="exportCsvBtn" style="background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 16px;font-size:.85rem;cursor:pointer;white-space:nowrap">Экспорт CSV</button></div>
<div><label>&nbsp;</label><button type="button" id="createLinkBtn" style="background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 16px;font-size:.85rem;cursor:pointer;white-space:nowrap">Ссылка</button></div> <div><label>&nbsp;</label><button type="button" id="createLinkBtn" style="background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 16px;font-size:.85rem;cursor:pointer;white-space:nowrap">Ссылка</button></div>
<div><label>&nbsp;</label><button type="button" id="lessonReportBtn" style="background:var(--card);border:1px solid var(--border);color:var(--accent);border-radius:8px;padding:8px 16px;font-size:.85rem;font-weight:600;cursor:pointer;white-space:nowrap">Что прошли на занятии</button></div>
<div><label>&nbsp;</label> <div><label>&nbsp;</label>
<div class="view-toggle" id="viewToggle"> <div class="view-toggle" id="viewToggle">
<button data-view="list" type="button">Список</button> <button data-view="list" type="button">Список</button>
@@ -64,6 +67,17 @@
<img id="imgModalSrc"> <img id="imgModalSrc">
</div> </div>
<div class="modal-overlay" id="videoModal">
<div class="video-modal">
<div class="video-head">
<span id="videoTitle"></span>
<a class="video-dl" id="videoDownload" download title="Скачать файл">Скачать</a>
<button type="button" class="video-close" id="videoClose" title="Закрыть">&times;</button>
</div>
<video id="videoPlayer" controls playsinline preload="metadata"></video>
</div>
</div>
<div class="modal-overlay" id="editModal"> <div class="modal-overlay" id="editModal">
<div class="edit-modal" style="max-width:720px"> <div class="edit-modal" style="max-width:720px">
<h3>Редактирование записи</h3> <h3>Редактирование записи</h3>
@@ -224,6 +238,13 @@
<div class="card-foot enhance-foot"> <div class="card-foot enhance-foot">
<button type="button" class="btn-primary ghost" id="enhanceHistoryBtn" title="История изменений фотографии">🕘 История</button> <button type="button" class="btn-primary ghost" id="enhanceHistoryBtn" title="История изменений фотографии">🕘 История</button>
<button type="button" class="btn-primary ghost" id="enhanceRestoreBtn" style="display:none" title="Вернуть исходную фотографию">↩ Вернуть оригинал</button> <button type="button" class="btn-primary ghost" id="enhanceRestoreBtn" style="display:none" title="Вернуть исходную фотографию">↩ Вернуть оригинал</button>
<select id="enhanceAiMode" class="settings-input enhance-ai-mode" style="display:none" title="Режим ИИ-обработки фото">
<option value="x2">Универсально (x2)</option>
<option value="x4">Быстро (x4)</option>
<option value="faces">Лица (GFPGAN)</option>
<option value="facesbg">Лица + фон</option>
</select>
<span class="enhance-ai-hint" id="enhanceAiHint" style="display:none"></span>
<button type="button" class="btn-primary ghost" id="enhanceAiBtn" style="display:none" title="ИИ-восстановление (Real-ESRGAN)">🤖 ИИ</button> <button type="button" class="btn-primary ghost" id="enhanceAiBtn" style="display:none" title="ИИ-восстановление (Real-ESRGAN)">🤖 ИИ</button>
<button type="button" class="btn-primary ghost" id="enhanceAutoBtn" title="Подобрать настройки автоматически">✨ Авто</button> <button type="button" class="btn-primary ghost" id="enhanceAutoBtn" title="Подобрать настройки автоматически">✨ Авто</button>
<button type="button" class="btn-primary ghost" id="enhanceCancelBtn">Оставить как есть</button> <button type="button" class="btn-primary ghost" id="enhanceCancelBtn">Оставить как есть</button>
+20 -2
View File
@@ -26,10 +26,16 @@ const ACTION_LABELS = {
'student.update': 'Изменён ученик', 'student.update': 'Изменён ученик',
'student.delete': 'Удалён ученик', 'student.delete': 'Удалён ученик',
'student.batch-group': 'Ученики перенесены в группу', 'student.batch-group': 'Ученики перенесены в группу',
'student.profile.update': 'Изменён профиль ученика',
'student.photo.create': 'Загружено фото ученика',
'student.photo.delete': 'Удалено фото ученика',
'student.photo.main': 'Главное фото ученика',
'module.create': 'Создан модуль', 'module.create': 'Создан модуль',
'module.update': 'Изменён модуль', 'module.update': 'Изменён модуль',
'module.delete': 'Модуль отключён', 'module.delete': 'Модуль отключён',
'module.restore': 'Модуль включён', 'module.restore': 'Модуль включён',
'module.photo.create': 'Загружено фото модуля',
'module.photo.delete': 'Удалено фото модуля',
'entry.update': 'Изменена запись', 'entry.update': 'Изменена запись',
'entry.photo.update': 'Изменено фото записи', 'entry.photo.update': 'Изменено фото записи',
'entry.photo.set_main': 'Главное фото записи', 'entry.photo.set_main': 'Главное фото записи',
@@ -47,6 +53,10 @@ const ACTION_LABELS = {
'photo-jobs.wake': 'Фото-воркер разбужен', 'photo-jobs.wake': 'Фото-воркер разбужен',
'photo-jobs.enabled': 'Переключена обработка фото', 'photo-jobs.enabled': 'Переключена обработка фото',
'photo-jobs.requeue-failed': 'Ошибочные фото-задания возвращены в очередь', 'photo-jobs.requeue-failed': 'Ошибочные фото-задания возвращены в очередь',
'notifications.delete': 'Удалено уведомление',
'notifications.clear': 'Очищены уведомления',
'settings.logo.upload': 'Загружен логотип системы',
'settings.logo.remove': 'Удалён логотип системы',
'entry.soft-delete': 'Запись в корзину', 'entry.soft-delete': 'Запись в корзину',
'entry.restore': 'Запись восстановлена', 'entry.restore': 'Запись восстановлена',
'entry.permanent-delete': 'Запись удалена безвозвратно', 'entry.permanent-delete': 'Запись удалена безвозвратно',
@@ -71,6 +81,14 @@ const ACTION_LABELS = {
'ai.profile.delete': 'Удалён профиль ИИ', 'ai.profile.delete': 'Удалён профиль ИИ',
'ai.profile.activate': 'Выбран активный профиль ИИ', 'ai.profile.activate': 'Выбран активный профиль ИИ',
'export.student': 'Экспорт данных ученика', 'export.student': 'Экспорт данных ученика',
'export.group_files': 'Экспорт файлов группы (ZIP)',
'lesson_report.create': 'Создан отчёт о занятии',
'lesson_report.update': 'Изменён отчёт о занятии',
'lesson_report.delete': 'Удалён отчёт о занятии',
'lesson_report.ai.format': 'ИИ оформил отчёт по шаблону',
'lesson_report.ai.error': 'Ошибка проверки отчёта по шаблону',
'lesson_report.ai.revert': 'Возвращён оригинал отчёта',
'lesson_report.version.restore': 'Восстановлена версия отчёта',
}; };
const AUDIT_PAGE_SIZE = 50; const AUDIT_PAGE_SIZE = 50;
@@ -191,7 +209,7 @@ function renderTargetDetails(target) {
<details class="audit-raw"><summary>Технические данные (JSON)</summary><pre>${esc(fullTarget(target))}</pre></details>`; <details class="audit-raw"><summary>Технические данные (JSON)</summary><pre>${esc(fullTarget(target))}</pre></details>`;
} }
function renderPager(container, page, totalPages, onPageChange) { function renderAuditPager(container, page, totalPages, onPageChange) {
if (totalPages <= 1) { container.innerHTML = ''; return; } if (totalPages <= 1) { container.innerHTML = ''; return; }
const pages = []; const pages = [];
const maxVisible = 5; const maxVisible = 5;
@@ -240,7 +258,7 @@ async function loadAudit() {
<td style="color:var(--muted);white-space:nowrap">${esc(r.ip || '')}</td> <td style="color:var(--muted);white-space:nowrap">${esc(r.ip || '')}</td>
</tr>`).join(''); </tr>`).join('');
} }
renderPager(document.getElementById('auditPager'), auditPage, auditTotal, goAuditPage); renderAuditPager(document.getElementById('auditPager'), auditPage, auditTotal, goAuditPage);
} }
function goAuditPage(p) { function goAuditPage(p) {
+31
View File
@@ -100,6 +100,24 @@ function renderPhotos(rows) {
</div>`).join(''); </div>`).join('');
} }
function renderLessons(rows) {
const el = document.getElementById('lessonList');
if (!el) return;
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Отчётов пока нет</div>'; return; }
el.innerHTML = rows.map(r => {
const date = String(r.lesson_date || '').slice(0, 10).split('-').reverse().join('.');
const time = r.lesson_time ? String(r.lesson_time).slice(0, 5) : '';
return `
<div class="lesson-dash-item" data-lesson="${r.id}">
<div class="lesson-dash-head">
<span class="lesson-dash-group">${esc(r.group_name)}</span>
<span class="lesson-dash-when">${esc(date + (time ? ' · ' + time : ''))}</span>
</div>
<div class="lesson-dash-text">${esc(r.text)}</div>
</div>`;
}).join('');
}
async function loadDashboard() { async function loadDashboard() {
const res = await fetch(`${API}/api/dashboard`, { headers: hdr() }); const res = await fetch(`${API}/api/dashboard`, { headers: hdr() });
if (!res.ok) { document.getElementById('stats').innerHTML = '<div class="dash-empty">Не удалось загрузить данные</div>'; return; } if (!res.ok) { document.getElementById('stats').innerHTML = '<div class="dash-empty">Не удалось загрузить данные</div>'; return; }
@@ -110,11 +128,24 @@ async function loadDashboard() {
renderActive(d.active_groups); renderActive(d.active_groups);
renderTop(d.top_students); renderTop(d.top_students);
renderPhotos(d.photos); renderPhotos(d.photos);
renderLessons(d.recent_lessons || []);
renderIcons(); renderIcons();
} }
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open')); document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
const lessonBtn = document.getElementById('lessonReportBtn');
if (lessonBtn) lessonBtn.addEventListener('click', () => openLessonModal());
function onLessonReportSaved() {
loadDashboard();
}
document.addEventListener('click', e => {
const item = e.target.closest('[data-lesson]');
if (item && !e.target.closest('button')) openLessonModal({ id: item.dataset.lesson });
});
(async () => { (async () => {
if (await checkAuth()) { if (await checkAuth()) {
buildSidebar(document.body.dataset.page); buildSidebar(document.body.dataset.page);
+102
View File
@@ -65,6 +65,7 @@ async function loadGroups() {
<button class="photo-btn" data-group-id="${g.id}" data-group-name="${esc(g.name)}" title="Фото группы (хронология)">Фото</button> <button class="photo-btn" data-group-id="${g.id}" data-group-name="${esc(g.name)}" title="Фото группы (хронология)">Фото</button>
<button class="btn-link" data-export-group="${g.id}" data-group-name="${esc(g.name)}" title="Экспортировать записи группы">CSV</button> <button class="btn-link" data-export-group="${g.id}" data-group-name="${esc(g.name)}" title="Экспортировать записи группы">CSV</button>
<button class="btn-link" data-export-urls="${g.id}" data-group-name="${esc(g.name)}" title="Скачать .url файлы учеников группы">URL</button> <button class="btn-link" data-export-urls="${g.id}" data-group-name="${esc(g.name)}" title="Скачать .url файлы учеников группы">URL</button>
<button class="btn-link" data-export-files="${g.id}" data-group-name="${esc(g.name)}" title="Скачать ZIP-архив файлов группы за период">ZIP</button>
</div> </div>
<div class="sched-status">${schedSet ? `${DAYS[g.day_of_week]} ${g.time_start.slice(0,5)}–${g.time_end.slice(0,5)}` : 'Не настроено'}</div> <div class="sched-status">${schedSet ? `${DAYS[g.day_of_week]} ${g.time_start.slice(0,5)}–${g.time_end.slice(0,5)}` : 'Не настроено'}</div>
</div> </div>
@@ -297,6 +298,101 @@ async function exportGroupUrls(groupId, groupName) {
showToast(`Экспортировано: ${list.length}`); showToast(`Экспортировано: ${list.length}`);
} }
function openGroupFilesModal(groupId, groupName) {
const sel = document.getElementById('groupFilesGroup');
sel.innerHTML = allGroups.map(g => `<option value="${g.id}" ${String(g.id) === String(groupId) ? 'selected' : ''}>${esc(g.name)}</option>`).join('');
document.getElementById('groupFilesTitle').textContent = `Архив файлов — ${groupName}`;
document.getElementById('groupFilesFrom').value = '';
document.getElementById('groupFilesTo').value = todayISOLocal();
document.getElementById('groupFilesIncludeWorks').checked = true;
document.getElementById('groupFilesIncludePhotos').checked = false;
document.getElementById('groupFilesMainPhoto').checked = true;
document.getElementById('groupFilesGallery').checked = false;
document.getElementById('groupFilesOriginals').checked = false;
onGroupFilesPhotosToggle();
document.getElementById('groupFilesModal').classList.add('open');
}
function closeGroupFilesModal() {
document.getElementById('groupFilesModal').classList.remove('open');
}
function todayISOLocal() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
const GROUP_FILES_PHOTO_BOXES = ['groupFilesMainPhoto', 'groupFilesGallery', 'groupFilesOriginals'];
function onGroupFilesPhotosToggle() {
const on = document.getElementById('groupFilesIncludePhotos').checked;
GROUP_FILES_PHOTO_BOXES.forEach(id => {
const el = document.getElementById(id);
if (el) el.disabled = !on;
});
}
function groupFilesPhotosOptions() {
const main = document.getElementById('groupFilesMainPhoto').checked;
const gallery = document.getElementById('groupFilesGallery').checked;
const originals = document.getElementById('groupFilesOriginals').checked;
return {
main,
gallery,
originals,
mode: gallery ? 'all' : 'latest',
any: main || gallery || originals,
};
}
async function downloadGroupFiles() {
const groupId = document.getElementById('groupFilesGroup').value;
const dateFrom = document.getElementById('groupFilesFrom').value;
const dateTo = document.getElementById('groupFilesTo').value;
const includeWorks = document.getElementById('groupFilesIncludeWorks').checked;
const includePhotos = document.getElementById('groupFilesIncludePhotos').checked;
const photoOpts = groupFilesPhotosOptions();
if (!groupId) { alert('Выберите группу'); return; }
if (!includeWorks && !includePhotos) { alert('Выберите хотя бы одну категорию: работы или фото записи'); return; }
if (includePhotos && !photoOpts.any) { alert('Выберите, какие фото выгружать: главное фото, галерея или оригиналы'); return; }
if (dateFrom && dateTo && dateFrom > dateTo) { alert('Дата «С» позже даты «По»'); return; }
const btn = document.getElementById('downloadGroupFilesBtn');
btn.disabled = true;
btn.textContent = 'Формируем...';
try {
const q = new URLSearchParams();
if (dateFrom) q.set('date_from', dateFrom);
if (dateTo) q.set('date_to', dateTo);
q.set('include_files', includeWorks ? '1' : '0');
q.set('include_photos', includePhotos ? '1' : '0');
q.set('include_main', photoOpts.main ? '1' : '0');
q.set('photos_mode', photoOpts.mode);
q.set('include_originals', photoOpts.originals ? '1' : '0');
const res = await fetch(`${API}/api/groups/${groupId}/export/files?${q}`, { headers: hdr() });
if (!res.ok) {
let msg = 'Не удалось сформировать архив';
try { const e = await res.json(); msg = e.error || msg; } catch {}
alert(msg);
return;
}
const blob = await res.blob();
let disp = res.headers.get('Content-Disposition') || '';
let fname = 'group_files.zip';
const star = disp.match(/filename\*=UTF-8''([^;]+)/i);
const plain = disp.match(/filename="?([^";]+)"?/i);
if (star) { try { fname = decodeURIComponent(star[1]); } catch { fname = plain ? plain[1] : fname; } }
else if (plain) fname = plain[1];
downloadBlob(blob, fname);
showToast('Архив сформирован');
closeGroupFilesModal();
} catch (err) {
alert('Ошибка при формировании архива: ' + err.message);
} finally {
btn.disabled = false;
btn.textContent = 'Скачать ZIP';
}
}
document.getElementById('newGroup').addEventListener('keydown', e => { if (e.key === 'Enter') addGroup(); }); document.getElementById('newGroup').addEventListener('keydown', e => { if (e.key === 'Enter') addGroup(); });
document.getElementById('addGroupBtn').addEventListener('click', addGroup); document.getElementById('addGroupBtn').addEventListener('click', addGroup);
document.getElementById('photoFile').addEventListener('change', previewPhotoFile); document.getElementById('photoFile').addEventListener('change', previewPhotoFile);
@@ -307,6 +403,10 @@ document.getElementById('photoForm').addEventListener('submit', e => {
document.getElementById('photosModal').addEventListener('click', e => { if (e.target === e.currentTarget) closePhotos(); }); document.getElementById('photosModal').addEventListener('click', e => { if (e.target === e.currentTarget) closePhotos(); });
document.getElementById('photoFormModal').addEventListener('click', e => { if (e.target === e.currentTarget) closePhotoForm(); }); document.getElementById('photoFormModal').addEventListener('click', e => { if (e.target === e.currentTarget) closePhotoForm(); });
document.getElementById('groupFilesModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeGroupFilesModal(); });
document.getElementById('closeGroupFilesBtn').addEventListener('click', closeGroupFilesModal);
document.getElementById('groupFilesForm').addEventListener('submit', e => { e.preventDefault(); downloadGroupFiles(); });
document.getElementById('groupFilesIncludePhotos').addEventListener('change', onGroupFilesPhotosToggle);
document.getElementById('openPhotoBtn').addEventListener('click', openAddPhoto); document.getElementById('openPhotoBtn').addEventListener('click', openAddPhoto);
document.getElementById('closePhotosBtn').addEventListener('click', closePhotos); document.getElementById('closePhotosBtn').addEventListener('click', closePhotos);
document.getElementById('closePhotoFormBtn').addEventListener('click', closePhotoForm); document.getElementById('closePhotoFormBtn').addEventListener('click', closePhotoForm);
@@ -320,6 +420,8 @@ document.getElementById('groupList').addEventListener('click', e => {
if (eg) { exportGroup(parseInt(eg.dataset.exportGroup, 10), eg.dataset.groupName); return; } if (eg) { exportGroup(parseInt(eg.dataset.exportGroup, 10), eg.dataset.groupName); return; }
const eu = e.target.closest('[data-export-urls]'); const eu = e.target.closest('[data-export-urls]');
if (eu) { exportGroupUrls(parseInt(eu.dataset.exportUrls, 10), eu.dataset.groupName); return; } if (eu) { exportGroupUrls(parseInt(eu.dataset.exportUrls, 10), eu.dataset.groupName); return; }
const ef = e.target.closest('[data-export-files]');
if (ef) { openGroupFilesModal(parseInt(ef.dataset.exportFiles, 10), ef.dataset.groupName); return; }
const photoBtn = e.target.closest('.photo-btn'); const photoBtn = e.target.closest('.photo-btn');
if (photoBtn) { if (photoBtn) {
const id = parseInt(photoBtn.dataset.groupId, 10); const id = parseInt(photoBtn.dataset.groupId, 10);
+19 -5
View File
@@ -120,8 +120,10 @@ function clearFiles() {
} }
const MAX_FILES = 10; const MAX_FILES = 10;
const MAX_FILE_BYTES = 10 * 1024 * 1024; const DEFAULT_FILE_LIMIT_MB = 50;
const MAX_TOTAL_BYTES = 30 * 1024 * 1024; const DEFAULT_TOTAL_LIMIT_MB = 200;
let MAX_FILE_BYTES = DEFAULT_FILE_LIMIT_MB * 1024 * 1024;
let MAX_TOTAL_BYTES = DEFAULT_TOTAL_LIMIT_MB * 1024 * 1024;
const BLOCKED_FILE_EXT = /\.(?:html?|js|mjs|cjs|svg|xml|json|map|wasm|php\d?|phtml|asp|aspx|jsp|sh|bat|cmd|cgi|exe|dll|com|msi|scr|hta|vbs|py|r|rb|htaccess)$/i; const BLOCKED_FILE_EXT = /\.(?:html?|js|mjs|cjs|svg|xml|json|map|wasm|php\d?|phtml|asp|aspx|jsp|sh|bat|cmd|cgi|exe|dll|com|msi|scr|hta|vbs|py|r|rb|htaccess)$/i;
const MIME_EXT = { const MIME_EXT = {
'image/png': '.png', 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp', 'image/png': '.png', 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp',
@@ -172,14 +174,25 @@ function totalUploadBytes() {
return (capturedBlob ? capturedBlob.size : 0) + selectedFiles.reduce((sum, f) => sum + f.size, 0); return (capturedBlob ? capturedBlob.size : 0) + selectedFiles.reduce((sum, f) => sum + f.size, 0);
} }
function applyUploadLimits(fileMb, totalMb) {
const f = parseInt(fileMb, 10);
const t = parseInt(totalMb, 10);
const fileLimit = Number.isFinite(f) && f >= 1 ? f : DEFAULT_FILE_LIMIT_MB;
const totalLimit = Number.isFinite(t) && t >= fileLimit ? t : Math.max(DEFAULT_TOTAL_LIMIT_MB, fileLimit);
MAX_FILE_BYTES = fileLimit * 1024 * 1024;
MAX_TOTAL_BYTES = totalLimit * 1024 * 1024;
}
function addFiles(list) { function addFiles(list) {
const added = []; const added = [];
const skipped = []; const skipped = [];
const fileLimitMb = Math.round(MAX_FILE_BYTES / 1024 / 1024);
const totalLimitMb = Math.round(MAX_TOTAL_BYTES / 1024 / 1024);
for (const file of list) { for (const file of list) {
if (selectedFiles.length >= MAX_FILES) { skipped.push('Можно прикрепить не более 10 файлов'); break; } if (selectedFiles.length >= MAX_FILES) { skipped.push(`Можно прикрепить не более ${MAX_FILES} файлов`); break; }
if (file.size > MAX_FILE_BYTES) { skipped.push(`«${file.name}» больше 10 МБ`); continue; } if (file.size > MAX_FILE_BYTES) { skipped.push(`«${file.name}» больше ${fileLimitMb} МБ`); continue; }
if (BLOCKED_FILE_EXT.test(extOf(file.name))) { skipped.push(`«${file.name}»: недопустимый тип файла`); continue; } if (BLOCKED_FILE_EXT.test(extOf(file.name))) { skipped.push(`«${file.name}»: недопустимый тип файла`); continue; }
if (totalUploadBytes() + file.size > MAX_TOTAL_BYTES) { skipped.push('Суммарный размер файлов — не более 30 МБ'); break; } if (totalUploadBytes() + file.size > MAX_TOTAL_BYTES) { skipped.push(`Суммарный размер файлов — не более ${totalLimitMb} МБ`); break; }
selectedFiles.push(file); selectedFiles.push(file);
added.push(file.name); added.push(file.name);
} }
@@ -319,6 +332,7 @@ async function resolveModuleId(name) {
if (Number.isFinite(h) && h >= 120 && h <= 4096) captureHeight = h; if (Number.isFinite(h) && h >= 120 && h <= 4096) captureHeight = h;
const q = parseFloat(f.photo_capture_quality); const q = parseFloat(f.photo_capture_quality);
if (Number.isFinite(q) && q >= 0.5 && q <= 1) captureQuality = q; if (Number.isFinite(q) && q >= 0.5 && q <= 1) captureQuality = q;
applyUploadLimits(f.upload_file_limit_mb, f.upload_total_limit_mb);
if (f.camera_enabled === 'false') { if (f.camera_enabled === 'false') {
const camBtns = document.getElementById('camBtns'); const camBtns = document.getElementById('camBtns');
if (camBtns) camBtns.style.display = 'none'; if (camBtns) camBtns.style.display = 'none';
+129 -8
View File
@@ -34,6 +34,14 @@ function isImageFile(name) {
return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || ''); return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
} }
function isPlayableVideoFile(name) {
return /\.(mp4|m4v|webm|ogv)$/i.test(name || '');
}
function isVideoFile(name) {
return /\.(mp4|m4v|webm|ogv|mov|mkv|avi|mpeg|mpg|3gp|ts)$/i.test(name || '');
}
function mainPhotoPath(e) { function mainPhotoPath(e) {
if (e.photo_path) return e.photo_path; if (e.photo_path) return e.photo_path;
if (e.photos && e.photos.length) return e.photos[0].photo_path; if (e.photos && e.photos.length) return e.photos[0].photo_path;
@@ -43,9 +51,16 @@ function mainPhotoPath(e) {
function filesHTML(files) { function filesHTML(files) {
if (!files || !files.length) return ''; if (!files || !files.length) return '';
return `<div class="entry-files">${files.map(f => { return `<div class="entry-files">${files.map(f => {
const link = isImageFile(f.name) const url = `${API}/api/files/${f.token}`;
? `<a class="f-link" href="#" data-img="${API}/api/files/${f.token}" title="Открыть">${esc(f.name)}</a>` let link;
: `<a class="f-link" href="${API}/api/files/${f.token}" download title="Скачать">${esc(f.name)}</a>`; if (isImageFile(f.name)) {
link = `<a class="f-link" href="#" data-img="${url}" title="Открыть">${esc(f.name)}</a>`;
} else if (isPlayableVideoFile(f.name)) {
link = `<a class="f-link" href="${url}?play=1" data-video="${url}?play=1" data-video-name="${esc(f.name)}" data-video-token="${f.token}" title="Смотреть">${esc(f.name)}</a>`;
} else {
const note = isVideoFile(f.name) ? 'Скачать (браузер не умеет играть этот формат)' : 'Скачать';
link = `<a class="f-link" href="${url}" download title="${note}">${esc(f.name)}</a>`;
}
return `<span class="f-item">${link}<button class="f-detach" data-detach="${f.id}" title="Открепить файл">✕</button></span>`; return `<span class="f-item">${link}<button class="f-detach" data-detach="${f.id}" title="Открепить файл">✕</button></span>`;
}).join('')}</div>`; }).join('')}</div>`;
} }
@@ -498,6 +513,9 @@ let enhanceSaving = false;
let enhanceEngine = 'auto'; let enhanceEngine = 'auto';
let enhanceEngineLoaded = false; let enhanceEngineLoaded = false;
let photoAiEnabled = false; let photoAiEnabled = false;
let photoAiFaceMode = 'off';
let photoAiFaceModel = 'gfpgan';
let photoAiDevice = '';
let photoAiPollTimer = null; let photoAiPollTimer = null;
let enhanceAiResultPath = null; let enhanceAiResultPath = null;
let enhanceAiJobId = null; let enhanceAiJobId = null;
@@ -603,6 +621,46 @@ function resetEnhanceZoom() {
area.addEventListener('dblclick', resetEnhanceZoom); area.addEventListener('dblclick', resetEnhanceZoom);
})(); })();
const PHOTO_AI_MODES = {
x2: { model: 'x2plus', face: 'off', face_model: null, title: 'Универсально (x2)' },
x4: { model: 'general-x4v3', face: 'off', face_model: null, title: 'Быстро (x4)' },
faces: { model: 'x2plus', face: 'face', face_model: null, title: 'Лица (восстановление)' },
facesbg: { model: 'x2plus', face: 'all', face_model: null, title: 'Лица + фон' },
};
function photoAiMode() {
const sel = document.getElementById('enhanceAiMode');
const key = sel && PHOTO_AI_MODES[sel.value] ? sel.value : 'x2';
const m = { ...PHOTO_AI_MODES[key] };
m.face_model = m.face === 'off' ? null : photoAiFaceModel;
return m;
}
function defaultPhotoAiModeKey() {
if (photoAiFaceMode === 'all') return 'facesbg';
if (photoAiFaceMode === 'face') return 'faces';
return 'x2';
}
function updateAiHint() {
const hint = document.getElementById('enhanceAiHint');
if (!hint) return;
const cpu = photoAiDevice.startsWith('cpu');
hint.textContent = cpu ? 'Фото-ИИ считает на CPU — лицевые режимы могут занять несколько минут' : '';
hint.style.display = photoAiEnabled && cpu ? '' : 'none';
}
async function loadPhotoAiDevice() {
if (!isAdmin()) return;
try {
const r = await fetch(`${API}/api/photo-ai/health`, { headers: hdr() });
if (!r.ok) return;
const h = await r.json();
if (h && h.device) photoAiDevice = String(h.device);
updateAiHint();
} catch (e) {}
}
async function loadEnhanceEngine() { async function loadEnhanceEngine() {
if (enhanceEngineLoaded) return; if (enhanceEngineLoaded) return;
enhanceEngineLoaded = true; enhanceEngineLoaded = true;
@@ -611,18 +669,31 @@ async function loadEnhanceEngine() {
const f = await r.json(); const f = await r.json();
if (['auto', 'server', 'client'].includes(f.photo_enhance_engine)) enhanceEngine = f.photo_enhance_engine; if (['auto', 'server', 'client'].includes(f.photo_enhance_engine)) enhanceEngine = f.photo_enhance_engine;
photoAiEnabled = f.photo_ai_enabled === 'true'; photoAiEnabled = f.photo_ai_enabled === 'true';
if (['off', 'face', 'all'].includes(f.photo_ai_face_mode)) photoAiFaceMode = f.photo_ai_face_mode;
if (['gfpgan', 'codeformer'].includes(f.photo_ai_face_model)) photoAiFaceModel = f.photo_ai_face_model;
const sel = document.getElementById('enhanceAiMode');
if (sel) sel.value = defaultPhotoAiModeKey();
loadPhotoAiDevice();
} catch (e) {} } catch (e) {}
} }
async function runPhotoAi() { async function runPhotoAi() {
if (!enhanceEntryId || enhanceSaving) return; if (!enhanceEntryId || enhanceSaving) return;
const entryId = enhanceEntryId; const entryId = enhanceEntryId;
if (!confirm('ИИ-восстановление фото (Real-ESRGAN). Может занять до минуты на CPU. Продолжить?')) return; const mode = photoAiMode();
const faceNote = mode.face === 'off'
? 'Может занять до минуты на CPU.'
: `Восстановление лиц (${mode.face_model}), на CPU может занять несколько минут.`;
if (!confirm(`ИИ-обработка фото: ${mode.title}. ${faceNote} Продолжить?`)) return;
enhanceSaving = true; enhanceSaving = true;
const btn = document.getElementById('enhanceAiBtn'); const btn = document.getElementById('enhanceAiBtn');
btn.disabled = true; btn.disabled = true;
try { try {
const res = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/enhance-ai`, { method: 'POST', headers: hdr() }); const res = await fetch(`${API}/api/entries/${entryId}/photo/enhance-ai`, {
method: 'POST',
headers: hdrJson(),
body: JSON.stringify({ model: mode.model, face: mode.face, face_model: mode.face_model }),
});
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
throw new Error(err.error || 'Ошибка запуска ИИ'); throw new Error(err.error || 'Ошибка запуска ИИ');
@@ -630,12 +701,14 @@ async function runPhotoAi() {
const { jobId } = await res.json(); const { jobId } = await res.json();
enhanceAiJobId = jobId; enhanceAiJobId = jobId;
showEnhanceResult(false); showEnhanceResult(false);
setEnhanceLoadingText('ИИ обрабатывает фото... (может занять до минуты)'); setEnhanceLoadingText(mode.face === 'off'
? 'ИИ обрабатывает фото... (может занять до минуты)'
: 'ИИ обрабатывает фото и восстанавливает лица...');
btn.textContent = '⏳ ИИ...'; btn.textContent = '⏳ ИИ...';
const result = await new Promise((resolve, reject) => { const result = await new Promise((resolve, reject) => {
photoAiPollTimer = setInterval(async () => { photoAiPollTimer = setInterval(async () => {
try { try {
const r = await fetch(`${API}/api/entries/${enhanceEntryId}/photo/enhance-ai/${jobId}`, { headers: hdr() }); const r = await fetch(`${API}/api/entries/${entryId}/photo/enhance-ai/${jobId}`, { headers: hdr() });
if (!r.ok) throw new Error('Ошибка опроса статуса'); if (!r.ok) throw new Error('Ошибка опроса статуса');
const s = await r.json(); const s = await r.json();
if (s.status === 'done') { clearInterval(photoAiPollTimer); resolve(s); } if (s.status === 'done') { clearInterval(photoAiPollTimer); resolve(s); }
@@ -715,7 +788,10 @@ function openEnhance(id) {
orig.src = `${API}${mp}`; orig.src = `${API}${mp}`;
document.getElementById('enhanceRestoreBtn').style.display = e.photo_original_path ? '' : 'none'; document.getElementById('enhanceRestoreBtn').style.display = e.photo_original_path ? '' : 'none';
loadEnhanceEngine().then(() => { loadEnhanceEngine().then(() => {
document.getElementById('enhanceAiBtn').style.display = photoAiEnabled ? '' : 'none'; const show = photoAiEnabled ? '' : 'none';
document.getElementById('enhanceAiBtn').style.display = show;
document.getElementById('enhanceAiMode').style.display = show;
updateAiHint();
}); });
const canvas = document.getElementById('enhanceCanvas'); const canvas = document.getElementById('enhanceCanvas');
setEnhanceClip(50); setEnhanceClip(50);
@@ -1528,8 +1604,53 @@ document.getElementById('aiCorrectModal').addEventListener('click', e => {
if (e.target === e.currentTarget) closeAiCorrect(); if (e.target === e.currentTarget) closeAiCorrect();
}); });
function openVideo(url, name, token) {
const modal = document.getElementById('videoModal');
const player = document.getElementById('videoPlayer');
const title = document.getElementById('videoTitle');
const dl = document.getElementById('videoDownload');
if (!modal || !player) return;
player.src = url;
player.currentTime = 0;
title.textContent = name || 'Видео';
dl.href = `${API}/api/files/${token}`;
dl.setAttribute('download', '');
modal.classList.add('open');
const play = player.play();
if (play && typeof play.catch === 'function') play.catch(() => {});
}
function closeVideo() {
const modal = document.getElementById('videoModal');
const player = document.getElementById('videoPlayer');
if (!modal || !player) return;
try { player.pause(); } catch {}
player.removeAttribute('src');
player.load();
modal.classList.remove('open');
}
document.getElementById('videoModal').addEventListener('click', e => {
if (e.target === e.currentTarget) closeVideo();
});
document.getElementById('videoClose').addEventListener('click', closeVideo);
document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
if (document.getElementById('videoModal').classList.contains('open')) closeVideo();
});
document.addEventListener('click', e => {
const v = e.target.closest('[data-video]');
if (!v) return;
e.preventDefault();
openVideo(v.dataset.video, v.dataset.videoName, v.dataset.videoToken);
});
document.getElementById('exportCsvBtn').addEventListener('click', exportCSV); document.getElementById('exportCsvBtn').addEventListener('click', exportCSV);
document.getElementById('createLinkBtn').addEventListener('click', openLinkCreate); document.getElementById('createLinkBtn').addEventListener('click', openLinkCreate);
document.getElementById('lessonReportBtn').addEventListener('click', () => {
openLessonModal({ groupId: document.getElementById('groupFilter').value });
});
document.querySelectorAll('#viewToggle button').forEach(b => b.addEventListener('click', () => setView(b.dataset.view))); document.querySelectorAll('#viewToggle button').forEach(b => b.addEventListener('click', () => setView(b.dataset.view)));
document.getElementById('editCancelBtn').addEventListener('click', closeEdit); document.getElementById('editCancelBtn').addEventListener('click', closeEdit);
document.getElementById('linkCreateCloseBtn').addEventListener('click', closeLinkCreate); document.getElementById('linkCreateCloseBtn').addEventListener('click', closeLinkCreate);
+187
View File
@@ -0,0 +1,187 @@
const PAGE_SIZE = 20;
let page = 1;
let total = 0;
let searchTimer = null;
let groupsLoaded = false;
function lessonDateText(iso) {
const s = String(iso || '').slice(0, 10);
if (!s) return '';
const d = new Date(s + 'T00:00:00');
if (Number.isNaN(d.getTime())) return s;
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: '2-digit' });
}
function lessonWhenText(r) {
const date = lessonDateText(r.lesson_date);
const time = r.lesson_time ? String(r.lesson_time).slice(0, 5) : '';
return time ? `${date}, ${time}` : date;
}
function lessonAuthorText(r) {
const name = r.author_name || r.author_username;
if (!name) return '';
const upd = r.updated_at && r.created_at && new Date(r.updated_at).getTime() - new Date(r.created_at).getTime() > 60000;
return upd ? `${name} · изменён ${notifyTime(r.updated_at)}` : name;
}
function lessonAiBadgeHtml(r) {
const st = r.ai_status;
if (!st || st === 'none') return '';
if (st === 'pending' || st === 'processing') return `<span class="ai-badge ai-badge-ic ai-badge-processing" title="ИИ проверяет текст по шаблону"><i data-lucide="loader"></i></span>`;
if (st === 'done' && r.text_ai) return '<span class="ai-badge ai-badge-done" title="ИИ оформил текст по шаблону">ИИ</span>';
if (st === 'skipped') return '<span class="ai-badge ai-badge-skipped" title="Текст уже соответствует шаблону">По шаблону</span>';
if (st === 'reverted') return '<span class="ai-badge ai-badge-reverted" title="Возвращена исходная версия текста">Оригинал</span>';
if (st === 'error') return `<span class="ai-badge ai-badge-error" title="Проверка по шаблону не удалась: ${esc(r.ai_error || 'ошибка ИИ')}">Ошибка ИИ</span>`;
return `<span class="ai-badge ai-badge-ic ai-badge-${esc(st)}" title="Статус ИИ: ${esc(st)}">${esc(st)}</span>`;
}
function lessonItemHtml(r) {
const isToday = String(r.lesson_date).slice(0, 10) === lessonTodayIso();
const author = lessonAuthorText(r);
const canRevert = !!r.text_original && r.text_original !== r.text;
return `
<div class="lesson-item" data-lesson="${r.id}">
<div class="lesson-item-head">
<span class="lesson-group">${esc(r.group_name || '')}</span>
<span class="lesson-when"><i data-lucide="clock-3"></i>${esc(lessonWhenText(r))}</span>
${isToday ? '<span class="lesson-badge today">сегодня</span>' : ''}
${lessonAiBadgeHtml(r)}
<div class="lesson-actions">
<button type="button" class="btn-link" data-versions="${r.id}">История</button>
${canRevert ? `<button type="button" class="btn-link" data-revert="${r.id}" title="Вернуть исходный текст тьютора">↩ Оригинал</button>` : ''}
<button type="button" class="btn-link" data-edit="${r.id}">Редактировать</button>
${isAdmin() ? `<button type="button" class="btn-link danger" data-del="${r.id}">Удалить</button>` : ''}
</div>
</div>
<div class="lesson-text">${esc(r.text || '')}</div>
${author ? `<div class="lesson-author">${esc(author)}</div>` : ''}
</div>`;
}
function renderLessons(items) {
const list = document.getElementById('lessonsList');
const state = document.getElementById('lessonsState');
list.innerHTML = items.length
? items.map(lessonItemHtml).join('')
: '<div class="empty">Отчётов пока нет</div>';
if (state) {
state.textContent = total ? `Показано ${items.length} из ${total}` : '';
}
renderIcons();
}
async function loadLessons() {
const q = new URLSearchParams();
const gid = document.getElementById('groupFilter').value;
const df = document.getElementById('dateFrom').value;
const dt = document.getElementById('dateTo').value;
const search = document.getElementById('searchInput').value.trim();
if (gid) q.set('group_id', gid);
if (df) q.set('date_from', df);
if (dt) q.set('date_to', dt);
if (search) q.set('search', search);
q.set('limit', String(PAGE_SIZE));
q.set('offset', String((page - 1) * PAGE_SIZE));
const list = document.getElementById('lessonsList');
try {
const res = await fetch(`${API}/api/lesson-reports?${q.toString()}`, { headers: hdr() });
if (!res.ok) {
list.innerHTML = `<div class="empty">Ошибка загрузки (${res.status})</div>`;
return;
}
const data = await res.json();
total = Number(data.total) || 0;
const maxPage = Math.max(1, Math.ceil(total / PAGE_SIZE));
if (page > maxPage) { page = maxPage; return loadLessons(); }
renderLessons(data.items || []);
renderPager(document.getElementById('pager'), page, maxPage, p => { page = p; loadLessons(); });
} catch {
list.innerHTML = '<div class="empty">Ошибка сети</div>';
}
}
async function loadGroupFilter() {
if (groupsLoaded) return;
const sel = document.getElementById('groupFilter');
const groups = await loadLessonGroups();
groups.forEach(g => {
const o = document.createElement('option');
o.value = g.id;
o.textContent = g.branch_name ? `${g.name} (${g.branch_name})` : g.name;
sel.appendChild(o);
});
groupsLoaded = true;
}
async function deleteLesson(id) {
if (!confirm('Удалить отчёт о занятии?')) return;
const res = await fetch(`${API}/api/lesson-reports/${id}`, { method: 'DELETE', headers: hdr() });
const data = await res.json().catch(() => ({}));
if (!res.ok) { showToast(data.error || 'Не удалось удалить отчёт'); return; }
showToast('Отчёт удалён');
loadLessons();
}
async function revertLessonAi(id) {
if (!confirm('Вернуть исходный текст отчёта (до оформления по шаблону)?')) return;
const res = await fetch(`${API}/api/lesson-reports/${id}/ai/revert`, { method: 'POST', headers: hdr() });
const data = await res.json().catch(() => ({}));
if (!res.ok) { showToast(data.error || 'Не удалось вернуть оригинал'); return; }
showToast('Возвращён исходный текст отчёта');
loadLessons();
}
function onLessonReportSaved() {
loadLessons();
}
document.getElementById('lessonsList').addEventListener('click', e => {
const edit = e.target.closest('[data-edit]');
if (edit) { openLessonModal({ id: edit.dataset.edit }); return; }
const versions = e.target.closest('[data-versions]');
if (versions) { openLessonVersions(versions.dataset.versions); return; }
const revert = e.target.closest('[data-revert]');
if (revert) { revertLessonAi(revert.dataset.revert); return; }
const del = e.target.closest('[data-del]');
if (del) { deleteLesson(del.dataset.del); }
});
document.getElementById('addLessonBtn').addEventListener('click', () => {
openLessonModal({ groupId: document.getElementById('groupFilter').value });
});
document.getElementById('groupFilter').addEventListener('change', () => { page = 1; loadLessons(); });
document.getElementById('dateFrom').addEventListener('change', () => { page = 1; loadLessons(); });
document.getElementById('dateTo').addEventListener('change', () => { page = 1; loadLessons(); });
document.getElementById('searchInput').addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => { page = 1; loadLessons(); }, 350);
});
let lessonLiveSource = null;
function connectLessonLive() {
if (lessonLiveSource) { lessonLiveSource.close(); lessonLiveSource = null; }
const connect = () => {
const t = sessionStorage.getItem('authToken');
if (!t) return;
lessonLiveSource = new EventSource(`${API}/api/events?token=${encodeURIComponent(t)}`);
lessonLiveSource.addEventListener('lesson_report_status', () => loadLessons());
lessonLiveSource.onerror = () => {
if (lessonLiveSource) { lessonLiveSource.close(); lessonLiveSource = null; }
setTimeout(connect, 5000);
};
};
connect();
window.addEventListener('beforeunload', () => { if (lessonLiveSource) lessonLiveSource.close(); });
}
connectLessonLive();
(async () => {
if (!(await checkAuth())) return;
buildSidebar(document.body.dataset.page);
await loadGroupFilter();
loadLessons();
})();
+12 -1
View File
@@ -3,6 +3,11 @@ let mPage = 1;
const M_PAGE_SIZE = 25; const M_PAGE_SIZE = 25;
let mSearchTimer = null; let mSearchTimer = null;
let modulePhotoDeleteFlag = false; let modulePhotoDeleteFlag = false;
let modulePhotoLimitMb = 50;
function modulePhotoHint() {
return `jpg, png, gif, webp, avif, heic и др., до ${modulePhotoLimitMb} МБ · или вставьте из буфера (Ctrl+V)`;
}
function mPlural(n, one, few, many) { function mPlural(n, one, few, many) {
n = Math.abs(n) % 100; const n1 = n % 10; n = Math.abs(n) % 100; const n1 = n % 10;
@@ -68,7 +73,7 @@ function resetModulePhoto(m) {
preview.removeAttribute('src'); preview.removeAttribute('src');
preview.style.display = 'none'; preview.style.display = 'none';
delBtn.style.display = 'none'; delBtn.style.display = 'none';
note.textContent = 'jpg, png, gif, webp, avif, heic и др., до 10 МБ · или вставьте из буфера (Ctrl+V)'; note.textContent = modulePhotoHint();
} }
} }
@@ -313,6 +318,12 @@ document.getElementById('moduleList').addEventListener('click', e => {
(async () => { (async () => {
if (await requireAdminPage()) { if (await requireAdminPage()) {
try {
const res = await fetch(`${API}/api/public-settings`);
const f = await res.json();
const n = parseInt(f.upload_file_limit_mb, 10);
if (Number.isFinite(n) && n >= 1) modulePhotoLimitMb = n;
} catch (e) { /* ignore */ }
buildSidebar(document.body.dataset.page); buildSidebar(document.body.dataset.page);
loadModules(); loadModules();
} }
+106 -9
View File
@@ -1,6 +1,6 @@
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'trashPurgeDays', 'notifyEnabled', 'notifyRetentionDays']; const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'lessonAiPrompt', 'lessonAiEnabled', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'photoAiFaceMode', 'photoAiFaceModel', 'photoAiDevicePref', 'trashPurgeDays', 'notifyEnabled', 'notifyRetentionDays'];
const NOTIFY_IDS = []; const NOTIFY_IDS = [];
const SECTION_IDS = ['sec-system', 'sec-stack', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-notify', 'sec-bans']; const SECTION_IDS = ['sec-system', 'sec-stack', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-lesson-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-notify', 'sec-photo', 'sec-photo-ai', 'sec-bans'];
const SPAM_MAX = 10080; const SPAM_MAX = 10080;
const BAN_REASONS = { const BAN_REASONS = {
'honeypot': 'Антиспам-поле', 'honeypot': 'Антиспам-поле',
@@ -12,6 +12,7 @@ const BAN_REASONS = {
let currentLogo = ''; let currentLogo = '';
let logoOriginal = ''; let logoOriginal = '';
let currentSystemName = ''; let currentSystemName = '';
let photoAiService = null;
function renderLogoPreview() { function renderLogoPreview() {
const img = document.getElementById('logoPreviewImg'); const img = document.getElementById('logoPreviewImg');
@@ -205,6 +206,8 @@ async function loadSettings() {
document.getElementById('footerRight').value = s.footer_right || ''; document.getElementById('footerRight').value = s.footer_right || '';
document.getElementById('aiPrompt').value = s.ai_prompt || ''; document.getElementById('aiPrompt').value = s.ai_prompt || '';
document.getElementById('aiAutoCheck').checked = s.ai_autocheck_enabled !== 'false'; document.getElementById('aiAutoCheck').checked = s.ai_autocheck_enabled !== 'false';
document.getElementById('lessonAiEnabled').checked = s.lesson_ai_enabled !== 'false';
document.getElementById('lessonAiPrompt').value = s.lesson_ai_prompt || '';
document.getElementById('shareShowMessage').checked = s.share_show_student_message === 'true'; document.getElementById('shareShowMessage').checked = s.share_show_student_message === 'true';
document.getElementById('shareShowDate').checked = s.share_show_entry_date === 'true'; document.getElementById('shareShowDate').checked = s.share_show_entry_date === 'true';
document.getElementById('shareShowNames').checked = s.share_show_student_names !== 'false'; document.getElementById('shareShowNames').checked = s.share_show_student_names !== 'false';
@@ -217,6 +220,12 @@ async function loadSettings() {
const pe = document.getElementById('photoEngine'); const pe = document.getElementById('photoEngine');
pe.value = ['auto', 'server', 'client'].includes(s.photo_enhance_engine) ? s.photo_enhance_engine : 'auto'; pe.value = ['auto', 'server', 'client'].includes(s.photo_enhance_engine) ? s.photo_enhance_engine : 'auto';
if (!pe.value) pe.value = 'auto'; if (!pe.value) pe.value = 'auto';
const afm = document.getElementById('photoAiFaceMode');
afm.value = ['off', 'face', 'all'].includes(s.photo_ai_face_mode) ? s.photo_ai_face_mode : 'off';
const afm2 = document.getElementById('photoAiFaceModel');
afm2.value = ['gfpgan', 'codeformer'].includes(s.photo_ai_face_model) ? s.photo_ai_face_model : 'gfpgan';
const adp = document.getElementById('photoAiDevicePref');
adp.value = ['auto', 'cuda', 'cpu'].includes(s.photo_ai_device_pref) ? s.photo_ai_device_pref : 'auto';
document.getElementById('camEnabled').checked = s.camera_enabled !== 'false'; document.getElementById('camEnabled').checked = s.camera_enabled !== 'false';
await renderNotifyTypes(s); await renderNotifyTypes(s);
snapshotDirty(); snapshotDirty();
@@ -225,6 +234,7 @@ async function loadSettings() {
loadAiProfiles(); loadAiProfiles();
renderStats(stRes); renderStats(stRes);
loadSystemInfo(); loadSystemInfo();
loadPhotoAiStatus();
} }
async function renderStats(res) { async function renderStats(res) {
@@ -369,6 +379,52 @@ function stackRow(label, value) {
return `<div class="sys-item"><span class="sys-label">${esc(label)}</span><span class="sys-value">${value}</span></div>`; return `<div class="sys-item"><span class="sys-label">${esc(label)}</span><span class="sys-value">${value}</span></div>`;
} }
function photoAiDeviceMismatch(pref, device) {
const d = String(device || '');
if (pref === 'cuda') return !d.startsWith('cuda');
if (pref === 'cpu') return d !== 'cpu';
return false;
}
function renderPhotoAiStatus() {
const el = document.getElementById('photoAiStatus');
if (!el) return;
const svc = photoAiService;
if (!svc) { el.innerHTML = '<span class="hint">Статус фото-ИИ недоступен</span>'; return; }
const pref = document.getElementById('photoAiDevicePref').value;
const device = svc.device || '—';
const mismatch = photoAiDeviceMismatch(pref, svc.device);
const color = svc.reachable === false ? '#ef4444' : mismatch ? '#f59e0b' : '#16a34a';
const chips = [];
chips.push(`<span class="chip" style="color:${color}">фактическое: <b>${esc(device)}</b></span>`);
if (svc.device_name) chips.push(`<span class="chip">${esc(String(svc.device_name))}</span>`);
if (svc.vram_total_mb) chips.push(`<span class="chip">VRAM: <b>${esc(String(svc.vram_free_mb ?? '—'))}</b> свободно из ${esc(String(svc.vram_total_mb))} МБ</span>`);
chips.push(`<span class="chip">Модели: <b>${esc((svc.models || []).join(', ') || '—')}</b></span>`);
chips.push(`<span class="chip">Модели лиц: <b>${esc((svc.face_models || []).join(', ') || '—')}</b></span>`);
if (svc.loaded && svc.loaded.length) chips.push(`<span class="chip">Загружено: <b>${esc(svc.loaded.join(', '))}</b></span>`);
if (!svc.configured) chips.push('<span class="chip" style="color:#ef4444">PHOTO_AI_URL не настроен — ИИ-фото отключено</span>');
else if (svc.reachable === false) chips.push(`<span class="chip" style="color:#ef4444">сервис недоступен${svc.error ? `: ${esc(String(svc.error))}` : ''}</span>`);
else if (svc.ready === false) chips.push('<span class="chip" style="color:#f59e0b">модели загружаются</span>');
if (mismatch) chips.push('<span class="chip" style="color:#f59e0b">желаемое устройство не совпадает с фактическим — задаётся переменной PHOTO_AI_DEVICE в контейнере</span>');
el.innerHTML = chips.join('');
}
async function loadPhotoAiStatus(btn) {
const el = document.getElementById('photoAiStatus');
if (!el) return;
if (btn) btn.classList.add('spinning');
try {
const res = await fetch(`${API}/api/photo-jobs/status?recent_limit=1`, { headers: hdr() });
if (!res.ok) throw new Error('status');
photoAiService = (await res.json()).service || null;
renderPhotoAiStatus();
} catch {
el.innerHTML = '<span class="hint">Не удалось загрузить статус фото-ИИ</span>';
} finally {
if (btn) btn.classList.remove('spinning');
}
}
function renderStackInfo(stack) { function renderStackInfo(stack) {
const el = document.getElementById('stackInfo'); const el = document.getElementById('stackInfo');
if (!el) return; if (!el) return;
@@ -419,6 +475,16 @@ function renderStackInfo(stack) {
? stackRow('Бакет', esc(`${st.bucket || '—'}${st.region ? ` · ${st.region}` : ''}`)) ? stackRow('Бакет', esc(`${st.bucket || '—'}${st.region ? ` · ${st.region}` : ''}`))
: stackRow('Каталог', esc(st.dir || '—')); : stackRow('Каталог', esc(st.dir || '—'));
const ai = stack.photo_ai || {};
const aiState = !ai.configured
? stackState('warn', 'не настроен')
: ai.reachable
? stackState(ai.ready === false ? 'warn' : 'on', ai.ready === false ? 'модели грузятся' : 'отвечает')
: stackState('err', 'нет связи');
const aiVram = ai.vram_total_mb
? `${ai.vram_free_mb ?? '—'} свободно из ${ai.vram_total_mb} МБ`
: null;
el.innerHTML = ` el.innerHTML = `
<div class="sys-card"> <div class="sys-card">
<div class="sys-title">Приложение</div> <div class="sys-title">Приложение</div>
@@ -473,6 +539,20 @@ function renderStackInfo(stack) {
${stackRow('Локальный кеш', s3 ? esc(st.local_fallback ? 'включён' : 'выключен') : esc('—'))} ${stackRow('Локальный кеш', s3 ? esc(st.local_fallback ? 'включён' : 'выключен') : esc('—'))}
${s3 ? stackRow('Локальные копии', esc(st.keep_local ? 'хранятся' : 'удаляются')) : ''} ${s3 ? stackRow('Локальные копии', esc(st.keep_local ? 'хранятся' : 'удаляются')) : ''}
</div> </div>
<div class="sys-card">
<div class="sys-title">Фото-ИИ</div>
${stackRow('Состояние', aiState)}
${stackRow('Движок', esc(ai.engine || 'Real-ESRGAN + GFPGAN'))}
${stackRow('Адрес', esc(ai.host || '—'))}
${stackRow('Устройство', esc(`${ai.device || '—'}${ai.device_name ? ` · ${ai.device_name}` : ''}`))}
${ai.half === null || ai.half === undefined ? '' : stackRow('Режим', esc(ai.half ? 'fp16 (half)' : 'fp32'))}
${aiVram ? stackRow('VRAM', esc(aiVram)) : ''}
${stackRow('Модели', esc((ai.models || []).join(', ') || '—'))}
${stackRow('Модели лиц', esc((ai.face_models || []).join(', ') || '—'))}
${stackRow('Загружено', esc((ai.loaded || []).join(', ') || '—'))}
${ai.error ? `<div class="sys-meta" style="color:#ef4444">${esc(String(ai.error))}</div>` : ''}
</div>
`; `;
} }
@@ -729,6 +809,8 @@ async function saveSettings() {
footer_right: document.getElementById('footerRight').value.trim(), footer_right: document.getElementById('footerRight').value.trim(),
ai_prompt: document.getElementById('aiPrompt').value.trim(), ai_prompt: document.getElementById('aiPrompt').value.trim(),
ai_autocheck_enabled: document.getElementById('aiAutoCheck').checked ? 'true' : 'false', ai_autocheck_enabled: document.getElementById('aiAutoCheck').checked ? 'true' : 'false',
lesson_ai_enabled: document.getElementById('lessonAiEnabled').checked ? 'true' : 'false',
lesson_ai_prompt: document.getElementById('lessonAiPrompt').value.trim(),
share_show_student_message: document.getElementById('shareShowMessage').checked ? 'true' : 'false', share_show_student_message: document.getElementById('shareShowMessage').checked ? 'true' : 'false',
share_show_entry_date: document.getElementById('shareShowDate').checked ? 'true' : 'false', share_show_entry_date: document.getElementById('shareShowDate').checked ? 'true' : 'false',
share_show_student_names: document.getElementById('shareShowNames').checked ? 'true' : 'false', share_show_student_names: document.getElementById('shareShowNames').checked ? 'true' : 'false',
@@ -737,6 +819,9 @@ async function saveSettings() {
photo_capture_resolution: document.getElementById('photoResolution').value, photo_capture_resolution: document.getElementById('photoResolution').value,
photo_capture_quality: String(document.getElementById('photoQuality').value), photo_capture_quality: String(document.getElementById('photoQuality').value),
photo_enhance_engine: document.getElementById('photoEngine').value, photo_enhance_engine: document.getElementById('photoEngine').value,
photo_ai_face_mode: document.getElementById('photoAiFaceMode').value,
photo_ai_face_model: document.getElementById('photoAiFaceModel').value,
photo_ai_device_pref: document.getElementById('photoAiDevicePref').value,
camera_enabled: document.getElementById('camEnabled').checked ? 'true' : 'false', camera_enabled: document.getElementById('camEnabled').checked ? 'true' : 'false',
trash_purge_days: String(trashDays), trash_purge_days: String(trashDays),
...notifySettingsPayload(notifyRetentionDays), ...notifySettingsPayload(notifyRetentionDays),
@@ -778,11 +863,19 @@ async function downloadBackup() {
showError('Ошибка формирования бэкапа: ' + (data.error || res.status)); showError('Ошибка формирования бэкапа: ' + (data.error || res.status));
return; return;
} }
const head = await fetch(data.url, { method: 'HEAD' }).catch(() => null); const size = formatBackupSize(data.size);
if (head && !head.ok) { if (st) {
if (st) { st.classList.add('err'); st.textContent = 'Ссылка на архив устарела, попробуйте ещё раз'; } st.classList.remove('err');
showError('Ошибка скачивания бэкапа'); st.innerHTML = '';
return; const link = document.createElement('a');
link.href = data.url;
link.download = data.filename || 'whatido-backup.tar.gz';
link.textContent = data.filename || 'whatido-backup.tar.gz';
link.style.color = 'var(--accent)';
st.appendChild(link);
const note = document.createElement('span');
note.textContent = `${size ? ` (${size})` : ''} — ссылка активна 30 минут, скачивание можно повторить`;
st.appendChild(note);
} }
const a = document.createElement('a'); const a = document.createElement('a');
a.href = data.url; a.href = data.url;
@@ -790,8 +883,6 @@ async function downloadBackup() {
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
a.remove(); a.remove();
const size = formatBackupSize(data.size);
if (st) st.textContent = `Архив ${data.filename}${size ? ` (${size})` : ''} — скачивается браузером`;
showToast('Бэкап сформирован' + (size ? ': ' + size : '')); showToast('Бэкап сформирован' + (size ? ': ' + size : ''));
} catch (err) { } catch (err) {
if (st) { st.classList.add('err'); st.textContent = 'Не удалось сформировать бэкап: ' + err.message; } if (st) { st.classList.add('err'); st.textContent = 'Не удалось сформировать бэкап: ' + err.message; }
@@ -888,6 +979,12 @@ if (systemRefreshBtn) systemRefreshBtn.addEventListener('click', () => loadSyste
const stackRefreshBtn = document.getElementById('stackRefreshBtn'); const stackRefreshBtn = document.getElementById('stackRefreshBtn');
if (stackRefreshBtn) stackRefreshBtn.addEventListener('click', () => loadSystemInfo(stackRefreshBtn)); if (stackRefreshBtn) stackRefreshBtn.addEventListener('click', () => loadSystemInfo(stackRefreshBtn));
const photoAiRefreshBtn = document.getElementById('photoAiRefreshBtn');
if (photoAiRefreshBtn) photoAiRefreshBtn.addEventListener('click', () => loadPhotoAiStatus(photoAiRefreshBtn));
const photoAiDevicePref = document.getElementById('photoAiDevicePref');
if (photoAiDevicePref) photoAiDevicePref.addEventListener('change', () => renderPhotoAiStatus());
const notifyTestBtn = document.getElementById('notifyTestBtn'); const notifyTestBtn = document.getElementById('notifyTestBtn');
if (notifyTestBtn) notifyTestBtn.addEventListener('click', async () => { if (notifyTestBtn) notifyTestBtn.addEventListener('click', async () => {
notifyTestBtn.classList.add('spinning'); notifyTestBtn.classList.add('spinning');
+69 -1
View File
@@ -30,6 +30,53 @@ async function loadStudents() {
renderStudents(); renderStudents();
} }
function studentInitials(name) {
const parts = String(name || '').trim().split(/\s+/).filter(Boolean);
if (!parts.length) return 'У';
const a = parts[0].charAt(0);
const b = parts.length > 1 ? parts[1].charAt(0) : (parts[0].length > 1 ? parts[0].charAt(1) : '');
return (a + b).toUpperCase();
}
function photoFullSrc(p) {
if (!p) return '';
return p.startsWith('/') ? `${API}${p}` : p;
}
function setStudentAvatar(el, path, name) {
if (!el) return;
if (path) {
el.classList.remove('empty');
el.dataset.studentPhoto = path;
el.title = 'Увеличить фото';
el.innerHTML = `<img src="${esc(thumbSrc(path))}" alt="" loading="lazy">`;
const img = el.querySelector('img');
if (img) img.addEventListener('error', () => setStudentAvatar(el, '', name), { once: true });
} else {
el.classList.add('empty');
delete el.dataset.studentPhoto;
el.title = 'Фото не загружено — нажмите, чтобы загрузить';
el.innerHTML = `<span>${esc(studentInitials(name))}</span>`;
}
}
async function syncStudentAvatar(id, photoPath) {
const el = document.getElementById(`savatar-${id}`);
if (!el) return;
const nameInput = document.getElementById(`sname-${id}`);
const name = nameInput ? nameInput.value : '';
if (photoPath !== undefined) {
setStudentAvatar(el, photoPath || '', name);
return;
}
try {
const res = await fetch(`${API}/api/students/${id}/photos`, { headers: hdr() });
if (!res.ok) return;
const data = await res.json();
setStudentAvatar(el, data.photo_path || '', name);
} catch (err) {}
}
function renderStudents() { function renderStudents() {
const q = document.getElementById('studentSearch').value.trim().toLowerCase(); const q = document.getElementById('studentSearch').value.trim().toLowerCase();
const gid = document.getElementById('studentGroupFilter').value; const gid = document.getElementById('studentGroupFilter').value;
@@ -43,7 +90,10 @@ function renderStudents() {
else { else {
list.innerHTML = slice.map(s => ` list.innerHTML = slice.map(s => `
<div class="item"> <div class="item">
<div class="student-main">
<button type="button" class="student-avatar" id="savatar-${s.id}" data-avatar-student="${s.id}"></button>
<input class="name-input student-name-input" id="sname-${s.id}" value="${esc(s.name)}"> <input class="name-input student-name-input" id="sname-${s.id}" value="${esc(s.name)}">
</div>
<select class="group-select" id="sgroup-${s.id}"></select> <select class="group-select" id="sgroup-${s.id}"></select>
<div class="student-actions"> <div class="student-actions">
<button class="btn-save" data-save-student="${s.id}">Сохранить</button> <button class="btn-save" data-save-student="${s.id}">Сохранить</button>
@@ -55,7 +105,10 @@ function renderStudents() {
</div> </div>
</div> </div>
`).join(''); `).join('');
slice.forEach(s => loadGroupOptions(document.getElementById(`sgroup-${s.id}`), s.group_id)); slice.forEach(s => {
setStudentAvatar(document.getElementById(`savatar-${s.id}`), s.photo_path, s.name);
loadGroupOptions(document.getElementById(`sgroup-${s.id}`), s.group_id);
});
} }
renderPager(document.getElementById('pager'), sPage, totalPages, goStudentPage); renderPager(document.getElementById('pager'), sPage, totalPages, goStudentPage);
} }
@@ -244,6 +297,7 @@ async function uploadStudentPhoto(file) {
const res = await fetch(`${API}/api/students/${profileDataId}/photos`, { method: 'POST', headers: hdr(), body: fd }); const res = await fetch(`${API}/api/students/${profileDataId}/photos`, { method: 'POST', headers: hdr(), body: fd });
if (res.ok) { if (res.ok) {
await loadStudentPhotos(profileDataId); await loadStudentPhotos(profileDataId);
syncStudentAvatar(profileDataId);
showToast('Фото загружено'); showToast('Фото загружено');
} else { } else {
const e = await res.json().catch(() => ({})); const e = await res.json().catch(() => ({}));
@@ -262,6 +316,7 @@ async function setMainStudentPhoto(pid) {
const r = await res.json(); const r = await res.json();
document.getElementById('pfPhoto').value = r.photo_path || ''; document.getElementById('pfPhoto').value = r.photo_path || '';
renderStudentPhotos(); renderStudentPhotos();
syncStudentAvatar(profileDataId, r.photo_path || '');
showToast('Главное фото обновлено'); showToast('Главное фото обновлено');
} else { } else {
const e = await res.json().catch(() => ({})); const e = await res.json().catch(() => ({}));
@@ -279,6 +334,7 @@ async function delStudentPhoto(pid) {
const res = await fetch(`${API}/api/students/${profileDataId}/photos/${pid}`, { method: 'DELETE', headers: hdr() }); const res = await fetch(`${API}/api/students/${profileDataId}/photos/${pid}`, { method: 'DELETE', headers: hdr() });
if (res.ok) { if (res.ok) {
await loadStudentPhotos(profileDataId); await loadStudentPhotos(profileDataId);
syncStudentAvatar(profileDataId);
showToast('Фото удалено'); showToast('Фото удалено');
} else { } else {
const e = await res.json().catch(() => ({})); const e = await res.json().catch(() => ({}));
@@ -522,7 +578,19 @@ document.getElementById('attachModal').addEventListener('click', e => { if (e.ta
document.getElementById('batchCancelBtn').addEventListener('click', closeBatchAdd); document.getElementById('batchCancelBtn').addEventListener('click', closeBatchAdd);
document.getElementById('attachCancelBtn').addEventListener('click', closeBatchAttach); document.getElementById('attachCancelBtn').addEventListener('click', closeBatchAttach);
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
document.addEventListener('keydown', e => {
if (e.key === 'Escape') document.getElementById('imgModal').classList.remove('open');
});
document.getElementById('studentList').addEventListener('click', e => { document.getElementById('studentList').addEventListener('click', e => {
const av = e.target.closest('[data-avatar-student]');
if (av) {
const p = av.dataset.studentPhoto;
if (p) showImg(photoFullSrc(p));
else openProfileData(parseInt(av.dataset.avatarStudent, 10));
return;
}
const sv = e.target.closest('[data-save-student]'); const sv = e.target.closest('[data-save-student]');
if (sv) { saveStudent(parseInt(sv.dataset.saveStudent, 10)); return; } if (sv) { saveStudent(parseInt(sv.dataset.saveStudent, 10)); return; }
const cp = e.target.closest('[data-copy-student]'); const cp = e.target.closest('[data-copy-student]');
+52 -3
View File
@@ -19,6 +19,7 @@ const STATUS_BADGE = {
let state = null; let state = null;
let photoState = null; let photoState = null;
let photoJobRow = null; let photoJobRow = null;
let photoJobMeta = {};
let refreshTimer = null; let refreshTimer = null;
let busy = false; let busy = false;
let photoBusy = false; let photoBusy = false;
@@ -29,6 +30,8 @@ let photoTotal = 0;
const PHOTO_ACTION_LABELS = { const PHOTO_ACTION_LABELS = {
ai: 'ИИ-обработка', ai: 'ИИ-обработка',
ai_face: 'ИИ + лица',
ai_upscale: 'ИИ-апскейл',
enhance: 'Улучшение', enhance: 'Улучшение',
restore: 'Возврат оригинала', restore: 'Возврат оригинала',
rollback: 'Откат', rollback: 'Откат',
@@ -224,9 +227,15 @@ function render() {
function renderPhoto() { function renderPhoto() {
if (!photoState) return; if (!photoState) return;
const w = photoState.worker || {}; const w = photoState.worker || {};
document.getElementById('photoStateHint').textContent = !photoState.enabled const svc = photoState.service || {};
? 'Обработка фотографий выключена' let hint;
: (!photoState.ai_configured ? 'PHOTO_AI_URL не настроен' : (w.processing ? `Обрабатывается задание #${w.current_id ?? ''}` : 'Ожидание очереди')); if (!photoState.enabled) hint = 'Обработка фотографий выключена';
else if (!photoState.ai_configured) hint = 'PHOTO_AI_URL не настроен';
else if (svc.reachable === false) hint = 'Сервис фото-ИИ недоступен — задания ждут';
else if (w.processing) hint = `Обрабатывается задание #${w.current_id ?? ''}`;
else hint = 'Ожидание очереди';
if (photoState.enabled && photoState.ai_configured && svc.reachable && svc.device) hint += ` · расчёт на ${svc.device}`;
document.getElementById('photoStateHint').textContent = hint;
document.getElementById('photoToggleBtn').textContent = photoState.enabled ? '📷 Выключить фото-воркер' : '📷 Включить фото-воркер'; document.getElementById('photoToggleBtn').textContent = photoState.enabled ? '📷 Выключить фото-воркер' : '📷 Включить фото-воркер';
const c = photoState.counts || {}; const c = photoState.counts || {};
document.getElementById('photoCounts').innerHTML = [ document.getElementById('photoCounts').innerHTML = [
@@ -297,12 +306,52 @@ function setPhotoJobClip(p) {
document.getElementById('photoJobDivider').style.left = v + '%'; document.getElementById('photoJobDivider').style.left = v + '%';
} }
async function loadPhotoJobMeta() {
try {
const res = await fetch(`${API}/api/audit?action=photo.job.preview&limit=200`, { headers: hdr() });
if (!res.ok) return;
const rows = await res.json();
const map = {};
rows.forEach(r => {
const t = r.target;
if (t && t.job_id && !map[t.job_id]) map[t.job_id] = t;
});
photoJobMeta = map;
} catch {
}
}
function renderPhotoJobMeta(r) {
const el = document.getElementById('photoJobMeta');
if (!el) return;
const p = r.params && typeof r.params === 'object' ? r.params : {};
const m = photoJobMeta[r.id] || {};
const chips = [];
const model = m.model || p.model;
if (model) chips.push(['модель', esc(String(model))]);
const face = m.face || p.face;
if (face && face !== 'off') {
const fm = m.face_model || p.face_model;
const title = face === 'all' ? 'лица + фон' : 'только лица';
chips.push(['режим', esc(title) + (fm ? ` (${esc(String(fm))})` : '')]);
}
if (m.faces_found !== null && m.faces_found !== undefined) chips.push(['найдено лиц', String(m.faces_found)]);
if (m.device) chips.push(['устройство', esc(String(m.device))]);
if (m.elapsed_ms !== null && m.elapsed_ms !== undefined) chips.push(['обработка', esc(fmtDur(m.elapsed_ms))]);
if (Array.isArray(m.warnings) && m.warnings.length) chips.push(['предупреждения', esc(m.warnings.join('; '))]);
el.innerHTML = chips.length
? chips.map(([k, v]) => `<span class="chip">${esc(k)}: <b>${v}</b></span>`).join('')
: '<span class="hint">Метаданные обработки недоступны</span>';
}
function openPhotoJob(r) { function openPhotoJob(r) {
photoJobRow = r; photoJobRow = r;
document.getElementById('photoJobTitle').textContent = `Обработка фото — запись #${r.entry_id} ${r.student_name || ''}`; document.getElementById('photoJobTitle').textContent = `Обработка фото — запись #${r.entry_id} ${r.student_name || ''}`;
document.getElementById('photoJobBefore').src = thumbUrl(r.before_path); document.getElementById('photoJobBefore').src = thumbUrl(r.before_path);
document.getElementById('photoJobAfter').src = r.after_path ? `${API}${r.after_path}` : ''; document.getElementById('photoJobAfter').src = r.after_path ? `${API}${r.after_path}` : '';
document.getElementById('photoJobRollbackBtn').style.display = (r.before_path && r.action !== 'rollback') ? '' : 'none'; document.getElementById('photoJobRollbackBtn').style.display = (r.before_path && r.action !== 'rollback') ? '' : 'none';
renderPhotoJobMeta(r);
loadPhotoJobMeta().then(() => { if (photoJobRow === r) renderPhotoJobMeta(r); });
setPhotoJobClip(50); setPhotoJobClip(50);
document.getElementById('photoJobModal').classList.add('open'); document.getElementById('photoJobModal').classList.add('open');
} }
+32
View File
@@ -0,0 +1,32 @@
<title>Отчёты о занятиях — Админ-панель</title>
<link rel="stylesheet" href="admin.css">
</head>
<body data-page="lessons">
<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="filters">
<div><label>Поиск</label><input type="text" id="searchInput" class="search-input" placeholder="Текст отчёта..."></div>
<div><label>Группа</label><select id="groupFilter"><option value="">Все группы</option></select></div>
<div><label>С</label><input type="date" id="dateFrom"></div>
<div><label>По</label><input type="date" id="dateTo"></div>
<div><label>&nbsp;</label><button type="button" class="btn-primary" id="addLessonBtn"><i data-lucide="notebook-pen"></i> Что прошли на занятии</button></div>
</div>
<div class="hint" id="lessonsState" style="margin-bottom:12px"></div>
<div class="lesson-list" id="lessonsList"><div class="empty">Загрузка…</div></div>
<div class="pager" id="pager"></div>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script>
<script src="js/lessons.js"></script>
</body>
</html>
+1 -1
View File
@@ -52,7 +52,7 @@
<input type="file" id="modulePhotoFile" accept="image/*" style="display:none"> <input type="file" id="modulePhotoFile" accept="image/*" style="display:none">
<button type="button" id="modulePhotoDeleteBtn" class="btn-link danger" style="display:none">Удалить</button> <button type="button" id="modulePhotoDeleteBtn" class="btn-link danger" style="display:none">Удалить</button>
</div> </div>
<span id="modulePhotoNote" class="hint">jpg, png, gif, webp, avif, heic и др., до 10 МБ · или вставьте из буфера (Ctrl+V)</span> <span id="modulePhotoNote" class="hint">jpg, png, gif, webp, avif, heic и др. · или вставьте из буфера (Ctrl+V)</span>
</div> </div>
</div> </div>
<div class="card-foot" style="justify-content:flex-end;gap:8px"> <div class="card-foot" style="justify-content:flex-end;gap:8px">
+58
View File
@@ -26,8 +26,10 @@
<a href="#sec-share" data-nav="sec-share"><i data-lucide="link"></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-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" 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-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-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-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-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> <a href="#sec-bans" data-nav="sec-bans"><i data-lucide="shield-off"></i> Блокировки</a>
@@ -164,6 +166,24 @@
<div class="ai-queue" id="aiQueueInfo"></div> <div class="ai-queue" id="aiQueueInfo"></div>
</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="settings-card">
<div class="card-head"> <div class="card-head">
<div class="ic"><i data-lucide="boxes"></i></div> <div class="ic"><i data-lucide="boxes"></i></div>
@@ -259,6 +279,44 @@
</div> </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="settings-card" id="sec-trash">
<div class="card-head"> <div class="card-head">
<div class="ic"><i data-lucide="trash-2"></i></div> <div class="ic"><i data-lucide="trash-2"></i></div>
+4
View File
@@ -209,6 +209,10 @@
</div> </div>
</div> </div>
<div class="modal-overlay" id="imgModal">
<img id="imgModalSrc" alt="">
</div>
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script> <script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script> <script src="admin.js"></script>
+1
View File
@@ -178,6 +178,7 @@
<div class="enhance-badge left">Было</div> <div class="enhance-badge left">Было</div>
<div class="enhance-badge right">Стало</div> <div class="enhance-badge right">Стало</div>
</div> </div>
<div class="data-stats" id="photoJobMeta" style="margin-top:12px"></div>
<div class="card-foot" style="justify-content:flex-end;gap:8px;margin-top:14px"> <div class="card-foot" style="justify-content:flex-end;gap:8px;margin-top:14px">
<button type="button" class="btn-primary ghost" id="photoJobCloseBtn">Закрыть</button> <button type="button" class="btn-primary ghost" id="photoJobCloseBtn">Закрыть</button>
<button type="button" class="btn-primary" id="photoJobRollbackBtn">↩ Откатить к версии «Было»</button> <button type="button" class="btn-primary" id="photoJobRollbackBtn">↩ Откатить к версии «Было»</button>
+1047 -403
View File
File diff suppressed because it is too large Load Diff
+67
View File
@@ -35,6 +35,22 @@ const MIME_TYPES = {
'.7z': 'application/x-7z-compressed', '.7z': 'application/x-7z-compressed',
'.doc': 'application/msword', '.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.mp4': 'video/mp4',
'.m4v': 'video/mp4',
'.mov': 'video/quicktime',
'.webm': 'video/webm',
'.ogv': 'video/ogg',
'.mpeg': 'video/mpeg',
'.mpg': 'video/mpeg',
'.mkv': 'video/x-matroska',
'.avi': 'video/x-msvideo',
'.3gp': 'video/3gpp',
'.m3u8': 'application/vnd.apple.mpegurl',
'.mp3': 'audio/mpeg',
'.m4a': 'audio/mp4',
'.oga': 'audio/ogg',
'.wav': 'audio/wav',
'.flac': 'audio/flac',
}; };
const SAFE_SEGMENT = /^[\w,.()-]+$/; const SAFE_SEGMENT = /^[\w,.()-]+$/;
@@ -234,6 +250,10 @@ function createStorage(options = {}) {
if (!fp || !fs.existsSync(fp)) return null; if (!fp || !fs.existsSync(fp)) return null;
return { stream: fs.createReadStream(fp), contentLength: fs.statSync(fp).size, contentType: mimeFor(key) }; return { stream: fs.createReadStream(fp), contentLength: fs.statSync(fp).size, contentType: mimeFor(key) };
} }
if (localFallback && localExists(key)) {
const fp = localFile(key);
return { stream: fs.createReadStream(fp), contentLength: fs.statSync(fp).size, contentType: mimeFor(key) };
}
try { try {
const out = await client.send(new GetObjectCommand({ Bucket: bucket, Key: objKey })); const out = await client.send(new GetObjectCommand({ Bucket: bucket, Key: objKey }));
return { stream: out.Body, contentLength: out.ContentLength || 0, contentType: out.ContentType || mimeFor(key) }; return { stream: out.Body, contentLength: out.ContentLength || 0, contentType: out.ContentType || mimeFor(key) };
@@ -249,6 +269,33 @@ function createStorage(options = {}) {
return null; return null;
} }
async function getRange(key, start, end) {
const k = normalizeKey(key);
if (!k || !Number.isInteger(start) || start < 0) return null;
const last = Number.isInteger(end) && end >= start ? end : null;
if (localFallback && localExists(k)) {
const fp = localFile(k);
const size = fs.statSync(fp).size;
const to = last === null ? size - 1 : Math.min(last, size - 1);
if (start > to) return null;
return { stream: fs.createReadStream(fp, { start, end: to }), contentLength: to - start + 1, contentType: mimeFor(k) };
}
const objKey = objectKey(k);
if (!objKey) return null;
try {
const out = await client.send(new GetObjectCommand({
Bucket: bucket,
Key: objKey,
Range: `bytes=${start}-${last === null ? '' : last}`,
}));
return { stream: out.Body, contentLength: out.ContentLength || 0, contentType: out.ContentType || mimeFor(k) };
} catch (err) {
if (isMissingError(err)) return null;
if (err && (err.name === 'InvalidRange' || (err.$metadata && err.$metadata.httpStatusCode === 416))) return null;
throw err;
}
}
async function del(key) { async function del(key) {
const k = normalizeKey(key); const k = normalizeKey(key);
if (!k) return false; if (!k) return false;
@@ -402,6 +449,24 @@ function createStorage(options = {}) {
return true; return true;
} }
async function streamRangeTo(res, key, start, end, opts = {}) {
const k = normalizeKey(key);
if (!k) return false;
const total = await sizeOf(k);
if (!total) return false;
const source = await getRange(k, start, end);
if (!source || !source.contentLength) return false;
const lastByte = start + source.contentLength - 1;
res.status(206);
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Content-Range', `bytes ${start}-${lastByte}/${total}`);
res.setHeader('Content-Type', opts.contentType || source.contentType || mimeFor(k));
res.setHeader('Content-Length', String(source.contentLength));
if (opts.cacheControl) res.setHeader('Cache-Control', opts.cacheControl);
await pipeline(source.stream, res);
return true;
}
async function downloadAll(destDir, opts = {}) { async function downloadAll(destDir, opts = {}) {
fs.mkdirSync(destDir, { recursive: true }); fs.mkdirSync(destDir, { recursive: true });
const root = path.resolve(destDir); const root = path.resolve(destDir);
@@ -525,12 +590,14 @@ function createStorage(options = {}) {
sizeOf, sizeOf,
getStream, getStream,
getBuffer, getBuffer,
getRange,
del, del,
copyObject, copyObject,
listAll, listAll,
localize, localize,
persist, persist,
streamTo, streamTo,
streamRangeTo,
downloadAll, downloadAll,
uploadTree, uploadTree,
ensureBucket, ensureBucket,
+105 -26
View File
@@ -207,6 +207,7 @@ function safeHref(value) {
const IMG_EXT = new Set(['JPG', 'JPEG', 'PNG', 'GIF', 'WEBP', 'BMP', 'AVIF', 'SVG', 'ICO', 'JFIF', 'HEIC', 'HEIF']); const IMG_EXT = new Set(['JPG', 'JPEG', 'PNG', 'GIF', 'WEBP', 'BMP', 'AVIF', 'SVG', 'ICO', 'JFIF', 'HEIC', 'HEIF']);
const VID_EXT = new Set(['MP4', 'WEBM', 'MOV', 'M4V', 'OGV', 'MKV', 'MPEG', 'MPG', '3GP', 'AVI']); const VID_EXT = new Set(['MP4', 'WEBM', 'MOV', 'M4V', 'OGV', 'MKV', 'MPEG', 'MPG', '3GP', 'AVI']);
const NAV_LIMIT = 5;
const EXT_ICONS = { const EXT_ICONS = {
PDF: 'file-text', DOC: 'file-text', DOCX: 'file-text', TXT: 'file-text', RTF: 'file-text', PDF: 'file-text', DOC: 'file-text', DOCX: 'file-text', TXT: 'file-text', RTF: 'file-text',
XLS: 'table-2', XLSX: 'table-2', CSV: 'table-2', PPT: 'presentation', PPTX: 'presentation', XLS: 'table-2', XLSX: 'table-2', CSV: 'table-2', PPT: 'presentation', PPTX: 'presentation',
@@ -294,6 +295,18 @@ function buildProfileView(data, opts) {
}; };
}); });
const studentPhotoCards = showPhotos ? arr(data.studentPhotos).map(sp => {
const src = 'photos/' + sp.stored;
const g = galleryAdd('image', src, sp.main ? 'Главное фото' : 'Фото ученика');
return {
src,
g,
date: showDates ? longDate(sp.createdAt) : '',
main: !!sp.main,
student: true,
};
}) : [];
const photoByEntry = new Map(); const photoByEntry = new Map();
for (const p of photos) { for (const p of photos) {
if (!photoByEntry.has(p.entryId)) photoByEntry.set(p.entryId, []); if (!photoByEntry.has(p.entryId)) photoByEntry.set(p.entryId, []);
@@ -465,18 +478,20 @@ function buildProfileView(data, opts) {
if (files.length) autoBio.push('Работ в портфолио: ' + files.length + '.'); if (files.length) autoBio.push('Работ в портфолио: ' + files.length + '.');
const tagList = arr(profile.tags).map(t => str(t, 40)).filter(Boolean); const tagList = arr(profile.tags).map(t => str(t, 40)).filter(Boolean);
const heroTags = tagList.length ? tagList : education.slice(0, 6).map(m => m.title); const heroTags = tagList.length ? tagList : education.slice(0, 6).map(m => m.title);
const avatar = showPhotos && photoCards.length ? (photoCards[1] || photoCards[0]) : null; const mainStudentCard = studentPhotoCards.find(p => p.main) || studentPhotoCards[0] || null;
const cover = showPhotos && photoCards.length ? photoCards[0] : null; const avatar = showPhotos ? (photoCards[1] || photoCards[0] || mainStudentCard) : null;
const cover = showPhotos ? (photoCards[0] || mainStudentCard) : null;
const mainPhotoCard = showPhotos && groupPhotoCards.length ? groupPhotoCards[0] : null; const mainPhotoCard = showPhotos && groupPhotoCards.length ? groupPhotoCards[0] : null;
const profilePhotoCard = showPhotos && photoCards.length ? photoCards[0] : null; const profilePhotoCard = showPhotos ? (mainStudentCard || photoCards[0] || null) : null;
const eduYears = education.map(m => m.period).filter(Boolean); const eduYears = education.map(m => m.period).filter(Boolean);
const idLabel = student.id ? 'ID S-' + String(student.id).padStart(4, '0') : null; const idLabel = student.id ? 'ID S-' + String(student.id).padStart(4, '0') : null;
const logo = /^[a-zA-Z0-9._-]+\.[a-zA-Z0-9]{1,8}$/.test(String(data.logo || '')) ? String(data.logo) : null;
return { return {
name, student, profile, entries, education, projects, photoCards, groupPhotoCards, tasks, gallery, groups, stats, name, student, profile, entries, education, projects, photoCards, groupPhotoCards, studentPhotoCards, tasks, gallery, groups, stats,
skillGroups, experience, contacts, achievements, facts, heroTags, skillGroups, experience, contacts, achievements, facts, heroTags,
avatar, cover, idLabel, mainPhotoCard, profilePhotoCard, avatar, cover, idLabel, mainPhotoCard, profilePhotoCard, logo,
role: str(profile.role, 200) || (education.length ? 'Резидент программы: ' + education[0].title : null), role: str(profile.role, 200),
bio: str(profile.bio, 2000) || (autoBio.join(' ') || null), bio: str(profile.bio, 2000) || (autoBio.join(' ') || null),
quote: str(profile.quote, 300), quote: str(profile.quote, 300),
status: str(profile.status, 60) || 'Активно учится', status: str(profile.status, 60) || 'Активно учится',
@@ -519,9 +534,10 @@ svg{display:block;flex:none}
.topbar{position:sticky;top:0;z-index:60;background:var(--topbar-bg);border-bottom:1px solid var(--stroke);backdrop-filter:blur(20px) saturate(160%);-webkit-backdrop-filter:blur(20px) saturate(160%)} .topbar{position:sticky;top:0;z-index:60;background:var(--topbar-bg);border-bottom:1px solid var(--stroke);backdrop-filter:blur(20px) saturate(160%);-webkit-backdrop-filter:blur(20px) saturate(160%)}
.topbar__inner{max-width:var(--max);margin:0 auto;padding:12px 24px;display:flex;align-items:center;gap:14px} .topbar__inner{max-width:var(--max);margin:0 auto;padding:12px 24px;display:flex;align-items:center;gap:14px}
@media (max-width:640px){.topbar__inner{padding:10px 14px}} @media (max-width:640px){.topbar__inner{padding:10px 14px}}
.brand{display:flex;align-items:center;gap:12px;font-weight:800;letter-spacing:-.02em} .brand{display:flex;align-items:center;gap:12px;font-weight:800;letter-spacing:-.02em;min-width:0}
.brand__logo{width:44px;height:44px;border-radius:14px;display:grid;place-items:center;background:linear-gradient(135deg,var(--brand),var(--brand-2));color:#fff;box-shadow:0 10px 24px rgba(109,140,255,.35)} .brand__logo{height:44px;flex:0 1 auto;min-width:0;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,var(--brand),var(--brand-2));color:#fff;box-shadow:0 10px 24px rgba(109,140,255,.35);overflow:hidden}
.brand__logo svg{width:22px;height:22px;stroke-width:2.2} .brand__logo svg{width:22px;height:22px;stroke-width:2.2;flex:none}
.brand__logo img{max-width:min(40vw,240px);max-height:44px;width:auto;height:auto;object-fit:contain;display:block}
.brand__text small{display:block;font-size:.72rem;font-weight:600;color:var(--muted);letter-spacing:.06em;text-transform:uppercase} .brand__text small{display:block;font-size:.72rem;font-weight:600;color:var(--muted);letter-spacing:.06em;text-transform:uppercase}
@media (max-width:540px){.brand__text{display:none}} @media (max-width:540px){.brand__text{display:none}}
.topbar__nav{display:flex;align-items:center;gap:4px;flex:1;justify-content:center} .topbar__nav{display:flex;align-items:center;gap:4px;flex:1;justify-content:center}
@@ -612,7 +628,7 @@ svg{display:block;flex:none}
.stat__delta{display:inline-flex;align-items:center;gap:5px;font-size:.78rem;font-weight:800;color:var(--ok)} .stat__delta{display:inline-flex;align-items:center;gap:5px;font-size:.78rem;font-weight:800;color:var(--ok)}
.stat__delta svg{width:14px;height:14px;stroke-width:2.4} .stat__delta svg{width:14px;height:14px;stroke-width:2.4}
.modules{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:var(--gap)} .modules{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:var(--gap)}
@media (max-width:720px){.modules{grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}} @media (max-width:760px){.modules{grid-template-columns:1fr}}
.module{padding:clamp(14px,1.7vw,20px);display:flex;flex-direction:column;gap:12px;min-width:0} .module{padding:clamp(14px,1.7vw,20px);display:flex;flex-direction:column;gap:12px;min-width:0}
.module__head{display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap} .module__head{display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap}
.module__head>div{min-width:0;flex:1 1 55%} .module__head>div{min-width:0;flex:1 1 55%}
@@ -636,7 +652,8 @@ svg{display:block;flex:none}
.rail--half>*{flex:0 0 calc(50% - var(--gap)/2)} .rail--half>*{flex:0 0 calc(50% - var(--gap)/2)}
.rail--third>*{flex:0 0 calc((100% - var(--gap) * 2) / 3)} .rail--third>*{flex:0 0 calc((100% - var(--gap) * 2) / 3)}
@media (max-width:1100px){.rail--third>*{flex:0 0 calc(50% - var(--gap)/2)}} @media (max-width:1100px){.rail--third>*{flex:0 0 calc(50% - var(--gap)/2)}}
@media (max-width:720px){.rail{gap:12px;padding-bottom:12px}.rail--half>*,.rail--third>*{flex:0 0 calc(50% - 6px)}} @media (max-width:720px){.rail{gap:12px;padding-bottom:12px}.rail--half>*{flex:0 0 calc(50% - 6px)}}
@media (max-width:720px){#projectsRail>*{flex:0 0 100%}}
.rail-controls{display:flex;align-items:center;gap:8px} .rail-controls{display:flex;align-items:center;gap:8px}
.rail-progress{height:6px;border-radius:999px;background:var(--surface-2);border:1px solid var(--stroke);overflow:hidden;width:min(260px,40vw)} .rail-progress{height:6px;border-radius:999px;background:var(--surface-2);border:1px solid var(--stroke);overflow:hidden;width:min(260px,40vw)}
.rail-progress>i{display:block;height:100%;width:20%;border-radius:inherit;background:linear-gradient(90deg,var(--brand),var(--accent));transition:transform .25s ease,width .25s ease} .rail-progress>i{display:block;height:100%;width:20%;border-radius:inherit;background:linear-gradient(90deg,var(--brand),var(--accent));transition:transform .25s ease,width .25s ease}
@@ -660,12 +677,18 @@ svg{display:block;flex:none}
.prj-card__stats span{display:inline-flex;align-items:center;gap:5px} .prj-card__stats span{display:inline-flex;align-items:center;gap:5px}
.prj-card__stats svg{width:15px;height:15px} .prj-card__stats svg{width:15px;height:15px}
.photos{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px} .photos{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px}
@media (max-width:620px){.photos{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}} @media (max-width:620px){.photos{grid-template-columns:1fr;gap:10px}}
.photo-card{overflow:hidden;display:flex;flex-direction:column;min-width:0} .photo-card{overflow:hidden;display:flex;flex-direction:column;min-width:0}
.photo-card figcaption{padding:10px 12px;font-size:.8rem;font-weight:600;color:var(--muted);display:flex;flex-direction:column;gap:2px;min-width:0} .photo-card figcaption{padding:10px 12px;font-size:.8rem;font-weight:600;color:var(--muted);display:flex;flex-direction:column;gap:2px;min-width:0}
.photo-card figcaption b{color:var(--text);font-weight:700;overflow-wrap:anywhere} .photo-card figcaption b{color:var(--text);font-weight:700;overflow-wrap:anywhere}
.sp-list{display:flex;flex-direction:column;gap:var(--gap)}
.prj-card--row{flex-direction:row;align-items:stretch}
.prj-card--row>.media{flex:0 0 clamp(180px,26%,360px);aspect-ratio:4/3}
.prj-card--row .prj-card__body{flex:1}
@media (max-width:620px){.prj-card--row{flex-direction:column}.prj-card--row>.media{flex:0 0 auto;aspect-ratio:16/9}}
@media print{.sp-list>.prj-card{page-break-inside:avoid}}
.skills{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:var(--gap)} .skills{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:var(--gap)}
@media (max-width:720px){.skills{grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}} @media (max-width:720px){.skills{grid-template-columns:1fr;gap:12px}}
.skill-group{padding:clamp(14px,1.6vw,20px);display:flex;flex-direction:column;gap:14px;min-width:0} .skill-group{padding:clamp(14px,1.6vw,20px);display:flex;flex-direction:column;gap:14px;min-width:0}
.skill-group__head{display:flex;align-items:center;gap:10px;font-weight:800;font-size:1rem} .skill-group__head{display:flex;align-items:center;gap:10px;font-weight:800;font-size:1rem}
.skill-group__head svg{width:19px;height:19px;color:var(--brand);flex:none} .skill-group__head svg{width:19px;height:19px;color:var(--brand);flex:none}
@@ -677,7 +700,7 @@ svg{display:block;flex:none}
.skill__val{font-size:.74rem;font-weight:800;color:var(--muted);white-space:nowrap} .skill__val{font-size:.74rem;font-weight:800;color:var(--muted);white-space:nowrap}
.tasks{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:12px} .tasks{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:12px}
@media (max-width:760px){.tasks{grid-template-columns:1fr}} @media (max-width:760px){.tasks{grid-template-columns:1fr}}
.task{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:14px;align-items:center;padding:14px 16px;transition:background .2s ease,border-color .2s ease} .task{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:14px;align-items:start;padding:14px 16px;transition:background .2s ease,border-color .2s ease}
.task:hover{background:var(--surface-hover);border-color:var(--stroke-strong)} .task:hover{background:var(--surface-hover);border-color:var(--stroke-strong)}
.task__check{width:42px;height:42px;border-radius:12px;display:grid;place-items:center;flex:none;background:rgba(52,211,153,.14);border:1px solid rgba(52,211,153,.32);color:var(--ok)} .task__check{width:42px;height:42px;border-radius:12px;display:grid;place-items:center;flex:none;background:rgba(52,211,153,.14);border:1px solid rgba(52,211,153,.32);color:var(--ok)}
.task__check svg{width:20px;height:20px;stroke-width:2.4} .task__check svg{width:20px;height:20px;stroke-width:2.4}
@@ -694,6 +717,7 @@ svg{display:block;flex:none}
.task__thumbs{display:flex;gap:6px;flex-wrap:wrap} .task__thumbs{display:flex;gap:6px;flex-wrap:wrap}
.task__thumbs img{width:64px;height:64px;object-fit:cover;border-radius:10px;border:1px solid var(--stroke);cursor:zoom-in} .task__thumbs img{width:64px;height:64px;object-fit:cover;border-radius:10px;border:1px solid var(--stroke);cursor:zoom-in}
@media (max-width:520px){.task{grid-template-columns:auto minmax(0,1fr)}.task__right{grid-column:2;align-items:flex-start}} @media (max-width:520px){.task{grid-template-columns:auto minmax(0,1fr)}.task__right{grid-column:2;align-items:flex-start}}
@media (max-width:620px){.task__right{grid-column:1 / -1;order:-1;align-items:flex-start}.task__thumbs{display:grid;grid-template-columns:1fr;gap:8px;width:100%}.task__thumbs img{width:100%;height:auto;aspect-ratio:4 / 3;border-radius:12px}}
.about{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(0,1fr);gap:var(--gap)} .about{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(0,1fr);gap:var(--gap)}
@media (max-width:960px){.about{grid-template-columns:1fr}} @media (max-width:960px){.about{grid-template-columns:1fr}}
.about__bio{padding:clamp(18px,2vw,26px);display:flex;flex-direction:column;gap:14px} .about__bio{padding:clamp(18px,2vw,26px);display:flex;flex-direction:column;gap:14px}
@@ -821,6 +845,7 @@ function heroHtml(v) {
actions.push('<button class="btn btn--primary" type="button" data-action="print">' + iconTag('printer') + 'Печать / PDF</button>'); actions.push('<button class="btn btn--primary" type="button" data-action="print">' + iconTag('printer') + 'Печать / PDF</button>');
const mail = v.contacts.find(c => (c.href || '').toLowerCase().startsWith('mailto:')) || v.contacts.find(c => c.href); const mail = v.contacts.find(c => (c.href || '').toLowerCase().startsWith('mailto:')) || v.contacts.find(c => c.href);
if (mail) actions.push('<a class="btn" href="' + escapeHtml(mail.href) + '">' + iconTag('send') + 'Написать</a>'); if (mail) actions.push('<a class="btn" href="' + escapeHtml(mail.href) + '">' + iconTag('send') + 'Написать</a>');
if (v.studentPhotoCards.length) actions.push('<a class="btn btn--ghost" href="#student-photos">' + iconTag('user-round') + 'Фото ученика</a>');
if (v.photoCards.length) actions.push('<a class="btn btn--ghost" href="#photos">' + iconTag('images') + 'Фотографии</a>'); if (v.photoCards.length) actions.push('<a class="btn btn--ghost" href="#photos">' + iconTag('images') + 'Фотографии</a>');
const facts = v.facts.length const facts = v.facts.length
? v.facts.map(f => '<span class="fact">' + iconTag(f.icon) + '<span>' + escapeHtml(f.text) + '</span></span>').join('') ? v.facts.map(f => '<span class="fact">' + iconTag(f.icon) + '<span>' + escapeHtml(f.text) + '</span></span>').join('')
@@ -930,6 +955,30 @@ function groupPhotosHtml(v) {
'</figure>').join(''); '</figure>').join('');
} }
function studentPhotosHtml(v) {
if (!v.studentPhotoCards.length) {
return emptyBlock('image', 'Фотографий ученика пока нет',
'Личные фотографии резидента появятся здесь, как только их добавят в данных профиля.');
}
const total = v.studentPhotoCards.length;
return '<div class="sp-list">' + v.studentPhotoCards.map((p, i) =>
'<article class="card prj-card prj-card--row reveal">' +
mediaBlock(p.src, '4x3', v.name + ' — фото', p.g) +
'<div class="prj-card__body">' +
'<span class="badge ' + (p.main ? 'badge--ok' : 'badge--info') + '">' +
iconTag(p.main ? 'star' : 'image') + (p.main ? 'Главное фото' : 'Фото ученика') + '</span>' +
'<h3 class="prj-card__title">' + escapeHtml(total > 1 ? 'Фото ' + (i + 1) + ' из ' + total : 'Фотография резидента') + '</h3>' +
'<p class="prj-card__text">' + escapeHtml(
(p.main ? 'Личная фотография резидента' : 'Дополнительная фотография резидента') +
(total > 1 ? ' · ' + v.name : '')) + '</p>' +
'<div class="prj-card__foot"><span class="prj-card__stats">' +
(p.date ? '<span>' + iconTag('calendar-days') + escapeHtml(p.date) + '</span>' : '') +
(total > 1 ? '<span>' + iconTag('images') + total + ' ' + plural(total, 'фото', 'фото', 'фото') + '</span>' : '') +
'</span><a class="btn btn--sm" href="' + escapeHtml(p.src) + '" data-g="' + p.g + '">' +
iconTag('maximize-2') + 'Просмотр</a></div>' +
'</div></article>').join('') + '</div>';
}
function tasksHtml(v) { function tasksHtml(v) {
if (!v.tasks.length) { if (!v.tasks.length) {
return emptyBlock('list-checks', 'Записей о занятиях пока нет', return emptyBlock('list-checks', 'Записей о занятиях пока нет',
@@ -968,7 +1017,9 @@ function aboutHtml(v) {
: (v.avatar : (v.avatar
? '<div class="about__photo"><img src="' + escapeHtml(v.avatar.src) + '" alt="' + escapeHtml(v.name) + '" data-g="' + v.avatar.g + '" loading="lazy"></div>' ? '<div class="about__photo"><img src="' + escapeHtml(v.avatar.src) + '" alt="' + escapeHtml(v.name) + '" data-g="' + v.avatar.g + '" loading="lazy"></div>'
: phBlock('Фото автора', 800, 600, 'image')))) + : phBlock('Фото автора', 800, 600, 'image')))) +
'<p class="meta">' + iconTag('info') + ((v.profilePhotoCard || v.mainPhotoCard || v.avatar) ? 'Фотография из архива занятий' : 'Место под реальное фото автора профиля') + '</p></article>'; '<p class="meta">' + iconTag('info') + ((v.profilePhotoCard || v.mainPhotoCard || v.avatar)
? (v.profilePhotoCard && v.profilePhotoCard.student ? 'Личная фотография резидента' : 'Фотография из архива занятий')
: 'Место под реальное фото автора профиля') + '</p></article>';
const contactsCard = '<article class="card about__card reveal"><h3>' + iconTag('at-sign') + 'Контакты</h3>' + const contactsCard = '<article class="card about__card reveal"><h3>' + iconTag('at-sign') + 'Контакты</h3>' +
(v.contacts.length (v.contacts.length
@@ -988,6 +1039,7 @@ function aboutHtml(v) {
function footerHtml(v, generatedAt) { function footerHtml(v, generatedAt) {
const links = []; const links = [];
if (v.entries.length) links.push('<a href="#lessons">Занятия</a>'); if (v.entries.length) links.push('<a href="#lessons">Занятия</a>');
if (v.studentPhotoCards.length) links.push('<a href="#student-photos">Фото ученика</a>');
if (v.photoCards.length) links.push('<a href="#photos">Фотографии</a>'); if (v.photoCards.length) links.push('<a href="#photos">Фотографии</a>');
if (v.groupPhotoCards.length) links.push('<a href="#group-photos">Фото группы</a>'); if (v.groupPhotoCards.length) links.push('<a href="#group-photos">Фото группы</a>');
if (v.projects.length) links.push('<a href="#projects">Работы</a>'); if (v.projects.length) links.push('<a href="#projects">Работы</a>');
@@ -998,16 +1050,35 @@ function footerHtml(v, generatedAt) {
} }
function navItems(v) { function navItems(v) {
const items = ['<a class="nav-link" href="#profile" data-spy="profile">' + iconTag('user-round') + '<span>Профиль</span></a>']; const all = [
if (v.photoCards.length) items.push('<a class="nav-link" href="#photos" data-spy="photos">' + iconTag('images') + '<span>Фотографии</span></a>'); { id: 'profile', icon: 'user-round', label: 'Профиль', rank: 0 },
if (v.groupPhotoCards.length) items.push('<a class="nav-link" href="#group-photos" data-spy="group-photos">' + iconTag('users-round') + '<span>Фото группы</span></a>'); { id: 'student-photos', icon: 'images', label: 'Фото ученика', rank: 1 },
items.push('<a class="nav-link" href="#education" data-spy="education">' + iconTag('book-open') + '<span>Образование</span></a>'); { id: 'lessons', icon: 'list-checks', label: 'Занятия', rank: 2 },
if (v.experience.length) items.push('<a class="nav-link" href="#experience" data-spy="experience">' + iconTag('briefcase') + '<span>Опыт</span></a>'); { id: 'projects', icon: 'folder-git-2', label: 'Работы', rank: 3 },
if (v.skillGroups.length) items.push('<a class="nav-link" href="#skills" data-spy="skills">' + iconTag('sparkles') + '<span>Навыки</span></a>'); { id: 'education', icon: 'book-open', label: 'Образование', rank: 4 },
if (v.projects.length) items.push('<a class="nav-link" href="#projects" data-spy="projects">' + iconTag('folder-git-2') + '<span>Работы</span></a>'); { id: 'about', icon: 'info', label: 'О себе', rank: 5 },
if (v.tasks.length) items.push('<a class="nav-link" href="#lessons" data-spy="lessons">' + iconTag('list-checks') + '<span>Занятия</span></a>'); { id: 'photos', icon: 'images', label: 'Фотографии', rank: 6 },
items.push('<a class="nav-link" href="#about" data-spy="about">' + iconTag('info') + '<span>О себе</span></a>'); { id: 'group-photos', icon: 'users-round', label: 'Фото группы', rank: 7 },
return items.join(''); { id: 'skills', icon: 'sparkles', label: 'Навыки', rank: 8 },
{ id: 'experience', icon: 'briefcase', label: 'Опыт', rank: 9 },
];
const sections = {
'profile': true,
'student-photos': v.studentPhotoCards.length > 0,
'photos': v.photoCards.length > 0,
'group-photos': v.groupPhotoCards.length > 0,
'education': true,
'experience': v.experience.length > 0,
'skills': v.skillGroups.length > 0,
'projects': v.projects.length > 0,
'lessons': v.tasks.length > 0,
'about': true,
};
return all
.filter(i => sections[i.id])
.slice(0, NAV_LIMIT)
.map(i => '<a class="nav-link" href="#' + i.id + '" data-spy="' + i.id + '">' + iconTag(i.icon) + '<span>' + i.label + '</span></a>')
.join('');
} }
function educationHtml(v) { function educationHtml(v) {
@@ -1146,6 +1217,11 @@ function renderStudentReport(data, opts) {
sectionHead('activity', 'Информация об авторе', 'Показатели резидента по данным журнала занятий и архива работ.') + sectionHead('activity', 'Информация об авторе', 'Показатели резидента по данным журнала занятий и архива работ.') +
(periodChip ? '<div class="hero__tags">' + periodChip + '</div>' : '') + (periodChip ? '<div class="hero__tags">' + periodChip + '</div>' : '') +
'<div class="stats">' + statsHtml(v) + '</div></section>'); '<div class="stats">' + statsHtml(v) + '</div></section>');
sections.push('<section class="section" id="student-photos">' +
sectionHead('user-round', 'Фотографии ученика', v.studentPhotoCards.length > 1
? 'Личные фотографии резидента. Нажмите «Просмотр», чтобы открыть снимок крупнее.'
: 'Личная фотография резидента. Нажмите «Просмотр», чтобы открыть её крупнее.') +
studentPhotosHtml(v) + '</section>');
sections.push('<section class="section" id="photos">' + sections.push('<section class="section" id="photos">' +
sectionHead('images', 'Фотографии', 'Снимки из записей занятий. Нажмите на фотографию, чтобы открыть её крупнее.') + sectionHead('images', 'Фотографии', 'Снимки из записей занятий. Нажмите на фотографию, чтобы открыть её крупнее.') +
'<div class="photos">' + photosHtml(v) + '</div></section>'); '<div class="photos">' + photosHtml(v) + '</div></section>');
@@ -1155,10 +1231,12 @@ function renderStudentReport(data, opts) {
sections.push('<section class="section" id="education">' + sections.push('<section class="section" id="education">' +
sectionHead('book-open', 'Образование', 'Темы программы, которые проходил резидент, и прогресс по ним.') + sectionHead('book-open', 'Образование', 'Темы программы, которые проходил резидент, и прогресс по ним.') +
'<div class="modules">' + educationHtml(v) + '</div></section>'); '<div class="modules">' + educationHtml(v) + '</div></section>');
if (v.experience.length) {
sections.push('<section class="section" id="experience">' + sections.push('<section class="section" id="experience">' +
railHead('briefcase', 'Опыт работы', 'Стажировки, фриланс и командные проекты — ленту можно прокручивать вбок.', 'experienceRail') + railHead('briefcase', 'Опыт работы', 'Стажировки, фриланс и командные проекты — ленту можно прокручивать вбок.', 'experienceRail') +
'<div class="rail-wrap"><div class="rail rail--half" id="experienceRail" data-rail="experienceRail" tabindex="0" aria-label="Лента опыта">' + '<div class="rail-wrap"><div class="rail rail--half" id="experienceRail" data-rail="experienceRail" tabindex="0" aria-label="Лента опыта">' +
experienceHtml(v) + '</div></div></section>'); experienceHtml(v) + '</div></div></section>');
}
sections.push('<section class="section" id="skills">' + sections.push('<section class="section" id="skills">' +
sectionHead('sparkles', 'Навыки', 'Темы и технологии, которые резидент уже проходил.') + sectionHead('sparkles', 'Навыки', 'Темы и технологии, которые резидент уже проходил.') +
'<div class="skills">' + skillsHtml(v) + '</div></section>'); '<div class="skills">' + skillsHtml(v) + '</div></section>');
@@ -1174,7 +1252,8 @@ function renderStudentReport(data, opts) {
'<div class="about">' + aboutHtml(v) + '</div></section>'); '<div class="about">' + aboutHtml(v) + '</div></section>');
const topbar = '<header class="topbar" id="top"><div class="topbar__inner">' + const topbar = '<header class="topbar" id="top"><div class="topbar__inner">' +
'<a class="brand" href="#top"><span class="brand__logo">' + iconTag('graduation-cap') + '</span>' + '<a class="brand" href="#top"><span class="brand__logo">' +
(v.logo ? '<img src="' + escapeHtml(v.logo) + '" alt="Логотип">' : iconTag('graduation-cap')) + '</span>' +
'<span class="brand__text">KIBERone<small>Профиль резидента</small></span></a>' + '<span class="brand__text">KIBERone<small>Профиль резидента</small></span></a>' +
'<nav class="topbar__nav" id="mainNav" aria-label="Меню профиля">' + navItems(v) + '</nav>' + '<nav class="topbar__nav" id="mainNav" aria-label="Меню профиля">' + navItems(v) + '</nav>' +
'<div class="topbar__actions">' + '<div class="topbar__actions">' +
+425 -17
View File
@@ -6,7 +6,12 @@ const MIN_TEXT_CHARS = 4;
const crypto = require('crypto'); const crypto = require('crypto');
const { textDiff, FIELD_LABELS } = require('./diff'); const { textDiff, FIELD_LABELS } = require('./diff');
const PHOTO_MAX_ATTEMPTS = 3; const PHOTO_MAX_ATTEMPTS = 3;
const PHOTO_AI_TIMEOUT_MS = 300000; const PHOTO_AI_TIMEOUT_MS = Math.max(1000, parseInt(process.env.PHOTO_AI_TIMEOUT_MS || '300000', 10) || 300000);
const PHOTO_AI_FACE_TIMEOUT_MS = Math.max(PHOTO_AI_TIMEOUT_MS, parseInt(process.env.PHOTO_AI_FACE_TIMEOUT_MS || '600000', 10) || 600000);
const PHOTO_AI_DEFAULT_MODEL = 'x2plus';
const PHOTO_AI_DEFAULT_FACE_MODEL = String(process.env.PHOTO_AI_FACE_MODEL || 'gfpgan').trim() || 'gfpgan';
const PHOTO_AI_DEFAULT_STRENGTH = 0.7;
const PHOTO_AI_ACTIONS = new Set(['ai', 'ai_face', 'ai_upscale']);
const PHOTO_SOFT_MAX_RETRIES = Math.max(1, parseInt(process.env.PHOTO_AI_SOFT_MAX_RETRIES || '60', 10) || 60); const PHOTO_SOFT_MAX_RETRIES = Math.max(1, parseInt(process.env.PHOTO_AI_SOFT_MAX_RETRIES || '60', 10) || 60);
const PHOTO_SOFT_BACKOFF_MS = Math.max(1000, parseInt(process.env.PHOTO_AI_SOFT_BACKOFF_MS || '10000', 10) || 10000); const PHOTO_SOFT_BACKOFF_MS = Math.max(1000, parseInt(process.env.PHOTO_AI_SOFT_BACKOFF_MS || '10000', 10) || 10000);
const PHOTO_SOFT_BACKOFF_MAX_MS = Math.max(PHOTO_SOFT_BACKOFF_MS, parseInt(process.env.PHOTO_AI_SOFT_BACKOFF_MAX_MS || '300000', 10) || 300000); const PHOTO_SOFT_BACKOFF_MAX_MS = Math.max(PHOTO_SOFT_BACKOFF_MS, parseInt(process.env.PHOTO_AI_SOFT_BACKOFF_MAX_MS || '300000', 10) || 300000);
@@ -38,10 +43,14 @@ function softFailure(message, e) {
return err; return err;
} }
function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntries, sharp, photoAiUrl, storage, bus, notifyEvent }) { function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntries, sharp, photoAiUrl, storage, bus, notifyEvent, faceTimeoutMs, defaultModel, defaultFaceModel }) {
const AI_URL = photoAiUrl || process.env.PHOTO_AI_URL || ''; const AI_URL = photoAiUrl || process.env.PHOTO_AI_URL || '';
const IDLE_MIN = 2000; const IDLE_MIN = 2000;
const IDLE_MAX = 30000; const IDLE_MAX = 30000;
const AI_TIMEOUT_MS = PHOTO_AI_TIMEOUT_MS;
const AI_FACE_TIMEOUT_MS = Math.max(AI_TIMEOUT_MS, parseInt(faceTimeoutMs, 10) || PHOTO_AI_FACE_TIMEOUT_MS);
const DEFAULT_MODEL = String(defaultModel || PHOTO_AI_DEFAULT_MODEL).trim() || PHOTO_AI_DEFAULT_MODEL;
const DEFAULT_FACE_MODEL = String(defaultFaceModel || PHOTO_AI_DEFAULT_FACE_MODEL).trim() || PHOTO_AI_DEFAULT_FACE_MODEL;
async function sendNotification(entryId, payload) { async function sendNotification(entryId, payload) {
if (!notifyEvent) return; if (!notifyEvent) return;
@@ -64,12 +73,15 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
const CONFIG = { const CONFIG = {
idle_min_ms: IDLE_MIN, idle_min_ms: IDLE_MIN,
idle_max_ms: IDLE_MAX, idle_max_ms: IDLE_MAX,
ai_timeout_ms: PHOTO_AI_TIMEOUT_MS, ai_timeout_ms: AI_TIMEOUT_MS,
face_timeout_ms: AI_FACE_TIMEOUT_MS,
max_attempts: PHOTO_MAX_ATTEMPTS, max_attempts: PHOTO_MAX_ATTEMPTS,
soft_max_retries: PHOTO_SOFT_MAX_RETRIES, soft_max_retries: PHOTO_SOFT_MAX_RETRIES,
soft_backoff_ms: PHOTO_SOFT_BACKOFF_MS, soft_backoff_ms: PHOTO_SOFT_BACKOFF_MS,
soft_backoff_max_ms: PHOTO_SOFT_BACKOFF_MAX_MS, soft_backoff_max_ms: PHOTO_SOFT_BACKOFF_MAX_MS,
ai_url: AI_URL, ai_url: AI_URL,
default_model: DEFAULT_MODEL,
face_model: DEFAULT_FACE_MODEL,
}; };
function sleep(ms) { function sleep(ms) {
@@ -150,48 +162,177 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
return `/uploads/${newName}`; return `/uploads/${newName}`;
} }
async function runAiEnhance(srcKey) { function aiParams(job) {
const p = job && job.params && typeof job.params === 'object' ? job.params : {};
const face = String(p.face || (job && job.action === 'ai_face' ? 'face' : 'off')).trim().toLowerCase() || 'off';
const strength = Number(p.strength);
return {
model: String(p.model || DEFAULT_MODEL).trim().toLowerCase() || DEFAULT_MODEL,
face,
face_model: String(p.face_model || DEFAULT_FACE_MODEL).trim().toLowerCase() || DEFAULT_FACE_MODEL,
strength: Number.isFinite(strength) ? strength : PHOTO_AI_DEFAULT_STRENGTH,
timeout_ms: face === 'off' ? AI_TIMEOUT_MS : AI_FACE_TIMEOUT_MS,
};
}
function aiMeta(source) {
const o = source && typeof source === 'object' ? source : {};
const num = (v) => (Number.isFinite(Number(v)) && v !== null && v !== '' ? Number(v) : null);
return {
model: o.model ? String(o.model) : null,
face: o.face ? String(o.face) : null,
face_model: o.face_model ? String(o.face_model) : null,
faces_found: num(o.faces_found),
device: o.device ? String(o.device) : null,
elapsed_ms: num(o.elapsed_ms),
warnings: Array.isArray(o.warnings) ? o.warnings.map((w) => String(w).slice(0, 300)).slice(0, 5) : [],
};
}
async function readErrorBody(resp) {
try {
const text = (await resp.text()).trim();
if (!text) return null;
let data = null;
try { data = JSON.parse(text); } catch (e) {}
if (data && typeof data === 'object' && !Array.isArray(data)) {
return String(data.error || data.detail || '').slice(0, 400) || null;
}
return text.slice(0, 400);
} catch (e) {
return null;
}
}
async function runAiEnhance(srcKey, job) {
if (!AI_URL) throw new Error('PHOTO_AI_URL не настроен'); if (!AI_URL) throw new Error('PHOTO_AI_URL не настроен');
const opt = aiParams(job);
const buf = await storage.getBuffer(srcKey); const buf = await storage.getBuffer(srcKey);
if (!buf) throw new Error('Файл фото не найден'); if (!buf) throw new Error('Файл фото не найден');
const fd = new FormData(); const fd = new FormData();
fd.append('image', new Blob([buf], { type: 'image/jpeg' }), 'photo.jpg'); fd.append('image', new Blob([buf], { type: 'image/jpeg' }), 'photo.jpg');
fd.append('scale', '2'); fd.append('scale', '2');
fd.append('model', opt.model);
fd.append('face', opt.face);
fd.append('face_model', opt.face_model);
fd.append('strength', String(opt.strength));
let resp; let resp;
try { try {
resp = await fetch(AI_URL.replace(/\/+$/, '') + '/enhance', { resp = await fetch(AI_URL.replace(/\/+$/, '') + '/enhance', {
method: 'POST', method: 'POST',
headers: { Accept: 'application/json' },
body: fd, body: fd,
signal: AbortSignal.timeout(PHOTO_AI_TIMEOUT_MS), signal: AbortSignal.timeout(opt.timeout_ms),
}); });
} catch (e) { } catch (e) {
if (isTimeoutFailure(e)) { if (isTimeoutFailure(e)) {
throw new Error(`ИИ-сервис не ответил за ${Math.round(PHOTO_AI_TIMEOUT_MS / 1000)} с`); throw new Error(`ИИ-сервис не ответил за ${Math.round(opt.timeout_ms / 1000)} с`);
} }
throw softFailure('ИИ-сервис недоступен', e); throw softFailure('ИИ-сервис недоступен', e);
} }
if (!resp.ok) { if (!resp.ok) {
const retryAfter = resp.headers.get('retry-after'); const retryAfter = resp.headers.get('retry-after');
const detail = `ИИ-сервис ответил ${resp.status}${retryAfter ? ` (Retry-After: ${retryAfter})` : ''}`; const detail = `ИИ-сервис ответил ${resp.status}${retryAfter ? ` (Retry-After: ${retryAfter})` : ''}`;
if (isSoftStatus(resp.status)) throw softFailure(detail); const reason = await readErrorBody(resp);
throw new Error(detail); const message = reason ? `${detail}: ${reason}` : detail;
if (isSoftStatus(resp.status)) throw softFailure(message);
throw new Error(message);
} }
const contentType = resp.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
const out = Buffer.from(await resp.arrayBuffer()); const out = Buffer.from(await resp.arrayBuffer());
return {
out,
meta: aiMeta({
model: resp.headers.get('x-photo-ai-model') || opt.model,
face: resp.headers.get('x-photo-ai-face') || opt.face,
face_model: opt.face === 'off' ? null : opt.face_model,
device: resp.headers.get('x-photo-ai-device'),
elapsed_ms: resp.headers.get('x-photo-ai-elapsed-ms'),
}),
};
}
let data;
try {
data = await resp.json();
} catch (e) {
throw new Error('ИИ-сервис вернул нечитаемый JSON');
}
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('ИИ-сервис вернул неожиданный JSON');
if (data.ok === false) throw new Error(String(data.error || 'ИИ-сервис вернул ошибку').slice(0, 500));
if (typeof data.image_base64 !== 'string' || !data.image_base64) throw new Error('ИИ-сервис не вернул изображение');
let out;
try {
out = Buffer.from(data.image_base64, 'base64');
} catch (e) {
throw new Error('ИИ-сервис вернул изображение в нечитаемом base64');
}
if (!out.length) throw new Error('ИИ-сервис вернул пустое изображение');
return {
out,
meta: aiMeta({
model: data.model || opt.model,
face: data.face || opt.face,
face_model: data.face_model || (opt.face === 'off' ? null : opt.face_model),
faces_found: data.faces_found,
device: data.device,
elapsed_ms: data.elapsed_ms,
warnings: data.warnings,
}),
};
}
async function enhanceWithAi(srcKey, job) {
const { out, meta } = await runAiEnhance(srcKey, job);
const newName = crypto.randomBytes(12).toString('hex') + '.jpg'; const newName = crypto.randomBytes(12).toString('hex') + '.jpg';
await storage.put(newName, out); await storage.put(newName, out);
return `/uploads/${newName}`; return { path: `/uploads/${newName}`, meta };
} }
async function applyResult(job, newPath) {
async function applyResult(job, newPath, meta) {
const m = meta || {};
await pool.query( await pool.query(
`UPDATE photo_jobs SET status = 'done', after_path = $1, error = NULL, finished_at = now() WHERE id = $2`, `UPDATE photo_jobs SET status = 'done', after_path = $1, error = NULL, finished_at = now() WHERE id = $2`,
[newPath, job.id] [newPath, job.id]
); );
if (logAudit) await logAudit(null, 'photo.job.preview', { entry_id: job.entry_id, job_id: job.id, action: job.action, after_path: newPath }); if (logAudit) {
await logAudit(null, 'photo.job.preview', {
entry_id: job.entry_id,
job_id: job.id,
action: job.action,
after_path: newPath,
model: m.model,
face: m.face,
face_model: m.face_model,
device: m.device,
faces_found: m.faces_found,
elapsed_ms: m.elapsed_ms,
warnings: m.warnings && m.warnings.length ? m.warnings : null,
});
}
const title = job.action === 'ai_face'
? 'Фото обработано нейросетью с восстановлением лиц: {student}'
: job.action === 'ai_upscale'
? 'Фото обработано нейросетью (апскейл): {student}'
: job.action === 'ai'
? 'Фото обработано нейросетью: {student}'
: 'Фото улучшено на сервере: {student}';
const warn = m.warnings && m.warnings.length ? ' · ' + m.warnings.join('; ').slice(0, 300) : '';
await sendNotification(job.entry_id, { await sendNotification(job.entry_id, {
type: 'photo.job.done', type: 'photo.job.done',
title: job.action === 'ai' ? 'Фото обработано нейросетью: {student}' : 'Фото улучшено на сервере: {student}', title,
body: `Группа {group} · запись #${job.entry_id} · результат ждёт применения`, body: `Группа {group} · запись #${job.entry_id} · результат ждёт применения${warn}`,
target: { job_id: job.id, action: job.action, after_path: newPath }, target: {
job_id: job.id,
action: job.action,
after_path: newPath,
model: m.model,
face: m.face,
face_model: m.face_model,
device: m.device,
faces_found: m.faces_found,
elapsed_ms: m.elapsed_ms,
},
}); });
} }
async function softRetry(job, message) { async function softRetry(job, message) {
@@ -253,8 +394,10 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
} }
const photoPath = rows[0].photo_path; const photoPath = rows[0].photo_path;
try { try {
const newPath = job.action === 'ai' ? await runAiEnhance(photoPath) : await enhanceWithSharp(photoPath, job.params); const result = PHOTO_AI_ACTIONS.has(job.action)
await applyResult(job, newPath); ? await enhanceWithAi(photoPath, job)
: { path: await enhanceWithSharp(photoPath, job.params), meta: null };
await applyResult(job, result.path, result.meta);
softTries.delete(job.id); softTries.delete(job.id);
stats.done++; stats.done++;
stats.last_at = new Date().toISOString(); stats.last_at = new Date().toISOString();
@@ -623,4 +766,269 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm
return { start, notify, getStats, getInfo }; return { start, notify, getStats, getInfo };
} }
module.exports = { createEntryAutoChecker, createPhotoEnhanceWorker }; function createLessonReportChecker({ pool, getSetting, logAudit, aiUrl, model, defaultPrompt, bus, onDone }) {
const AI_URL = aiUrl || process.env.AI_URL || 'http://text-corrector:8080';
const MODEL = model || process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf';
const DEFAULT_PROMPT = defaultPrompt || '';
const TIMEOUT_MS = Math.max(5000, parseInt(process.env.LESSON_AI_TIMEOUT_MS || '120000', 10) || 120000);
const MIN_CHARS = 20;
const MAX_INPUT_CHARS = 4000;
const TEXT_MAX = 5000;
const MAX_ATTEMPTS = 3;
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, formatted: 0, unchanged: 0, errors: 0, last_at: null, last_error: null };
const attempts = new Map();
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();
if (bus) bus.publish();
}
if (bus) bus.subscribe(() => notify());
async function isEnabled() {
return String(await getSetting('lesson_ai_enabled', 'true')) !== 'false';
}
async function claimNext() {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(
`SELECT lr.id, lr.text, lr.text_original, lr.lesson_date, lr.lesson_time, lr.author_id,
g.name AS group_name, g.branch_id
FROM lesson_reports lr
JOIN groups g ON g.id = lr.group_id
WHERE lr.ai_status = 'pending'
ORDER BY lr.id ASC LIMIT 1 FOR UPDATE OF lr SKIP LOCKED`
);
if (!rows.length) {
await client.query('COMMIT');
return null;
}
await client.query(`UPDATE lesson_reports 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 resolveActiveProfile() {
try {
const active = await getSetting('ai_active_profile', 'native');
if (active && active !== 'native') {
const raw = await getSetting('ai_profiles', '[]');
const list = JSON.parse(raw || '[]');
if (Array.isArray(list)) {
return list.find(p => p && p.id === active && p.base_url && p.model) || null;
}
}
} catch (e) {}
return null;
}
function normalizeOpenAiBase(base) {
const b = String(base || '').trim().replace(/\/+$/, '');
if (!/^https?:\/\//i.test(b)) return null;
if (!/\/v1$/i.test(b)) return b + '/v1';
return b;
}
async function callModel(userText, systemPrompt) {
const profile = await resolveActiveProfile();
const headers = { 'Content-Type': 'application/json' };
let url;
let model;
let maxTokens;
if (profile) {
const base = normalizeOpenAiBase(profile.base_url);
if (!base) throw new Error('Некорректный base_url профиля ИИ');
url = `${base}/chat/completions`;
model = profile.model;
if (profile.api_key) headers.Authorization = `Bearer ${profile.api_key}`;
maxTokens = parseInt(profile.max_tokens, 10) || Math.min(4096, Math.max(1024, userText.length * 2 + 512));
} else {
url = `${AI_URL.replace(/\/+$/, '')}/v1/chat/completions`;
model = MODEL;
maxTokens = Math.min(1536, Math.max(512, userText.length + 512));
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const res = await fetch(url, {
method: 'POST',
headers,
signal: controller.signal,
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userText },
],
temperature: 0.1,
max_tokens: maxTokens,
}),
});
if (!res.ok) throw new Error(`AI service error: ${res.status}`);
const data = await res.json();
const choice = data.choices && data.choices[0];
return (choice && choice.message && choice.message.content || '').trim();
} finally {
clearTimeout(timer);
}
}
function normalizeOutput(out) {
let s = String(out || '').trim();
if (!s) return '';
s = s.replace(/^```[a-zA-Z]*\s*/, '').replace(/```$/, '').trim();
s = s.replace(/^(?:итоговый|результативный|готовый|итог)\s*текст\s*(?:отчёта|отчета|report)?\s*[:—-]\s*/i, '').trim();
s = s.replace(/^["'«”]+/, '').replace(/["'«»"]+$/, '').trim();
s = s.replace(/\r\n/g, '\n').replace(/\n{3,}/g, '\n\n');
return s;
}
async function finish(row, status, text, error, aiText) {
await pool.query(
`UPDATE lesson_reports SET
text = $1, text_ai = $2, ai_status = $3, ai_error = $4, ai_checked_at = now(), updated_at = now()
WHERE id = $5`,
[text, aiText, status, error || null, row.id]
);
attempts.delete(row.id);
stats.last_at = new Date().toISOString();
stats.last_error = error || null;
if (onDone) await onDone({ row, status, text, aiText, error: error || null });
}
async function processOne(row) {
const original = String(row.text_original || row.text || '').trim();
if (original.length < MIN_CHARS) {
await finish(row, 'skipped', original, null, null);
stats.unchanged++;
return true;
}
const prompt = (await getSetting('lesson_ai_prompt', DEFAULT_PROMPT)) || DEFAULT_PROMPT;
const date = String(row.lesson_date).slice(0, 10);
const time = row.lesson_time ? String(row.lesson_time).slice(0, 5) : '';
const input = original.length > MAX_INPUT_CHARS ? original.slice(0, MAX_INPUT_CHARS) : original;
const context = [
`Группа: ${row.group_name || '—'}`,
`Дата занятия: ${date}`,
time ? `Время занятия: ${time}` : '',
'',
'Текст отчёта:',
input,
].filter(l => l !== '').join('\n');
try {
const out = normalizeOutput(await callModel(context, prompt));
if (!out || out.length > TEXT_MAX) {
const reason = out ? 'Модель вернула слишком длинный текст' : 'Модель вернула пустой ответ';
await finish(row, 'error', original, reason, null);
stats.errors++;
return true;
}
const changed = out !== original;
await finish(row, changed ? 'done' : 'skipped', changed ? out : original, null, changed ? out : null);
stats.checks++;
if (changed) stats.formatted++; else stats.unchanged++;
return true;
} catch (e) {
const message = (e && e.message ? e.message : 'error').slice(0, 500);
const tries = (attempts.get(row.id) || 0) + 1;
if (tries < MAX_ATTEMPTS) {
attempts.set(row.id, tries);
await pool.query(`UPDATE lesson_reports SET ai_status = 'pending', ai_error = $1 WHERE id = $2`, [message, row.id]);
if (logAudit) await logAudit(null, 'lesson_report.ai.retry', { id: row.id, attempt: tries, error: message });
return false;
}
await finish(row, 'error', original, message, null);
stats.errors++;
if (logAudit) await logAudit(null, 'lesson_report.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('Lesson AI claim error:', e.message);
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('Lesson AI process error:', e.message);
} finally {
processing = false;
currentId = null;
}
if (!ok) await sleep(IDLE_MAX_MS);
}
}
function resetStale() {
return pool.query(`UPDATE lesson_reports SET ai_status = 'pending' WHERE ai_status = 'processing'`);
}
function start() {
if (started) return;
started = true;
startedAt = Date.now();
resetStale()
.catch((e) => console.error('Lesson AI reset error:', e.message))
.finally(() => { loop().catch((e) => console.error('Lesson AI loop error:', e.message)); });
}
function stop() {
stopped = true;
notify();
}
function getInfo() {
return {
started_at: startedAt ? new Date(startedAt).toISOString() : null,
uptime_ms: startedAt ? Date.now() - startedAt : 0,
processing,
current_id: currentId,
config: { idle_min_ms: IDLE_MIN_MS, idle_max_ms: IDLE_MAX_MS, timeout_ms: TIMEOUT_MS, model: MODEL, ai_url: AI_URL },
...stats,
};
}
return { start, stop, notify, getInfo };
}
module.exports = { createEntryAutoChecker, createPhotoEnhanceWorker, createLessonReportChecker };