Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19b2365b4c | ||
|
|
76d36e5c35 | ||
|
|
32aabe9a80 | ||
|
|
2363d099d2 | ||
|
|
b931c0a760 | ||
|
|
a84f33307e | ||
|
|
0dc8ffdb87 | ||
|
|
2377707297 | ||
|
|
70b0c7ae9b | ||
|
|
104bdc4f49 | ||
|
|
449b86955e | ||
|
|
55c4b281c3 | ||
|
|
45033bffa8 | ||
|
|
884188e9ec |
@@ -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=замените-на-длинный-секрет
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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-сети).
|
||||||
|
|||||||
@@ -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` не ломают приложение.
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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` | Скриншоты |
|
||||||
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 514 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 514 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 681 KiB |
|
After Width: | Height: | Size: 508 KiB |
|
After Width: | Height: | Size: 947 KiB |
|
After Width: | Height: | Size: 318 KiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 448 KiB |
|
After Width: | Height: | Size: 450 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 592 KiB |
|
After Width: | Height: | Size: 647 KiB |
|
After Width: | Height: | Size: 372 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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="">
|
||||||
|
|||||||
@@ -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> </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> </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> </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> </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> </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> </label>
|
<div><label> </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="Закрыть">×</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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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">
|
||||||
<input class="name-input student-name-input" id="sname-${s.id}" value="${esc(s.name)}">
|
<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)}">
|
||||||
|
</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]');
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> </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>
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>');
|
||||||
sections.push('<section class="section" id="experience">' +
|
if (v.experience.length) {
|
||||||
railHead('briefcase', 'Опыт работы', 'Стажировки, фриланс и командные проекты — ленту можно прокручивать вбок.', 'experienceRail') +
|
sections.push('<section class="section" id="experience">' +
|
||||||
'<div class="rail-wrap"><div class="rail rail--half" id="experienceRail" data-rail="experienceRail" tabindex="0" aria-label="Лента опыта">' +
|
railHead('briefcase', 'Опыт работы', 'Стажировки, фриланс и командные проекты — ленту можно прокручивать вбок.', 'experienceRail') +
|
||||||
experienceHtml(v) + '</div></div></section>');
|
'<div class="rail-wrap"><div class="rail rail--half" id="experienceRail" data-rail="experienceRail" tabindex="0" aria-label="Лента опыта">' +
|
||||||
|
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">' +
|
||||||
|
|||||||
@@ -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 out = Buffer.from(await resp.arrayBuffer());
|
const contentType = resp.headers.get('content-type') || '';
|
||||||
|
if (!contentType.includes('application/json')) {
|
||||||
|
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 };
|
||||||
|
|||||||