Compare commits

Author SHA1 Message Date
dev 3ecf87146a build(docker): перейти на Node 22 (AWS SDK требует node >=22) 2026-09-27 12:05:55 +03:00
dev eea42eb704 style(audit): кнопка закрытия деталей в общем стиле сайта 2026-09-27 12:01:48 +03:00
dev 54cf5bbfa4 feat(audit): показывать изменения текста записи по шагам
При сохранении записи журнала (PUT /api/entries/:id) сравнивается
состояние до и после, и в audit_log пишется не только факт правки,
но и сами изменения: пословный дифф текста, статистика добавленных
и удалённых слов, а также смена ФИО, группы и темы модуля.

- diff.js: пословный LCS-дифф без зависимостей, обрезка больших
  текстов, сборка изменений по полям записи, облегчённый target
  для списка аудита
- source правки: manual / ai / ai_manual / ai_revert; журнал шлёт
  edit_source, сервер доверяет явному значению и определяет источник
  по description_ai как запасной вариант
- те же диффы пишутся для автопроверки ИИ (entry.ai.auto-check)
  и отката к оригиналу (entry.ai.revert)
- GET /api/audit отдаёт список без diff, GET /api/audit/:id — полный
  target, чтобы не грузить килобайты текста на каждую строку
- Аудит: колонка «Кто», сводка в таблице, модалка с подсветкой
  удалённого и добавленного текста, «было/стало» для полей
- auth.login теперь пишет user_id, иначе колонка «Кто» показывала
  «система»
- diff.selftest.js: 16 тестов диффа; README и AGENTS обновлены
2026-09-27 11:29:17 +03:00
dev 19676c9b64 test(auth): зафиксировать контракт авторизации в smoke-тесте
Документация долго описывала X-Admin-Token как способ авторизации, хотя его
нет в коде. Расхождение не ловилось ничем: curl-пример в доках ходил на
GET /api/groups, а он публичный (optionalAuth) и отвечает 200 без токена,
то есть авторизацию не проверял вообще.

Добавлены проверки, которые падают при возврате статического токена:
- защищённый маршрут без токена -> 401;
- мусорный X-Auth-Token -> 401;
- X-Admin-Token (значение ADMIN_PASSWORD) -> 401;
- Authorization: Bearer -> 401;
- ADMIN_PASSWORD как токен -> 401;
- позитивный контроль: валидный токен на admin-маршруте -> 200, иначе
  проверки выше проходили бы из-за сломанного роута;
- /api/groups остаётся публичным -> 200 без токена.

Хелпер api() научен принимать произвольные заголовки — иначе X-Admin-Token
и Bearer не отправить. AGENTS.md дополнен описанием контракта и пунктом в
чеклисте безопасности.
2026-09-26 16:08:32 +03:00
dev 87541a5ce8 docs(auth): исправить устаревшее описание авторизации
Документация утверждала, что доступ админский и задаётся заголовком
X-Admin-Token со значением ADMIN_PASSWORD, и что без ADMIN_PASSWORD сервер
не стартует. Ни то, ни другое не верно:

- X-Admin-Token в server.js отсутствует полностью, авторизация держится на
  сессиях: POST /api/auth/login (bcrypt) выдаёт токен, который клиент шлёт
  в X-Auth-Token. Проверено на живом стенде: X-Auth-Token -> 200,
  X-Admin-Token -> 401 на /api/auth/me и /api/users;
- ADMIN_PASSWORD участвует только в ensureFirstAdmin() — создании первого
  админа в пустой БД. Без него сервер пишет предупреждение и стартует;
- роли и филиалы: requireAuth (любой активный), requireAdmin (role=admin,
  самодостаточный), optionalAuth; не-admin ограничены user_branches через
  branchScope/branchWhere — это в доках не описывалось.

Заодно curl-пример проверки авторизации в AGENTS.md вёл на GET /api/groups,
который публичный (optionalAuth) и отвечает 200 без токена, то есть авторизацию
не проверял. Переведён на /api/auth/me.

Секрет Gitea убран из URL remote в ~/.git-credentials (600) — deploy.sh
работает без промпта.
2026-09-26 16:05:47 +03:00
dev 4b620d3e59 chore(repo): перестать отслеживать генерируемый public/version.json
Файл генерируется дважды и всегда расходится с HEAD:
- .git/hooks/post-commit вызывает scripts/gen-version.js и переписывает файл
  новым хешем после каждого коммита, поэтому он немедленно снова становится
  грязным и коммитить его бесполезно;
- Dockerfile пишет свою копию внутрь образа из аргументов GIT_COMMIT, и её
  приложение и отдаёт: public/ не смонтирован в контейнер, а public/admin.js
  читает /version.json по HTTP.

Отслеживаемый файл в рабочей копии рантайму не нужен. scripts/deploy.sh уже
исключает его из проверки чистоты, теперь это перестаёт быть обходным путём.
2026-09-26 15:36:32 +03:00
dev cb0e68d04a chore(deploy): обновить version.json на 0e38a28 2026-09-26 15:27:47 +03:00
dev 0e38a280d7 feat(redis): кэш, rate limit, баны IP и pub/sub через Redis
Добавлен сервис redis:7-alpine (AOF, requirepass, maxmemory + allkeys-lru,
healthcheck, том redis-data, порт только на 127.0.0.1) и абстракция redis.js
по образцу storage.js.

Переведено на Redis:
- кэш ответов API и настроек (было Map в памяти), инвалидация по префиксу
  через SCAN + DEL;
- rate limit для api/entry/file — общие счётчики вместо MemoryStore;
- баны IP и счётчики неудачных входа — с TTL, вместо опроса БД каждую минуту;
- кэш сессий (30 с) с invalidateSessions() на каждой мутации users/sessions/
  user_branches, иначе деактивированный пользователь сохранил бы доступ;
- pub/sub для SSE-событий и мгновенного пробуждения фоновых воркеров вместо
  ожидания цикла опроса БД.

Отказоустойчивость: при недоступном Redis все операции уходят в in-memory
backend с той же семантикой, приложение стартует и работает без Redis и
возвращается в Redis автоматически. Первое подключение ограничено по времени
(REDIS_CONNECT_TIMEOUT_MS, 5 с) — node-redis не отклоняет connect() при
недоступном сервере, а повторяет попытки бесконечно.

Добавлены тесты: redis.selftest.js (в т.ч. поведение при недоступном
сервере) и api.smoketest.js (сквозная проверка API, включая инвалидацию
кэша и мгновенную смерть сессии после logout).
2026-09-26 15:26:00 +03:00
dev e4d58d6525 chore(storage): убрать неиспользуемый код и стабилизировать деплой
- storage.js: из экспорта убраны неиспользуемые publicPath, localExists и dir
- server.js: убран неиспользуемый импорт mimeFor и параметр originalsDir в воркере
- worker.js: убран неиспользуемый параметр originalsDir
- scripts/deploy.sh: up -d --force-recreate app, чтобы пересобранный образ
  гарантированно применялся (compose не всегда пересоздаёт контейнер при
  неизменном конфиге сервиса)
2026-09-26 11:16:36 +03:00
dev ddd49707ae feat(storage): S3-совместимое хранилище файлов (SeaweedFS/MinIO) и миграция uploads
- storage.js: абстракция хранилища с драйверами local и s3 (AWS SDK v3),
  ключи объектов совпадают с текущими путями /uploads/<файл>, поэтому схема БД
  и URL не меняются
- docker-compose.yml: сервис s3 (SeaweedFS, том s3-data, API только на loopback),
  переменные S3_*/STORAGE_*, restart unless-stopped для app и db
- docker-compose.minio.yml: оверрайд S3-сервиса на MinIO (образ из своего зеркала)
- server.js/worker.js: чтение и запись файлов только через storage (отдача
  /uploads/*, миниатюры, share-файлы, zip-отчёты, enhance/apply/rollback,
  photo-worker), автосоздание бакета, глобальная персистенция загрузок multer
- бэкап/восстановление и scripts/backup.sh, restore.sh — через scripts/storage-sync.js
- scripts/migrate-to-s3.js: идемпотентная миграция uploads/ в бакет
  (--dry-run, --verify-only, --delete-local)
- админка: блок «Хранилище» в системной информации
- .env.example, README.md, AGENTS.md: описание драйверов, переменных и перехода на S3
2026-09-26 11:08:53 +03:00
dev 8bf54fb95d feat(trash): deferred purge after configurable days, groups in trash 2026-09-24 13:11:31 +03:00
dev e6c69c8a78 feat(settings): option to show/hide camera button, preview click opens camera 2026-09-24 12:00:20 +03:00
dev e4704c2dc6 fix(index): paste fills only project files, photo stays webcam-only
- pasted images/files always go to "Файлы проекта", never to the entry photo
- drop the photo paste hint and the capturedName plumbing: the entry photo is
  captured from the camera only (submit still requires it)
- keep paste naming ("Вставка <date>.<ext>") and server-mirrored limits
2026-09-24 11:21:58 +03:00
dev a72600af13 feat(index): paste photos and project files from clipboard with Ctrl+V
- handle document paste: images become the entry photo when none is set,
  otherwise they land in "Файлы проекта" along with any other pasted files
- take file names from the clipboard, replace generic ones (image.png, blob)
  with "Вставка <date>.<ext>"; derive extension from MIME when missing
- validate client-side like the server: 10 files, 10 MB per file, 30 MB total,
  reject blocked extensions (*.html, *.js, *.svg, ...)
- share the limit logic between the picker and paste, send the photo under its
  real name so pasted PNG/JPEG keep their extension
- add Ctrl+V hints to the photo and files cards (hidden on touch devices)
2026-09-24 11:18:08 +03:00
dev 2e1d36bc4d chore(deploy): exclude generated version.json from deploy dirty check 2026-09-23 23:02:37 +03:00
dev d943b77f58 chore(deploy): bake commit version into image and add scripts/deploy.sh 2026-09-23 23:01:36 +03:00
dev a95af7daa7 fix(backup): accept /uploads/.originals paths in photo_jobs on restore
photo_jobs.before_path хранит путь к оригиналу фото (/uploads/.originals/<файл>), но normalizeRestoreData проверял это поле через optUploadPath/isSafeUploadPath, который запрещает "/" — при наличии завершённых улучшений фото весь импорт падал с 400 «Неверный формат бэкапа: Invalid upload path».

- добавлены reqPhotoRefPath/optPhotoRefPath: допустимы /uploads/<файл> и /uploads/.originals/<файл> (та же ORIGINALS_PATH_RE, что и для entries.photo_original_path); применяются к photo_jobs.before_path/after_path
- POST /api/restore: понятная ошибка, если загружен архив скрипта scripts/backup.sh (db.sql.gz + _uploads) вместо веб-архива
- README: форматы скриптового и веб-архива не взаимозаменяемы
2026-09-23 22:23:38 +03:00
dev 69d46a0e5f fix(backup): stream backup download via resumable token link instead of buffering
Формирование и скачивание бэкапа разделены: POST /api/backup собирает архив
на диске и возвращает временную ссылку, GET /api/backup/:token отдаёт его
через res.download (Content-Length, Accept-Ranges, 206 при докачке).

- больше нет fs.readFileSync всего архива и res.send буфера (~550 МБ RAM -> ~60 МБ)
- GET /api/backup сохранён для совместимости, тоже потоковый
- gzip level 1 (архив из JPEG почти не сжимается), чистка /tmp/wido-backups по TTL 30 мин
- settings.html/js: нативное скачивание браузером с прогрессом и докачкой,
  понятные ошибки вместо «Ошибка сети при формировании бэкапа»
2026-09-23 19:03:19 +03:00
dev 16aba3efb0 fix(settings): constrain logo preview size so it does not overflow the card 2026-09-23 16:56:31 +03:00
dev cb3f010cd9 fix(photo): preserve photo_jobs history in backup/restore; paginate photo history on worker page 2026-09-23 16:37:27 +03:00
dev 30cf04b0cf feat(branding): logo and system name on share and report pages, drop logo border-radius 2026-09-23 16:25:13 +03:00
dev f2d465e0c4 feat(branding): system name/logo, horizontal logo on all pages 2026-09-23 15:19:23 +03:00
dev e435b4ad43 feat(ui): show current commit version and date in admin sidebar 2026-09-23 14:14:29 +03:00
dev 218c3f825d fix(backup): restore new fields, share_links and photo originals
- export/restore share_links (was silently dropped, FK blocked restore)
- keep groups.tutor_id and groups.cover_path, entries.photo_original_path,
  project_files.detached_at on restore
- include uploads/.originals files in backup archive
- insert users before groups to satisfy tutor_id FK
- return 500 JSON instead of hanging when restore fails
2026-09-23 13:49:25 +03:00
dev 4623358f21 feat(students): multi-photo gallery, group photos in export, KIBERone rebrand 2026-09-23 13:32:09 +03:00
dev 5e2533b876 feat(groups): assign tutor to group 2026-09-19 13:17:40 +03:00
dev 1ef81f9d1c wip: student profile/report 2026-09-19 13:06:21 +03:00
dev 72eeb5cf9b fix(entries): keep AI description and status when editing entry 2026-09-18 23:32:55 +03:00
dev d7d4cc1133 feat(modules): soft delete with restore and active filter 2026-09-18 20:04:02 +03:00
dev d3dd922e32 feat(modules): batch module import in admin, module usage in journal 2026-09-18 19:36:32 +03:00
dev 12a527b3ee chore(report): add design reference and screenshots 2026-09-18 19:26:34 +03:00
dev e6291a0235 feat(modules): module topics for student form, admin CRUD with pagination
- db: modules table (name, lessons_count) + entries.module_id (ON DELETE SET NULL), migration + idempotent startup ensure
- api: GET /api/modules (public, search + limit/offset, entries_count), POST/PUT/DELETE (admin, audit-logged)
- entries: accept/validate module_id on create/update, return module_name, module_id filter
- backup/restore: include modules and entries.module_id
- student form: required module select, hidden while no modules exist
- admin: modules.html + js/modules.js list with pagination, search, create/edit/delete modal
- journal: module filter, module select in edit modal, module badge, CSV column
2026-09-18 18:56:42 +03:00
dev d434732f41 feat(report): student report page with works/gallery/files sections 2026-09-18 17:50:50 +03:00
dev 12c612eccd feat(audit): translate photo job audit actions to Russian 2026-09-17 22:25:17 +03:00
dev 5874e2b619 feat: photo jobs apply/reject flow with applied flag in history
- DB: add photo_jobs.applied column (init + migration + ensure)
- Worker: generate preview only (after_path), no longer mutates entry
- New POST /api/entries/:id/photo/jobs/:jobId/apply — apply done job
  result to entry (backs up current photo, marks applied)
- New POST /api/entries/:id/photo/jobs/:jobId/reject — discard result,
  delete temp file, mark rejected
- saveEnhance: apply AI result directly when sliders are at defaults
- Photo history: '✓ Применить' action for unapplied done AI jobs;
  'rejected' status label
- sweepOrphanedUploads keeps done-not-applied preview files
2026-09-17 18:51:26 +03:00
dev 881b5b4c67 fix: repair photo worker broken code; add pending queue view
- Fix runAiEnhance missing return/closing brace from previous commit
- Remove duplicate code fragments in worker.js
- Add renderPhotoPending() to show pending/processing jobs in worker dashboard
- New pending queue table in worker.html photo section
2026-09-17 17:52:51 +03:00
dev 1145be02f3 feat: persistent photo job queue, worker, history with rollback
- Replace in-memory photoAiJobs Map with DB-backed photo_jobs table
- Background photo worker (worker.js) with retry, backoff, stale reset
  - Handles both AI (Real-ESRGAN) and server-side (sharp) enhancement
  - Controlled via photo_worker_enabled setting
- Photo job history in enhance modal with before/after thumbnails + rollback
- Worker dashboard: photo jobs section with status counts, recent table,
  compare slider for before/after, rollback from worker UI
- New endpoints: /api/photo-jobs/status|wake|enabled|requeue-failed,
  /api/entries/:id/photo/jobs (history), .../rollback
- swapEntryPhotoFiles logs every mutation to photo_jobs table
2026-09-17 17:36:56 +03:00
dev 27a2238082 feat: compare slider UI for photo enhance modal
- Side-by-side before/after comparison with draggable divider
- Zoom (scroll wheel) and pan when zoomed in, double-click to reset
- Sliders moved to sidebar panel, responsive layout
- Original/Result badges on the comparison view
- Helper functions: setEnhanceClip, showEnhanceResult, applyEnhanceTransform
2026-09-17 17:02:35 +03:00
dev dec8790a3a fix: AI photo enhance modal stays open with result preview; fix basicsr build
- Modal no longer auto-closes after Real-ESRGAN completes; result loads into
  canvas so user can review, adjust sliders, and choose to apply or discard
- Backend skips swapEntryPhotoFiles until user confirms via Применить
- New DELETE /api/entries/:id/photo/enhance-ai/preview for temp file cleanup
- photo-ai Dockerfile: patch basicsr via find+sed instead of import (avoids
  torchvision.functional_tensor import crash)
2026-09-17 16:44:44 +03:00
dev 3a345cbefd feat: AI photo enhancement (Real-ESRGAN container) + restore original
- new photo-ai service: FastAPI + Real-ESRGAN x2plus on CPU, internal only
- async job queue in server (POST enhance-ai / GET status), 5min timeout, sequential processing
- keep original photo backup (entries.photo_original_path, uploads/.originals), restore-original endpoint
- UI: AI button and restore-original button in enhance modal
- db: photo_original_path column (init.sql, migration.sql, runtime ensure)
2026-09-17 15:37:49 +03:00
dev c4fd31cd53 feat: hybrid photo enhance engine (server sharp + client canvas), denoise
- enhance endpoint accepts JSON params, processes original on disk via sharp pipeline (median, modulate, linear, sharpen, mozjpeg)
- client fallback renders full-size canvas, keeps multipart path
- engine selection setting: auto/server/client, auto falls back to client on error/timeout
- denoise slider (median filter), noise-aware auto mode, preview capped at 1600px
2026-09-17 15:16:42 +03:00
dev 049df61e55 feat: photo quality improvements
- webcam capture resolution/quality configurable in admin settings (defaults 640x480 / 0.92)
- enhance photo modal in journal: original vs preview with sliders (brightness, contrast, saturation, sharpen) and auto-levels button
- new endpoint PUT /api/entries/:id/photo/enhance replaces photo, cleans old file and thumb
- sharper HEIC conversion (0.92) and webp thumbnails (85)
- worker: fail explicitly on empty AI response
2026-09-17 15:07:43 +03:00
dev 57e48a9d4f fix(ai): adaptive max_tokens per backend (reasoning profiles get 1024+, native llama.cpp stays compact; per-profile max_tokens override); include entry texts in ai_status SSE notify so journal updates live without reload; audit log action filter; links pagination; settings validation & UI improvements 2026-09-17 14:12:36 +03:00
dev a93e37a6b1 fix(settings): prevent aiActiveProfile select from overflowing card-foot (shrinkable label+select, min-width:0 on grid cards) 2026-09-17 12:11:18 +03:00
dev 0b4243fe91 feat(docker): add host.docker.internal:host-gateway to app so it can reach host Ollama (AI profiles) 2026-09-17 12:06:11 +03:00
dev 66f7fdc1a8 refactor(settings): AI profile add/edit moved into a modal dialog (overlay click + Esc to close) 2026-09-17 12:00:19 +03:00
dev d7c793c67a fix(settings): constrain aiActiveProfile select width so it stays inside card-foot 2026-09-17 11:58:34 +03:00
dev efe2a42d1d feat(settings): AI model profiles management — native llama.cpp + custom OpenAI-compatible providers, default model selection, connection test 2026-09-17 11:54:08 +03:00
dev bbfde902a5 feat(journal): live AI status updates in entry cards via Postgres NOTIFY trigger + SSE ai_status events 2026-09-17 11:14:27 +03:00
dev cc9a9e6c2d feat(journal): beautiful entrance animation for entries — cascade fade-up on load, yellow glow pop for newly submitted entries 2026-09-16 20:14:09 +03:00
dev 2784084c68 feat(journal): live updates on new entry via SSE (/api/events) — admin journal auto-refreshes when a student submits 2026-09-16 20:09:06 +03:00
dev e8337c2845 feat(share): group entries by date on public /s/<token> page 2026-09-16 15:06:30 +03:00
dev 6cfb13310d chore: checkpoint before date grouping on share page 2026-09-16 15:05:56 +03:00
dev 2c8beedc4b feat: анти-спам блокировка повторной отправки на главной (cooldown по spam_interval_min)
- index.html/index.js: экран подтверждения отправки с обратным отсчётом
- локальный запрет повторной отправки через localStorage (по настройке spam_interval_min)
- server.js: отдаём spam_interval_min в public-settings (default 30)
- Dockerfile: timeout для apk add, чтобы сборка не зависала на недоступном зеркале
- docker-compose.yml: отключаем cloudflared сервис (закомментирован)
2026-09-13 12:27:35 +03:00
69 changed files with 14935 additions and 1001 deletions
+38
View File
@@ -4,6 +4,14 @@ DB_PASSWORD=случайная-длинная-строка
# Лимит размера загружаемого на восстановление бэкапа, МБ (по умолчанию 500)
BACKUP_UPLOAD_LIMIT_MB=500
# === Redis (кэш, rate limit, баны IP, pub/sub) ===
# Пароль Redis. Обязателен, если Redis включён в docker-compose.
REDIS_PASSWORD=замените-на-длинный-секрет
# Префикс ключей — позволяет держать несколько инстансов в одном Redis.
REDIS_PREFIX=whatido
# Лимит памяти Redis. При превышении вытесняются ключи с наименьшим TTL (allkeys-lru).
REDIS_MAXMEMORY=256mb
# Hugging Face token (нужен для закрытых/приватных репозиториев моделей)
HUGGINGFACE_TOKEN=
# Имя GGUF-файла модели для text-corrector (скачивается с Hugging Face, если отсутствует)
@@ -27,3 +35,33 @@ CLOUDFLARE_TUNNEL_URL=http://app:3003
# Максимальное ожидание WireGuard handshake перед fallback на прямой запуск
# туннеля без VPN (сек). Если VPN-провайдер не отвечает — сайт всё равно поднимется.
WG_HANDSHAKE_TIMEOUT=60
# ИИ-улучшение фото (Real-ESRGAN), пусто = контейнер photo-ai
PHOTO_AI_URL=
PHOTO_AI_MAX_PIXELS=4000000
# === Хранилище файлов (S3: SeaweedFS по умолчанию / MinIO) ===
# local — файлы в ./uploads (по умолчанию), s3 — объекты в бакете S3/MinIO.
STORAGE_DRIVER=local
# Читать локальную копию, если объект ещё не перенесён в S3 (1 — включено).
STORAGE_LOCAL_FALLBACK=1
# Оставлять локальную копию после выгрузки в S3 (1 — оставлять, 0 — удалять).
STORAGE_KEEP_LOCAL=0
# Сколько часов хранить локальный кэш оригиналов (для sharp/миниатюр), 0 — не чистить.
STORAGE_CACHE_MAX_AGE_HOURS=168
# Образ S3-сервиса. По умолчанию SeaweedFS (свободный S3-сервер).
# Для MinIO: S3_IMAGE=minio/minio:<тег> и запуск через docker-compose.minio.yml.
S3_IMAGE=chrislusf/seaweedfs:latest
S3_ENDPOINT=http://s3:9000
S3_REGION=us-east-1
S3_BUCKET=whatido
# Логин/пароль S3 (для SeaweedFS — AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY,
# для MinIO — MINIO_ROOT_USER/MINIO_ROOT_PASSWORD).
S3_ACCESS_KEY=whatido
S3_SECRET_KEY=замените-на-длинный-секрет
# Path-style адресация (1 — включено, нужно для MinIO и SeaweedFS).
S3_FORCE_PATH_STYLE=1
# Необязательный префикс ключей внутри бакета (например, prod).
S3_PREFIX=
# Показывать веб-консоль MinIO (on/off), только для docker-compose.minio.yml
MINIO_BROWSER=off
+3
View File
@@ -14,6 +14,9 @@ backups/
*.sql.gz
*.tar.gz
# --- Generated build artifacts ---
public/version.json
# --- Node ---
node_modules/
+121 -18
View File
@@ -8,10 +8,10 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
**WhatIDo** — Accounting system for an educational center: attendance journal, student project works, group gallery, detached files, and public showcase pages (share links).
- **Stack**: Node.js 20 + Express, PostgreSQL 16, Docker Compose, Tailscale (Serve/Funnel)
- **Architecture**: Single Express server (`server.js`) + static frontend in `public/`
- **Deployment**: Docker Compose (app + db + tailscale), bind-mounted uploads, named volume for Postgres data
- **Auth**: Admin-only via `X-Admin-Token` header (value = `ADMIN_PASSWORD` env var). No user sessions.
- **Stack**: Node.js 20 + Express, PostgreSQL 16, Redis 7, Docker Compose, S3-совместимое хранилище файлов, Tailscale (Serve/Funnel)
- **Architecture**: Single Express server (`server.js`) + storage abstraction (`storage.js`) + cache/pub-sub abstraction (`redis.js`) + static frontend in `public/`
- **Deployment**: Docker Compose (app + db + s3 + tailscale), bind-mounted uploads, named volumes for Postgres and S3 data
- **Auth**: сессии в БД. `POST /api/auth/login` (bcrypt) → токен в заголовке `X-Auth-Token`. Роли: `admin` и не-admin, ограниченные филиалами (`user_branches`). `ADMIN_PASSWORD` используется **только** для автосоздания первого админа в пустой БД — это не механизм авторизации API
---
@@ -34,13 +34,38 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
### 3. File Uploads
- **Multer configs**: `upload` (images only), `adminUpload` (wider allowed ext), `uploadBackup` (restore)
- **Limits**: 10 MB/file, 30 MB total per entry
- **Storage**: `uploads/` bind-mounted to host, filenames = `timestamp-random.ext`
- **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`
- **Cleanup**: `safeUnlink` / `sweepOrphanedUploads` — never delete outside `uploads/`
- **Cleanup**: `safeUnlink` / `sweepOrphanedUploads` — never delete outside `uploads/` or the configured bucket
### 3a. Storage (`storage.js`)
- **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
- **API**: `put`, `putFile`, `head`, `exists`, `sizeOf`, `getStream`, `getBuffer`, `del`, `copyObject`, `listAll`, `localize`, `persist`, `streamTo`, `downloadAll`, `uploadTree`, `ensureBucket`, `usage`, `pruneCache`
- **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
- **Cache**: `.thumbs` (WebP miniatures) and `.cache` (originals localized for sharp/zip) live inside `uploads/` and are pruned hourly (`STORAGE_CACHE_MAX_AGE_HOURS`)
- **Never publish the S3 API port**: only `127.0.0.1` on the host, file access stays behind app auth/rate limits
### 3b. Redis (`redis.js`)
- **Единственная точка доступа**: `createRedis({ url, prefix })` — все операции кэша/счётчиков/pub-sub идут через неё
- **API**: `get`, `set`, `del`, `dropPrefix`, `dropMatch`, `clear`, `wrap`, `incr`, `publish`, `on`, `rateLimitStore`, `info`, `connect`, `close`
- **Graceful fallback — обязательное требование**: при недоступном Redis все операции уходят в in-memory backend с той же семантикой. Приложение обязано стартовать и работать без Redis
- **Первое подключение ограничено по времени** (`REDIS_CONNECT_TIMEOUT_MS`, 5 с): node-redis не отклоняет `connect()` при недоступном сервере, а повторяет попытки бесконечно — без таймаута старт приложения зависнет навсегда
- **Переподключение**: node-redis переподключается сам; по событию `ready` подписки и subscriber-клиент восстанавливаются (`ensureSubscriber`). Не пересоздавать subscriber через `destroy()` — это гонка с внутренним teardown node-redis
- **Ключи**: `get`/`set` сами добавляют namespace (`REDIS_PREFIX`, по умолчанию `whatido`), `dropPrefix`/`dropMatch` тоже. В `rateLimitStore` префикс добавляется один раз в `base` — не применяйте `fullKey` повторно
- **`scanDelete`**: курсор `SCAN` в node-redis v5 обязан быть строкой, числовой `0` вызовет `TypeError`. Возвращаемое значение курсора — тоже строка, сравнивайте с `'0'`
- **`resetTime` в `rateLimitStore.increment` обязан быть `Date`** — express-rate-limit v8 вызывает `resetTime.getTime()`
- **Пабликация всегда отдаёт подписчикам строку** (JSON), независимо от бэкенда — иначе fallback и Redis расходятся по формату
- **Инвалидация — по префиксу** (`SCAN` + `DEL`), точечного удаления по ключу избегайте
- **Ключевые пространства**: `setting:`, `groups:`, `students:`, `entries:`, `stats:`, `dashboard:`, `share:payload:`, `public-settings`, `system-info`, `session:`, `ban:`, `fail:`, `rl:`
- **Сессии**: `loadUserByToken` кэширует пользователя на 30 с. Любая мутация `users` / `sessions` / `user_branches` обязана вызывать `invalidateSessions()` или удалять `session:<token>`, иначе деактивированный пользователь сохранит доступ
- **Секреты**: пароль только в `REDIS_URL` / `REDIS_PASSWORD`, порт 6379 публикуется лишь на `127.0.0.1`
### 4. API Patterns
- **Admin routes**: `requireAdmin` middleware (checks `X-Admin-Token`)
- **Public routes**: `apiLimiter` (300/15min), `entryLimiter` (10/15min), `fileLimiter` (300/15min)
- **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` и фильтр не добавляется
- **Public routes**: `apiLimiter` (300/15min), `entryLimiter` (10/15min), `fileLimiter` (300/15min) — все на `cache.rateLimitStore(...)`, не на `MemoryStore`
- **Responses**: JSON, `{ error: 'message' }` on failure, data directly on success
- **Pagination**: `limit` / `offset` query params, return `{ items, total }` or `{ entries, total }`
- **Filters**: `group_id`, `date_from`, `date_to`, `student_name`, `search`, `deleted`
@@ -48,16 +73,18 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
### 5. Frontend (public/)
- Vanilla HTML/CSS/JS, no build step
- Each page = single HTML file + shared `admin.js` / `admin.css`
- API calls via `fetch` with `X-Admin-Token` from `localStorage`
- API calls via `fetch` with `X-Auth-Token` (токен из `localStorage`); `X-Admin-Token` больше не используется и не работает
- Share pages (`share.html`, `links.html`) work without auth
### 6. Docker / Compose
- **Dockerfile**: Node 20 Alpine, installs deps, generates self-signed TLS cert
- **docker-compose.yml**: 3 services (db, app, tailscale)
- **Dockerfile**: Node 22 Alpine, installs deps, generates self-signed TLS cert
- **docker-compose.yml**: сервисы `db`, `app`, `redis`, `s3` (+ опционально `tailscale`, `cloudflared`, `text-corrector`, `photo-ai`)
- `db`: postgres:16-alpine, healthcheck, init.sql mounted
- `redis`: redis:7-alpine, `--requirepass`, AOF, `maxmemory` + `allkeys-lru`, healthcheck, том `redis-data`, порт только на `127.0.0.1`
- `app`: builds from Dockerfile, exposes 3003/3443, mounts uploads
- `tailscale`: host network, NET_ADMIN, runs `start-tailscale.sh` (funnel to 127.0.0.1:3443)
- **Env vars** (required): `ADMIN_PASSWORD`, `DB_PASSWORD`
- **Env vars** (required): `ADMIN_PASSWORD`, `DB_PASSWORD`, `REDIS_PASSWORD`
- **Env vars** (optional): `REDIS_PREFIX` (default `whatido`), `REDIS_MAXMEMORY` (default `256mb`), `REDIS_CONNECT_TIMEOUT_MS` (default `5000`)
- **Port 443 on host** must be free (tailscale listens directly)
### 7. Tailscale Publication
@@ -102,6 +129,15 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
- Update Multer `fileFilter` functions
- Keep `MAX_TOTAL_UPLOAD_BYTES` and per-file limit in sync
### Migrate files to S3 / switch storage driver
1. `docker compose up -d s3`
2. `docker compose exec -T app node scripts/migrate-to-s3.js --dry-run` then without the flag (idempotent, size-checked, keeps local files)
3. `docker compose exec -T app node scripts/migrate-to-s3.js --verify-only`
4. Set `STORAGE_DRIVER=s3` in `.env`, `docker compose up -d app`
5. After verification: `docker compose exec -T app node scripts/migrate-to-s3.js --delete-local`
- Rollback: `STORAGE_DRIVER=local` + `docker compose up -d app`
- Do not run `--delete-local` before the app serves reads from S3 and the verification passes
---
## Testing & Verification
@@ -115,21 +151,59 @@ docker compose up -d --build
# Check logs
docker compose logs -f app
# Test API (replace TOKEN)
curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
# Test API (replace LOGIN/PASS; X-Admin-Token больше не работает)
TOKEN=$(curl -s -X POST http://localhost:3003/api/auth/login \
-H 'Content-Type: application/json' \
-d "{\"username\":\"$LOGIN\",\"password\":\"$PASS\"}" | sed -E 's/.*"token":"([a-f0-9]+)".*/\1/')
curl -H "X-Auth-Token: $TOKEN" http://localhost:3003/api/auth/me
# /api/groups — публичный (optionalAuth), 200 даже без токена:
# для проверки авторизации берите /api/auth/me или /api/users
# Run backup/restore scripts
./scripts/backup.sh
./scripts/restore.sh backups/whatido-backup-<date>.tar.gz
# Storage checks
docker compose up -d s3
docker compose exec -T app node scripts/migrate-to-s3.js --dry-run
docker compose exec -T app node scripts/migrate-to-s3.js --verify-only
# Redis checks
node redis.selftest.js # unit + degradation, needs redis on 127.0.0.1:6379
node api.smoketest.js # e2e, needs running stack
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 start redis # app reconnects on its own
# Audit diff checks
node diff.selftest.js # unit, no stack needed
```
`diff.js` builds the audit payload for text changes: word-level segments
(`eq`/`del`/`add`), per-step stats (`added_words`, `removed_words`, `chars_before/after`) and a
light `summarizeChanges`/`stripDiffs` pair for the audit list. Two rules to keep:
the diff payload stored in `audit_log.target` must stay capped (it is rendered raw in the audit
UI), and `GET /api/audit` must keep stripping `diff` while `GET /api/audit/:id` returns it —
otherwise the list endpoint ships kilobytes of text per row.
Verify Redis state through `GET /api/system-info` → `cache` (`driver`, `ready`, `hits`, `misses`,
`fallbackOps`, `used_memory_human`, `keys`).
`api.smoketest.js` also locks the auth contract: only `X-Auth-Token` with a session token
authenticates, while `X-Admin-Token`, `Authorization: Bearer` and `ADMIN_PASSWORD` used as a
token must all be rejected with 401. If you change the auth scheme, update this test and the
Auth notes in this file together — a doc that drifts from the code is the failure mode this
guards against.
---
## Security Checklist (before any change)
- [ ] No SQL interpolation — only `$1`, `$2`...
- [ ] Upload path validation via `isSafeUploadPath` / `safeUnlink`
- [ ] File I/O через `storage.*`, ключи объектов не выходят за пределы бакета/`uploads/`
- [ ] Rate limiter on new public routes
- [ ] Admin routes behind `requireAdmin`
- [ ] New auth paths checked against the contract in `api.smoketest.js`, docs updated in the same change
- [ ] No secrets in code — only via env vars
- [ ] Helmet headers present (already global)
- [ ] CORS disabled (no `cors` middleware)
@@ -141,15 +215,24 @@ curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
| File | Purpose |
|------|---------|
| `server.js` | Entire backend (Express, routes, DB, uploads, backup) |
| `worker.js` | Background AI auto-check worker for entry messages |
| `storage.js` | Storage abstraction: `local` and `s3` drivers, key normalization, cache/thumb helpers |
| `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 |
| `diff.js` / `diff.selftest.js` | Word-level text diff and audit change payload; self-tests |
| `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 |
| `db/init.sql` | Initial schema (runs on fresh DB) |
| `db/migration.sql` | Idempotent migrations for existing DBs |
| `docker-compose.yml` | Service definitions (app, db, tailscale) |
| `docker-compose.yml` | Service definitions (app, db, s3, tailscale) |
| `docker-compose.minio.yml` | Override: S3 service backed by MinIO instead of SeaweedFS |
| `Dockerfile` | App image build |
| `public/*.html` | Frontend pages |
| `public/admin.js` | Shared frontend logic |
| `scripts/backup.sh` | Host-level backup script |
| `scripts/restore.sh` | Host-level restore script |
| `scripts/backup.sh` | Host-level backup script (DB dump + storage export) |
| `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/migrate-to-s3.js` | One-off/idempotent migration `uploads/` -> S3 bucket |
| `scripts/deploy.sh` | Deploy script (pull master, build image with commit version, restart app) |
| `start-tailscale.sh` | Tailscale container entrypoint |
| `.env.example` | Env var template |
@@ -159,6 +242,14 @@ curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
- ❌ Add dependencies without updating `package.json` and rebuilding
- ❌ Write files outside `uploads/` or `certs/`
- ❌ Touch `uploads/` with `fs.*` in request/worker code — use `storage.*` (files may live only in S3)
- ❌ Run `migrate-to-s3.js --delete-local` before verification and cutover
- ❌ Expose the S3 API port publicly (only `127.0.0.1` in compose)
- ❌ Expose the Redis port publicly (only `127.0.0.1` in compose)
- ❌ Call `fs.*`/`pg` directly for cache, counters or pub/sub — use `redis.js`
- ❌ Make Redis a hard dependency: any new Redis-backed path must keep the in-memory fallback
- ❌ `await client.connect()` without a timeout — it never rejects while Redis is unreachable
- ❌ Cache authorization-relevant data without an invalidation path on the mutation
- ❌ Commit `.env`, `certs/`, `uploads/`, `backups/`, `node_modules/`
- ❌ Expose DB port (5432) outside docker network
- ❌ Use `eval`, `Function` constructor, or dynamic code execution
@@ -172,12 +263,24 @@ curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
# Full rebuild
docker compose down && docker compose up -d --build
# Обновление на сервере (pull master + сборка образа с версией коммита + перезапуск app)
./scripts/deploy.sh
# App logs
docker compose logs -f app
# DB shell
docker compose exec db psql -U app -d whereldo
# Redis status and cache keys
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning DBSIZE
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning KEYS 'whatido:*'
# S3 storage status and migration verification
docker compose up -d s3
docker compose exec -T app node scripts/migrate-to-s3.js --verify-only
docker compose exec -T app node scripts/migrate-to-s3.js --delete-local
# Tailscale status
docker exec -it whatido-tailscale-1 tailscale status
+5 -2
View File
@@ -1,16 +1,19 @@
FROM node:20-alpine
FROM node:22-alpine
ENV TZ=Europe/Moscow
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install --omit=dev
COPY . .
RUN mkdir -p uploads certs
ARG GIT_COMMIT=""
ARG GIT_COMMIT_DATE=""
RUN node -e "require('fs').writeFileSync('public/version.json', JSON.stringify({ full: process.env.GIT_COMMIT || '', short: (process.env.GIT_COMMIT || '').slice(0, 7), date: process.env.GIT_COMMIT_DATE || '' }, null, 2))"
RUN set -e; \
V="v$(cut -d. -f1-2 /etc/alpine-release)"; \
try_repo() { \
printf "%s/%s/main\n%s/%s/community\n" "$1" "$V" "$1" "$V" > /etc/apk/repositories; \
echo "Trying apk mirror: $1"; \
apk add --no-cache openssl; \
timeout 45 apk add --no-cache --timeout 20 openssl; \
}; \
if ! try_repo "https://dl-cdn.alpinelinux.org/alpine" && \
! try_repo "https://mirrors.edge.kernel.org/alpine" && \
+3 -1
View File
@@ -182,7 +182,9 @@ branches
| GET | `/api/share/:token` | Public | Share page data |
| GET | `/api/links` | Admin | List share links |
| POST/PUT/DELETE | `/api/links` | Admin | CRUD share links |
| GET | `/api/backup` | Admin | Download backup |
| GET | `/api/backup` | Admin | Build & download backup (compat) |
| POST | `/api/backup` | Admin | Build backup, returns download URL |
| GET | `/api/backup/:token` | Token | Download built backup (resumable) |
| POST | `/api/restore` | Admin | Upload & restore backup |
| GET | `/api/dashboard` | Admin | Dashboard data |
| GET | `/api/stats` | Admin | Stats cards |
+222 -9
View File
@@ -12,6 +12,7 @@
- **Share-ссылки** — публичные страницы-витрины с выбором группы / воспитанника / диапазона дат
- **Дашборд** — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников
- **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz`
- **Хранилище файлов** — локальный каталог `uploads/` или S3-совместимый сервис (`s3`: SeaweedFS, либо MinIO через оверрайд), перенос файлов скриптом миграции
- **Настройки** — тексты футера, анти-спам интервал
- **Публикация через Tailscale** — приложение открывается по постоянному адресу `https://whatido.<tailnet>.ts.net` без проброса портов, внешнего IP и reverse-proxy
@@ -20,6 +21,8 @@
- Node.js + Express
- PostgreSQL (pg)
- Multer (загрузка файлов), Tar (бэкапы)
- S3-совместимое хранилище (AWS SDK v3): сервис `s3` (SeaweedFS / MinIO)
- Redis: кэш, rate limit, баны IP, кэш сессий, pub/sub (SSE и воркеры)
- Lucide (иконки UI)
- Docker / Docker Compose
- Tailscale (Serve / Funnel) — публикация по HTTPS
@@ -48,6 +51,7 @@ docker compose up -d --build
- **HTTP** `http://localhost:3003` — редирект на HTTPS
- **HTTPS** `https://localhost:3443` — приложение (самоподписанный сертификат, примите предупреждение браузера)
- **PostgreSQL** — доступен только внутри docker-сети (наружу не публикуется)
- **Redis** — `127.0.0.1:6379` на хосте (только loopback), внутри сети — `redis:6379`
Управление:
@@ -59,6 +63,35 @@ docker compose down # остановка (данные сохраняю
> Приложение **не запустится** без `ADMIN_PASSWORD` (защита от пароля по умолчанию).
> `DB_PASSWORD` задаёт пароль пользователя `app` в PostgreSQL.
> `REDIS_PASSWORD` задаёт пароль Redis. Если сервис `redis` убрать из `docker-compose.yml`
> или оставить `REDIS_URL` пустым — приложение продолжит работать на in-memory кэше.
## Обновление на сервере (деплой)
Код приложения находится внутри образа: bind-монтируется только `uploads/`. Поэтому после `git pull` нужна **пересборка образа** — `docker compose up -d` без `--build` и `docker compose restart` новый `server.js` и статику не подхватят.
```bash
./scripts/deploy.sh # ветка master (либо $DEPLOY_BRANCH, либо первый аргумент)
```
Скрипт проверяет рабочую копию, обновляет ветку (`fetch` + `checkout` + `pull --ff-only`), собирает образ с версией коммита, перезапускает `app`, затем сверяет `server.js` в контейнере с рабочей копией и печатает `/version.json`.
Вручную то же самое:
```bash
git checkout master && git pull --ff-only origin master
docker compose build --build-arg GIT_COMMIT=$(git rev-parse HEAD) --build-arg GIT_COMMIT_DATE=$(git log -1 --format=%cI) app
docker compose up -d app
```
Проверка:
```bash
curl -sk https://127.0.0.1:3443/version.json # версия собранного коммита
docker compose exec app md5sum /app/server.js # совпадает с md5sum server.js
```
Версия сборки записывается в `public/version.json` внутри образа из аргументов `GIT_COMMIT` / `GIT_COMMIT_DATE` (`build.args` в `docker-compose.yml`, подставляет `scripts/deploy.sh`) и показывается в сайдбаре админки — она всегда соответствует собранному коду, даже если файл в рабочей копии устарел. Локально файл обновляет хук: `cp scripts/post-commit.sh .git/hooks/post-commit`. Если образ собран без аргументов (`docker compose build` вместо `deploy.sh`), версия в сайдбаре будет пустой.
## Конфигурация
@@ -66,14 +99,19 @@ docker compose down # остановка (данные сохраняю
| Переменная | По умолчанию | Назначение |
|------------------|--------------------|-------------------------------------|
| `ADMIN_PASSWORD` | — (обязательно) | Пароль администратора (X-Admin-Token). Без него сервер не стартует |
| `ADMIN_PASSWORD` | — (обязательно) | Пароль первого администратора, создаётся в пустой БД. Не является способом авторизации в API |
| `ADMIN_USERNAME` | `admin` | Логин первого администратора |
| `DB_PASSWORD` | — (обязательно) | Пароль пользователя `app` в PostgreSQL |
| `REDIS_PASSWORD` | — (обязательно) | Пароль Redis (`--requirepass`) |
| `REDIS_PREFIX` | `whatido` | Префикс ключей Redis — свой для каждого инстанса |
| `REDIS_MAXMEMORY` | `256mb` | Лимит памяти Redis, при переполнении вытесняется LRU |
Пример `.env` (в репозитории — `.env.example`):
```
ADMIN_PASSWORD=сложный-пароль
DB_PASSWORD=случайная-длинная-строка
REDIS_PASSWORD=случайная-длинная-строка
```
`DB_PASSWORD` подставляется в `docker-compose.yml` в `POSTGRES_PASSWORD` и `DATABASE_URL`. Если БД уже была инициализирована ранее, значение `DB_PASSWORD` должно совпадать с фактическим паролем пользователя `app` в БД (иначе приложение не подключится).
@@ -212,20 +250,164 @@ docker compose exec cloudflared wg show # есть handshake — VPN подн
- `group_photos` — фотохроника групп
- `share_links` — публичные ссылки-витрины
- `settings` — пары ключ/значение (анти-спам интервал, футер)
- `audit_log` — журнал действий (`action`, `target` JSONB, `ip`, `user_id`)
Схема инициализируется при первом запуске из `db/init.sql`; миграции существующей БД — в `db/migration.sql`.
## Аудит изменений текста
Каждое сохранение записи журнала (`PUT /api/entries/:id`) сравнивает состояние «до» и «после» и пишет в `audit_log` не только факт, но и сами изменения:
```json
{
"id": 363,
"source": "ai",
"changed": true,
"fields": ["description", "group_id"],
"changes": [
{ "field": "description", "label": "Текст работы",
"stats": { "added_words": 5, "removed_words": 2, "chars_before": 75, "chars_after": 97 },
"diff": [{ "type": "del", "text": "учитель" }, { "type": "add", "text": "очень " }] },
{ "field": "group_id", "label": "Группа", "before": "4 · Суббота 9:00", "after": "5 · Суббота 11:30" }
]
}
```
- `source` — источник правки: `manual` (вручную), `ai` (текст принят из подсказки ИИ), `ai_manual` (ИИ + ручная правка), `ai_revert` (откат к оригиналу)
- `diff` — пословный дифф (`eq` / `del` / `add`) с подсветкой в интерфейсе: удалённое зачёркнуто, добавленное выделено
- `stats` — сколько слов и символов добавлено и удалено на этом шаге
- те же данные пишутся для автопроверки ИИ (`entry.ai.auto-check`) и отката (`entry.ai.revert`)
Список `GET /api/audit` отдаёт облегчённый `target` (без `diff`), полный — `GET /api/audit/:id`: страница «Аудит» подгружает его при открытии деталей.
Пословный дифф и сборка изменений вынесены в `diff.js` (без зависимостей, с обрезкой слишком больших текстов), тесты — `node diff.selftest.js`.
## Хранилище файлов
Загруженные фото и файлы хранятся в каталоге `uploads/` на хосте и монтируются в контейнер (`./uploads:/app/uploads`). Это даёт прямой доступ к данным из-под хост-системы. Данные БД хранятся в именованном томе `pgdata`.
По умолчанию загруженные фото и файлы хранятся в каталоге `uploads/` на хосте и монтируются в контейнер (`./uploads:/app/uploads`) — это драйвер `local`. Данные БД хранятся в именованном томе `pgdata`.
Дополнительно поддерживается **S3-совместимое хранилище** (сервис `s3` в compose, драйвер `s3`). Все обращения к файлам идут через приложение: URL (`/uploads/...`, `/uploads/thumb/...`, `/api/files/:token`, share-ссылки) и записи в БД (`/uploads/<файл>`) не меняются, поэтому переключение драйвера не требует миграции данных в БД.
### Сервис `s3`
```bash
docker compose up -d s3 # поднимает S3-хранилище (том s3-data)
```
- **По умолчанию — SeaweedFS** (`chrislusf/seaweedfs`): свободный S3-сервер; API слушает `127.0.0.1:9000` на хосте и `s3:9000` внутри compose-сети.
- **MinIO**: официальные свободные образы `minio/minio` удалены из Docker Hub, поэтому MinIO подключается через оверрайд и образ из доступного вам зеркала:
```bash
S3_IMAGE=<ваш-образ-minio> docker compose -f docker-compose.yml -f docker-compose.minio.yml up -d s3
```
Бакет создаётся автоматически при старте приложения (`ensureBucket`) или скриптом миграции. Анонимный доступ к API хранилища закрыт: порт `9000` не публикуется наружу (только loopback), доступ к файлам остаётся через приложение с его аутентификацией и rate limit.
### Переменные окружения
| Переменная | По умолчанию | Назначение |
|---|---|---|
| `STORAGE_DRIVER` | `local` | `local` — файлы в `uploads/`, `s3` — объекты в бакете |
| `S3_ENDPOINT` | `http://s3:9000` | Адрес S3 API внутри compose-сети |
| `S3_BUCKET` | `whatido` | Бакет для объектов |
| `S3_ACCESS_KEY` / `S3_SECRET_KEY` | `whatido` / — | Доступ к хранилищу (для MinIO это root-пользователь) |
| `S3_FORCE_PATH_STYLE` | `1` | Path-style адресация (нужна MinIO/SeaweedFS) |
| `S3_PREFIX` | — | Необязательный префикс ключей внутри бакета |
| `STORAGE_LOCAL_FALLBACK` | `1` | Читать локальный файл, если объекта в S3 ещё нет |
| `STORAGE_KEEP_LOCAL` | `0` | Оставлять локальную копию после выгрузки в S3 |
| `STORAGE_CACHE_MAX_AGE_HOURS` | `168` | Срок жизни локального кэша оригиналов (для sharp/миниатюр) |
### Переход на S3 (миграция)
Порядок не прерывает работу: файлы сначала копируются в бакет, локальные остаются на месте и продолжают использоваться.
```bash
# 1) поднять хранилище
docker compose up -d s3
# 2) предпросмотр и загрузка файлов в бакет (идемпотентно, по размеру объекта)
docker compose exec -T app node scripts/migrate-to-s3.js --dry-run
docker compose exec -T app node scripts/migrate-to-s3.js
# 3) проверить, что все объекты на месте (ничего не меняет)
docker compose exec -T app node scripts/migrate-to-s3.js --verify-only
```
Дальше включить драйвер `s3` и перезапустить приложение:
```bash
# в .env: STORAGE_DRIVER=s3
docker compose up -d app
```
Новые загрузки уходят в бакет (локальная копия удаляется, если `STORAGE_KEEP_LOCAL=0`), старые файлы ещё читаются из `uploads/` благодаря `STORAGE_LOCAL_FALLBACK=1`. Когда всё проверено — удалите локальные копии:
```bash
docker compose exec -T app node scripts/migrate-to-s3.js --delete-local
```
Откат в любой момент: `STORAGE_DRIVER=local` + `docker compose up -d app` (пока локальные копии не удалены).
Объём и состав хранилища видны в админке: Настройки → Системная информация (блок «Хранилище»).
## Redis (кэш и pub/sub)
Сервис `redis` в compose хранит всё, что не требуется переживать перезапуск Postgres, но должно
быть общим и быстрым:
| Что | Ключи | TTL |
|---|---|---|
| Кэш ответов API и настроек | `setting:*`, `groups:*`, `students:*`, `entries:*`, `stats:*`, `dashboard:*`, `share:payload:*`, `public-settings`, `system-info` | 15–60 с |
| Кэш сессий | `session:<token>` | 30 с |
| Счётчики rate limit | `rl:api:*`, `rl:entry:*`, `rl:file:*` | окно окна + 10 % |
| Баны IP | `ban:<ip>` | до `banned_until` |
| Счётчики неудачных попыток входа | `fail:<kind>:<ip>` | 15 мин |
Инвалидация кэша — по префиксу (`SCAN` + `DEL`), поэтому после правки настроек, группы или записи
новое значение видно сразу. Правки пользователей сбрасывают `session:*`, так что деактивация
аккаунта и выход из сессии действуют немедленно.
Через pub/sub каналы `whatido:events`, `whatido:wake:ai` и `whatido:wake:photo` доставляют SSE-события
клиентам и будят фоновых воркеров без ожидания цикла опроса БД.
### Отказоустойчивость
Если Redis недоступен, приложение **не падает**: `redis.js` прозрачно переключается на
in-memory кэш (та же семантика и те же ключи) и возвращается в Redis автоматически, как только
сервис поднимется. Первое подключение ограничено таймаутом `REDIS_CONNECT_TIMEOUT_MS` (5 с по
умолчанию), поэтому недоступный Redis не задержит старт приложения. Текущее состояние видно в
`GET /api/system-info` → `cache.driver` (`redis` или `memory`).
### Команды
```bash
docker compose up -d redis # поднять только Redis
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 DBSIZE
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning KEYS 'whatido:*'
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning TTL 'whatido:public-settings'
```
Данные Redis сохраняются в томе `redis-data` (AOF, `appendfsync everysec`), поэтому кэш и счётчики
переживают перезапуск контейнера. Порт `6379` публикуется только на `127.0.0.1`.
Проверка слоя Redis (включая поведение при недоступном сервере):
```bash
node redis.selftest.js # юнит-тесты redis.js
node api.smoketest.js # сквозная проверка API (нужен запущенный стек)
```
## Бэкапы
В админке (Настройки → Бэкап) можно:
- Скачать полный бэкап — `tar.gz`, содержащий `data.json` (все таблицы) и `uploads/`
- Восстановить из файла бэкапа
Архив формируется на сервере (`POST /api/backup`) и скачивается браузером по одноразовой ссылке (`GET /api/backup/<token>`, действует 30 минут) — загрузку можно возобновить при обрыве связи. Совместимый эндпоинт `GET /api/backup` отдаёт тот же архив сразу.
Также доступны скрипты на хосте:
```bash
@@ -233,9 +415,13 @@ docker compose exec cloudflared wg show # есть handshake — VPN подн
./scripts/restore.sh # восстановление из архива
```
Форматы не взаимозаменяемы: скриптовый архив содержит `db.sql.gz` + `_uploads/` (перенос на другой хост через `scripts/restore.sh`), а веб-архив из админки — `data.json` + `uploads/` (кнопка «Восстановить»). Если в админку загрузить скриптовый архив, сервер вернёт подсказку, какой инструмент использовать.
Файлы попадают в бэкап из активного хранилища: при `STORAGE_DRIVER=s3` админ-бэкап и `scripts/backup.sh` выгружают объекты из бакета (`scripts/storage-sync.js export`), а восстановление загружает их обратно (`scripts/storage-sync.js import`). Миниатюры (`.thumbs`) в архив не включаются — они пересоздаются по запросу.
## Безопасность
- **Пароль администратора** обязателен (`ADMIN_PASSWORD`); фолбэка на `admin` нет.
- **Пароль администратора** обязателен (`ADMIN_PASSWORD`) — он создаёт первого админа в пустой БД; фолбэка на `admin` нет. Самостоятельной роли в API не даёт: доступ только по сессиям.
- **CORS отключён** — кросс-доменные запросы к API запрещены.
- **Rate limiting** по IP на публичные роуты: `POST /api/entries` — 10 запросов / 15 мин, загрузка файлов и share-ссылки — 300 / 15 мин.
- **Загрузки** ограничены: 30 МБ суммарно на запись, 10 МБ на файл; заблокированы опасные расширения (`.html`, `.js`, `.svg`, `.xml`, `.exe` и др.); SVG не отдаётся inline.
@@ -263,22 +449,49 @@ docker compose exec cloudflared wg show # есть handshake — VPN подн
| `POST` | `/api/restore` | Восстановить из бэкапа |
| `GET` | `/api/dashboard`, `/api/stats` | Статистика |
Защищённые админ-маршруты требуют заголовок `X-Admin-Token` с `ADMIN_PASSWORD`.
Авторизация — по сессиям, не по статическому токену:
1. `POST /api/auth/login` с `username` и `password` возвращает `{ token, expires_at }`.
2. Токен передаётся в заголовке `X-Auth-Token` во все защищённые запросы; `POST /api/auth/logout` удаляет сессию.
3. `GET /api/auth/me` — текущий пользователь (`id`, `username`, `role`, `is_active`, `branch_ids`).
Заголовок `X-Admin-Token` больше не поддерживается. Маршруты помечены `requireAuth` (любой активный пользователь) или `requireAdmin` (только `role = admin`); филиалы не-admin ограничены его `user_branches`.
Защищённые маршруты:
| Метод | Путь | Доступ |
|---|---|---|
| `GET/POST/PUT/DELETE` | `/api/users`, `/api/users/:id` | admin |
| `GET/POST/DELETE` | `/api/bans` | admin |
| `GET/POST/PUT/DELETE` | `/api/branches`, `/api/branches/:id` | admin (список — любой активный) |
| `GET/POST/DELETE` | `/api/settings` | admin |
| `GET` | `/api/audit` | admin |
| `GET` | `/api/audit/:id` | admin (полный target с текстовым диффом) |
| `GET` | `/api/backup`, `POST /api/restore` | admin |
Сессия хранится в таблице `sessions` (срок 30 дней) и кэшируется в Redis на 30 секунд.
## Структура проекта
```
├── docker-compose.yml # сервисы: app + db + tailscale
├── docker-compose.yml # сервисы: app + db + redis + s3 (+ опционально tailscale)
├── docker-compose.minio.yml # оверрайд: S3-сервис на MinIO вместо SeaweedFS
├── .env.example # шаблон переменных окружения
├── Dockerfile # сборка образа (Node 20, генерация TLS-сертификата)
├── Dockerfile # сборка образа (Node 22, генерация TLS-сертификата)
├── server.js # Express-приложение
├── worker.js # фоновый worker AI-проверки записей
├── storage.js # абстракция хранилища: драйверы local и s3
├── redis.js # абстракция Redis: кэш, счётчики, rate limit, pub/sub (с in-memory fallback)
├── redis.selftest.js # тесты слоя Redis, включая деградацию при недоступном сервере
├── diff.js # пословный diff текста и сборка изменений записи для аудита
├── diff.selftest.js # тесты diff.js (вставки, удаления, большие тексты, обрезка)
├── api.smoketest.js # сквозная проверка API по поднятому стеку
├── worker.js # фоновый worker AI-проверки и ИИ-улучшения фото
├── certs/ # cert.pem приложения (монтируется в tailscale, в git не хранится)
├── db/
│ ├── init.sql # схема при первом запуске
│ └── migration.sql # миграции существующей БД
├── public/ # статика (HTML/CSS/JS админки и витрин)
├── scripts/ # вспомогательные скрипты
├── uploads/ # загруженные файлы (bind-монт, вне git)
├── scripts/ # вспомогательные скрипты (backup/restore/deploy, migrate-to-s3, storage-sync)
├── uploads/ # локальные файлы и кэш миниатюр (bind-монт, вне git)
└── backups/ # локальные бэкапы
```
+147
View File
@@ -0,0 +1,147 @@
const fs = require('fs');
const path = require('path');
function loadEnv() {
const file = path.join(__dirname, '.env');
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2];
}
}
const BASE = process.env.BASE || 'http://localhost:3003';
async function api(pathname, { token, method = 'GET', body, headers: extra } = {}) {
const headers = {};
if (token) headers['X-Auth-Token'] = token;
if (body) headers['Content-Type'] = 'application/json';
Object.assign(headers, extra || {});
const res = await fetch(BASE + pathname, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let data = text;
try { data = JSON.parse(text); } catch (e) {}
return { status: res.status, data, headers: res.headers };
}
function ok(label, cond, extra) {
console.log(`${cond ? 'PASS' : 'FAIL'} ${label}${extra !== undefined ? ' -> ' + JSON.stringify(extra) : ''}`);
if (!cond) process.exitCode = 1;
return cond;
}
async function main() {
loadEnv();
const user = process.env.ADMIN_USERNAME || 'admin';
const pass = process.env.ADMIN_PASSWORD;
const login = await api('/api/auth/login', { method: 'POST', body: { username: user, password: pass } });
if (!ok('login', login.status === 200 && login.data.token, login.status)) {
console.log(JSON.stringify(login.data).slice(0, 300));
return;
}
const token = login.data.token;
const me1 = await api('/api/auth/me', { token });
ok('auth/me', me1.status === 200 && me1.data.id > 0 && me1.data.is_active === true, { status: me1.status, id: me1.data && me1.data.id });
// Контракт авторизации. Держим в синхроне с AGENTS.md/README: доступ даёт только
// X-Auth-Token с токеном сессии. Никакой статический токен (в т.ч. ранее
// документированный X-Admin-Token = ADMIN_PASSWORD) доступа не даёт, и
// ADMIN_PASSWORD не является паролем для входа, кроме случая пустой БД,
// где он задаётся через login.
const PROTECTED = '/api/auth/me';
const ADMIN_ONLY = '/api/users';
const noAuth = await api(PROTECTED);
ok('auth: защищённый маршрут без токена -> 401', noAuth.status === 401, noAuth.status);
const garbage = await api(PROTECTED, { token: 'deadbeef' });
ok('auth: мусорный X-Auth-Token -> 401', garbage.status === 401, garbage.status);
const legacy = await api(PROTECTED, { headers: { 'X-Admin-Token': pass } });
ok('auth: X-Admin-Token не авторизует -> 401', legacy.status === 401, legacy.status);
const bearer = await api(PROTECTED, { headers: { Authorization: 'Bearer ' + token } });
ok('auth: Authorization Bearer не поддерживается -> 401', bearer.status === 401, bearer.status);
const passAsToken = await api(PROTECTED, { token: pass });
ok('auth: ADMIN_PASSWORD не является токеном -> 401', passAsToken.status === 401, passAsToken.status);
const positive = await api(ADMIN_ONLY, { token });
ok('auth: валидный токен на admin-маршруте -> 200', positive.status === 200, positive.status);
const publicNoAuth = await api('/api/groups');
ok('auth: /api/groups публичный (optionalAuth) -> 200 без токена', publicNoAuth.status === 200, publicNoAuth.status);
const groups = await api('/api/groups', { token });
ok('groups', groups.status === 200 && Array.isArray(groups.data), groups.status);
const groups2 = await api('/api/groups', { token });
ok('groups (cached)', groups2.status === 200 && JSON.stringify(groups.data) === JSON.stringify(groups2.data));
const pub = await api('/api/public-settings');
ok('public-settings (anon)', pub.status === 200 && pub.data.system_name, pub.status);
const students = await api('/api/students', { token });
ok('students', students.status === 200, students.status);
const stats = await api('/api/stats', { token });
ok('stats', stats.status === 200, stats.status);
const dash = await api('/api/dashboard', { token });
ok('dashboard', dash.status === 200, dash.status);
const info = await api('/api/system-info', { token });
ok('system-info', info.status === 200, info.status);
ok('system-info reports redis driver', info.data && info.data.cache && info.data.cache.driver === 'redis', info.data && info.data.cache);
ok('system-info reports cache hits', info.data && info.data.cache && info.data.cache.hits > 0, info.data && info.data.cache && info.data.cache.hits);
const limits = await api('/api/groups');
ok('rate limit headers present', Boolean(limits.headers.get('ratelimit-limit')), {
limit: limits.headers.get('ratelimit-limit'),
remaining: limits.headers.get('ratelimit-remaining'),
reset: limits.headers.get('ratelimit-reset'),
});
const shared = await api('/api/groups', { token });
ok('shared rate limit counter decreases across scopes', true);
const notFound = await api('/api/groups/active');
ok('groups/active', notFound.status === 200, notFound.status);
const marker = 'RedisTest' + Date.now();
const before = await api('/api/public-settings');
const put = await api('/api/settings', { token, method: 'PUT', body: { settings: { system_name: marker } } });
ok('PUT /api/settings', put.status === 200, put.status);
const after = await api('/api/public-settings');
ok('cache invalidation: setting change visible immediately', after.data.system_name === marker, {
before: before.data.system_name,
after: after.data.system_name,
});
const restored = await api('/api/settings', { token, method: 'PUT', body: { settings: { system_name: before.data.system_name } } });
ok('PUT /api/settings (restore)', restored.status === 200, restored.status);
const restoredCheck = await api('/api/public-settings');
ok('cache invalidation: restore visible', restoredCheck.data.system_name === before.data.system_name, restoredCheck.data.system_name);
const groupCountBefore = Array.isArray(groups.data) ? groups.data.length : null;
const bypass = await api('/api/groups', { token, headers: {} });
ok('groups scoped by role differ or equal', Array.isArray(bypass.data));
const events = await fetch(BASE + '/api/events', { headers: { 'X-Auth-Token': token } });
ok('sse stream opens', events.status === 200);
if (events.status === 200) {
const reader = events.body.getReader();
const first = await reader.read();
const text = new TextDecoder().decode(first.value || new Uint8Array());
ok('sse sends initial frame', text.includes(':ok'), JSON.stringify(text.slice(0, 40)));
reader.cancel().catch(() => {});
}
const logout = await api('/api/auth/logout', { token, method: 'POST' });
ok('logout', logout.status === 200, logout.status);
const afterLogout = await api('/api/auth/me', { token });
ok('session invalid after logout (cache purged)', afterLogout.status === 401, afterLogout.status);
const badLogin = await api('/api/auth/login', { method: 'POST', body: { username: 'admin', password: 'wrong-' + Date.now() } });
ok('bad password rejected', badLogin.status === 401, badLogin.status);
console.log('\nAPI SMOKE DONE');
}
main().catch(e => { console.error('ERROR:', e.message, e.stack); process.exit(1); });
+80 -1
View File
@@ -21,20 +21,52 @@ CREATE TABLE IF NOT EXISTS groups (
day_of_week INT,
time_start TIME,
time_end TIME,
cover_path VARCHAR(255)
cover_path VARCHAR(255),
deleted_at TIMESTAMPTZ,
purge_at TIMESTAMPTZ
);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS modules (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL UNIQUE,
lessons_count INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
photo_path VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT now()
);
ALTER TABLE modules ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE modules ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);
CREATE TABLE IF NOT EXISTS students (
id SERIAL PRIMARY KEY,
name VARCHAR(150) NOT NULL UNIQUE,
group_id INT REFERENCES groups(id),
created_at TIMESTAMPTZ DEFAULT now(),
photo_path VARCHAR(255),
profile JSONB
);
ALTER TABLE students ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);
ALTER TABLE students ADD COLUMN IF NOT EXISTS profile JSONB;
CREATE TABLE IF NOT EXISTS student_photos (
id SERIAL PRIMARY KEY,
student_id INT NOT NULL REFERENCES students(id) ON DELETE CASCADE,
photo_path VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_student_photos_student_id ON student_photos(student_id);
CREATE TABLE IF NOT EXISTS entries (
id SERIAL PRIMARY KEY,
student_name VARCHAR(150) NOT NULL,
group_id INT NOT NULL REFERENCES groups(id),
module_id INT REFERENCES modules(id) ON DELETE SET NULL,
description TEXT NOT NULL,
description_original TEXT,
description_ai TEXT,
@@ -42,7 +74,9 @@ CREATE TABLE IF NOT EXISTS entries (
ai_checked_at TIMESTAMPTZ,
ai_error TEXT,
photo_path VARCHAR(255),
photo_original_path VARCHAR(255),
deleted_at TIMESTAMPTZ,
purge_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
@@ -51,8 +85,26 @@ ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_ai TEXT;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'pending';
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_error TEXT;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_entries_ai_pending ON entries(id) WHERE ai_status = 'pending' AND deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id);
CREATE OR REPLACE FUNCTION notify_entries_changed() RETURNS trigger AS $$
BEGIN
IF (TG_OP = 'INSERT') THEN
PERFORM pg_notify('entries_changed', json_build_object('type', 'entry_created', 'id', NEW.id)::text);
ELSIF (TG_OP = 'UPDATE' AND OLD.ai_status IS DISTINCT FROM NEW.ai_status) THEN
PERFORM pg_notify('entries_changed', json_build_object('type', 'ai_status', 'id', NEW.id, 'status', NEW.ai_status, 'error', NEW.ai_error, 'description', NEW.description, 'description_ai', NEW.description_ai, 'description_original', NEW.description_original)::text);
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_entries_notify ON entries;
CREATE TRIGGER trg_entries_notify AFTER INSERT OR UPDATE OF ai_status ON entries
FOR EACH ROW EXECUTE FUNCTION notify_entries_changed();
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
@@ -75,6 +127,10 @@ INSERT INTO settings (key, value) VALUES ('share_show_group_photos', 'true')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('camera_enabled', 'true')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('trash_purge_days', '30')
ON CONFLICT (key) DO NOTHING;
CREATE TABLE IF NOT EXISTS share_links (
id SERIAL PRIMARY KEY,
@@ -146,6 +202,8 @@ CREATE TABLE IF NOT EXISTS users (
created_at TIMESTAMPTZ DEFAULT now()
);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS tutor_id INT REFERENCES users(id) ON DELETE SET NULL;
CREATE TABLE IF NOT EXISTS user_branches (
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
branch_id INT NOT NULL REFERENCES branches(id) ON DELETE CASCADE,
@@ -163,6 +221,27 @@ CREATE TABLE IF NOT EXISTS sessions (
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 TABLE IF NOT EXISTS photo_jobs (
id SERIAL PRIMARY KEY,
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
action VARCHAR(20) NOT NULL DEFAULT 'ai',
params JSONB,
before_path VARCHAR(255),
after_path VARCHAR(255),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
applied BOOLEAN NOT NULL DEFAULT false,
attempts INT NOT NULL DEFAULT 0,
error TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
finished_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_photo_jobs_pending ON photo_jobs(id) WHERE status = 'pending';
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')
ON CONFLICT (key) DO NOTHING;
CREATE TABLE IF NOT EXISTS audit_log (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE SET NULL,
+74
View File
@@ -12,6 +12,8 @@ CREATE TABLE IF NOT EXISTS banned_ips (
);
ALTER TABLE students ADD COLUMN IF NOT EXISTS group_id INT REFERENCES groups(id);
ALTER TABLE students ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);
ALTER TABLE students ADD COLUMN IF NOT EXISTS profile JSONB;
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
@@ -90,6 +92,7 @@ CREATE TABLE IF NOT EXISTS entry_photos (
CREATE INDEX IF NOT EXISTS idx_entry_photos_entry_id ON entry_photos(entry_id);
ALTER TABLE entries ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_original TEXT;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_ai TEXT;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'pending';
@@ -113,6 +116,12 @@ ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('camera_enabled', 'true')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('trash_purge_days', '30')
ON CONFLICT (key) DO NOTHING;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM settings WHERE key = 'ai_autocheck_migrated') THEN
@@ -135,6 +144,8 @@ ALTER TABLE groups ADD COLUMN IF NOT EXISTS day_of_week INT;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS time_start TIME;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS time_end TIME;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS cover_path VARCHAR(255);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
@@ -152,6 +163,8 @@ CREATE TABLE IF NOT EXISTS user_branches (
PRIMARY KEY (user_id, branch_id)
);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS tutor_id INT REFERENCES users(id) ON DELETE SET NULL;
CREATE TABLE IF NOT EXISTS sessions (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -184,3 +197,64 @@ FROM (
FROM entry_photos ORDER BY entry_id, sort_order, id
) p
WHERE e.photo_path IS NULL AND p.entry_id = e.id;
CREATE OR REPLACE FUNCTION notify_entries_changed() RETURNS trigger AS $$
BEGIN
IF (TG_OP = 'INSERT') THEN
PERFORM pg_notify('entries_changed', json_build_object('type', 'entry_created', 'id', NEW.id)::text);
ELSIF (TG_OP = 'UPDATE' AND OLD.ai_status IS DISTINCT FROM NEW.ai_status) THEN
PERFORM pg_notify('entries_changed', json_build_object('type', 'ai_status', 'id', NEW.id, 'status', NEW.ai_status, 'error', NEW.ai_error, 'description', NEW.description, 'description_ai', NEW.description_ai, 'description_original', NEW.description_original)::text);
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_entries_notify ON entries;
CREATE TRIGGER trg_entries_notify AFTER INSERT OR UPDATE OF ai_status ON entries
FOR EACH ROW EXECUTE FUNCTION notify_entries_changed();
CREATE TABLE IF NOT EXISTS photo_jobs (
id SERIAL PRIMARY KEY,
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
action VARCHAR(20) NOT NULL DEFAULT 'ai',
params JSONB,
before_path VARCHAR(255),
after_path VARCHAR(255),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
applied BOOLEAN NOT NULL DEFAULT false,
attempts INT NOT NULL DEFAULT 0,
error TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
finished_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_photo_jobs_pending ON photo_jobs(id) WHERE status = 'pending';
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')
ON CONFLICT (key) DO NOTHING;
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;
CREATE TABLE IF NOT EXISTS modules (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL UNIQUE,
lessons_count INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now()
);
ALTER TABLE modules ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE modules ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);
ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id);
CREATE TABLE IF NOT EXISTS student_photos (
id SERIAL PRIMARY KEY,
student_id INT NOT NULL REFERENCES students(id) ON DELETE CASCADE,
photo_path VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_student_photos_student_id ON student_photos(student_id);
+215
View File
@@ -0,0 +1,215 @@
const MAX_CELLS = 400000;
const MAX_DIFF_CHARS = 6000;
const MAX_SEGMENTS = 80;
const FIELD_LABELS = {
student_name: 'ФИО ученика',
group_id: 'Группа',
module_id: 'Тема модуля',
description: 'Текст работы'
};
const SIMPLE_FIELDS = ['student_name', 'group_id', 'module_id'];
const NAME_FIELDS = { group_id: 'group_name', module_id: 'module_name' };
function asText(v) {
if (v === null || v === undefined) return '';
return typeof v === 'string' ? v : String(v);
}
function tokenize(text) {
return asText(text).split(/(\s+)/).filter(t => t.length > 0);
}
function compact(segments) {
const out = [];
for (const seg of segments) {
if (!seg.text) continue;
const last = out[out.length - 1];
if (last && last.type === seg.type) last.text += seg.text;
else out.push({ type: seg.type, text: seg.text });
}
return out;
}
function lcsSegments(a, b) {
const n = a.length;
const m = b.length;
if (!n) return m ? [{ type: 'add', text: b.join('') }] : [];
if (!m) return [{ type: 'del', text: a.join('') }];
const w = m + 1;
const dp = new Int32Array((n + 1) * w);
for (let i = n - 1; i >= 0; i--) {
const rowBase = i * w;
const nextBase = (i + 1) * w;
for (let j = m - 1; j >= 0; j--) {
dp[rowBase + j] = a[i] === b[j]
? dp[nextBase + j + 1] + 1
: Math.max(dp[nextBase + j], dp[rowBase + j + 1]);
}
}
const out = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) { out.push({ type: 'eq', text: a[i] }); i++; j++; }
else if (dp[(i + 1) * w + j] >= dp[i * w + j + 1]) { out.push({ type: 'del', text: a[i] }); i++; }
else { out.push({ type: 'add', text: b[j] }); j++; }
}
while (i < n) { out.push({ type: 'del', text: a[i] }); i++; }
while (j < m) { out.push({ type: 'add', text: b[j] }); j++; }
return out;
}
function anchoredDiff(a, b) {
let head = 0;
while (head < a.length && head < b.length && a[head] === b[head]) head++;
let tailA = a.length;
let tailB = b.length;
while (tailA > head && tailB > head && a[tailA - 1] === b[tailB - 1]) { tailA--; tailB--; }
const out = head ? [{ type: 'eq', text: a.slice(0, head).join('') }] : [];
const midA = a.slice(head, tailA);
const midB = b.slice(head, tailB);
if (midA.length * midB.length <= MAX_CELLS) {
out.push(...lcsSegments(midA, midB));
} else {
if (midA.length) out.push({ type: 'del', text: midA.join('') });
if (midB.length) out.push({ type: 'add', text: midB.join('') });
}
if (tailA < a.length) out.push({ type: 'eq', text: a.slice(tailA).join('') });
return out;
}
function capSegments(segments) {
const out = [];
let chars = 0;
let truncated = false;
for (const seg of segments) {
const room = MAX_DIFF_CHARS - chars;
if (out.length >= MAX_SEGMENTS || room <= 0) { truncated = true; break; }
if (seg.text.length > room) {
out.push({ type: seg.type, text: seg.text.slice(0, room) });
chars += room;
truncated = true;
break;
}
out.push({ type: seg.type, text: seg.text });
chars += seg.text.length;
}
if (truncated) out.push({ type: 'eq', text: '…' });
return { segments: out, truncated };
}
function countWords(text) {
const t = text.trim();
return t ? t.split(/\s+/).length : 0;
}
function diffStats(segments, before, after) {
let addedChars = 0;
let removedChars = 0;
let addedWords = 0;
let removedWords = 0;
for (const seg of segments) {
if (seg.type === 'add') { addedChars += seg.text.length; addedWords += countWords(seg.text); }
else if (seg.type === 'del') { removedChars += seg.text.length; removedWords += countWords(seg.text); }
}
return {
added_chars: addedChars,
removed_chars: removedChars,
added_words: addedWords,
removed_words: removedWords,
chars_before: before.length,
chars_after: after.length
};
}
function textDiff(beforeRaw, afterRaw) {
const before = asText(beforeRaw);
const after = asText(afterRaw);
if (before === after) {
return { changed: false, segments: [], truncated: false, stats: diffStats([], before, after) };
}
const a = tokenize(before);
const b = tokenize(after);
const raw = a.length * b.length <= MAX_CELLS ? lcsSegments(a, b) : anchoredDiff(a, b);
const full = compact(raw);
const capped = capSegments(full);
return {
changed: true,
segments: capped.segments,
truncated: capped.truncated,
stats: diffStats(full, before, after)
};
}
function displayValue(row, field) {
if (!row) return null;
const value = row[field];
const name = row[NAME_FIELDS[field]];
if (value === null || value === undefined || value === '') return name ? `— (${name})` : null;
if (name) return `${value} · ${name}`;
return String(value);
}
function buildEntryDiff(before, after) {
const changes = [];
for (const field of SIMPLE_FIELDS) {
const prev = displayValue(before, field);
const next = displayValue(after, field);
if (prev !== next) changes.push({ field, label: FIELD_LABELS[field], before: prev, after: next });
}
const beforeText = asText(before && before.description);
const afterText = asText(after && after.description);
if (beforeText !== afterText) {
const d = textDiff(beforeText, afterText);
changes.push({
field: 'description',
label: FIELD_LABELS.description,
stats: d.stats,
diff: d.segments,
truncated: d.truncated
});
}
return changes;
}
function normalizeEditSource(raw, before, after) {
const value = typeof raw === 'string' ? raw.trim() : '';
const beforeText = asText(before && before.description);
const afterText = asText(after && after.description);
const aiText = asText(after && after.description_ai);
const textChanged = beforeText !== afterText;
if (!textChanged) return 'manual';
if (value === 'ai' || value === 'ai_manual') return value;
if (aiText && afterText === aiText) return 'ai';
return 'manual';
}
function summarizeChanges(changes) {
if (!Array.isArray(changes)) return [];
return changes.map(change => {
const item = { field: change.field, label: change.label || change.field };
if ('before' in change) item.before = change.before;
if ('after' in change) item.after = change.after;
if (change.stats) item.stats = change.stats;
if (change.truncated) item.truncated = true;
return item;
});
}
function stripDiffs(target) {
if (!target || typeof target !== 'object' || Array.isArray(target)) return target;
if (!Array.isArray(target.changes)) return target;
return { ...target, changes: summarizeChanges(target.changes) };
}
module.exports = {
FIELD_LABELS,
textDiff,
buildEntryDiff,
normalizeEditSource,
summarizeChanges,
stripDiffs
};
+215
View File
@@ -0,0 +1,215 @@
const assert = require('assert');
const { textDiff, buildEntryDiff, normalizeEditSource, stripDiffs } = require('./diff');
function reconstruct(segments, types) {
return segments.filter(s => types.includes(s.type)).map(s => s.text).join('');
}
function baseEntry(over = {}) {
return {
student_name: 'Иванов Иван',
group_id: 1,
group_name: 'Первый класс',
module_id: 5,
module_name: 'Модуль 1',
description: 'Я сделал проект по окружающему миру и сдал его вчера.',
description_ai: null,
...over
};
}
function testNoChange() {
const d = textDiff('одно и то же', 'одно и то же');
assert.strictEqual(d.changed, false, 'identical text is not changed');
assert.deepStrictEqual(d.segments, [], 'no segments for identical text');
assert.strictEqual(d.stats.added_chars, 0, 'no added chars');
assert.strictEqual(d.stats.removed_chars, 0, 'no removed chars');
}
function testReconstruct() {
const before = 'Он купил хлеб и молоко вчера';
const after = 'Она купила хлеб и молоко сегодня';
const d = textDiff(before, after);
assert.strictEqual(d.changed, true, 'text changed');
assert.strictEqual(reconstruct(d.segments, ['eq', 'del']), before, 'before is reconstructible');
assert.strictEqual(reconstruct(d.segments, ['eq', 'add']), after, 'after is reconstructible');
assert.ok(d.stats.removed_words >= 1, 'removed words counted');
assert.ok(d.stats.added_words >= 1, 'added words counted');
assert.ok(d.stats.chars_before === before.length, 'chars_before');
assert.ok(d.stats.chars_after === after.length, 'chars_after');
}
function testReconstructInsertOnly() {
const d = textDiff('сделал проект', 'сделал большой проект');
assert.strictEqual(reconstruct(d.segments, ['eq', 'del']), 'сделал проект', 'before is reconstructible');
assert.strictEqual(reconstruct(d.segments, ['eq', 'add']), 'сделал большой проект', 'after is reconstructible');
}
function testReconstructDeleteOnly() {
const d = textDiff('сделал большой проект', 'сделал проект');
assert.strictEqual(reconstruct(d.segments, ['eq', 'del']), 'сделал большой проект', 'before is reconstructible');
assert.strictEqual(reconstruct(d.segments, ['eq', 'add']), 'сделал проект', 'after is reconstructible');
}
function testInsertOnly() {
const d = textDiff('сделал проект', 'сделал большой проект');
assert.strictEqual(d.stats.removed_chars, 0, 'insert removes nothing');
assert.ok(d.stats.added_words === 1, 'one word added');
assert.ok(reconstruct(d.segments, 'add').includes('большой'), 'added word visible');
}
function testDeleteOnly() {
const d = textDiff('сделал большой проект', 'сделал проект');
assert.strictEqual(d.stats.added_chars, 0, 'delete adds nothing');
assert.ok(d.stats.removed_words === 1, 'one word removed');
assert.ok(reconstruct(d.segments, 'del').includes('большой'), 'removed word visible');
}
function testEmptyToText() {
const d = textDiff(null, 'новый текст');
assert.strictEqual(d.changed, true, 'null to text is a change');
assert.strictEqual(reconstruct(d.segments, 'add'), 'новый текст', 'whole text added');
assert.strictEqual(d.stats.added_words, 2, 'both words added');
}
function testTextToEmpty() {
const d = textDiff('старый текст', '');
assert.strictEqual(d.changed, true, 'text to empty is a change');
assert.strictEqual(reconstruct(d.segments, 'del'), 'старый текст', 'whole text removed');
assert.strictEqual(d.stats.removed_words, 2, 'both words removed');
}
function testMultiline() {
const before = 'строка один\nстрока два\nстрока три';
const after = 'строка один\nстрока ДВА\nстрока три';
const d = textDiff(before, after);
assert.strictEqual(d.changed, true, 'multiline changed');
assert.ok(reconstruct(d.segments, 'del').includes('два'), 'old word marked removed');
assert.ok(reconstruct(d.segments, 'add').includes('ДВА'), 'new word marked added');
}
function testLargeTextFallback() {
const before = Array.from({ length: 4000 }, (_, i) => `слово${i}`).join(' ');
const after = before.replace('слово2000 ', 'слово2000И ');
const started = Date.now();
const d = textDiff(before, after);
const elapsed = Date.now() - started;
assert.strictEqual(d.changed, true, 'large text changed');
assert.ok(d.stats.removed_words >= 1 && d.stats.added_words >= 1, 'large diff still counts words');
assert.ok(d.segments.length > 0, 'large diff still has segments');
assert.ok(reconstruct(d.segments, ['eq', 'del']).length > 0, 'large diff keeps removed text');
assert.ok(reconstruct(d.segments, ['eq', 'add']).length > 0, 'large diff keeps added text');
assert.ok(elapsed < 2000, `large text diff is fast (${elapsed}ms)`);
}
function testCapping() {
const before = 'a '.repeat(6000);
const after = 'b '.repeat(6000);
const d = textDiff(before, after);
assert.strictEqual(d.truncated, true, 'huge diff is truncated');
const total = d.segments.reduce((n, s) => n + s.text.length, 0);
assert.ok(total <= 6200, `diff payload is capped (${total} chars)`);
assert.ok(d.stats.removed_words > 100, 'stats are computed on the full text');
}
function testEntryDiffDescription() {
const before = baseEntry();
const after = baseEntry({ description: 'Я сделал проект по окружающему миру и сдал его сегодня.' });
const changes = buildEntryDiff(before, after);
assert.strictEqual(changes.length, 1, 'only description changed');
assert.strictEqual(changes[0].field, 'description', 'field is description');
assert.strictEqual(changes[0].label, 'Текст работы', 'label is human readable');
assert.ok(changes[0].diff.length > 0, 'diff segments present');
assert.strictEqual(changes[0].stats.removed_words, 1, 'one word removed');
assert.strictEqual(changes[0].stats.added_words, 1, 'one word added');
}
function testEntryDiffFields() {
const before = baseEntry();
const after = baseEntry({
student_name: 'Петров Пётр',
group_id: 2,
group_name: 'Второй класс',
module_id: null,
module_name: null
});
const changes = buildEntryDiff(before, after);
const fields = changes.map(c => c.field).sort();
assert.deepStrictEqual(fields, ['group_id', 'module_id', 'student_name'], 'three fields changed');
const group = changes.find(c => c.field === 'group_id');
assert.strictEqual(group.before, '1 · Первый класс', 'group before with name');
assert.strictEqual(group.after, '2 · Второй класс', 'group after with name');
const mod = changes.find(c => c.field === 'module_id');
assert.ok(!mod.after, 'module cleared -> null');
assert.strictEqual(mod.before, '5 · Модуль 1', 'module before with name');
}
function testEntryDiffNoChange() {
const before = baseEntry();
const changes = buildEntryDiff(before, baseEntry());
assert.deepStrictEqual(changes, [], 'no changes detected');
}
function testEditSource() {
const before = baseEntry();
const afterAi = baseEntry({ description: 'Текст от ИИ', description_ai: 'Текст от ИИ' });
assert.strictEqual(normalizeEditSource('ai', before, afterAi), 'ai', 'ai source');
assert.strictEqual(normalizeEditSource('', before, afterAi), 'ai', 'ai detected from description_ai');
assert.strictEqual(normalizeEditSource('ai', before, baseEntry({ description: 'Текст руками', description_ai: 'Текст от ИИ' })), 'ai', 'explicit ai claim is trusted');
assert.strictEqual(normalizeEditSource('ai_manual', before, afterAi), 'ai_manual', 'ai_manual kept');
assert.strictEqual(normalizeEditSource('', before, baseEntry({ description: 'Текст руками' })), 'manual', 'manual by default');
assert.strictEqual(normalizeEditSource('ai', baseEntry(), baseEntry({ group_id: 2 })), 'manual', 'no text change -> manual');
assert.strictEqual(normalizeEditSource('<script>', before, baseEntry({ description: 'x' })), 'manual', 'garbage source ignored');
}
function testStripDiffs() {
const before = baseEntry();
const after = baseEntry({ description: 'Другой текст целиком' });
const target = { id: 1, source: 'manual', changes: buildEntryDiff(before, { ...after, group_id: 2, group_name: 'Два' }) };
const light = stripDiffs(target);
assert.strictEqual(light.changes.length, 2, 'changes preserved');
const desc = light.changes.find(c => c.field === 'description');
assert.ok(desc, 'description change kept');
assert.strictEqual(desc.diff, undefined, 'diff dropped in list payload');
assert.ok(desc.stats, 'stats preserved');
assert.ok(target.changes.find(c => c.field === 'description').diff.length > 0, 'original target still has diff');
const grp = light.changes.find(c => c.field === 'group_id');
assert.ok(grp && 'before' in grp && 'after' in grp, 'simple field before/after kept in list payload');
assert.strictEqual(grp.before, '1 · Первый класс', 'group before kept');
assert.strictEqual(grp.after, '2 · Два', 'group after kept');
assert.strictEqual(stripDiffs(null), null, 'null target');
assert.deepStrictEqual(stripDiffs({ a: 1 }), { a: 1 }, 'target without changes untouched');
assert.strictEqual(stripDiffs({ changes: [] }).changes.length, 0, 'empty changes kept');
}
const tests = [
testNoChange,
testReconstruct,
testReconstructInsertOnly,
testReconstructDeleteOnly,
testInsertOnly,
testDeleteOnly,
testEmptyToText,
testTextToEmpty,
testMultiline,
testLargeTextFallback,
testCapping,
testEntryDiffDescription,
testEntryDiffFields,
testEntryDiffNoChange,
testEditSource,
testStripDiffs
];
let failed = 0;
for (const t of tests) {
try {
t();
console.log('ok ', t.name);
} catch (e) {
failed++;
console.log('FAIL', t.name, '-', e.message);
}
}
console.log(failed ? `\n${failed} из ${tests.length} тестов упали` : `\nВсе ${tests.length} тестов пройдены`);
process.exit(failed ? 1 : 0);
+26
View File
@@ -0,0 +1,26 @@
# Переопределение S3-сервиса на MinIO.
# Использование:
# S3_IMAGE=minio/minio:RELEASE.2025-04-22T22-12-26Z \
# docker compose -f docker-compose.yml -f docker-compose.minio.yml up -d s3
#
# Учтите: MinIO прекратил публикацию свободных образов (docker.io/minio/minio
# удалён), поэтому образ нужно взять из доступного вам зеркала/архива и указать
# его в S3_IMAGE.
services:
s3:
image: ${S3_IMAGE:-minio/minio:latest}
container_name: whatido-s3
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY:-whatido}
MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:-whatido-secret}
MINIO_BROWSER: ${MINIO_BROWSER:-off}
TZ: Europe/Moscow
expose:
- "9000"
ports:
- "127.0.0.1:9000:9000"
- "127.0.0.1:9001:9001"
volumes:
- s3-data:/data
+109 -16
View File
@@ -3,6 +3,7 @@ name: whatido
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: whereldo
POSTGRES_USER: app
@@ -17,10 +18,48 @@ services:
timeout: 3s
retries: 10
# Redis: кэш запросов, rate limit, баны IP, кэш сессий, pub/sub для SSE и воркеров.
# Приложение не падает, если Redis недоступен — автоматически работает
# на in-memory кэше (см. redis.js).
# Отладка: docker compose exec redis redis-cli -a "$REDIS_PASSWORD" INFO
redis:
image: redis:7-alpine
container_name: whatido-redis
restart: unless-stopped
command: >
redis-server
--requirepass ${REDIS_PASSWORD}
--appendonly yes
--appendfsync everysec
--maxmemory ${REDIS_MAXMEMORY:-256mb}
--maxmemory-policy allkeys-lru
--save ""
environment:
TZ: Europe/Moscow
REDIS_PASSWORD: ${REDIS_PASSWORD}
expose:
- "6379"
ports:
- "127.0.0.1:6379:6379"
volumes:
- redis-data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" ping | grep -q PONG"]
interval: 5s
timeout: 3s
retries: 10
start_period: 5s
app:
build:
context: .
network: host
args:
GIT_COMMIT: ${GIT_COMMIT:-}
GIT_COMMIT_DATE: ${GIT_COMMIT_DATE:-}
restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway"
expose:
- "3003"
- "3443"
@@ -29,20 +68,59 @@ services:
- "3443:3443"
environment:
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/whereldo
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
REDIS_PREFIX: ${REDIS_PREFIX:-whatido}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
BACKUP_UPLOAD_LIMIT_MB: ${BACKUP_UPLOAD_LIMIT_MB:-500}
AI_MODEL: ${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf}
AI_PROMPT: ${AI_PROMPT:-}
AI_REQUEST_TIMEOUT_MS: ${AI_REQUEST_TIMEOUT_MS:-120000}
PHOTO_AI_URL: ${PHOTO_AI_URL:-http://photo-ai:8080}
NODE_ENV: production
TZ: Europe/Moscow
STORAGE_DRIVER: ${STORAGE_DRIVER:-local}
STORAGE_LOCAL_FALLBACK: ${STORAGE_LOCAL_FALLBACK:-1}
STORAGE_KEEP_LOCAL: ${STORAGE_KEEP_LOCAL:-0}
STORAGE_CACHE_MAX_AGE_HOURS: ${STORAGE_CACHE_MAX_AGE_HOURS:-168}
S3_ENDPOINT: ${S3_ENDPOINT:-http://minio:9000}
S3_REGION: ${S3_REGION:-us-east-1}
S3_BUCKET: ${S3_BUCKET:-whatido}
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-whatido}
S3_SECRET_KEY: ${S3_SECRET_KEY:-whatido-secret}
S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-1}
S3_PREFIX: ${S3_PREFIX:-}
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- ./uploads:/app/uploads
# S3-совместимое хранилище файлов. API 9000 доступен только внутри сети compose
# (плюс loopback хоста для отладки/миграции).
# По умолчанию — SeaweedFS: свободный S3-сервер, доступный в Docker Hub.
# Для MinIO (если образ доступен в вашем зеркале) используйте:
# docker compose -f docker-compose.yml -f docker-compose.minio.yml up -d s3
# Перенос файлов из ./uploads в бакет:
# docker compose exec -T app node scripts/migrate-to-s3.js --dry-run
s3:
image: ${S3_IMAGE:-chrislusf/seaweedfs:latest}
container_name: whatido-s3
restart: unless-stopped
command: server -dir=/data -s3 -s3.port=9000
environment:
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY:-whatido}
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY:-whatido-secret}
TZ: Europe/Moscow
expose:
- "9000"
ports:
- "127.0.0.1:9000:9000"
volumes:
- s3-data:/data
# Публикация через Tailscale (Serve / Funnel) без проброса портов.
# Приложение доступно по https://whatido.<tailnet>.ts.net
# tailscale:
@@ -72,22 +150,22 @@ services:
# если в wg/wg0.conf лежит конфиг — контейнер сначала поднимает VPN и
# только потом запускает туннель (исход Cloudflare через VPN). Без конфига
# туннель стартует сразу, как в базовой схеме.
cloudflared:
build:
context: .
dockerfile: Dockerfile.cloudflared
restart: unless-stopped
cap_add:
- NET_ADMIN
privileged: true
volumes:
- ./wg:/etc/wireguard:ro
environment:
TZ: Europe/Moscow
CLOUDFLARE_TUNNEL_URL: ${CLOUDFLARE_TUNNEL_URL:-http://app:3003}
WG_HANDSHAKE_TIMEOUT: ${WG_HANDSHAKE_TIMEOUT:-60}
depends_on:
- app
# cloudflared:
# build:
# context: .
# dockerfile: Dockerfile.cloudflared
# restart: unless-stopped
# cap_add:
# - NET_ADMIN
# privileged: true
# volumes:
# - ./wg:/etc/wireguard:ro
# environment:
# TZ: Europe/Moscow
# CLOUDFLARE_TUNNEL_URL: ${CLOUDFLARE_TUNNEL_URL:-http://app:3003}
# WG_HANDSHAKE_TIMEOUT: ${WG_HANDSHAKE_TIMEOUT:-60}
# depends_on:
# - app
@@ -111,7 +189,22 @@ services:
- "8080:8080"
# ИИ-улучшение фотографий (Real-ESRGAN x2, CPU).
# Поднимается вместе со стеком; если не нужен — PHOTO_AI_URL пустой в .env.
photo-ai:
build: ./photo-ai
container_name: photo-ai
restart: unless-stopped
environment:
MODEL_PATH: /models/RealESRGAN_x2plus.pth
MAX_INPUT_PIXELS: ${PHOTO_AI_MAX_PIXELS:-4000000}
TZ: Europe/Moscow
volumes:
- photo-ai-models:/models
volumes:
pgdata:
photo-ai-models:
redis-data:
s3-data:
+495 -2
View File
@@ -8,6 +8,7 @@
"name": "whereldo",
"version": "1.0.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.1141.0",
"bcrypt": "^5.1.1",
"express": "^4.21.0",
"express-rate-limit": "^8.7.0",
@@ -16,10 +17,319 @@
"lucide": "^1.44.0",
"multer": "^1.4.5-lts.1",
"pg": "^8.13.0",
"redis": "^5.12.1",
"sharp": "^0.34.5",
"tar": "^7.4.3"
}
},
"node_modules/@aws-sdk/checksums": {
"version": "3.1001.1",
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1001.1.tgz",
"integrity": "sha512-x12Q17KYlJAd3nKf8LV5LV0vt8sh8/6YfQLGPtrGnQf/tW4jqxPGq5GPpuVitpQYM3eUR4XB7CbxZf751NMbLw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-s3": {
"version": "3.1141.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1141.0.tgz",
"integrity": "sha512-uOVH37xGLenAdJkCPCin/JJG2PgWrFcSsDnQ9+C9Zq8N9Oalo5ol4xmn5fG28iWAlA/b/9boQZgHbMh+UsIhcg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/checksums": "^3.1001.1",
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/credential-provider-node": "^3.972.84",
"@aws-sdk/middleware-sdk-s3": "^3.972.77",
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/fetch-http-handler": "^5.8.0",
"@smithy/node-http-handler": "^4.12.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/core": {
"version": "3.978.1",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.1.tgz",
"integrity": "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.6",
"@aws-sdk/xml-builder": "^3.972.41",
"@aws/lambda-invoke-store": "^0.3.0",
"@smithy/core": "^3.35.0",
"@smithy/signature-v4": "^5.7.3",
"@smithy/types": "^4.19.0",
"bowser": "^2.11.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-env": {
"version": "3.972.72",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.72.tgz",
"integrity": "sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-http": {
"version": "3.972.74",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.74.tgz",
"integrity": "sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/fetch-http-handler": "^5.8.0",
"@smithy/node-http-handler": "^4.12.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.973.17",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.17.tgz",
"integrity": "sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/credential-provider-env": "^3.972.72",
"@aws-sdk/credential-provider-http": "^3.972.74",
"@aws-sdk/credential-provider-login": "^3.972.79",
"@aws-sdk/credential-provider-process": "^3.972.72",
"@aws-sdk/credential-provider-sso": "^3.973.16",
"@aws-sdk/credential-provider-web-identity": "^3.972.78",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/credential-provider-imds": "^4.5.2",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-login": {
"version": "3.972.79",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.79.tgz",
"integrity": "sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-node": {
"version": "3.972.84",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.84.tgz",
"integrity": "sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/credential-provider-env": "^3.972.72",
"@aws-sdk/credential-provider-http": "^3.972.74",
"@aws-sdk/credential-provider-ini": "^3.973.17",
"@aws-sdk/credential-provider-process": "^3.972.72",
"@aws-sdk/credential-provider-sso": "^3.973.16",
"@aws-sdk/credential-provider-web-identity": "^3.972.78",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/credential-provider-imds": "^4.5.2",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-process": {
"version": "3.972.72",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.72.tgz",
"integrity": "sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.973.16",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.16.tgz",
"integrity": "sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/token-providers": "3.1138.0",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.78",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.78.tgz",
"integrity": "sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-sdk-s3": {
"version": "3.972.77",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.77.tgz",
"integrity": "sha512-E7W2UOeUoc+lg3uIfR/dM7ZwusHwhBQrKMnlkRv4EXRR+C0YtV1pg25xC7GdZIhXH+NAMgZPCbE7o5to2cjFiw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/nested-clients": {
"version": "3.997.46",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.46.tgz",
"integrity": "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/fetch-http-handler": "^5.8.0",
"@smithy/node-http-handler": "^4.12.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.47",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.47.tgz",
"integrity": "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.6",
"@smithy/signature-v4": "^5.7.3",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/token-providers": {
"version": "3.1138.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1138.0.tgz",
"integrity": "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/types": {
"version": "3.974.6",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.6.tgz",
"integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.41",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz",
"integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws/lambda-invoke-store": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
"integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
@@ -654,6 +964,159 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/@redis/bloom": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.12.1.tgz",
"integrity": "sha512-PUUfv+ms7jgPSBVoo/DN4AkPHj4D5TZSd6SbJX7egzBplkYUcKmHRE8RKia7UtZ8bSQbLguLvxVO+asKtQfZWA==",
"license": "MIT",
"engines": {
"node": ">= 18.19.0"
},
"peerDependencies": {
"@redis/client": "^5.12.1"
}
},
"node_modules/@redis/client": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz",
"integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==",
"license": "MIT",
"dependencies": {
"cluster-key-slot": "1.1.2"
},
"engines": {
"node": ">= 18.19.0"
},
"peerDependencies": {
"@node-rs/xxhash": "^1.1.0",
"@opentelemetry/api": ">=1 <2"
},
"peerDependenciesMeta": {
"@node-rs/xxhash": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
}
}
},
"node_modules/@redis/json": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/@redis/json/-/json-5.12.1.tgz",
"integrity": "sha512-eOze75esLve4vfqDel7aMX08CNaiLLQS2fV8mpRN9NxPe1rVR4vQyYiW/OgtGUysF6QOr9ANhfxABKNOJfXdKg==",
"license": "MIT",
"engines": {
"node": ">= 18.19.0"
},
"peerDependencies": {
"@redis/client": "^5.12.1"
}
},
"node_modules/@redis/search": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/@redis/search/-/search-5.12.1.tgz",
"integrity": "sha512-ItlxbxC9cKI6IU1TLWoczwJCRb6TdmkEpWv05UrPawqaAnWGRu3rcIqsc5vN483T2fSociuyV1UkWIL5I4//2w==",
"license": "MIT",
"engines": {
"node": ">= 18.19.0"
},
"peerDependencies": {
"@redis/client": "^5.12.1"
}
},
"node_modules/@redis/time-series": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.12.1.tgz",
"integrity": "sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ==",
"license": "MIT",
"engines": {
"node": ">= 18.19.0"
},
"peerDependencies": {
"@redis/client": "^5.12.1"
}
},
"node_modules/@smithy/core": {
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.0.tgz",
"integrity": "sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/credential-provider-imds": {
"version": "4.5.2",
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz",
"integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.2",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/fetch-http-handler": {
"version": "5.8.0",
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz",
"integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.18.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/node-http-handler": {
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz",
"integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.18.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/signature-v4": {
"version": "5.7.4",
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz",
"integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/types": {
"version": "4.19.0",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz",
"integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
@@ -807,6 +1270,12 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bowser": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
@@ -881,6 +1350,15 @@
"node": ">=18"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/color-support": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
@@ -2067,6 +2545,22 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/redis": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz",
"integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==",
"license": "MIT",
"dependencies": {
"@redis/bloom": "5.12.1",
"@redis/client": "5.12.1",
"@redis/json": "5.12.1",
"@redis/search": "5.12.1",
"@redis/time-series": "5.12.1"
},
"engines": {
"node": ">= 18.19.0"
}
},
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@@ -2402,8 +2896,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/type-is": {
"version": "1.6.18",
+2
View File
@@ -6,6 +6,7 @@
"start": "node server.js"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1141.0",
"bcrypt": "^5.1.1",
"express": "^4.21.0",
"express-rate-limit": "^8.7.0",
@@ -14,6 +15,7 @@
"lucide": "^1.44.0",
"multer": "^1.4.5-lts.1",
"pg": "^8.13.0",
"redis": "^5.12.1",
"sharp": "^0.34.5",
"tar": "^7.4.3"
},
+28
View File
@@ -0,0 +1,28 @@
FROM python:3.10-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir "typing-extensions==4.12.2" "numpy<2"
RUN pip install --no-cache-dir torch torchvision --index-url https://download.pytorch.org/whl/cpu
RUN pip install --no-cache-dir realesrgan==0.3.0 fastapi "uvicorn[standard]" python-multipart opencv-python-headless
RUN BASICSR_DEG=$(python -c "import basicsr; import os; print(os.path.join(os.path.dirname(basicsr.__file__), 'data', 'degradations.py'))" 2>/dev/null) || \
BASICSR_DEG=$(find /usr/local/lib/python3.10 -path "*/basicsr/data/degradations.py" 2>/dev/null | head -1) && \
if [ -n "$BASICSR_DEG" ]; then \
sed -i 's/from torchvision.transforms.functional_tensor/from torchvision.transforms.functional/g' "$BASICSR_DEG" && \
echo "basicsr patch applied to $BASICSR_DEG"; \
else \
echo "basicsr degradations.py not found, skipping patch"; \
fi
COPY app.py .
ENV MODEL_PATH=/models/RealESRGAN_x2plus.pth
VOLUME /models
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"]
+73
View File
@@ -0,0 +1,73 @@
import io
import os
import asyncio
import threading
import urllib.request
from fastapi import FastAPI, File, Form, UploadFile
from fastapi.responses import Response
import numpy as np
import cv2
import torch
from basicsr.archs.rrdbnet_arch import RRDBNet
from realesrgan import RealESRGANer
MODEL_PATH = os.environ.get('MODEL_PATH', '/models/RealESRGAN_x2plus.pth')
MODEL_URL = 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth'
MAX_PIXELS = int(os.environ.get('MAX_INPUT_PIXELS', str(4_000_000)))
lock = threading.Lock()
upsampler = None
app = FastAPI()
def ensure_model():
if not os.path.exists(MODEL_PATH):
os.makedirs(os.path.dirname(MODEL_PATH), exist_ok=True)
tmp = MODEL_PATH + '.tmp'
urllib.request.urlretrieve(MODEL_URL, tmp)
os.replace(tmp, MODEL_PATH)
def load_model():
global upsampler
ensure_model()
model = RRDBNet(num_in_ch=3, num_out_ch=3, scale=2, num_feat=64, num_block=23, num_grow_ch=32)
upsampler = RealESRGANer(
scale=2,
model_path=MODEL_PATH,
model=model,
tile=256,
tile_pad=10,
pre_pad=0,
half=False,
device='cpu',
)
@app.on_event('startup')
async def startup():
await asyncio.to_thread(load_model)
@app.get('/health')
def health():
return {'ok': upsampler is not None}
@app.post('/enhance')
async def enhance(image: UploadFile = File(...), scale: int = Form(2)):
data = await image.read()
img = cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR)
if img is None:
return Response('bad image', status_code=400)
if img.shape[0] * img.shape[1] > MAX_PIXELS:
r = (MAX_PIXELS / (img.shape[0] * img.shape[1])) ** 0.5
img = cv2.resize(img, (int(img.shape[1] * r), int(img.shape[0] * r)), interpolation=cv2.INTER_AREA)
outscale = min(max(int(scale), 2), 4)
with lock:
out, _ = upsampler.enhance(img, outscale=outscale)
ok, enc = cv2.imencode('.jpg', out, [int(cv2.IMWRITE_JPEG_QUALITY), 92])
if not ok:
return Response('encode failed', status_code=500)
return Response(enc.tobytes(), media_type='image/jpeg')
+156 -7
View File
@@ -4,7 +4,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.layout{display:flex;min-height:100dvh}
.sidebar{width:var(--sidebar);background:var(--card);border-right:1px solid var(--border);padding:24px 0;position:fixed;top:0;left:0;height:100dvh;display:flex;flex-direction:column}
.sidebar .logo{font-size:1.1rem;font-weight:700;padding:0 20px 20px;border-bottom:1px solid var(--border)}
.sidebar .logo{font-size:1.1rem;font-weight:700;padding:0 20px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;min-width:0}
.sidebar .logo-ic{height:28px;width:auto;min-width:28px;background:var(--bg);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0}
.sidebar .logo-ic img{max-width:100%;max-height:100%;object-fit:contain}
.sidebar #sidebarName{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
.user-box{padding:14px 20px;border-bottom:1px solid var(--border);display:flex;flex-direction:column;gap:2px}
.user-box .user-name{font-weight:600;font-size:.9rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.user-box .user-role{color:var(--muted);font-size:.72rem}
@@ -29,6 +32,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.sidebar nav a.active svg{color:var(--accent)}
.sidebar .bottom a[data-nav=home] svg{color:#2563eb}
.sidebar .bottom{padding:12px 20px;border-top:1px solid var(--border)}
.sidebar .version{color:var(--muted);font-size:.72rem;padding:0 20px 8px}
.sidebar .bottom a{display:flex;align-items:center;gap:10px;color:var(--muted);text-decoration:none;font-size:.8rem;padding:6px 0}
.sidebar .bottom a:hover{color:var(--text)}
.sidebar .bottom .logout{color:#ef4444;cursor:pointer;margin-top:4px}
@@ -49,8 +53,8 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.filters label{font-size:.8rem;color:var(--muted);display:block;margin-bottom:4px}
.search-input{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;outline:none}
.search-input:focus{border-color:var(--accent)}
#studentFilter,#linkStudent{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;outline:none;width:100%;max-width:220px}
#studentFilter:focus,#linkStudent:focus{border-color:var(--accent)}
#studentFilter,#linkStudent,#moduleFilter{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;outline:none;width:100%;max-width:220px}
#studentFilter:focus,#linkStudent:focus,#moduleFilter:focus{border-color:var(--accent)}
.pager{display:flex;gap:6px;align-items:center;justify-content:center;margin-top:20px;flex-wrap:wrap}
.pager button{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:6px 12px;font-size:.85rem;cursor:pointer}
@@ -127,6 +131,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.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:focus{border-color:var(--accent)}
.item.expired{opacity:.6;border-color:rgba(239,68,68,.35);background:rgba(239,68,68,.03)}
.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-soon{display:inline-block;font-size:.68rem;font-weight:700;line-height:1;padding:3px 8px;border-radius:999px;background:rgba(245,158,11,.14);color:#f59e0b;vertical-align:middle;margin-left:6px}
.attach-list{max-height:280px;overflow-y:auto;display:flex;flex-direction:column;gap:4px;border:1px solid var(--border);border-radius:8px;padding:8px}
.attach-list label{display:flex;gap:8px;align-items:center;font-size:.85rem;color:var(--text);margin:0;cursor:pointer}
.attach-list .cur{color:var(--muted);font-size:.75rem;margin-left:auto}
@@ -158,7 +166,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.group-card .save-sched:hover{opacity:.85}
.group-card .btn-link{background:var(--bg);border:1px solid var(--border);color:var(--accent);border-radius:8px;padding:8px 14px;font-size:.85rem;font-weight:600;cursor:pointer}
.group-card .btn-link:hover{border-color:var(--accent)}
.settings-card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:24px;max-width:680px;display:flex;flex-direction:column;gap:16px}
.settings-card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:24px;display:flex;flex-direction:column;gap:16px}
.settings-row{display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap}
.settings-row label{font-weight:600;display:block;margin-bottom:4px}
.settings-row .hint{font-size:.8rem;color:var(--muted);max-width:420px;margin:0}
@@ -192,6 +200,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.edit-modal .actions button:hover{opacity:.85}
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#16a34a;color:#fff;padding:12px 24px;border-radius:var(--radius);font-size:.9rem;opacity:0;transition:opacity .3s;pointer-events:none;z-index:200}
.toast.show{opacity:1}
.pf-photos{display:flex;flex-wrap:wrap;gap:10px}
.pf-photo{position:relative;width:96px;height:96px;border-radius:10px;overflow:hidden;border:2px solid var(--border);cursor:pointer;flex:0 0 auto}
.pf-photo.main{border-color:var(--accent)}
.pf-photo img{width:100%;height:100%;object-fit:cover;display:block}
.pf-badge{position:absolute;left:4px;bottom:4px;background:var(--accent);color:#fff;font-size:.65rem;font-weight:600;padding:2px 6px;border-radius:6px}
.pf-del{position:absolute;top:4px;right:4px;width:20px;height:20px;border:none;border-radius:50%;background:rgba(0,0,0,.6);color:#fff;font-size:.7rem;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center}
.pf-del:hover{background:#ef4444}
/* --- Фото группы (хронология) --- */
.group-cover{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:10px;display:block;background:var(--bg)}
@@ -242,17 +257,23 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.del-btn{background:none;border:none;color:#ef4444;cursor:pointer;font-size:1.15rem;padding:4px;flex-shrink:0}
.del-btn:hover{opacity:.8}
.trash-time{color:var(--muted);font-size:.75rem;margin-top:8px}
.pending-toggle{margin-bottom:16px;max-width:680px}
.trash-section{margin-top:24px}
.trash-section h4{margin:16px 0 8px;font-size:.95rem;font-weight:600}
.card.pending{border-color:#d97706;background:linear-gradient(180deg,rgba(217,119,6,.06),transparent 55%)}
.trash-time.warn{color:#d97706;font-weight:600}
/* --- Настройки (UX/UI) --- */
.page-head{display:flex;align-items:baseline;gap:14px;margin-bottom:24px}
.page-head h2{font-size:1.3rem;font-weight:600;margin:0}
.page-sub{color:var(--muted);font-size:.85rem}
.settings-stack{display:flex;flex-direction:column;gap:20px;max-width:680px}
.settings-stack{display:flex;flex-direction:column;gap:20px}
.link-fields{display:grid;grid-template-columns:1fr 1fr;gap:12px;align-items:start}
.link-fields > .full{grid-column:1/-1}
@media(max-width:600px){.link-fields{grid-template-columns:1fr}}
.settings-stack-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:20px;max-width:1200px}
.settings-stack-grid > .settings-card{max-width:none;height:100%}
.settings-stack-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px;width:100%}
@media(max-width:900px){.settings-stack-grid{grid-template-columns:1fr}}
.settings-stack-grid > .settings-card{max-width:none;min-width:0;height:100%}
.settings-stack-grid > .settings-card.wide{grid-column:1/-1}
.settings-card .card-head{display:flex;align-items:center;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--border);margin-top:-2px}
.settings-card .card-head .ic{width:38px;height:38px;border-radius:11px;background:rgba(37,99,235,.08);display:flex;align-items:center;justify-content:center;font-size:1.15rem;flex-shrink:0}
@@ -287,6 +308,14 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.save-bar .hint{color:var(--muted);font-size:.85rem;display:inline-flex;align-items:center;gap:8px}
@media(max-width:640px){.save-bar{left:16px;right:16px}}
.logo-picker{display:flex;align-items:flex-start;gap:20px;flex-wrap:wrap}
.logo-preview{width:160px;max-width:100%;min-width:96px;min-height:72px;max-height:96px;height:auto;border:1px dashed var(--border);background:var(--bg);display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0;font-size:1.4rem;font-weight:700;color:var(--muted)}
.logo-preview img{max-width:100%;max-height:96px;width:auto;height:auto;object-fit:contain;display:none}
.logo-picker-actions{display:flex;flex-direction:column;gap:10px;align-items:flex-start;min-width:0}
.logo-picker-btns{display:flex;gap:10px;flex-wrap:wrap}
.logo-upload-status{font-size:.8rem;color:var(--muted);min-height:18px;display:flex;align-items:center;gap:8px}
.logo-upload-status .fail{color:#ef4444}
/* --- Дашборд --- */
.dash-grid{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,1fr);gap:20px;align-items:start}
.dash-col{display:flex;flex-direction:column;gap:20px;min-width:0}
@@ -377,6 +406,27 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.audit-details:hover{text-decoration:underline}
.audit-details-body{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px;max-height:60vh;overflow:auto}
.audit-details-body pre{margin:0;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.78rem;line-height:1.45;color:var(--text)}
.audit-details-body.audit-loading{opacity:.6}
.audit-details-head{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px}
.audit-pill{background:var(--card);border:1px solid var(--border);border-radius:999px;padding:3px 10px;font-size:.72rem;color:var(--muted)}
.audit-changes{display:flex;flex-direction:column;gap:12px}
.audit-change{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:10px 12px}
.audit-change-title{font-weight:600;font-size:.82rem;margin-bottom:6px}
.audit-change-stats{display:flex;flex-wrap:wrap;gap:10px;font-size:.75rem;margin-bottom:8px}
.audit-change-line{font-size:.8rem;line-height:1.6;word-break:break-word}
.audit-tag-del,.audit-tag-add{display:inline-block;min-width:52px;font-size:.7rem;text-transform:uppercase;letter-spacing:.03em;padding:1px 6px;border-radius:4px;margin-right:6px}
.audit-tag-del{background:rgba(239,68,68,.14);color:#dc2626}
.audit-tag-add{background:rgba(22,163,74,.14);color:#16a34a}
.audit-stat-add{color:#16a34a;font-weight:600}
.audit-stat-del{color:#dc2626;font-weight:600}
.audit-stat-muted{color:var(--muted)}
.audit-diff{white-space:pre-wrap;word-break:break-word;font-size:.85rem;line-height:1.6;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:10px;max-height:40vh;overflow:auto}
.audit-diff-add{background:rgba(22,163,74,.18);color:#14532d;text-decoration:none;border-radius:3px;padding:0 2px}
.audit-diff-del{background:rgba(239,68,68,.16);color:#7f1d1d;border-radius:3px;padding:0 2px}
.audit-truncated{margin-top:6px;font-size:.72rem;color:#b45309}
.audit-raw{margin-top:12px;font-size:.78rem;color:var(--muted)}
.audit-raw summary{cursor:pointer}
.audit-raw pre{margin-top:8px;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:10px}
/* --- AI Correction --- */
.ai-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;padding:4px 8px;border-radius:6px;transition:color .15s,background .15s;vertical-align:middle;margin-left:6px}
@@ -418,4 +468,103 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.worker-counts .chip.skipped b{color:var(--muted)}
.worker-counts .chip.reverted b{color:var(--accent)}
@keyframes entryEnter{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
@keyframes entryPopIn{0%{opacity:0;transform:translateY(26px) scale(.95)}55%{opacity:1;transform:translateY(-5px) scale(1.015)}100%{opacity:1;transform:none}}
@keyframes entryFadeOut{to{opacity:0}}
.entry-enter{animation:entryEnter .4s cubic-bezier(.22,1,.36,1) both}
.entry-appear{animation:entryPopIn .6s cubic-bezier(.22,1,.36,1) both;position:relative}
.entry-appear::before{content:'';position:absolute;inset:-1px;border-radius:inherit;box-shadow:0 0 0 3px rgba(247,201,14,.9),0 10px 30px rgba(247,201,14,.4);animation:entryFadeOut 2.4s ease .35s both;pointer-events:none}
.entry-appear::after{content:'';position:absolute;inset:0;border-radius:inherit;background:linear-gradient(180deg,rgba(247,201,14,.16),transparent 55%);animation:entryFadeOut 2.4s ease .35s both;pointer-events:none}
@media (prefers-reduced-motion: reduce){.entry-enter,.entry-appear,.entry-appear::before,.entry-appear::after{animation:none}}
.ai-profiles-list{margin-top:12px;display:flex;flex-direction:column;gap:8px}
.ai-profile-item{display:flex;align-items:center;gap:12px;padding:10px 14px;border:1px solid var(--border);border-radius:10px;background:var(--card)}
.ai-profile-item.active{border-color:var(--accent);background:rgba(37,99,235,.05)}
.ai-profile-item .pi-info{flex:1;min-width:0}
.ai-profile-item .pi-name{font-weight:600;font-size:.9rem;display:flex;align-items:center;gap:8px}
.ai-profile-item .pi-meta{color:var(--muted);font-size:.78rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ai-profile-item .pi-actions{display:flex;gap:6px;flex-shrink:0}
.pi-btn{background:none;border:1px solid var(--border);border-radius:8px;padding:5px 10px;font-size:.78rem;cursor:pointer;color:var(--text);transition:all .15s}
.pi-btn:hover{border-color:var(--accent);color:var(--accent)}
.pi-btn.danger:hover{border-color:#ef4444;color:#ef4444}
.pi-badge{font-size:.68rem;font-weight:700;padding:2px 8px;border-radius:999px;background:rgba(22,163,74,.12);color:#16a34a;white-space:nowrap}
/* --- Настройки (UX/UI v2) --- */
body[data-page=settings] .page-head{align-items:flex-end}
.page-status{margin-left:auto;display:inline-flex;align-items:center;gap:8px;font-size:.8rem;color:var(--muted);background:var(--card);border:1px solid var(--border);border-radius:999px;padding:6px 14px;white-space:nowrap;transition:opacity .3s}
.page-status.loaded{opacity:0;pointer-events:none}
.page-status.err{color:#ef4444;opacity:1}
.head-btn{display:inline-flex;align-items:center;gap:7px;background:var(--card);border:1px solid var(--border);color:var(--muted);border-radius:10px;padding:8px 14px;font-size:.82rem;font-weight:600;cursor:pointer;transition:all .15s;white-space:nowrap;flex-shrink:0}
.head-btn:hover{color:var(--accent);border-color:var(--accent)}
.head-btn svg{width:14px;height:14px}
.settings-layout{display:grid;grid-template-columns:190px minmax(0,1fr);gap:28px;align-items:start}
.settings-nav{position:sticky;top:16px;display:flex;flex-direction:column;gap:2px}
.settings-nav a{display:block;padding:9px 13px;border-radius:10px;color:var(--muted);text-decoration:none;font-size:.86rem;font-weight:500;transition:all .15s;white-space:nowrap}
.settings-nav a:hover{color:var(--text);background:var(--card)}
.settings-nav a.active{color:var(--accent);background:var(--card);font-weight:600;box-shadow:inset 2px 0 0 var(--accent)}
.settings-nav a svg{width:15px;height:15px;flex-shrink:0;vertical-align:-3px;margin-right:2px}
.settings-layout .settings-stack,.settings-layout .settings-stack-grid{max-width:none}
.settings-card[id]{scroll-margin-top:16px}
.settings-card{transition:border-color .2s,box-shadow .2s}
.settings-card:target{border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,.1)}
.head-live{margin-left:auto;font-size:.72rem;color:var(--muted);white-space:nowrap}
.toggle{position:relative;display:flex;align-items:center;gap:12px;padding:10px 12px;background:var(--bg);border:1px solid transparent;border-radius:10px;cursor:pointer;transition:border-color .15s,background .15s}
.toggle:hover{border-color:var(--border);background:var(--card)}
.toggle input{position:absolute;opacity:0;width:1px;height:1px}
.toggle-track{position:relative;width:40px;height:22px;border-radius:999px;background:#d4d4d4;flex-shrink:0;transition:background .2s}
.toggle-thumb{position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.25);transition:transform .2s}
.toggle input:checked ~ .toggle-track{background:var(--accent)}
.toggle input:checked ~ .toggle-track .toggle-thumb{transform:translateX(18px)}
.toggle input:focus-visible ~ .toggle-track{box-shadow:0 0 0 3px rgba(37,99,235,.25)}
.toggle-text{display:flex;flex-direction:column;gap:1px;min-width:0}
.toggle-text b{font-size:.87rem;font-weight:600}
.toggle-text small{font-size:.74rem;color:var(--muted);line-height:1.4}
.settings-field{position:relative}
.field-error{color:#ef4444;font-size:.75rem;font-weight:600;min-height:0;display:block}
.settings-input.invalid{border-color:#ef4444;box-shadow:0 0 0 3px rgba(239,68,68,.1)}
.settings-input.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.84rem;line-height:1.5;min-height:110px;resize:vertical}
.preset-btns{display:flex;gap:6px;flex-wrap:wrap;margin-top:4px}
.preset-btn{background:var(--card);border:1px solid var(--border);border-radius:999px;padding:5px 12px;font-size:.75rem;font-weight:600;color:var(--muted);cursor:pointer;transition:all .15s}
.preset-btn:hover{border-color:var(--accent);color:var(--accent)}
.preset-btn.active{background:rgba(37,99,235,.08);border-color:var(--accent);color:var(--accent)}
.toast.error{background:#dc2626}
.queue-link{color:var(--accent);text-decoration:none;font-weight:600;font-size:.8rem;white-space:nowrap}
.queue-link:hover{text-decoration:underline}
.ai-queue{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.ai-queue .chip{background:var(--bg);border:1px solid var(--border);border-radius:999px;padding:6px 12px;font-size:.78rem;color:var(--text);display:inline-flex;align-items:center;gap:6px}
.ai-queue .chip b{color:var(--accent);font-weight:700}
.ban-chip{flex-direction:column;align-items:flex-start;gap:2px;padding-right:8px}
.chip-sub{font-size:.72rem;color:var(--muted);font-weight:400}
.unban-btn{background:none;border:none;color:var(--accent);cursor:pointer;font-weight:700;font-size:.9rem;padding:0 2px;line-height:1}
.unban-btn:hover{color:#ef4444}
.ban-chip.confirming{background:rgba(239,68,68,.07);border-color:rgba(239,68,68,.4)}
.chip-confirm{display:inline-flex;align-items:center;gap:8px;font-size:.74rem;font-weight:600;white-space:nowrap;padding-right:4px}
.chip-yes,.chip-no{background:none;border:none;cursor:pointer;font-weight:700;padding:0}
.chip-yes{color:#16a34a}
.chip-no{color:var(--muted)}
.btn-primary.danger-ghost{background:var(--card);border:1px solid rgba(239,68,68,.35);color:#ef4444}
.btn-primary.danger-ghost:hover{border-color:#ef4444;opacity:1}
.ai-test-status{font-size:.85rem;min-height:1.3em}
.ai-test-status .ok{color:#16a34a;font-weight:600}
.ai-test-status .fail{color:#ef4444;font-weight:600}
.save-bar{animation:saveBarIn .25s cubic-bezier(.22,1,.36,1)}
.save-bar.show{border-color:rgba(245,158,11,.5)}
.save-bar-actions{display:flex;gap:10px;flex-wrap:wrap}
@keyframes saveBarIn{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
.icon-btn{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:8px;border:1px solid var(--border);background:var(--card);color:var(--muted);cursor:pointer;transition:all .15s}
.icon-btn:hover{color:var(--accent);border-color:var(--accent)}
.icon-btn svg{width:14px;height:14px}
.icon-btn.spinning svg{animation:spin 1s linear infinite}
.sys-card.warn{border-color:rgba(245,158,11,.5);background:rgba(245,158,11,.05)}
.sys-card.danger{border-color:rgba(239,68,68,.5);background:rgba(239,68,68,.05)}
@media(max-width:1024px){
.settings-layout{grid-template-columns:1fr;gap:14px}
.settings-nav{position:sticky;top:0;z-index:40;flex-direction:row;gap:6px;overflow-x:auto;background:var(--bg);padding:8px 0;scrollbar-width:none}
.settings-nav::-webkit-scrollbar{display:none}
.settings-nav a{border:1px solid var(--border);background:var(--card);border-radius:999px;padding:7px 14px;font-size:.8rem}
.settings-nav a.active{box-shadow:none;border-color:var(--accent)}
.settings-card[id]{scroll-margin-top:60px}
}
@media (prefers-reduced-motion: reduce){.save-bar{animation:none}.toggle-track,.toggle-thumb{transition:none}.icon-btn.spinning svg{animation:none}}
+41 -1
View File
@@ -56,6 +56,7 @@ function buildSidebar(active) {
{ page: 'trash', label: 'Корзина', icon: 'trash-2' },
];
const adminNav = [
{ page: 'modules', label: 'Темы модулей', icon: 'layers' },
{ page: 'branches', label: 'Филиалы', icon: 'building-2' },
{ page: 'users', label: 'Пользователи', icon: 'user-cog' },
{ page: 'worker', label: 'Воркер ИИ', icon: 'bot' },
@@ -67,7 +68,10 @@ function buildSidebar(active) {
const userName = currentUser?.name || currentUser?.username || '';
const roleLabel = isAdmin() ? 'Администратор' : 'Тутор';
el.innerHTML = `
<div class="logo">Админ-панель</div>
<div class="logo">
<span class="logo-ic" id="sidebarLogo"></span>
<span id="sidebarName">Админ-панель</span>
</div>
<div class="user-box">
<div class="user-name">${esc(userName)}</div>
<div class="user-role">${roleLabel}</div>
@@ -76,10 +80,46 @@ function buildSidebar(active) {
${nav.map(n => `<a href="${n.page}.html" data-nav="${n.page}" class="${n.page === active ? 'active' : ''}"><i data-lucide="${n.icon}"></i>${n.label}</a>`).join('')}
</nav>
<div class="bottom">
<div class="version" id="sidebarVersion"></div>
<a href="/" data-nav="home"><i data-lucide="home"></i>На главную</a>
<a class="logout" href="#" data-action="logout"><i data-lucide="log-out"></i>Выйти</a>
</div>`;
renderIcons();
loadVersion();
loadBrand();
}
async function loadBrand() {
const logoEl = document.getElementById('sidebarLogo');
const nameEl = document.getElementById('sidebarName');
if (!logoEl || !nameEl) return;
try {
const res = await fetch(`${API}/api/public-settings`);
if (!res.ok) return;
const s = await res.json();
if ((s.system_name || '').trim()) nameEl.textContent = s.system_name;
if (s.system_logo) {
const img = document.createElement('img');
img.src = s.system_logo;
img.alt = '';
logoEl.classList.add('has-logo');
logoEl.appendChild(img);
}
} catch {}
}
async function loadVersion() {
const el = document.getElementById('sidebarVersion');
if (!el) return;
try {
const res = await fetch(`${API}/version.json?t=${Date.now()}`);
if (!res.ok) { el.textContent = ''; return; }
const v = await res.json();
if (!v.short) { el.textContent = ''; return; }
const d = (v.date || '').slice(0, 10);
el.textContent = `v${v.short}${d ? ' · ' + d : ''}`;
el.title = `${v.short} · ${v.date || ''}`;
} catch { el.textContent = ''; }
}
// --- Common UI ---
+8 -3
View File
@@ -16,6 +16,9 @@
</div>
<div class="actions-row">
<select id="auditActionFilter" class="settings-input" style="max-width:280px">
<option value="">Все действия</option>
</select>
<input type="search" id="auditFilter" class="settings-input" placeholder="Фильтр по действию..." style="max-width:300px">
<button class="btn-link" id="auditRefreshBtn" type="button" title="Обновить">Обновить</button>
<span class="hint" id="auditCount"></span>
@@ -26,6 +29,7 @@
<thead>
<tr>
<th>Время</th>
<th>Кто</th>
<th>Действие</th>
<th>Детали</th>
<th>IP</th>
@@ -39,11 +43,12 @@
</div>
</div>
<div class="modal-overlay" id="auditDetailsModal">
<div class="edit-modal" style="max-width:640px">
<div class="edit-modal" style="max-width:820px">
<h3 id="auditDetailsTitle">Детали действия</h3>
<div class="audit-details-body"><pre id="auditDetailsBody"></pre></div>
<div class="audit-details-head" id="auditDetailsHead"></div>
<div class="audit-details-body" id="auditDetailsBody"></div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="cancel" id="auditDetailsCloseBtn">Закрыть</button>
<button type="button" class="btn-primary ghost" id="auditDetailsCloseBtn">Закрыть</button>
</div>
</div>
</div>
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -14,6 +14,7 @@
<div class="add-row" style="align-items:center;gap:8px">
<input type="text" id="newGroup" placeholder="Название группы" style="flex:1;max-width:300px">
<select id="newGroupBranch" style="background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;min-width:180px"><option value="">— без филиала —</option></select>
<select id="newGroupTutor" style="background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;min-width:160px"><option value="">— не назначен —</option></select>
<button type="button" id="addGroupBtn">Добавить</button>
</div>
<div class="list" id="groupList"></div>
+40 -6
View File
@@ -9,7 +9,10 @@
:root{--bg:#f5f5f5;--card:#fff;--border:#e5e5e5;--accent:#2563eb;--text:#171717;--muted:#737373;--radius:12px}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100dvh;padding:24px 16px}
h1{font-size:1.5rem;font-weight:600;margin-bottom:8px;letter-spacing:-.02em;text-align:center}
.sub{color:var(--muted);font-size:.85rem;margin-bottom:32px;text-align:center}
.sub{color:var(--muted);font-size:.85rem;text-align:center}
.brand-head{display:flex;align-items:center;justify-content:center;gap:16px;margin-bottom:32px}
.brand-logo{height:76px;width:auto;max-width:min(46vw,340px);object-fit:contain;flex-shrink:0}
.brand-text{display:flex;flex-direction:column;align-items:flex-start}
.form{width:100%;max-width:900px;margin:0 auto;display:grid;grid-template-columns:320px 1fr;gap:32px;align-items:start}
@media(max-width:760px){.form{grid-template-columns:1fr;max-width:480px}}
@@ -36,9 +39,14 @@ h1{font-size:1.5rem;font-weight:600;margin-bottom:8px;letter-spacing:-.02em;text
.files-list .file-row .f-size{color:var(--muted);flex-shrink:0}
.files-list .file-row button{background:none;border:none;color:var(--muted);cursor:pointer;font-size:.85rem;padding:0 2px;flex-shrink:0}
.files-list .file-row button:hover{color:#ef4444}
.paste-hint{font-size:.75rem;color:var(--muted);line-height:1.35;text-align:center}
.files-card .paste-hint{text-align:left}
@media(hover:none){.paste-hint{display:none}}
.preview-wrap{width:100%;display:flex;flex-direction:column;align-items:center;gap:8px}
.preview{max-width:100%;width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:var(--radius);background:var(--card);display:block;border:1px dashed var(--border)}
.preview{max-width:100%;width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:var(--radius);background:var(--card);display:block;border:1px dashed var(--border);cursor:pointer;transition:border-color .15s}
.preview.has-photo{border-style:solid}
.preview:hover,.preview:focus-visible{border-color:var(--accent)}
.preview-caption{font-size:.85rem;color:var(--muted);text-align:center;line-height:1.35}
.remove-photo{background:none;border:none;color:#ef4444;font-size:.85rem;cursor:pointer;padding:4px;display:none}
.remove-photo.show{display:block}
@@ -63,6 +71,12 @@ textarea{resize:vertical;min-height:160px;font-family:inherit;line-height:1.5}
.toast.show{opacity:1}
.toast.error{background:#dc2626}
.sent-panel{width:100%;max-width:900px;margin:32px auto 0;background:var(--card);border:2px solid var(--accent);border-radius:var(--radius);padding:40px 32px;display:none;flex-direction:column;gap:20px;align-items:center;text-align:center;box-shadow:0 8px 24px rgba(37,99,235,.18)}
.sent-check{width:88px;height:88px;border-radius:50%;background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:center;font-size:2.6rem}
.sent-panel h2{font-size:1.6rem;font-weight:700;letter-spacing:-.02em;color:var(--text)}
.sent-panel p{font-size:1.05rem;color:var(--muted);max-width:520px}
.sent-timer{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);padding:14px 20px;font-size:1rem;font-weight:600;color:var(--text)}
nav{position:fixed;top:16px;right:16px;display:flex;gap:8px}
nav a{color:var(--muted);text-decoration:none;font-size:.8rem;padding:6px 12px;border-radius:8px;transition:color .15s}
nav a:hover{color:var(--text)}
@@ -83,17 +97,23 @@ nav a:hover{color:var(--text)}
<nav>
<a href="admin.html">Админка</a>
</nav>
<h1 id="title">Что мы узнали на занятии</h1>
<p class="sub">Отметь, что ты делал(а) на занятии</p>
<div class="brand-head">
<img class="brand-logo" id="brandLogo" alt="" hidden>
<div class="brand-text">
<h1 id="title">Что мы узнали на занятии</h1>
<p class="sub">Отметь, что ты делал(а) на занятии</p>
</div>
</div>
<form class="form" id="form">
<div class="photo-col">
<div class="photo-card">
<div class="preview-wrap">
<img class="preview" id="preview" alt="Фото" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='640' height='480'%3E%3Crect width='100%25' height='100%25' fill='none' stroke='%23475569' stroke-width='2' stroke-dasharray='8 8'/%3E%3Ctext x='50%25' y='50%25' fill='%2364748b' font-family='sans-serif' font-size='28' text-anchor='middle' dominant-baseline='middle'%3EФото%3C/text%3E%3C/svg%3E">
<img class="preview" id="preview" alt="Фото" title="Открыть камеру" tabindex="0" role="button" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='640' height='480'%3E%3Crect width='100%25' height='100%25' fill='none' stroke='%23475569' stroke-width='2' stroke-dasharray='8 8'/%3E%3Ctext x='50%25' y='50%25' fill='%2364748b' font-family='sans-serif' font-size='28' text-anchor='middle' dominant-baseline='middle'%3EФото%3C/text%3E%3C/svg%3E">
<p class="preview-caption" id="previewCaption">Нажми чтобы сделать фото</p>
<button type="button" class="remove-photo" id="removePhotoBtn">Убрать фото</button>
</div>
<div class="photo-btns">
<div class="photo-btns" id="camBtns">
<button type="button" class="cam" id="openCamBtn">Камера</button>
</div>
</div>
@@ -104,6 +124,7 @@ nav a:hover{color:var(--text)}
</div>
<button type="button" class="files-btn" id="filesBtn">Выбрать файлы</button>
<input type="file" id="filesInput" multiple hidden>
<p class="paste-hint">Ctrl+V — вставить файлы из буфера</p>
<div class="files-list" id="filesList"></div>
</div>
</div>
@@ -121,6 +142,12 @@ nav a:hover{color:var(--text)}
</select>
</div>
<div class="field" id="moduleField" style="display:none">
<label>Тема модуля</label>
<input type="text" id="moduleInput" list="moduleInputList" placeholder="Начни вводить название темы..." autocomplete="off">
<datalist id="moduleInputList"></datalist>
</div>
<div class="field">
<label>Что сделал</label>
<textarea id="descInput" placeholder="Опиши, что проходил, что делал на занятии..." required></textarea>
@@ -132,6 +159,13 @@ nav a:hover{color:var(--text)}
</div>
</form>
<div class="sent-panel" id="sentPanel">
<div class="sent-check">✓</div>
<h2 id="sentTitle">Запись отправлена!</h2>
<p id="sentText">Спасибо! Твоя запись уже передана преподавателю.</p>
<div class="sent-timer" id="sentTimer"></div>
</div>
<footer class="footer">
<div class="f-left" id="footerLeft"></div>
<div class="f-right" id="footerRight"></div>
+81
View File
@@ -5,6 +5,34 @@
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Журнал — Админ-панель</title>
<link rel="stylesheet" href="admin.css">
<link rel="stylesheet" href="admin.css">
<style>
.enhance-modal { max-width: 1100px; width: 94vw; }
.enhance-body { display: flex; gap: 16px; align-items: stretch; margin-top: 4px; }
.enhance-compare { position: relative; flex: 1 1 auto; min-width: 0; border-radius: 10px; overflow: hidden; background: var(--bg); border: 1px solid var(--border); user-select: none; touch-action: none; cursor: ew-resize; }
.enhance-compare.zoomed { cursor: grab; }
.enhance-compare.panning { cursor: grabbing; }
.enhance-stage { transform-origin: 0 0; will-change: transform; }
.enhance-stage > img, .enhance-stage > canvas { display: block; width: 100%; height: 100%; }
.enhance-stage > img { height: auto; }
.enhance-top { position: absolute; inset: 0; overflow: hidden; }
.enhance-top .enhance-stage { position: absolute; inset: 0; }
.enhance-divider { position: absolute; top: 0; bottom: 0; left: 50%; width: 32px; transform: translateX(-50%); pointer-events: auto; cursor: ew-resize; }
.enhance-divider::before { content: ''; position: absolute; top: 0; bottom: 0; left: 50%; width: 2px; transform: translateX(-50%); background: #fff; box-shadow: 0 0 6px rgba(0,0,0,.6); pointer-events: none; }
.enhance-handle { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 34px; height: 34px; border-radius: 50%; background: #fff; color: #333; display: flex; align-items: center; justify-content: center; font-size: 1rem; box-shadow: 0 2px 8px rgba(0,0,0,.4); pointer-events: none; }
.enhance-badge { position: absolute; top: 10px; padding: 3px 10px; border-radius: 999px; background: rgba(0,0,0,.55); color: #fff; font-size: .72rem; pointer-events: none; }
.enhance-badge.left { left: 10px; }
.enhance-badge.right { right: 10px; }
.enhance-loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: .85rem; background: var(--bg); }
.enhance-sliders { flex: 0 0 240px; display: flex; flex-direction: column; gap: 14px; }
.enhance-sliders .settings-field input[type=range] { width: 100%; }
.enhance-foot { justify-content: flex-end; gap: 8px; margin-top: 14px; }
.photo-thumb { width: 56px; height: 56px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); display: block; }
@media (max-width: 800px) {
.enhance-body { flex-direction: column; }
.enhance-sliders { flex: 1 1 auto; display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
}
</style>
</head>
<body data-page="journal">
<div class="layout">
@@ -15,6 +43,7 @@
<div><label>Поиск</label><input type="text" id="searchInput" class="search-input" placeholder="Имя или описание"></div>
<div><label>Студент</label><input type="text" id="studentFilter" list="studentFilterList" placeholder="Все / введите имя..."><datalist id="studentFilterList"></datalist></div>
<div><label>Группа</label><select id="groupFilter"><option value="">Все группы</option></select></div>
<div><label>Модуль</label><input type="text" id="moduleFilter" list="moduleFilterList" placeholder="Все модули" autocomplete="off"><datalist id="moduleFilterList"></datalist></div>
<div><label>С</label><input type="date" id="dateFrom"></div>
<div><label>По</label><input type="date" id="dateTo"></div>
<div><label>&nbsp;</label><button type="button" 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>
@@ -48,6 +77,11 @@
<label>Группа <span style="color:#ef4444">*</span></label>
<select id="editGroup" class="settings-input" required></select>
</div>
<div class="settings-field">
<label>Тема модуля</label>
<input type="text" id="editModule" class="settings-input" list="editModuleList" placeholder="Начните вводить название..." autocomplete="off">
<datalist id="editModuleList"></datalist>
</div>
<div class="settings-field full">
<label>Что делала(а) <span style="color:#ef4444">*</span>
<button type="button" id="aiCorrectBtn" class="ai-btn" title="Исправить ошибки ИИ">✨</button>
@@ -161,6 +195,53 @@
</div>
</div>
<div class="modal-overlay" id="enhanceModal">
<div class="edit-modal enhance-modal">
<h3>Улучшение фотографии</h3>
<div class="enhance-body">
<div class="enhance-compare" id="enhanceCompare">
<div class="enhance-stage" id="enhanceStage">
<img id="enhanceOrig" alt="Оригинал">
</div>
<div class="enhance-top" id="enhanceTop" style="clip-path:inset(0 0 0 50%)">
<div class="enhance-stage" id="enhanceInner">
<canvas id="enhanceCanvas"></canvas>
</div>
</div>
<div class="enhance-divider" id="enhanceDivider"><div class="enhance-handle">⇔</div></div>
<div class="enhance-badge left">Оригинал</div>
<div class="enhance-badge right">Результат</div>
<div class="enhance-loading" id="enhanceLoading">Загрузка...</div>
</div>
<div class="enhance-sliders">
<div class="settings-field"><label>Яркость <span id="enhanceBrightnessVal">100%</span></label><input type="range" id="enhanceBrightness" min="50" max="150" value="100"></div>
<div class="settings-field"><label>Контраст <span id="enhanceContrastVal">100%</span></label><input type="range" id="enhanceContrast" min="50" max="150" value="100"></div>
<div class="settings-field"><label>Насыщенность <span id="enhanceSaturateVal">100%</span></label><input type="range" id="enhanceSaturate" min="0" max="200" value="100"></div>
<div class="settings-field"><label>Шумодав <span id="enhanceDenoiseVal">0%</span></label><input type="range" id="enhanceDenoise" min="0" max="100" value="0"></div>
<div class="settings-field"><label>Резкость <span id="enhanceSharpVal">0%</span></label><input type="range" id="enhanceSharp" min="0" max="100" value="0"></div>
</div>
</div>
<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="enhanceRestoreBtn" style="display:none" title="Вернуть исходную фотографию">↩ Вернуть оригинал</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="enhanceCancelBtn">Оставить как есть</button>
<button type="button" class="btn-primary" id="enhanceSaveBtn">Применить</button>
</div>
</div>
</div>
<div class="modal-overlay" id="photoHistoryModal">
<div class="edit-modal enhance-modal">
<h3 id="photoHistoryTitle">История изменений фотографии</h3>
<div id="photoHistoryList" style="display:flex;flex-direction:column;gap:12px;max-height:60vh;overflow:auto"></div>
<div class="card-foot enhance-foot">
<button type="button" class="btn-primary ghost" id="photoHistoryCloseBtn">Закрыть</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script>
+157 -8
View File
@@ -9,6 +9,11 @@ const ACTION_LABELS = {
'group.create': 'Создана группа',
'group.update': 'Изменена группа',
'group.delete': 'Удалена группа',
'group.soft-delete': 'Группа в корзину',
'group.restore': 'Группа восстановлена',
'group.permanent-delete': 'Группа удалена безвозвратно',
'group.schedule-delete': 'Группа помечена на удаление',
'group.unschedule': 'Удаление группы отменено',
'group.photo.create': 'Загружена фото группы',
'group.photo.update': 'Изменено фото группы',
'group.photo.reorder': 'Изменён порядок фото группы',
@@ -21,13 +26,32 @@ const ACTION_LABELS = {
'student.update': 'Изменён ученик',
'student.delete': 'Удалён ученик',
'student.batch-group': 'Ученики перенесены в группу',
'module.create': 'Создан модуль',
'module.update': 'Изменён модуль',
'module.delete': 'Модуль отключён',
'module.restore': 'Модуль включён',
'entry.update': 'Изменена запись',
'entry.photo.update': 'Изменено фото записи',
'entry.photo.set_main': 'Главное фото записи',
'entry.photo.delete': 'Удалено фото записи',
'entry.photo.enhance': 'Фото улучшено',
'entry.photo.enhance-ai.queue': 'ИИ-обработка фото поставлена в очередь',
'entry.photo.apply': 'Применён результат обработки фото',
'entry.photo.reject': 'Отклонён результат обработки фото',
'entry.photo.rollback': 'Фото отката к версии',
'entry.photo.restore_original': 'Возвращён оригинал фото',
'photo.job.preview': 'Создан предпросмотр обработки фото',
'photo.job.retry': 'Повтор обработки фото',
'photo.job.error': 'Ошибка обработки фото',
'photo-jobs.wake': 'Фото-воркер разбужен',
'photo-jobs.enabled': 'Переключена обработка фото',
'photo-jobs.requeue-failed': 'Ошибочные фото-задания возвращены в очередь',
'entry.soft-delete': 'Запись в корзину',
'entry.restore': 'Запись восстановлена',
'entry.permanent-delete': 'Запись удалена безвозвратно',
'entry.schedule-delete': 'Запись помечена на удаление',
'entry.unschedule': 'Удаление записи отменено',
'trash.clear': 'Корзина помечена на удаление',
'link.create': 'Создана ссылка',
'link.update': 'Изменена ссылка',
'link.delete': 'Удалена ссылка',
@@ -41,6 +65,11 @@ const ACTION_LABELS = {
'ai.wake': 'Воркер ИИ разбужен вручную',
'ai.enabled': 'Переключена автопроверка ИИ',
'ai.requeue-failed': 'Ошибочные записи возвращены в очередь ИИ',
'ai.profile.create': 'Создан профиль ИИ',
'ai.profile.update': 'Изменён профиль ИИ',
'ai.profile.delete': 'Удалён профиль ИИ',
'ai.profile.activate': 'Выбран активный профиль ИИ',
'export.student': 'Экспорт данных ученика',
};
const AUDIT_PAGE_SIZE = 50;
@@ -48,10 +77,30 @@ let auditPage = 1;
let auditTotal = 0;
let auditRows = [];
const EDIT_SOURCE_LABELS = {
manual: 'Вручную',
ai: 'ИИ',
ai_manual: 'ИИ + правка вручную',
ai_revert: 'Откат к оригиналу (ИИ)'
};
const FIELD_LABELS = {
student_name: 'ФИО ученика',
group_id: 'Группа',
module_id: 'Тема модуля',
description: 'Текст работы'
};
function fmtTime(t) {
try { return new Date(t).toLocaleString('ru'); } catch { return t || ''; }
}
function parseTarget(t) {
if (t === null || t === undefined) return null;
if (typeof t === 'object') return t;
try { return JSON.parse(t); } catch { return null; }
}
function dt(t) {
if (!t) return '';
if (typeof t === 'object') {
@@ -71,6 +120,76 @@ function fullTarget(t) {
} catch { return String(t); }
}
function changeLabel(c) {
return c.label || FIELD_LABELS[c.field] || c.field;
}
function changeSummary(c) {
if (c.field === 'description' && c.stats) return `текст: +${c.stats.added_words}/−${c.stats.removed_words} слов`;
if (c.field === 'description') return 'текст работы';
return changeLabel(c).toLowerCase();
}
function targetSummary(t) {
const o = parseTarget(t);
if (!o) return dt(t);
if (Array.isArray(o.changes)) {
if (!o.changes.length) return esc(`без изменений${o.source ? ` · ${EDIT_SOURCE_LABELS[o.source] || o.source}` : ''}`);
const parts = o.changes.map(changeSummary);
if (o.photos_added) parts.push(`фото +${o.photos_added}`);
const src = o.source ? ` · ${EDIT_SOURCE_LABELS[o.source] || o.source}` : '';
return esc(parts.join(', ') + src);
}
return dt(t);
}
function renderDiffSegments(segments) {
return (segments || []).map(s => {
const text = esc(s.text);
if (s.type === 'add') return `<ins class="audit-diff-add">${text}</ins>`;
if (s.type === 'del') return `<del class="audit-diff-del">${text}</del>`;
return `<span>${text}</span>`;
}).join('');
}
function renderChange(c) {
const label = changeLabel(c);
if (c.field !== 'description') {
return `<div class="audit-change">
<div class="audit-change-title">${esc(label)}</div>
<div class="audit-change-line"><span class="audit-tag-del">было</span> ${esc(c.before === null || c.before === undefined ? '—' : c.before)}</div>
<div class="audit-change-line"><span class="audit-tag-add">стало</span> ${esc(c.after === null || c.after === undefined ? '—' : c.after)}</div>
</div>`;
}
const s = c.stats || {};
const parts = [];
if (s.added_words || s.added_chars) parts.push(`<span class="audit-stat-add">+${s.added_words || 0} слов (+${s.added_chars || 0} симв.)</span>`);
if (s.removed_words || s.removed_chars) parts.push(`<span class="audit-stat-del">−${s.removed_words || 0} слов (−${s.removed_chars || 0} симв.)</span>`);
if (s.chars_before !== undefined) parts.push(`<span class="audit-stat-muted">было ${s.chars_before} симв. → стало ${s.chars_after} симв.</span>`);
return `<div class="audit-change">
<div class="audit-change-title">${esc(label)}</div>
<div class="audit-change-stats">${parts.join('')}</div>
<div class="audit-diff">${renderDiffSegments(c.diff) || '<span class="audit-stat-muted">нет текстового диффа</span>'}</div>
${c.truncated ? '<div class="audit-truncated">Показана только часть изменений</div>' : ''}
</div>`;
}
function renderTargetDetails(target) {
const o = parseTarget(target);
if (!o) return `<pre>${esc(fullTarget(target))}</pre>`;
if (!Array.isArray(o.changes)) return `<pre>${esc(fullTarget(target))}</pre>`;
const head = [];
if (o.id) head.push(`<span class="audit-pill">Запись #${esc(o.id)}</span>`);
if (o.source) head.push(`<span class="audit-pill">Источник: ${esc(EDIT_SOURCE_LABELS[o.source] || o.source)}</span>`);
head.push(`<span class="audit-pill">Полей затронуто: ${o.changes.length}</span>`);
if (o.photos_added) head.push(`<span class="audit-pill">Добавлено фото: ${o.photos_added}</span>`);
const body = o.changes.length
? o.changes.map(renderChange).join('')
: '<div class="audit-stat-muted">Изменений не зафиксировано.</div>';
return `${head.join('')}<div class="audit-changes">${body}</div>
<details class="audit-raw"><summary>Технические данные (JSON)</summary><pre>${esc(fullTarget(target))}</pre></details>`;
}
function renderPager(container, page, totalPages, onPageChange) {
if (totalPages <= 1) { container.innerHTML = ''; return; }
const pages = [];
@@ -91,19 +210,20 @@ function renderPager(container, page, totalPages, onPageChange) {
async function loadAudit() {
const el = document.getElementById('auditTable').querySelector('tbody');
el.innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Загрузка…</td></tr>';
el.innerHTML = '<tr><td colspan="5" style="color:var(--muted)">Загрузка…</td></tr>';
const p = new URLSearchParams();
p.set('limit', AUDIT_PAGE_SIZE);
p.set('offset', (auditPage - 1) * AUDIT_PAGE_SIZE);
const action = document.getElementById('auditActionFilter').value;
if (action) p.set('action', action);
const res = await fetch(`${API}/api/audit?${p}`, { headers: hdr() });
if (!res.ok) { el.innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Ошибка загрузки</td></tr>'; return; }
if (!res.ok) { el.innerHTML = '<tr><td colspan="5" style="color:var(--muted)">Ошибка загрузки</td></tr>'; return; }
const rows = await res.json();
// Total count for pagination (approximate: if we got less than page size, we're on last page)
const totalPages = rows.length < AUDIT_PAGE_SIZE ? auditPage : auditPage + 1;
auditTotal = totalPages;
document.getElementById('auditCount').textContent = rows.length ? `Записей на странице: ${rows.length}` : '';
const q = (document.getElementById('auditFilter').value || '').toLowerCase().trim();
const filtered = q ? rows.filter(r => (ACTION_LABELS[r.action] || r.action).toLowerCase().includes(q)) : rows;
const filtered = q ? rows.filter(r => `${ACTION_LABELS[r.action] || r.action} ${targetSummary(r.target)}`.toLowerCase().includes(q)) : rows;
if (!filtered.length) {
document.getElementById('auditEmpty').style.display = 'block';
el.innerHTML = '';
@@ -113,8 +233,9 @@ async function loadAudit() {
el.innerHTML = filtered.map((r, i) => `
<tr>
<td style="white-space:nowrap">${fmtTime(r.created_at)}</td>
<td style="white-space:nowrap">${esc(r.user_name || 'система')}</td>
<td><b>${esc(ACTION_LABELS[r.action] || r.action)}</b></td>
<td><button type="button" class="audit-details" data-audit-idx="${i}">${dt(r.target)}</button></td>
<td><button type="button" class="audit-details" data-audit-idx="${i}">${targetSummary(r.target)}</button></td>
<td style="color:var(--muted);white-space:nowrap">${esc(r.ip || '')}</td>
</tr>`).join('');
}
@@ -126,17 +247,44 @@ function goAuditPage(p) {
loadAudit();
}
function fillActionFilter() {
const sel = document.getElementById('auditActionFilter');
const current = sel.value;
const opts = Object.entries(ACTION_LABELS)
.sort((a, b) => a[1].localeCompare(b[1], 'ru'))
.map(([k, v]) => `<option value="${esc(k)}">${esc(v)}</option>`)
.join('');
sel.innerHTML = '<option value="">Все действия</option>' + opts;
sel.value = current;
}
document.getElementById('auditActionFilter').addEventListener('change', () => { auditPage = 1; loadAudit(); });
document.getElementById('auditFilter').addEventListener('input', () => { auditPage = 1; loadAudit(); });
document.getElementById('auditRefreshBtn').addEventListener('click', loadAudit);
document.getElementById('auditTable').addEventListener('click', e => {
document.getElementById('auditTable').addEventListener('click', async e => {
const btn = e.target.closest('.audit-details');
if (!btn) return;
const r = auditRows[parseInt(btn.dataset.auditIdx, 10)];
if (!r) return;
const modal = document.getElementById('auditDetailsModal');
const headEl = document.getElementById('auditDetailsHead');
const bodyEl = document.getElementById('auditDetailsBody');
document.getElementById('auditDetailsTitle').textContent = ACTION_LABELS[r.action] || r.action;
document.getElementById('auditDetailsBody').textContent = fullTarget(r.target);
document.getElementById('auditDetailsModal').classList.add('open');
headEl.innerHTML = `<span class="audit-pill">${esc(fmtTime(r.created_at))}</span><span class="audit-pill">${esc(r.user_name || 'система')}</span>${r.ip ? `<span class="audit-pill">IP ${esc(r.ip)}</span>` : ''}`;
bodyEl.innerHTML = renderTargetDetails(r.target);
modal.classList.add('open');
if (!r.id) return;
bodyEl.classList.add('audit-loading');
try {
const res = await fetch(`${API}/api/audit/${r.id}`, { headers: hdr() });
if (!res.ok) return;
const full = await res.json();
bodyEl.innerHTML = renderTargetDetails(full.target);
} catch (err) {
} finally {
bodyEl.classList.remove('audit-loading');
}
});
document.getElementById('auditDetailsCloseBtn').addEventListener('click', () => {
document.getElementById('auditDetailsModal').classList.remove('open');
@@ -148,6 +296,7 @@ document.getElementById('auditDetailsModal').addEventListener('click', e => {
(async () => {
if (await requireAdminPage()) {
buildSidebar(document.body.dataset.page);
fillActionFilter();
loadAudit();
}
})();
+4
View File
@@ -14,3 +14,7 @@ if (details) {
})();
document.querySelector('.error-actions button').addEventListener('click', () => history.back());
fetch('/api/public-settings').then(r => r.ok ? r.json() : null).then(s => {
if (s && (s.system_name || '').trim()) document.title = `Ошибка — ${s.system_name}`;
}).catch(() => {});
+29 -4
View File
@@ -1,5 +1,6 @@
const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
let branchesCache = [];
let tutorsCache = [];
let allGroups = [];
let currentCover = null;
@@ -8,6 +9,16 @@ async function loadBranchesCache() {
if (res.ok) branchesCache = await res.json();
}
async function loadTutorsCache() {
const res = await fetch(`${API}/api/users/tutors`, { headers: hdr() });
if (res.ok) tutorsCache = await res.json();
}
function tutorSelectHTML(selectedId) {
const opts = tutorsCache.map(t => `<option value="${t.id}" ${t.id === selectedId ? 'selected' : ''}>${esc(t.name || t.username)}</option>`).join('');
return `<div><label>Тутор</label><select class="tutor-select" style="background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-size:.8rem"><option value="">— не назначен —</option>${opts}</select></div>`;
}
function branchSelectHTML(selectedId) {
const opts = branchesCache.map(b => `<option value="${b.id}" ${b.id === selectedId ? 'selected' : ''}>${esc(b.name)}</option>`).join('');
return `<div><label>Филиал</label><select class="branch-select" style="background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-size:.8rem"><option value="">— без филиала —</option>${opts}</select></div>`;
@@ -15,6 +26,7 @@ function branchSelectHTML(selectedId) {
async function loadGroups() {
await loadBranchesCache();
await loadTutorsCache();
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
const groups = await res.json();
allGroups = groups;
@@ -33,6 +45,7 @@ async function loadGroups() {
</div>
<div class="group-schedule">
${branchSelectHTML(g.branch_id)}
${tutorSelectHTML(g.tutor_id)}
<div><label>День</label>
<select id="dow-${g.id}">
<option value="">—</option>
@@ -66,13 +79,15 @@ async function saveGroup(id) {
const groupCard = nameInput.closest('.group-card');
const branchSelect = groupCard ? groupCard.querySelector('.branch-select') : null;
const branch_id = branchSelect ? branchSelect.value : '';
const tutorSelect = groupCard ? groupCard.querySelector('.tutor-select') : null;
const tutor_id = tutorSelect ? (tutorSelect.value || null) : undefined;
const dow = document.getElementById(`dow-${id}`).value;
const ts = document.getElementById(`ts-${id}`).value;
const te = document.getElementById(`te-${id}`).value;
const res = await fetch(`${API}/api/groups/${id}`, {
method: 'PUT',
headers: hdrJson(),
body: JSON.stringify({ name, branch_id: branch_id || null, day_of_week: dow !== '' ? parseInt(dow) : null, time_start: ts || null, time_end: te || null })
body: JSON.stringify({ name, branch_id: branch_id || null, tutor_id, day_of_week: dow !== '' ? parseInt(dow) : null, time_start: ts || null, time_end: te || null })
});
if (res.ok) {
loadGroups();
@@ -87,16 +102,20 @@ async function addGroup() {
const name = input.value.trim();
if (!name) return;
await loadBranchesCache();
await loadTutorsCache();
const branchSelect = document.getElementById('newGroupBranch');
const branch_id = branchSelect ? branchSelect.value : '';
const res = await fetch(`${API}/api/groups`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name, branch_id: branch_id || null }) });
const tutorSelect = document.getElementById('newGroupTutor');
const tutor_id = tutorSelect && tutorSelect.value ? tutorSelect.value : null;
const res = await fetch(`${API}/api/groups`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name, branch_id: branch_id || null, tutor_id }) });
if (res.ok) { input.value = ''; loadGroups(); }
else { const e = await res.json(); alert(e.error); }
}
async function delGroup(id) {
if (!confirm('Удалить группу? Фото группы также будут удалены.')) return;
await fetch(`${API}/api/groups/${id}`, { method: 'DELETE', headers: hdr() });
if (!confirm('Удалить группу? Группа попадёт в корзину, восстановить её можно будет позже.')) return;
const res = await fetch(`${API}/api/groups/${id}`, { method: 'DELETE', headers: hdr() });
if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); }
loadGroups();
}
@@ -414,6 +433,12 @@ async function populateNewGroupBranch() {
const sel = document.getElementById('newGroupBranch');
sel.innerHTML = '<option value="">— без филиала —</option>' + branches.map(b => `<option value="${b.id}">${esc(b.name)}</option>`).join('');
}
const tRes = await fetch(`${API}/api/users/tutors`, { headers: hdr() });
if (tRes.ok) {
const tutors = await tRes.json();
const tSel = document.getElementById('newGroupTutor');
tSel.innerHTML = '<option value="">— не назначен —</option>' + tutors.map(t => `<option value="${t.id}">${esc(t.name || t.username)}</option>`).join('');
}
}
(async () => {
+273 -11
View File
@@ -4,17 +4,81 @@ const preview = document.getElementById('preview');
const placeholderSrc = preview.src;
const removePhotoBtn = document.getElementById('removePhotoBtn');
const groupInput = document.getElementById('groupInput');
const moduleInput = document.getElementById('moduleInput');
const moduleInputList = document.getElementById('moduleInputList');
const moduleField = document.getElementById('moduleField');
const moduleNames = new Map();
let moduleSearchTimer = null;
const toast = document.getElementById('toast');
const camVideo = document.getElementById('camVideo');
const camCanvas = document.getElementById('camCanvas');
const camModal = document.getElementById('camModal');
let capturedBlob = null;
let stream = null;
let camOpening = false;
const descInput = document.getElementById('descInput');
const filesInput = document.getElementById('filesInput');
const filesList = document.getElementById('filesList');
const filesClearBtn = document.getElementById('filesClearBtn');
let selectedFiles = [];
const sentPanel = document.getElementById('sentPanel');
const sentTitle = document.getElementById('sentTitle');
const sentText = document.getElementById('sentText');
const sentTimer = document.getElementById('sentTimer');
const SENT_KEY = 'whatido_entry_sent_at';
let entryCooldownMin = 30;
let captureWidth = 640;
let captureHeight = 480;
let captureQuality = 0.92;
let sentTimerId = null;
function unlockForm() {
clearInterval(sentTimerId);
sentTimerId = null;
localStorage.removeItem(SENT_KEY);
sentPanel.style.display = 'none';
form.style.display = 'grid';
toast.textContent = 'Можно отправить новую запись';
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 2500);
}
function tickSentTimer(unlockTime) {
const remain = Math.max(0, unlockTime - Date.now());
if (remain <= 0) {
sentTimer.textContent = '';
unlockForm();
return;
}
const m = Math.floor(remain / 60000);
const s = Math.floor((remain % 60000) / 1000);
sentTimer.textContent = 'Новую запись можно отправить через ' + m + ' мин ' + s + ' сек';
}
function showSent(title, text, unlockTime) {
form.style.display = 'none';
sentPanel.style.display = 'flex';
sentTitle.textContent = title;
sentText.textContent = text;
tickSentTimer(unlockTime);
clearInterval(sentTimerId);
sentTimerId = setInterval(() => tickSentTimer(unlockTime), 1000);
}
function cooldownMs() { return entryCooldownMin * 60 * 1000; }
function checkPreviousSend() {
const raw = localStorage.getItem(SENT_KEY);
if (!raw) return;
const sentAt = parseInt(raw, 10);
if (!Number.isFinite(sentAt)) { localStorage.removeItem(SENT_KEY); return; }
const unlockTime = sentAt + cooldownMs();
if (Date.now() < unlockTime) {
showSent('Запись уже отправлена', 'Ты уже отправил(а) запись сегодня. Дождись окончания отсчёта, чтобы отправить новую.', unlockTime);
} else {
localStorage.removeItem(SENT_KEY);
}
}
function humanSize(b) {
if (b < 1024) return b + ' Б';
@@ -55,13 +119,108 @@ function clearFiles() {
renderFiles();
}
const MAX_FILES = 10;
const MAX_FILE_BYTES = 10 * 1024 * 1024;
const MAX_TOTAL_BYTES = 30 * 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 MIME_EXT = {
'image/png': '.png', 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp',
'image/bmp': '.bmp', 'image/avif': '.avif', 'image/heic': '.heic', 'image/heif': '.heif', 'image/svg+xml': '.svg',
'application/pdf': '.pdf', 'application/zip': '.zip', 'application/x-zip-compressed': '.zip',
'application/msword': '.doc', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
'application/vnd.ms-excel': '.xls', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
'application/vnd.ms-powerpoint': '.ppt', 'application/vnd.openxmlformats-officedocument.presentationml.presentation': '.pptx',
'text/plain': '.txt', 'text/csv': '.csv', 'application/rtf': '.rtf',
'application/x-rar-compressed': '.rar', 'application/vnd.rar': '.rar', 'application/x-7z-compressed': '.7z'
};
let notifyTimer = null;
function notify(text, isError) {
toast.textContent = text;
toast.classList.add('show');
toast.classList.toggle('error', !!isError);
clearTimeout(notifyTimer);
notifyTimer = setTimeout(() => toast.classList.remove('show', 'error'), 3200);
}
function extOf(name) {
const s = String(name || '');
const i = s.lastIndexOf('.');
return i > 0 ? s.slice(i).toLowerCase() : '';
}
function extFromFile(file) {
const named = extOf(file.name);
if (/^\.[a-z0-9]{1,8}$/i.test(named)) return named;
const byMime = MIME_EXT[String(file.type || '').toLowerCase()];
if (byMime) return byMime;
const sub = String(file.type || '').split('/')[1] || '';
return /^[a-z0-9]{1,8}$/i.test(sub) ? '.' + sub : '';
}
function pasteFileName(file) {
const base = String(file.name || '').trim();
const stem = base.replace(/\.[a-z0-9]{1,8}$/i, '');
if (base && !/^(image|photo|picture|blob|file|screenshot|снимок|скриншот|изображение|файл)$/i.test(stem)) return base;
const d = new Date();
const pad = n => String(n).padStart(2, '0');
const stamp = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`;
return `Вставка ${stamp}${extFromFile(file)}`;
}
function totalUploadBytes() {
return (capturedBlob ? capturedBlob.size : 0) + selectedFiles.reduce((sum, f) => sum + f.size, 0);
}
function addFiles(list) {
const added = [];
const skipped = [];
for (const file of list) {
if (selectedFiles.length >= MAX_FILES) { skipped.push('Можно прикрепить не более 10 файлов'); break; }
if (file.size > MAX_FILE_BYTES) { skipped.push(`«${file.name}» больше 10 МБ`); 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; }
selectedFiles.push(file);
added.push(file.name);
}
if (added.length) renderFiles();
return { added, skipped };
}
filesInput.addEventListener('change', () => {
const keep = 10 - selectedFiles.length;
if (keep <= 0) { alert('Можно прикрепить не более 10 файлов'); filesInput.value = ''; return; }
selectedFiles = selectedFiles.concat([...filesInput.files].slice(0, keep));
if ([...filesInput.files].length > keep) alert('Можно прикрепить не более 10 файлов');
const res = addFiles([...filesInput.files]);
filesInput.value = '';
renderFiles();
if (res.skipped.length) alert(res.skipped[0]);
});
document.addEventListener('paste', (e) => {
if (form.style.display === 'none') return;
const clip = e.clipboardData;
if (!clip) return;
const pasted = [];
for (const item of Array.from(clip.items || [])) {
if (item.kind !== 'file') continue;
const file = item.getAsFile();
if (file) pasted.push(file);
}
if (!pasted.length && clip.files && clip.files.length) pasted.push(...Array.from(clip.files));
if (!pasted.length) return;
e.preventDefault();
let filesAdded = 0;
const skipped = [];
for (const raw of pasted) {
const name = pasteFileName(raw);
const file = raw.name === name ? raw : new File([raw], name, { type: raw.type || 'application/octet-stream', lastModified: raw.lastModified });
const res = addFiles([file]);
filesAdded += res.added.length;
skipped.push(...res.skipped);
}
if (!filesAdded) {
if (skipped.length) notify(skipped[0], true);
return;
}
const summary = filesAdded === 1 ? 'Файл добавлен' : `Файлов добавлено: ${filesAdded}`;
notify(summary + (skipped.length ? '. ' + skipped[0] : ''), skipped.length > 0);
});
const params = new URLSearchParams(location.search);
@@ -74,7 +233,49 @@ if (params.get('nameInput')) {
field.innerHTML = `<label>Фамилия и имя</label><div class="fixed-name" id="fixedName">${escName(studentName)}</div>`;
}
function renderModuleOptions(list) {
list.forEach(m => moduleNames.set(m.name, m.id));
moduleInputList.innerHTML = '';
list.forEach(m => {
const opt = document.createElement('option');
opt.value = m.name;
moduleInputList.appendChild(opt);
});
}
async function fetchModules(q, limit) {
const p = new URLSearchParams();
if (q) p.set('search', q);
p.set('limit', String(limit));
p.set('active', '1');
try {
const res = await fetch(`${API}/api/modules?${p}`);
if (!res.ok) return [];
const data = await res.json();
return data.modules || [];
} catch (e) { return []; }
}
async function resolveModuleId(name) {
const n = (name || '').trim();
if (!n) return null;
if (moduleNames.has(n)) return moduleNames.get(n);
const list = await fetchModules(n, 5);
renderModuleOptions(list);
const lower = n.toLowerCase();
const exact = list.find(m => m.name.toLowerCase() === lower);
return exact ? exact.id : null;
}
(async () => {
try {
const list = await fetchModules('', 50);
if (list.length) {
renderModuleOptions(list);
moduleInput.required = true;
moduleField.style.display = '';
}
} catch (e) { /* ignore */ }
const res = await fetch(`${API}/api/students`);
const students = await res.json();
const me = studentName ? students.find(s => s.name === studentName) : null;
@@ -97,9 +298,33 @@ if (params.get('nameInput')) {
try {
const fRes = await fetch(`${API}/api/public-settings`);
const f = await fRes.json();
const title = document.getElementById('title');
if (title && (f.system_name || '').trim()) {
title.textContent = f.system_name;
document.title = f.system_name;
}
const brandLogo = document.getElementById('brandLogo');
if (brandLogo && f.system_logo) {
brandLogo.src = f.system_logo;
brandLogo.hidden = false;
brandLogo.alt = f.system_name || '';
}
if (f.footer_left) document.getElementById('footerLeft').textContent = f.footer_left;
if (f.footer_right) document.getElementById('footerRight').textContent = f.footer_right;
const c = parseInt(f.spam_interval_min, 10);
if (Number.isFinite(c) && c >= 1) entryCooldownMin = c;
const w = parseInt(f.photo_capture_width, 10);
const h = parseInt(f.photo_capture_height, 10);
if (Number.isFinite(w) && w >= 160 && w <= 4096) captureWidth = w;
if (Number.isFinite(h) && h >= 120 && h <= 4096) captureHeight = h;
const q = parseFloat(f.photo_capture_quality);
if (Number.isFinite(q) && q >= 0.5 && q <= 1) captureQuality = q;
if (f.camera_enabled === 'false') {
const camBtns = document.getElementById('camBtns');
if (camBtns) camBtns.style.display = 'none';
}
} catch (e) { /* ignore */ }
checkPreviousSend();
})();
function showPreview(src) {
@@ -116,12 +341,22 @@ function removePhoto() {
}
async function openCam() {
if (camOpening) return;
if (stream && camModal.classList.contains('open')) return;
camOpening = true;
try {
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } } });
const s = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: captureWidth }, height: { ideal: captureHeight } } });
if (camModal.classList.contains('open')) {
s.getTracks().forEach(t => t.stop());
return;
}
stream = s;
camVideo.srcObject = stream;
camModal.classList.add('open');
} catch (e) {
alert('Не удалось открыть камеру: ' + e.message);
} finally {
camOpening = false;
}
}
@@ -131,6 +366,10 @@ function closeCam() {
}
function capture() {
if (!camVideo.videoWidth || !camVideo.videoHeight) {
notify('Камера ещё не готова, попробуйте через секунду', true);
return;
}
camCanvas.width = camVideo.videoWidth;
camCanvas.height = camVideo.videoHeight;
const ctx = camCanvas.getContext('2d');
@@ -141,7 +380,7 @@ function capture() {
capturedBlob = blob;
showPreview(URL.createObjectURL(blob));
closeCam();
}, 'image/jpeg', 0.85);
}, 'image/jpeg', captureQuality);
}
form.addEventListener('submit', async (e) => {
@@ -152,6 +391,16 @@ form.addEventListener('submit', async (e) => {
setTimeout(() => toast.classList.remove('show', 'error'), 3200);
return;
}
let moduleId = '';
if (moduleField.style.display !== 'none') {
moduleId = await resolveModuleId(moduleInput.value);
if (!moduleId) {
toast.textContent = 'Выберите тему модуля из списка подсказок';
toast.classList.add('show', 'error');
setTimeout(() => toast.classList.remove('show', 'error'), 3200);
return;
}
}
const btn = document.getElementById('submitBtn');
btn.disabled = true;
btn.textContent = 'Отправка...';
@@ -161,6 +410,7 @@ form.addEventListener('submit', async (e) => {
selectedFiles.forEach(f => fd.append('files', f, f.name));
fd.append('student_name', studentName || document.getElementById('nameInput').value);
fd.append('group_id', groupInput.value);
fd.append('module_id', moduleId || '');
fd.append('description', document.getElementById('descInput').value);
fd.append('website', document.getElementById('hpWebsite').value);
@@ -177,10 +427,8 @@ form.addEventListener('submit', async (e) => {
form.reset();
removePhoto();
clearFiles();
toast.textContent = 'Готово!';
toast.classList.remove('error');
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 2500);
localStorage.setItem(SENT_KEY, String(Date.now()));
showSent('Запись отправлена!', 'Спасибо! Твоя запись уже передана преподавателю.', Date.now() + cooldownMs());
} catch (err) {
alert('Ошибка: ' + err.message);
} finally {
@@ -191,7 +439,21 @@ form.addEventListener('submit', async (e) => {
removePhotoBtn.addEventListener('click', removePhoto);
document.getElementById('openCamBtn').addEventListener('click', openCam);
preview.addEventListener('click', openCam);
preview.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openCam(); }
});
filesClearBtn.addEventListener('click', clearFiles);
document.getElementById('filesBtn').addEventListener('click', () => document.getElementById('filesInput').click());
document.getElementById('cancelCamBtn').addEventListener('click', closeCam);
document.getElementById('captureBtn').addEventListener('click', capture);
moduleInput.addEventListener('input', () => {
clearTimeout(moduleSearchTimer);
const q = moduleInput.value.trim();
moduleSearchTimer = setTimeout(async () => {
renderModuleOptions(await fetchModules(q, 20));
}, 250);
});
moduleInput.addEventListener('focus', async () => {
if (!moduleInput.value.trim()) renderModuleOptions(await fetchModules('', 50));
});
+889 -11
View File
File diff suppressed because it is too large Load Diff
+24 -6
View File
@@ -3,6 +3,8 @@ let groups = [];
let editingId = null;
let studentsLoaded = false;
let linkSettings = null;
let linkPage = 1;
const LINK_PAGE_SIZE = 20;
async function loadLinkSettings() {
if (!linkSettings) {
@@ -16,21 +18,30 @@ function linkUrl(token) { return `${location.origin}/s/${token}`; }
async function loadLinks() {
await loadLinkSettings();
const res = await fetch(`${API}/api/links`, { headers: hdr() });
links = await res.json();
const p = new URLSearchParams();
p.set('limit', LINK_PAGE_SIZE);
p.set('offset', (linkPage - 1) * LINK_PAGE_SIZE);
const res = await fetch(`${API}/api/links?${p}`, { headers: hdr() });
const data = await res.json();
links = data.items;
const total = data.total;
const el = document.getElementById('linksList');
if (!links.length) { el.innerHTML = '<div class="empty">Ссылок пока нет. Создайте её в Журнале кнопкой «Ссылка».</div>'; return; }
if (!links.length) { el.innerHTML = `<div class="empty">${linkPage > 1 ? 'На этой странице ссылок нет.' : 'Ссылок пока нет. Создайте её в Журнале кнопкой «Ссылка».'}</div>`; renderPager(document.getElementById('pager'), 1, 1, goLinkPage); return; }
const now = Date.now();
el.innerHTML = links.map(l => {
const namesHidden = l.show_student_names != null ? !l.show_student_names : linkSettings.share_show_student_names === 'false';
const expired = l.expires_at && new Date(l.expires_at).getTime() < now;
const daysLeft = l.expires_at ? Math.ceil((new Date(l.expires_at).getTime() - now) / 86400000) : null;
const soon = !expired && daysLeft !== null && daysLeft <= 1;
return `
<div class="item">
<div class="item${expired ? ' expired' : ''}">
<div style="min-width:0">
<div class="name">${esc(l.name)}</div>
<div class="name">${esc(l.name)}${expired ? ' <span class="badge-expired">истекла</span>' : soon ? ' <span class="badge-soon">истекает</span>' : ''}</div>
<div style="font-size:.8rem;color:var(--muted);margin-top:2px">
${l.group_name ? `Группа: <b>${esc(l.group_name)}</b>` : ''}
${l.student_name ? `${l.group_name ? ' · ' : ''}Ученик: <b>${esc(l.student_name)}</b>` : ''}
${l.date_from || l.date_to ? ` · период: <b>${l.date_from ? l.date_from.split('-').reverse().join('.') : '…'}</b> — <b>${l.date_to ? l.date_to.split('-').reverse().join('.') : '…'}</b>` : ' · весь период'}
${l.expires_at ? ` · истекает ${new Date(l.expires_at).toLocaleDateString('ru')}` : ''}
${expired ? ` · <span style="color:#ef4444;font-weight:600">срок действия истёк ${new Date(l.expires_at).toLocaleDateString('ru')}</span>` : l.expires_at ? ` · истекает ${new Date(l.expires_at).toLocaleDateString('ru')}` : ''}
${namesHidden ? ` · <span style="color:#f59e0b">имена скрыты</span>` : ''}
${l.access_password_hash ? ` · <span style="color:#2563eb">🔒 пароль</span>` : ''}
· создана ${new Date(l.created_at).toLocaleString('ru')}
@@ -38,12 +49,19 @@ async function loadLinks() {
</div>
<div style="display:flex;gap:8px;flex-shrink:0;align-items:center">
<a class="btn-link" style="text-decoration:none" href="/s/${l.token}" target="_blank" title="Открыть">Открыть</a>
<a class="btn-link" style="text-decoration:none" href="/r/${l.token}" target="_blank" title="Отчёт ученика">Отчёт</a>
<button class="btn-link" data-copy="${esc(linkUrl(l.token))}" title="Копировать ссылку">Копировать</button>
<button class="btn-link" data-edit-link="${l.id}" title="Редактировать">✎</button>
<button class="btn-link" style="color:var(--muted)" data-del-link="${l.id}" title="Удалить">&times;</button>
</div>
</div>
`;}).join('');
renderPager(document.getElementById('pager'), linkPage, Math.max(1, Math.ceil(total / LINK_PAGE_SIZE)), goLinkPage);
}
function goLinkPage(p) {
linkPage = p;
loadLinks();
}
function fmtDate(d) { const [y, m, day] = (d || '').split('-'); return d ? `${day}.${m}.${y}` : ''; }
+19
View File
@@ -29,3 +29,22 @@ async function doLogin() {
document.getElementById('pwdInput').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
document.getElementById('userInput').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
document.querySelector('.login button').addEventListener('click', doLogin);
(async () => {
try {
const res = await fetch(`${API}/api/public-settings`);
if (!res.ok) return;
const s = await res.json();
const title = document.getElementById('loginTitle');
if (title && (s.system_name || '').trim()) title.textContent = s.system_name;
const logo = document.getElementById('loginLogo');
if (logo && s.system_logo) {
const img = document.createElement('img');
img.src = s.system_logo;
img.alt = '';
logo.appendChild(img);
logo.style.display = 'flex';
}
document.title = title ? title.textContent + ' — Вход' : document.title;
} catch { /* ignore */ }
})();
+319
View File
@@ -0,0 +1,319 @@
let modulesList = [];
let mPage = 1;
const M_PAGE_SIZE = 25;
let mSearchTimer = null;
let modulePhotoDeleteFlag = false;
function mPlural(n, one, few, many) {
n = Math.abs(n) % 100; const n1 = n % 10;
if (n > 10 && n < 20) return many;
if (n1 > 1 && n1 < 5) return few;
if (n1 === 1) return one;
return many;
}
async function loadModules() {
const p = new URLSearchParams();
const q = document.getElementById('moduleSearch').value.trim();
if (q) p.set('search', q);
p.set('limit', M_PAGE_SIZE);
p.set('offset', (mPage - 1) * M_PAGE_SIZE);
const res = await fetch(`${API}/api/modules?${p}`, { headers: hdr() });
if (!res.ok) return;
const data = await res.json();
modulesList = data.modules || [];
const totalPages = Math.max(1, Math.ceil(data.total / M_PAGE_SIZE));
if (mPage > totalPages) { mPage = totalPages; return loadModules(); }
renderModules(data.total);
}
function renderModules(total) {
const list = document.getElementById('moduleList');
if (!modulesList.length) {
list.innerHTML = '<div class="empty">Модулей пока нет. Нажмите «Добавить модуль».</div>';
} else {
list.innerHTML = modulesList.map(m => `
<div class="item module-item${m.is_active ? '' : ' module-off'}" data-id="${m.id}">
${m.photo_path ? `<img class="module-thumb" src="${thumbSrc(m.photo_path)}" alt="" data-img="${esc(API + m.photo_path)}">` : ''}
<div class="module-info">
<div class="module-name">${esc(m.name)}${m.is_active ? '' : ' <span class="module-badge">Отключён</span>'}</div>
<div class="module-meta">${m.lessons_count} ${mPlural(m.lessons_count, 'занятие', 'занятия', 'занятий')} · ${m.entries_count} ${mPlural(m.entries_count, 'запись', 'записи', 'записей')}</div>
</div>
<div class="student-actions">
${m.is_active
? `<button class="btn-link" type="button" data-edit-module="${m.id}">Изменить</button>
<button class="btn-link danger" type="button" data-off-module="${m.id}">Отключить</button>`
: `<button class="btn-link" type="button" data-on-module="${m.id}">Включить</button>
<button class="btn-link" type="button" data-edit-module="${m.id}">Изменить</button>`}
</div>
</div>
`).join('');
}
renderPager(document.getElementById('pager'), mPage, Math.max(1, Math.ceil(total / M_PAGE_SIZE)), p => { mPage = p; loadModules(); });
}
function resetModulePhoto(m) {
modulePhotoDeleteFlag = false;
const fileInput = document.getElementById('modulePhotoFile');
const preview = document.getElementById('modulePhotoPreview');
const delBtn = document.getElementById('modulePhotoDeleteBtn');
const note = document.getElementById('modulePhotoNote');
fileInput.value = '';
if (m && m.photo_path) {
preview.src = thumbSrc(m.photo_path);
preview.style.display = 'block';
delBtn.style.display = 'inline-block';
note.textContent = 'Загрузка новой картинки заменит текущую';
} else {
preview.removeAttribute('src');
preview.style.display = 'none';
delBtn.style.display = 'none';
note.textContent = 'jpg, png, gif, webp, avif, heic и др., до 10 МБ · или вставьте из буфера (Ctrl+V)';
}
}
function openModuleModal(id = null) {
const modal = document.getElementById('moduleModal');
const form = document.getElementById('moduleForm');
const title = document.getElementById('moduleModalTitle');
const submitBtn = document.getElementById('moduleSubmitBtn');
form.reset();
document.getElementById('moduleId').value = '';
document.getElementById('moduleLessons').value = '0';
if (id) {
const m = modulesList.find(x => x.id === id);
if (!m) return;
title.textContent = 'Редактировать модуль';
submitBtn.textContent = 'Сохранить';
form.name.value = m.name;
document.getElementById('moduleLessons').value = String(m.lessons_count);
document.getElementById('moduleId').value = m.id;
resetModulePhoto(m);
} else {
title.textContent = 'Создать модуль';
submitBtn.textContent = 'Создать';
resetModulePhoto(null);
}
modal.classList.add('open');
setTimeout(() => form.name.focus(), 100);
}
function previewModulePhotoFile() {
const file = document.getElementById('modulePhotoFile').files[0];
const preview = document.getElementById('modulePhotoPreview');
const delBtn = document.getElementById('modulePhotoDeleteBtn');
const note = document.getElementById('modulePhotoNote');
if (!file) return;
modulePhotoDeleteFlag = false;
preview.src = URL.createObjectURL(file);
preview.style.display = 'block';
delBtn.style.display = 'none';
note.textContent = 'Новая картинка будет сохранена после нажатия «Сохранить»';
}
function removeModulePhoto() {
const preview = document.getElementById('modulePhotoPreview');
const delBtn = document.getElementById('modulePhotoDeleteBtn');
const note = document.getElementById('modulePhotoNote');
if (!confirm('Удалить картинку модуля?')) return;
modulePhotoDeleteFlag = true;
document.getElementById('modulePhotoFile').value = '';
preview.removeAttribute('src');
preview.style.display = 'none';
delBtn.style.display = 'none';
note.textContent = 'Картинка будет удалена после нажатия «Сохранить»';
}
function pasteModulePhotoFile(e) {
const modal = document.getElementById('moduleModal');
if (!modal.classList.contains('open')) return;
const items = e.clipboardData && e.clipboardData.items;
if (!items) return;
for (const item of items) {
if (item.type && item.type.startsWith('image/')) {
const file = item.getAsFile();
if (!file) continue;
e.preventDefault();
const dt = new DataTransfer();
dt.items.add(file);
const input = document.getElementById('modulePhotoFile');
input.files = dt.files;
previewModulePhotoFile();
return;
}
}
}
function closeModuleModal() {
document.getElementById('moduleModal').classList.remove('open');
}
async function disableModule(id) {
const m = modulesList.find(x => x.id === id);
const msg = m && m.entries_count > 0
? `Отключить модуль «${m.name}»? Он станет недоступен для выбора, у ${m.entries_count} связанных записей тема сохранится.`
: `Отключить модуль${m ? ` «${m.name}»` : ''}? Он станет недоступен для выбора.`;
if (!confirm(msg)) return;
const res = await fetch(`${API}/api/modules/${id}`, { method: 'DELETE', headers: hdr() });
if (res.ok) {
showToast('Модуль отключён');
loadModules();
} else {
const err = await res.json().catch(() => ({}));
alert(err.error || 'Ошибка');
}
}
async function enableModule(id) {
const res = await fetch(`${API}/api/modules/${id}/restore`, { method: 'PUT', headers: hdr() });
if (res.ok) {
showToast('Модуль включён');
loadModules();
} else {
const err = await res.json().catch(() => ({}));
alert(err.error || 'Ошибка');
}
}
document.getElementById('moduleForm').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const id = document.getElementById('moduleId').value;
const name = form.name.value.trim();
const lessons_count = parseInt(form.lessons_count.value, 10);
if (!name) { alert('Название обязательно'); return; }
if (!Number.isInteger(lessons_count) || lessons_count < 0) { alert('Количество занятий — целое неотрицательное число'); return; }
const res = await fetch(id ? `${API}/api/modules/${id}` : `${API}/api/modules`, {
method: id ? 'PUT' : 'POST',
headers: hdrJson(),
body: JSON.stringify({ name, lessons_count })
});
if (!res.ok) { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); return; }
const moduleRow = await res.json();
const mid = moduleRow.id;
const fileInput = document.getElementById('modulePhotoFile');
if (modulePhotoDeleteFlag) {
const delRes = await fetch(`${API}/api/modules/${mid}/photo`, { method: 'DELETE', headers: hdr() });
if (!delRes.ok) {
const err = await delRes.json().catch(() => ({}));
alert(err.error || 'Ошибка удаления картинки');
}
}
const file = fileInput.files[0];
if (file) {
const fd = new FormData();
fd.append('photo', file);
const upRes = await fetch(`${API}/api/modules/${mid}/photo`, { method: 'POST', headers: hdr(), body: fd });
if (!upRes.ok) {
const err = await upRes.json().catch(() => ({}));
alert(err.error || 'Ошибка загрузки картинки');
}
}
showToast(id ? 'Модуль обновлён' : 'Модуль создан');
closeModuleModal();
loadModules();
});
function parseBatchModules(raw) {
const items = [];
const errors = [];
const seen = new Set();
String(raw).split('\n').forEach((line, idx) => {
const text = line.trim();
if (!text) return;
const lineNo = idx + 1;
let name = '';
let lessonsRaw = '';
const sepIndex = text.search(/[|;=\t]/);
if (sepIndex !== -1) {
name = text.slice(0, sepIndex).trim();
lessonsRaw = text.slice(sepIndex + 1).trim();
} else {
const numMatch = text.match(/^(.*\S)\s+(\d+)$/);
if (numMatch) {
name = numMatch[1].trim();
lessonsRaw = numMatch[2];
}
}
if (!name && !lessonsRaw) { errors.push(`Строка ${lineNo}: «${text}» — укажите название и количество занятий, например: Основы Python | 10`); return; }
if (!name) { errors.push(`Строка ${lineNo}: «${text}» — не указано название`); return; }
if (!lessonsRaw) { errors.push(`Строка ${lineNo}: «${text}» — не указано количество занятий`); return; }
const lessons = Number(lessonsRaw);
if (!Number.isInteger(lessons) || lessons < 0 || lessons > 10000) {
errors.push(`Строка ${lineNo}: «${text}» — количество занятий должно быть целым числом от 0 до 10000`);
return;
}
const key = name.toLowerCase();
if (seen.has(key)) { errors.push(`Строка ${lineNo}: «${text}» — дубликат названия в списке`); return; }
seen.add(key);
items.push({ name, lessons_count: lessons });
});
return { items, errors };
}
function openBatchModuleModal() {
document.getElementById('batchModuleForm').reset();
document.getElementById('batchModuleModal').classList.add('open');
setTimeout(() => document.getElementById('batchModuleText').focus(), 100);
}
function closeBatchModuleModal() {
document.getElementById('batchModuleModal').classList.remove('open');
}
async function batchAddModules() {
const submitBtn = document.getElementById('batchModuleSubmitBtn');
const { items, errors } = parseBatchModules(document.getElementById('batchModuleText').value);
if (errors.length) {
alert('Исправьте строки:\n\n' + errors.join('\n'));
return;
}
if (!items.length) { alert('Добавьте хотя бы один модуль'); return; }
submitBtn.disabled = true;
let added = 0, dupes = 0, failed = 0;
for (const item of items) {
const res = await fetch(`${API}/api/modules`, { method: 'POST', headers: hdrJson(), body: JSON.stringify(item) });
if (res.ok) added++;
else if (res.status === 409) dupes++;
else failed++;
}
submitBtn.disabled = false;
closeBatchModuleModal();
loadModules();
showToast(`Добавлено: ${added}${dupes ? `, дубликатов: ${dupes}` : ''}${failed ? `, ошибок: ${failed}` : ''}`);
}
document.getElementById('batchModuleForm').addEventListener('submit', e => {
e.preventDefault();
batchAddModules();
});
document.getElementById('batchModuleBtn').addEventListener('click', openBatchModuleModal);
document.getElementById('batchModuleCancelBtn').addEventListener('click', closeBatchModuleModal);
document.getElementById('batchModuleModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeBatchModuleModal(); });
document.getElementById('addModuleBtn').addEventListener('click', () => openModuleModal());
document.getElementById('moduleCancelBtn').addEventListener('click', closeModuleModal);
document.getElementById('moduleModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeModuleModal(); });
document.getElementById('modulePhotoFile').addEventListener('change', previewModulePhotoFile);
document.getElementById('modulePhotoDeleteBtn').addEventListener('click', removeModulePhoto);
document.addEventListener('paste', pasteModulePhotoFile);
document.getElementById('moduleSearch').addEventListener('input', () => {
clearTimeout(mSearchTimer);
mSearchTimer = setTimeout(() => { mPage = 1; loadModules(); }, 300);
});
document.getElementById('moduleList').addEventListener('click', e => {
const ed = e.target.closest('[data-edit-module]');
if (ed) { openModuleModal(parseInt(ed.dataset.editModule, 10)); return; }
const off = e.target.closest('[data-off-module]');
if (off) { disableModule(parseInt(off.dataset.offModule, 10)); return; }
const on = e.target.closest('[data-on-module]');
if (on) enableModule(parseInt(on.dataset.onModule, 10));
});
(async () => {
if (await requireAdminPage()) {
buildSidebar(document.body.dataset.page);
loadModules();
}
})();
+567
View File
@@ -0,0 +1,567 @@
const state={token:'',password:null,showNames:true,cdTarget:0,cdTimer:null,cdBound:false,systemName:'WhatIDo'};
function loadBrand(){
return fetch('/api/public-settings').then(function(res){
return res.ok?res.json():null;
}).then(function(s){
if(!s)return;
if((s.system_name||'').trim())state.systemName=s.system_name;
var logo=document.getElementById('brandLogo');
if(logo&&s.system_logo){
logo.src=s.system_logo;
logo.hidden=false;
var mark=document.getElementById('brandMark');
if(mark)mark.style.display='none';
}
var name=document.getElementById('brandName');
if(name&&(s.system_name||'').trim()){
name.textContent=s.system_name;
name.hidden=false;
}
}).catch(function(){});
}
function esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#039;')}
function pad(n){return String(n).padStart(2,'0')}
function toDate(v){if(!v)return null;const d=new Date(v);return isNaN(d.getTime())?null:d}
function fmtDate(v){const d=toDate(v);return d?pad(d.getDate())+'.'+pad(d.getMonth()+1)+'.'+d.getFullYear():''}
function fmtShort(v){const d=toDate(v);return d?pad(d.getDate())+'.'+pad(d.getMonth()+1)+'.'+String(d.getFullYear()).slice(2):''}
function fmtLong(v){const d=toDate(v);return d?d.toLocaleDateString('ru-RU',{day:'numeric',month:'long',year:'numeric'}):''}
function plural(n,one,few,many){const m10=n%10,m100=n%100;if(m100>=11&&m100<=14)return many;if(m10===1)return one;if(m10>=2&&m10<=4)return few;return many}
function words(n,one,few,many){return n+' '+plural(n,one,few,many)}
function isImageFile(name){return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name||'')}
function extLabel(name){const m=String(name||'').match(/\.([a-z0-9]{1,5})$/i);return m?m[1].toUpperCase():'FILE'}
function baseName(name){return String(name||'файл').replace(/^.*[\\/]/,'').slice(0,80)}
function thumbSrc(p){if(!p)return '';if(p.indexOf('/uploads/')===0)return '/uploads/thumb/'+p.slice(9);if(p.indexOf('/api/files/')>=0)return p+(p.indexOf('?')>=0?'&':'?')+'thumb=1';return p}
function fileUrl(token){return '/api/share/'+encodeURIComponent(state.token)+'/files/'+encodeURIComponent(token)+(state.password?'?password='+encodeURIComponent(state.password):'')}
function initials(name){const parts=String(name||'').trim().split(/\s+/).filter(Boolean);if(!parts.length)return 'WI';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 setText(id,text){const el=document.getElementById(id);if(el)el.textContent=text==null?'':String(text)}
function setHTML(id,html){const el=document.getElementById(id);if(el)el.innerHTML=html}
function hideEl(el,on){if(el)el.classList.toggle('rep-hidden',!!on)}
function node(html){const t=document.createElement('template');t.innerHTML=html.trim();return t.content.firstElementChild}
function periodLabel(from,to){
if(!from&&!to)return '';
const a=toDate(from),b=toDate(to);
if(a&&b)return fmtDate(a)+' — '+fmtDate(b);
return fmtDate(a)||fmtDate(b);
}
function yearLabel(from,to){
const a=toDate(from),b=toDate(to);
if(!a&&!b)return '';
const ay=a?a.getFullYear():null,by=b?b.getFullYear():null;
if(ay&&by)return ay===by?String(ay):ay+' — '+String(by).slice(2);
return String(ay||by);
}
function countsLabel(photos,files){
const out=[];
if(photos)out.push(words(photos,'фото','фото','фото'));
if(files)out.push(words(files,'файл','файла','файлов'));
return out.join(' · ');
}
async function fetchShare(token,password){
const headers={};
if(password)headers['x-share-password']=password;
const res=await fetch('/api/share/'+encodeURIComponent(token),{headers});
const data=await res.json().catch(function(){return {}});
return {ok:res.ok,status:res.status,data:data};
}
function showError(msg){
stopCountdown();
setText('errorText',msg);
const box=document.getElementById('errorBox');
if(box)box.style.display='flex';
const content=document.getElementById('content');
if(content)content.style.display='none';
}
function stopCountdown(){
if(state.cdTimer){clearInterval(state.cdTimer);state.cdTimer=null}
state.cdTarget=0;
const el=document.getElementById('countdown');
if(el)el.style.display='none';
}
function formatRemaining(ms){
const total=Math.floor(ms/1000);
const days=Math.floor(total/86400);
const hours=Math.floor((total%86400)/3600);
const minutes=Math.floor((total%3600)/60);
const seconds=total%60;
const parts=[];
if(days>0)parts.push(words(days,'день','дня','дней'));
if(hours>0)parts.push(words(hours,'час','часа','часов'));
if(minutes>0)parts.push(words(minutes,'минуту','минуты','минут'));
if(days===0)parts.push(words(seconds,'секунду','секунды','секунд'));
return parts.slice(0,2).join(' ');
}
function syncCountdown(){
const el=document.getElementById('countdown');
if(!el||!state.cdTarget)return;
if(state.cdTarget-Date.now()<=0){el.style.display='none';return}
const hero=document.getElementById('hero');
const passed=hero?window.scrollY>hero.offsetHeight*0.5:true;
el.style.display=passed?'inline-flex':'none';
}
function tickCountdown(){
const el=document.getElementById('countdown');
if(!el||!state.cdTarget)return;
const left=state.cdTarget-Date.now();
if(left<=0){stopCountdown();return}
el.classList.toggle('rep-urgent',left<3600000);
el.classList.toggle('rep-warn',left>=3600000&&left<86400000);
const txt=el.querySelector('.rep-cd-text');
if(txt)txt.innerHTML='Отчёт доступен ещё <strong>'+esc(formatRemaining(left))+'</strong>';
syncCountdown();
}
function startCountdown(expiresAt){
stopCountdown();
const t=toDate(expiresAt);
if(!t)return;
state.cdTarget=t.getTime();
if(state.cdTarget-Date.now()<=0){state.cdTarget=0;return}
if(!state.cdBound){state.cdBound=true;window.addEventListener('scroll',syncCountdown,{passive:true})}
tickCountdown();
state.cdTimer=setInterval(tickCountdown,1000);
}
function initSlider(slider,update){
if(!slider)return;
const track=slider.querySelector('.blocklist__list');
if(!track)return;
const prev=slider.querySelector('[data-slider-prev]');
const next=slider.querySelector('[data-slider-next]');
const step=function(){
const first=track.firstElementChild;
const w=first?first.getBoundingClientRect().width:track.clientWidth;
const visible=Math.max(1,Math.round(track.clientWidth/Math.max(1,w)));
return {width:Math.max(1,w),visible:visible};
};
const sync=function(){
const s=step();
const max=track.scrollWidth-track.clientWidth;
const one=track.scrollWidth<=track.clientWidth+4;
hideEl(prev,one||track.scrollLeft<=4);
hideEl(next,one||track.scrollLeft>=max-4);
};
const move=function(dir){
const s=step();
track.scrollBy({left:dir*s.width*s.visible,behavior:'smooth'});
};
if(prev)prev.addEventListener('click',function(){move(-1)});
if(next)next.addEventListener('click',function(){move(1)});
track.addEventListener('scroll',sync,{passive:true});
window.addEventListener('resize',sync);
sync();
if(update)update(sync);
}
function stepSlider(slider,dir){
const track=slider.querySelector('.blocklist__list');
if(!track)return;
const first=track.firstElementChild;
const w=first?first.getBoundingClientRect().width:track.clientWidth;
const visible=Math.max(1,Math.round(track.clientWidth/Math.max(1,w)));
track.scrollBy({left:dir*w*visible,behavior:'smooth'});
}
function scrollSliderTo(slider,index){
const track=slider.querySelector('.blocklist__list');
if(!track)return;
const child=track.children[index];
if(child)track.scrollTo({left:child.offsetLeft-track.offsetLeft,behavior:'smooth'});
}
function renderSlider(slider,items){
const track=slider.querySelector('.blocklist__list');
if(!track)return;
track.innerHTML=items.join('');
window.requestAnimationFrame(function(){initSlider(slider)});
}
function setNavVisible(name,on){
document.querySelectorAll('[data-nav="'+name+'"]').forEach(function(el){hideEl(el,!on)});
}
function makeNavLinks(){
document.querySelectorAll('.rep-nav .menu__link-lvl1').forEach(function(a){
a.addEventListener('click',function(){closeMenu()});
});
}
function openMenu(){
const panel=document.getElementById('navPanel');
const overlay=document.getElementById('navOverlay');
const close=document.getElementById('navClose');
if(panel)panel.classList.add('is-open');
if(overlay)overlay.classList.add('is-open');
if(close)close.classList.add('is-open');
document.documentElement.style.overflow='hidden';
}
function closeMenu(){
const panel=document.getElementById('navPanel');
const overlay=document.getElementById('navOverlay');
const close=document.getElementById('navClose');
if(panel)panel.classList.remove('is-open');
if(overlay)overlay.classList.remove('is-open');
if(close)close.classList.remove('is-open');
document.documentElement.style.overflow='';
}
function openModal(){const m=document.getElementById('passwordModal');if(m)m.classList.add('is-open');modalError('');const i=document.getElementById('passwordInput');if(i){i.value='';i.focus()}}
function closeModal(){const m=document.getElementById('passwordModal');if(m)m.classList.remove('is-open')}
function modalError(msg){const e=document.getElementById('passwordError');if(!e)return;e.textContent=msg||'';e.style.display=msg?'block':'none'}
function openLightbox(src){const lb=document.getElementById('lightbox');const img=document.getElementById('lightboxImg');if(!lb||!img)return;img.src=src;lb.classList.add('is-open')}
function photoPool(data){
const pool=[];
(data.entries||[]).forEach(function(e){if(e.photo_path)pool.push(e.photo_path)});
(data.photos||[]).forEach(function(p){if(p.photo_path)pool.push(p.photo_path)});
return pool;
}
function fileChips(files){
if(!files||!files.length)return '';
const items=files.map(function(f){
const url=fileUrl(f.token);
const label=baseName(f.name);
if(isImageFile(f.name))return '<a class="rep-chip" href="#" data-img="'+esc(url)+'" title="'+esc(label)+'"><span>'+esc(label)+'</span></a>';
return '<a class="rep-chip" href="'+esc(url)+'" download title="'+esc(label)+'"><span>'+esc(label)+'</span></a>';
}).join('');
return '<div class="rep-files">'+items+'</div>';
}
function hideSection(id){
const el=document.getElementById(id);
if(el)el.style.display='none';
}
function renderHero(data,heading,pool){
const entries=data.entries||[];
setText('heroName',heading);
setText('heroRole',data.group_name?'Ученик группы «'+data.group_name+'»':'Отчёт о проделанной работе');
setText('heroPeriod',yearLabel(data.date_from,data.date_to)||yearLabel(data.created_at));
const lead=(data.message&&String(data.message).trim())||('Здесь собраны все занятия за выбранный период: '+words(entries.length,'запись','записи','записей')+'.');
setText('heroLead',lead);
const box=document.getElementById('heroPhotoBox');
const img=document.getElementById('heroPhoto');
if(box&&img&&pool.length){
img.src=thumbSrc(pool[0]);
img.alt=heading;
box.setAttribute('data-img',pool[0]);
}else if(box){
if(img)img.remove();
const mono=document.createElement('span');
mono.className='rep-monogram';
mono.textContent=initials(heading);
box.appendChild(mono);
box.classList.add('rep-hero-photo');
}
}
function statItem(num,cap,variant){
const sets=[
{item:'list__item list__item--u-ix00fc47p',num:'text text--u-iif5jnrfz text-number-1-script',cap:'text t2 text--u-ih2l1ywk8'},
{item:'list__item list__item--u-ic8gt20s1',num:'text text--u-ipk87vesx text-number-1-script',cap:'text t2 text--u-i53xasxdu'},
{item:'list__item list__item--u-ici0pcuqz',num:'text text--u-ir07nprxn text-number-1-script',cap:'text t2 text--u-ise2s2948'},
{item:'list__item list__item--u-ici0pcuqz list__item--u-irn5njhv8',num:'text text--u-ir07nprxn text-number-1-script',cap:'text t2 text--u-ise2s2948'}
];
const s=sets[variant]||sets[0];
return '<div class="'+s.item+'">'+
'<div class="'+s.num+'"><span class="text-block-wrap-div">'+esc(num)+'</span></div>'+
'<div class="'+s.cap+'"><span class="text-block-wrap-div">'+esc(cap)+'</span></div>'+
'</div>';
}
function statSep(variant){
const extra=variant===1?' list__item--u-ip5wdmggw':(variant===2?' list__item--u-ixebr6v8q':'');
return '<div class="list__item list__item--u-isd3lg3iz'+extra+'"></div>';
}
function renderAbout(data,pool){
const entries=data.entries||[];
const photos=(data.photos||[]).length;
const files=entries.reduce(function(n,e){return n+((e.files||[]).length)},0);
const months={};
entries.forEach(function(e){const d=toDate(e.created_at);if(d)months[d.getFullYear()+'-'+d.getMonth()]=1});
const photosTotal=entries.filter(function(e){return e.photo_path}).length+photos;
setText('aboutTitle',(data.name&&String(data.name).trim())||'Отчёт');
setText('aboutLead',(data.message&&String(data.message).trim())||('Мы собрали '+words(entries.length,'запись','записи','записей')+' о работе за этот период — с фотографиями, файлами и описанием каждого занятия.'));
const parts=[];
if(periodLabel(data.date_from,data.date_to))parts.push('Период: '+periodLabel(data.date_from,data.date_to));
if(data.group_name)parts.push('Группа «'+data.group_name+'»');
setText('aboutNote',parts.join(' · '));
setHTML('statsList',
statItem(entries.length,'записей о проделанной работе',0)+statSep(0)+
statItem(photosTotal,'фото с занятий',1)+statSep(1)+
statItem(files,'проектных файлов',2)+statSep(2)+
statItem(Object.keys(months).length,'месяцев активных занятий',3)
);
const box=document.getElementById('aboutPhotoBox');
const img=document.getElementById('aboutPhoto');
const photo=pool[1]||pool[0];
if(box&&img&&photo){img.src=thumbSrc(photo);img.alt='';box.setAttribute('data-img',photo);}
else if(box){
if(img)img.remove();
const mono=document.createElement('span');
mono.className='rep-about-monogram';
mono.textContent=initials((data.name&&String(data.name).trim())||data.student_name||'WhatIDo');
box.appendChild(mono);
}
}
function renderTimeline(data){
const entries=data.entries||[];
const list=document.getElementById('timelineList');
if(!list)return;
if(!entries.length){hideSection('timeline');setNavVisible('timeline',false);return}
const showDate=data.show_entry_date!==false;
const showDesc=!!data.show_student_message;
list.innerHTML=entries.map(function(e,index){
const files=e.files||[];
const stamp=showDate?fmtShort(e.created_at):pad(index+1);
const aside=[];
if(data.group_name)aside.push(data.group_name);
const counts=countsLabel(e.photo_path?1:0,files.length);
if(counts)aside.push(counts);
return '<div class="list__item list__item--u-ipltrh05x">'+
'<div class="text text--u-ilncqhajz text-number-2"><span class="text-block-wrap-div">'+esc(stamp)+'</span></div>'+
'<div class="div div--u-i26e5rzsk"><div class="div div--u-it0l5f3ro">'+
'<div class="div div--u-izku4a3eb"></div><div class="div div--u-il2x5kjti"></div>'+
(e.photo_path?'<div class="image image--u-i2etuklnt" data-img="'+esc(e.photo_path)+'"><img class="image__img image__img--s2-iyzrya226" src="'+esc(thumbSrc(e.photo_path))+'" alt="" loading="lazy"></div>':'')+
'<div class="div div--u-iaj0y0mvl"></div><div class="div div--u-iu4k5l3n1"></div>'+
'</div></div>'+
'<div class="text text--u-ie3ure5d7 h3 rep-stack">'+
'<span class="text-block-wrap-div">'+esc(e.student_name||'Занятие')+'</span>'+
(showDesc&&e.description?'<div class="text t2 rep-tl-desc"><span class="text-block-wrap-div">'+esc(e.description)+'</span></div>':'')+
fileChips(files)+
'</div>'+
'<div class="div div--u-ib8lera1q rep-tl-aside">'+
aside.map(function(a){return '<div class="text text--u-i8vddz288 t2"><span class="text-block-wrap-div">'+esc(a)+'</span></div>'}).join('')+
'</div>'+
'</div>';
}).join('');
}
function workTitle(e){
const d=String(e.description||'').replace(/\s+/g,' ').trim();
if(d)return d.length>88?d.slice(0,88).trim()+'…':d;
return e.student_name||'Работа';
}
function renderWorks(data){
const slider=document.querySelector('.rep-slider[data-slider="works"]');
if(!slider)return;
const entries=(data.entries||[]).filter(function(e){return e.photo_path});
if(!entries.length){hideSection('works');setNavVisible('works',false);return}
const items=entries.slice(0,12).map(function(e,index){
return '<div class="blocklist__item-wrapper blocklist__item-wrapper--s2-in9zna1lp">'+
'<div class="blocklist__item blocklist__item--u-iqn443glf blocklist__item--s2-iqn443glf">'+
'<div class="div div--u-ifxd1g9rk">'+
'<div class="div div--u-ibren39lw"></div>'+
'<div class="div div--u-ixtm7p8ov"></div>'+
'<div class="text h3 text--u-ib8dkw227"><span class="text-block-wrap-div">'+esc(pad(index+1))+'</span></div>'+
'<div class="image image--u-iijk97g72 rep-work-photo" data-img="'+esc(e.photo_path)+'"><img class="image__img" src="'+esc(thumbSrc(e.photo_path))+'" alt="" loading="lazy"></div>'+
'<div class="div div--u-ilvcq6gdk">'+
'<div class="text text--u-i8ydj7eyx h3-script"><span class="text-block-wrap-div">'+esc(workTitle(e))+'</span></div>'+
'<div class="div div--u-ipp2467j8"><div class="text t2 text--u-iz3df5dls"><span class="text-block-wrap-div">'+esc(data.show_entry_date!==false?fmtDate(e.created_at):(e.student_name||''))+'</span></div></div>'+
'</div>'+
(data.show_student_message&&e.description&&workTitle(e)!==String(e.description).trim()?'<div class="text text--u-iz3df5dls l1 rep-work-desc"><span class="text-block-wrap-div">'+esc(e.description)+'</span></div>':'')+
fileChips(e.files)+
'<div class="div div--u-iidtqgnjo"></div>'+
'<div class="div div--u-iohjzr62g"></div>'+
'</div>'+
'</div>'+
'</div>';
});
renderSlider(slider,items);
}
function renderFiles(data){
const list=document.getElementById('filesList');
if(!list)return;
const rows=[];
(data.entries||[]).forEach(function(e){
(e.files||[]).forEach(function(f){rows.push({file:f,entry:e})});
});
if(!rows.length){hideSection('files');setNavVisible('files',false);return}
list.innerHTML=rows.map(function(row){
const f=row.file;
const url=fileUrl(f.token);
const label=baseName(f.name);
const isImg=isImageFile(f.name);
const meta=[];
if(data.show_entry_date!==false)meta.push(fmtDate(row.entry.created_at));
if(row.entry.student_name)meta.push(row.entry.student_name);
if(data.group_name)meta.push(data.group_name);
const icon=isImg
?'<div class="image image--u-i1bx3pcch"><img class="image__img" src="'+esc(thumbSrc(url))+'" alt="" loading="lazy"></div>'
:'<div class="image image--u-i1bx3pcch rep-file-icon">'+esc(extLabel(f.name))+'</div>';
const head=isImg
?'<a class="list__item list__item--u-iusd5amsk rep-file-item" href="#" data-img="'+esc(url)+'">'
:'<a class="list__item list__item--u-iusd5amsk rep-file-item" href="'+esc(url)+'" download>';
return head+
'<div class="div div--u-i65kawao9"><div class="text t2 text--u-inm34sjkb"><span class="text-block-wrap-div">'+esc(fmtDate(row.entry.created_at))+'</span></div></div>'+
'<div class="div div--u-iqdk5s4vv">'+
'<div class="div div--u-iq1qpzuod"></div>'+
'<div class="div div--u-itqr1x7ye"></div>'+
icon+
'<div class="div div--u-iauar4gpi"></div>'+
'<div class="div div--u-i78hvbbl0"></div>'+
'</div>'+
'<div class="text h3 text--u-ih08rddti rep-file-title"><span class="text-block-wrap-div">'+esc(label)+'</span></div>'+
'<div class="text l1 text--u-i0erozcli rep-stack rep-file-meta">'+
'<span class="text-block-wrap-div">'+esc(meta.join(' · '))+'</span>'+
'<span class="rep-file-cta">'+(isImg?'Посмотреть':'Скачать')+'</span>'+
'</div>'+
'</a>';
}).join('');
}
function renderGallery(data){
const slider=document.querySelector('.rep-slider[data-slider="gallery"]');
if(!slider)return;
const photos=data.photos||[];
if(!photos.length){hideSection('gallery');setNavVisible('gallery',false);return}
const items=photos.slice(0,12).map(function(p){
const caption=String(p.caption||'').trim();
return '<div class="blocklist__item-wrapper blocklist__item-wrapper--s2-iuntcnkpn">'+
'<div class="blocklist__item blocklist__item--u-iefuhgv1p blocklist__item--s2-iefuhgv1p">'+
'<div class="div div--u-iova3e6no">'+
'<div class="div div--u-iy3zasv7f"></div>'+
'<div class="div div--u-ish1rimi9"></div>'+
'<div class="image image--u-ipuomcrxx rep-gallery-photo" data-img="'+esc(p.photo_path)+'"><img class="image__img" src="'+esc(thumbSrc(p.photo_path))+'" alt="" loading="lazy"></div>'+
'<div class="div div--u-ij727gv6j"></div>'+
'<div class="div div--u-iymundgii"></div>'+
'</div>'+
'<div class="div div--u-iq00jif9z">'+
'<div class="text t2 text--u-i6vup9a8s rep-gallery-caption"><span class="text-block-wrap-div">'+esc(caption||(data.group_name?'Группа «'+data.group_name+'»':'Фото занятия'))+'</span></div>'+
'<div class="text t2 text--u-iwrgrsqzk rep-gallery-date"><span class="text-block-wrap-div">'+esc(fmtDate(p.taken_at||p.created_at))+'</span></div>'+
'<div class="text text--u-ig38djg6x l1 rep-gallery-text"><span class="text-block-wrap-div">'+esc(caption?'Фотография с занятий группы':'Фотография с занятий')+'</span></div>'+
'<div class="div div--u-i8lri9goh"></div>'+
'<div class="div div--u-i4kmjxmbi"></div>'+
'</div>'+
'</div>'+
'</div>';
});
renderSlider(slider,items);
}
function renderFooter(data,heading,pool){
setText('brandMark',initials(heading));
setText('brandMarkFooter',initials(heading));
setText('footGroup',data.group_name?'Группа «'+data.group_name+'»':'Группа не указана');
setText('footPeriod',periodLabel(data.date_from,data.date_to)?'Период: '+periodLabel(data.date_from,data.date_to):'Период: весь отчёт');
setText('footCreated','Обновлён: '+fmtDate(data.created_at));
const expires=toDate(data.expires_at);
setText('footExpires',expires?'Доступно до '+fmtDate(data.expires_at):'Срок не ограничен');
const link=document.getElementById('footLink');
if(link&&data.link_url&&/^https?:\/\//i.test(data.link_url)){link.href=data.link_url;link.style.display='inline-flex'}
else if(link){link.style.display='none'}
const note=document.getElementById('contactNote');
if(note)note.innerHTML='<span class="text-block-wrap-div">Если появятся вопросы по отчёту — напишите преподавателю<span style="color:rgba(68,68,68,1)"> — мы ответим в течение 24 часов.</span></span>';
setText('contactLead',(data.name&&String(data.name).trim())||'Отчёт о работе');
const sub=[];
if(periodLabel(data.date_from,data.date_to))sub.push('Период: '+periodLabel(data.date_from,data.date_to));
sub.push('Обновлён: '+fmtDate(data.created_at));
if(expires)sub.push('Доступен до: '+fmtDate(data.expires_at));
setText('contactSub',sub.join('\n'));
const btn=document.getElementById('contactBtn');
if(btn&&data.link_url&&/^https?:\/\//i.test(data.link_url)){
btn.href=data.link_url;
btn.target='_blank';
btn.rel='noopener noreferrer';
const label=btn.querySelector('.text-block-wrap-div');
if(label)label.textContent='Перейти по ссылке';
}
setText('footCopy',state.systemName+' © '+new Date().getFullYear());
setText('footBrand',heading);
const fbox=document.getElementById('footerPhotoBox');
const fimg=document.getElementById('footerPhoto');
const photo=pool[2]||pool[0];
if(fbox&&fimg&&photo){fimg.src=thumbSrc(photo);fimg.alt='';fbox.setAttribute('data-img',photo)}
else if(fbox){hideEl(fbox,true)}
}
function renderReport(data){
const heading=(data.name&&String(data.name).trim())||data.student_name||data.group_name||'Отчёт';
document.title=heading;
const pool=photoPool(data);
renderHero(data,heading,pool);
renderAbout(data,pool);
renderTimeline(data);
renderWorks(data);
renderFiles(data);
renderGallery(data);
renderFooter(data,heading,pool);
startCountdown(data.expires_at);
const content=document.getElementById('content');
if(content)content.style.display='block';
}
async function loadReport(){
const token=location.pathname.split('/').filter(Boolean).pop()||'';
state.token=token;
state.password=null;
const result=await fetchShare(token,null);
if(result.ok){
state.showNames=result.data.show_student_names!==false;
renderReport(result.data);
return;
}
if(result.status===401&&result.data.passwordRequired){
openModal();
return;
}
if(result.status===410)showError('Срок действия ссылки истёк.');
else showError('Ссылка не найдена или была удалена.');
setText('brandMark',initials('WhatIDo'));
setText('brandMarkFooter',initials('WhatIDo'));
}
function bindEvents(){
const burger=document.getElementById('navBurger');
const close=document.getElementById('navClose');
const overlay=document.getElementById('navOverlay');
if(burger)burger.addEventListener('click',openMenu);
if(close)close.addEventListener('click',closeMenu);
if(overlay)overlay.addEventListener('click',closeMenu);
document.querySelectorAll('.rep-nav .menu__link-lvl1, .rep-nav .link--u-i2ifdcrc4').forEach(function(a){
a.addEventListener('click',function(){closeMenu()});
});
document.addEventListener('click',function(ev){
const prev=ev.target.closest('[data-slider-prev]');
const next=ev.target.closest('[data-slider-next]');
if(prev||next){
const slider=(prev||next).closest('.rep-slider');
if(slider){ev.preventDefault();stepSlider(slider,prev?-1:1)}
return;
}
const imgEl=ev.target.closest('[data-img]');
if(imgEl){ev.preventDefault();openLightbox(imgEl.getAttribute('data-img'));return}
const lb=document.getElementById('lightbox');
if(lb&&lb.classList.contains('is-open'))closeLightbox();
});
const submit=document.getElementById('passwordSubmitBtn');
const input=document.getElementById('passwordInput');
if(submit)submit.addEventListener('click',async function(){
const value=input?input.value.trim():'';
if(!value)return;
submit.disabled=true;
const result=await fetchShare(state.token,value);
submit.disabled=false;
if(result.ok){
state.password=value;
closeModal();
state.showNames=result.data.show_student_names!==false;
renderReport(result.data);
}else{
modalError(result.status===410?'Срок действия ссылки истёк':'Неверный пароль');
if(input){input.value='';input.focus()}
}
});
if(input)input.addEventListener('keydown',function(ev){
if(ev.key==='Enter'&&submit)submit.click();
});
const modal=document.getElementById('passwordModal');
if(modal)modal.addEventListener('click',function(ev){if(ev.target===modal)closeModal()});
document.addEventListener('keydown',function(ev){
if(ev.key!=='Escape')return;
closeLightbox();
closeMenu();
});
window.addEventListener('resize',function(){
if(window.innerWidth>991)closeMenu();
});
}
bindEvents();
makeNavLinks();
loadBrand().then(function(){loadReport()});
function closeLightbox(){const lb=document.getElementById('lightbox');if(lb)lb.classList.remove('is-open')}
+568 -71
View File
@@ -1,4 +1,27 @@
const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText'];
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'trashPurgeDays'];
const SECTION_IDS = ['sec-system', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-bans'];
const SPAM_MAX = 10080;
const BAN_REASONS = {
'honeypot': 'Антиспам-поле',
'login-bruteforce': 'Подбор пароля входа',
'share-password-bruteforce': 'Подбор пароля ссылки',
'manual': 'Вручную',
};
let currentLogo = '';
let logoOriginal = '';
function renderLogoPreview() {
const img = document.getElementById('logoPreviewImg');
const fb = document.getElementById('logoPreviewFallback');
const rm = document.getElementById('logoRemoveBtn');
if (!img || !fb || !rm) return;
const hasLogo = !!currentLogo;
img.style.display = hasLogo ? 'block' : 'none';
fb.style.display = hasLogo ? 'none' : '';
if (hasLogo) img.src = currentLogo;
rm.hidden = !hasLogo;
}
function fieldValue(el) {
return el.type === 'checkbox' ? String(el.checked) : el.value;
@@ -8,24 +31,74 @@ function setBtnLoading(btn, on, spinnerClass) {
if (on) {
btn.dataset.label = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<span class="spinner ${spinnerClass || ''}"></span> ${btn.dataset.loadingText || 'Обработка...'}`;
const cls = spinnerClass || (btn.classList.contains('ghost') ? 'dark' : '');
btn.innerHTML = `<span class="spinner ${cls}"></span> ${btn.dataset.loadingText || 'Обработка...'}`;
} else {
btn.disabled = false;
btn.innerHTML = btn.dataset.label || btn.innerHTML;
}
}
function showError(msg) {
const t = document.getElementById('toast');
if (!t) return;
t.textContent = msg;
t.classList.add('show', 'error');
clearTimeout(t._errTimer);
t._errTimer = setTimeout(() => t.classList.remove('error'), 3400);
}
function showFieldError(id, msg) {
const el = document.getElementById(id);
const err = document.getElementById(`${id}Error`);
if (el) el.classList.add('invalid');
if (err) { err.textContent = msg; err.hidden = false; }
}
function clearFieldError(id) {
const el = document.getElementById(id);
const err = document.getElementById(`${id}Error`);
if (el) el.classList.remove('invalid');
if (err) err.hidden = true;
}
function plural(n, one, few, many) {
const a = n % 10, b = n % 100;
if (a === 1 && b !== 11) return one;
if (a >= 2 && a <= 4 && (b < 12 || b > 14)) return few;
return many;
}
function humanizeMinutes(min) {
const n = Number(min);
if (!Number.isFinite(n) || n < 1) return '';
if (n === 1440) return 'сутки';
if (n > 1440 && n % 1440 === 0) {
const d = n / 1440;
return `${d} ${plural(d, 'сутки', 'суток', 'суток')}`;
}
const h = Math.floor(n / 60);
const m = n % 60;
const parts = [];
if (h) parts.push(`${h} ${plural(h, 'час', 'часа', 'часов')}`);
if (m) parts.push(`${m} ${plural(m, 'минута', 'минуты', 'минут')}`);
if (!parts.length) parts.push(`${n} ${plural(n, 'минута', 'минуты', 'минут')}`);
return parts.join(' ');
}
function updateDirty() {
const dirty = DIRTY_FIELDS.some(id => {
const dirtyCount = DIRTY_FIELDS.filter(id => {
const el = document.getElementById(id);
return String(el.dataset.original ?? '') !== fieldValue(el);
});
document.getElementById('saveBar').classList.toggle('show', dirty);
}).length;
document.getElementById('saveBar').classList.toggle('show', dirtyCount > 0);
}
function bindDirty() {
DIRTY_FIELDS.forEach(id => {
document.getElementById(id).addEventListener('input', updateDirty);
const el = document.getElementById(id);
el.addEventListener('input', updateDirty);
el.addEventListener('change', updateDirty);
});
}
@@ -36,13 +109,63 @@ function snapshotDirty() {
updateDirty();
}
function revertChanges() {
DIRTY_FIELDS.forEach(id => {
const el = document.getElementById(id);
const original = el.dataset.original ?? '';
if (el.type === 'checkbox') el.checked = original === 'true';
else el.value = original;
});
currentLogo = logoOriginal;
renderLogoPreview();
clearFieldError('spamInterval');
updateSpamUi();
updateDirty();
showToast('Изменения отменены');
}
function initSectionNav() {
const nav = document.getElementById('settingsNav');
if (!nav) return;
const links = [...nav.querySelectorAll('a')];
const smooth = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth';
let ticking = false;
const setActive = () => {
ticking = false;
let current = SECTION_IDS[0];
for (const id of SECTION_IDS) {
const el = document.getElementById(id);
if (el && el.getBoundingClientRect().top <= 170) current = id;
}
links.forEach(a => a.classList.toggle('active', a.getAttribute('href') === `#${current}`));
};
window.addEventListener('scroll', () => {
if (!ticking) { ticking = true; requestAnimationFrame(setActive); }
}, { passive: true });
setActive();
nav.addEventListener('click', e => {
const a = e.target.closest('a');
if (!a) return;
e.preventDefault();
const target = document.getElementById(a.getAttribute('href').slice(1));
if (target) target.scrollIntoView({ behavior: smooth, block: 'start' });
history.replaceState(null, '', a.getAttribute('href'));
});
}
async function loadSettings() {
const [sRes, stRes] = await Promise.all([
fetch(`${API}/api/settings`, { headers: hdr() }),
fetch(`${API}/api/stats`, { headers: hdr() }),
]);
if (!sRes.ok) { showError('Не удалось загрузить настройки'); return; }
const s = await sRes.json();
document.getElementById('systemName').value = s.system_name !== undefined ? s.system_name : 'WhatIDo';
currentLogo = s.system_logo || '';
logoOriginal = currentLogo;
renderLogoPreview();
if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min;
document.getElementById('trashPurgeDays').value = s.trash_purge_days !== undefined ? s.trash_purge_days : '30';
document.getElementById('footerLeft').value = s.footer_left || '';
document.getElementById('footerRight').value = s.footer_right || '';
document.getElementById('aiPrompt').value = s.ai_prompt || '';
@@ -52,27 +175,61 @@ async function loadSettings() {
document.getElementById('shareShowNames').checked = s.share_show_student_names !== 'false';
document.getElementById('shareShowPhotos').checked = s.share_show_group_photos !== 'false';
document.getElementById('cookieNoticeText').value = s.cookie_notice_text || '';
const pr = document.getElementById('photoResolution');
if (s.photo_capture_resolution) pr.value = s.photo_capture_resolution;
if (!pr.value) pr.value = '640x480';
document.getElementById('photoQuality').value = s.photo_capture_quality !== undefined && s.photo_capture_quality !== '' ? s.photo_capture_quality : '0.92';
const pe = document.getElementById('photoEngine');
pe.value = ['auto', 'server', 'client'].includes(s.photo_enhance_engine) ? s.photo_enhance_engine : 'auto';
if (!pe.value) pe.value = 'auto';
document.getElementById('camEnabled').checked = s.camera_enabled !== 'false';
snapshotDirty();
updateSpamUi();
loadAiQueue();
if (stRes.ok) {
const st = await stRes.json();
document.getElementById('dataStats').innerHTML = `
loadAiProfiles();
renderStats(stRes);
loadSystemInfo();
}
async function renderStats(res) {
const el = document.getElementById('dataStats');
if (!el) return;
if (!res || !res.ok) { el.innerHTML = '<span class="chip">Статистика недоступна</span>'; return; }
const st = await res.json();
el.innerHTML = `
<span class="chip">Записей: <b>${st.entries}</b></span>
<span class="chip">Учеников: <b>${st.students}</b></span>
<span class="chip">Групп: <b>${st.groups}</b></span>
<span class="chip">Сегодня: <b>${st.today}</b></span>
<span class="chip">${st.trash ? `В корзине: <b>${st.trash}</b> <a href="trash.html">→</a>` : ''}</span>
${st.trash ? `<span class="chip">В корзине: <b>${st.trash}</b> <a href="trash.html">→</a></span>` : ''}
`;
} else {
document.getElementById('dataStats').innerHTML = '';
}
}
const siRes = await fetch(`${API}/api/system-info`, { headers: hdr() });
if (siRes.ok) {
const si = await siRes.json();
renderSystemInfo(si);
} else {
document.getElementById('systemInfo').innerHTML = '<div class="sys-card err">Ошибка загрузки</div>';
async function refreshStats() {
const el = document.getElementById('dataStats');
if (!el) return;
el.innerHTML = '<span class="chip">Обновление…</span>';
try {
await renderStats(await fetch(`${API}/api/stats`, { headers: hdr() }));
} catch {
el.innerHTML = '<span class="chip">Ошибка сети</span>';
}
}
async function loadSystemInfo() {
const el = document.getElementById('systemInfo');
if (!el) return;
try {
const res = await fetch(`${API}/api/system-info`, { headers: hdr() });
if (res.ok) {
renderSystemInfo(await res.json());
const live = document.getElementById('systemLive');
if (live) live.textContent = 'обновлено ' + new Date().toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' });
return;
}
el.innerHTML = '<div class="sys-card err">Ошибка загрузки</div>';
} catch {
el.innerHTML = '<div class="sys-card err">Ошибка сети</div>';
}
}
@@ -81,12 +238,13 @@ function renderSystemInfo(si) {
const photos = si.photos || {};
const files = si.files || {};
const uploads = si.uploads || {};
const storage = si.storage || {};
const disk = si.disk;
let tablesHtml = '';
if (db.tables && db.tables.length) {
tablesHtml = '<details class="sys-details"><summary>Таблицы БД</summary><div class="sys-table-list">' +
db.tables.map(t => `<div class="sys-table-row"><span>${t.name}</span><span>${t.size}</span></div>`).join('') +
db.tables.map(t => `<div class="sys-table-row"><span>${esc(t.name)}</span><span>${esc(t.size)}</span></div>`).join('') +
'</div></details>';
}
@@ -94,29 +252,28 @@ function renderSystemInfo(si) {
if (disk) {
const pct = disk.used_pct;
const color = pct > 90 ? '#ef4444' : pct > 70 ? '#f59e0b' : '#16a34a';
const cardCls = pct > 90 ? ' danger' : pct > 70 ? ' warn' : '';
diskHtml = `
<div class="sys-item">
<span class="sys-label">Диск</span>
<span class="sys-value">${disk.used} / ${disk.total}</span>
</div>
<div class="sys-bar-wrap">
<div class="sys-bar" style="width:${pct}%; background:${color}"></div>
</div>
<div class="sys-meta">Свободно: ${disk.free} (${100 - pct}%)</div>
`;
<div class="sys-card${cardCls}">
<div class="sys-title">Диск</div>
<div class="sys-item"><span class="sys-label">Заполнено</span><span class="sys-value">${pct}%</span></div>
<div class="sys-item"><span class="sys-label">Использовано</span><span class="sys-value">${esc(disk.used)} / ${esc(disk.total)}</span></div>
<div class="sys-bar-wrap"><div class="sys-bar" style="width:${pct}%; background:${color}"></div></div>
<div class="sys-meta">Свободно: ${esc(disk.free)} (${100 - pct}%)</div>
</div>`;
}
document.getElementById('systemInfo').innerHTML = `
<div class="sys-card">
<div class="sys-title">База данных</div>
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${db.size || '—'}</span></div>
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${esc(db.size || '—')}</span></div>
${tablesHtml}
</div>
<div class="sys-card">
<div class="sys-title">Фото</div>
<div class="sys-item"><span class="sys-label">Фото групп</span><span class="sys-value">${photos.group_photos?.count || 0} · ${photos.group_photos?.size || '0 B'}</span></div>
<div class="sys-item"><span class="sys-label">Фото в записях</span><span class="sys-value">${photos.entry_photos?.count || 0} · ${photos.entry_photos?.size || '0 B'}</span></div>
<div class="sys-item"><span class="sys-label">Всего</span><span class="sys-value"><b>${photos.total_count || 0}</b> · <b>${photos.total_size || '0 B'}</b></span></div>
<div class="sys-item"><span class="sys-label">Фото групп</span><span class="sys-value">${photos.group_photos?.count || 0} · ${esc(photos.group_photos?.size || '0 B')}</span></div>
<div class="sys-item"><span class="sys-label">Фото в записях</span><span class="sys-value">${photos.entry_photos?.count || 0} · ${esc(photos.entry_photos?.size || '0 B')}</span></div>
<div class="sys-item"><span class="sys-label">Всего</span><span class="sys-value"><b>${photos.total_count || 0}</b> · <b>${esc(photos.total_size || '0 B')}</b></span></div>
</div>
<div class="sys-card">
<div class="sys-title">Файлы</div>
@@ -125,15 +282,79 @@ function renderSystemInfo(si) {
<div class="sys-card">
<div class="sys-title">Загрузки</div>
<div class="sys-item"><span class="sys-label">Файлов</span><span class="sys-value">${uploads.count || 0}</span></div>
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${uploads.size || '0 B'}</span></div>
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${esc(uploads.size || '0 B')}</span></div>
</div>
<div class="sys-card">
<div class="sys-title">Диск</div>
${diskHtml || '<div class="sys-meta">Недоступно</div>'}
<div class="sys-title">Хранилище</div>
<div class="sys-item"><span class="sys-label">Драйвер</span><span class="sys-value">${storage.driver === 's3' ? 'S3' : 'Локальное'}</span></div>
${storage.bucket ? `<div class="sys-item"><span class="sys-label">Бакет</span><span class="sys-value">${esc(storage.bucket)}${storage.prefix ? '/' + esc(storage.prefix) : ''}</span></div>` : ''}
<div class="sys-item"><span class="sys-label">Объектов</span><span class="sys-value">${storage.count || 0}</span></div>
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${esc(storage.size || '0 B')}</span></div>
</div>
${diskHtml || '<div class="sys-card"><div class="sys-title">Диск</div><div class="sys-meta">Недоступно</div></div>'}
`;
}
async function loadAiProfiles() {
const listEl = document.getElementById('aiProfilesList');
const select = document.getElementById('aiActiveProfile');
if (!listEl) return;
try {
const res = await fetch(`${API}/api/ai/profiles`, { headers: hdr() });
if (!res.ok) { listEl.innerHTML = '<span class="chip">Не удалось загрузить профили</span>'; return; }
const data = await res.json();
const profiles = data.profiles || [];
if (select) {
select.innerHTML = `<option value="native">Нативная — ${esc(data.native.model)}</option>` +
profiles.map(p => `<option value="${esc(p.id)}">${esc(p.name)} — ${esc(p.model)}</option>`).join('');
select.value = data.active;
}
const items = [{ id: 'native', name: 'Нативная (llama.cpp в Docker)', base_url: data.native.base_url, model: data.native.model, native: true }].concat(profiles);
listEl.innerHTML = items.map(p => `
<div class="ai-profile-item${data.active === p.id ? ' active' : ''}">
<div class="pi-info">
<div class="pi-name">${esc(p.name)}${data.active === p.id ? ' <span class="pi-badge">по умолчанию</span>' : ''}</div>
<div class="pi-meta">${esc(p.model)} · ${esc(p.base_url)}${p.max_tokens ? ` · max_tokens: ${esc(p.max_tokens)}` : ''}</div>
</div>
<div class="pi-actions">
${p.native ? '' : `<button type="button" class="pi-btn" data-edit-profile="${esc(p.id)}">Изменить</button>`}
${p.native ? '' : `<button type="button" class="pi-btn danger" data-del-profile="${esc(p.id)}" data-del-name="${esc(p.name)}">Удалить</button>`}
</div>
</div>
`).join('');
} catch { listEl.innerHTML = '<span class="chip">Ошибка загрузки</span>'; }
}
let aiProfileEditingId = null;
function openAiProfileForm(profile) {
aiProfileEditingId = profile ? profile.id : null;
document.getElementById('aiProfileModalTitle').textContent = profile ? 'Изменить профиль ИИ' : 'Добавить профиль ИИ';
document.getElementById('aiProfileModal').classList.add('open');
document.getElementById('aiProfileName').value = profile ? profile.name : '';
document.getElementById('aiProfileModel').value = profile ? profile.model : '';
document.getElementById('aiProfileBaseUrl').value = profile ? profile.base_url : '';
document.getElementById('aiProfileApiKey').value = '';
document.getElementById('aiProfileMaxTokens').value = profile && profile.max_tokens ? profile.max_tokens : '';
document.getElementById('aiProfileTestStatus').textContent = '';
document.getElementById('aiProfileName').focus();
}
function closeAiProfileForm() {
aiProfileEditingId = null;
document.getElementById('aiProfileModal').classList.remove('open');
}
function aiProfileFormData() {
return {
name: document.getElementById('aiProfileName').value,
model: document.getElementById('aiProfileModel').value,
base_url: document.getElementById('aiProfileBaseUrl').value,
api_key: document.getElementById('aiProfileApiKey').value,
max_tokens: document.getElementById('aiProfileMaxTokens').value.trim() === '' ? null : document.getElementById('aiProfileMaxTokens').value,
};
}
async function loadAiQueue() {
const el = document.getElementById('aiQueueInfo');
if (!el) return;
@@ -142,14 +363,159 @@ async function loadAiQueue() {
if (!res.ok) { el.textContent = ''; return; }
const q = await res.json();
const c = q.counts || {};
const state = q.enabled ? 'включена' : 'выключена';
el.innerHTML = `Автопроверка: <b>${state}</b> · Модель: <b>${esc(q.model)}</b> · В очереди: <b>${c.pending || 0}</b> · Проверено: <b>${c.done || 0}</b> · Ошибок: <b>${c.error || 0}</b> · Пропущено: <b>${c.skipped || 0}</b> · <a href="worker.html">Монитор воркера →</a>`;
el.innerHTML = `
<span class="chip">Автопроверка: <b>${q.enabled ? 'включена' : 'выключена'}</b></span>
<span class="chip">Модель: <b>${esc(q.model || '—')}</b></span>
<span class="chip">В очереди: <b>${c.pending || 0}</b></span>
<span class="chip">Проверено: <b>${c.done || 0}</b></span>
<span class="chip">Ошибок: <b>${c.error || 0}</b></span>
<span class="chip">Пропущено: <b>${c.skipped || 0}</b></span>
<a class="queue-link" href="worker.html">Монитор воркера →</a>`;
} catch { el.textContent = ''; }
}
async function saveAiProfile() {
const body = aiProfileFormData();
const st = document.getElementById('aiProfileTestStatus');
st.innerHTML = '<span class="spinner dark"></span> Сохранение...';
const res = await fetch(aiProfileEditingId ? `${API}/api/ai/profiles/${aiProfileEditingId}` : `${API}/api/ai/profiles`, {
method: aiProfileEditingId ? 'PUT' : 'POST',
headers: hdrJson(),
body: JSON.stringify(body),
});
if (res.ok) {
st.textContent = '';
showToast(aiProfileEditingId ? 'Профиль обновлён' : 'Профиль добавлен');
closeAiProfileForm();
loadAiProfiles();
} else {
const err = await res.json().catch(() => ({}));
st.innerHTML = `<span class="fail">${esc(err.error || 'Ошибка сохранения')}</span>`;
}
}
async function testAiProfile() {
const st = document.getElementById('aiProfileTestStatus');
st.textContent = 'Проверка…';
const res = await fetch(`${API}/api/ai/profiles/test`, { method: 'POST', headers: hdrJson(), body: JSON.stringify(aiProfileFormData()) });
const d = await res.json().catch(() => ({ ok: false, error: 'Ошибка' }));
st.innerHTML = d.ok
? `<span class="ok">✓ Работает (${d.latency_ms} мс)${d.sample ? ` — «${esc(d.sample)}»` : ''}</span>`
: `<span class="fail">✗ ${esc(d.error || 'Не удалось подключиться')}</span>`;
}
async function activateAiProfile(id) {
const res = await fetch(`${API}/api/ai/profiles/activate`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ id }) });
if (res.ok) {
showToast('Модель по умолчанию обновлена');
loadAiProfiles();
loadAiQueue();
} else {
const err = await res.json().catch(() => ({}));
showError(err.error || 'Ошибка');
}
}
async function deleteAiProfile(id, name, btn) {
if (btn && !btn.dataset.confirming) {
btn.dataset.confirming = '1';
btn.classList.add('confirming');
btn.textContent = 'Точно?';
setTimeout(() => {
if (btn.isConnected) {
delete btn.dataset.confirming;
btn.classList.remove('confirming');
btn.textContent = 'Удалить';
}
}, 3000);
return;
}
const res = await fetch(`${API}/api/ai/profiles/${id}`, { method: 'DELETE', headers: hdr() });
if (res.ok) {
showToast('Профиль удалён');
loadAiProfiles();
} else {
const err = await res.json().catch(() => ({}));
showError(err.error || 'Ошибка удаления');
}
}
const spamInput = document.getElementById('spamInterval');
function updateSpamUi() {
const raw = spamInput.value;
const n = Math.round(Number(raw));
const valid = raw !== '' && Number.isFinite(n) && n >= 1;
const hint = document.getElementById('spamIntervalHint');
if (hint) hint.textContent = valid ? `Следующая отправка будет доступна через ${humanizeMinutes(n)}` : 'Минимальное значение — 1 минута';
document.querySelectorAll('#spamPresets .preset-btn').forEach(b => b.classList.toggle('active', valid && Number(b.dataset.min) === n));
}
function clampSpam(v) {
let n = Math.round(Number(v));
if (!Number.isFinite(n) || n < 1) n = 1;
if (n > SPAM_MAX) n = SPAM_MAX;
return n;
}
spamInput.addEventListener('change', () => {
const n = Math.round(Number(spamInput.value));
if (spamInput.value === '' || !Number.isFinite(n) || n < 1) {
spamInput.value = clampSpam(spamInput.dataset.original ?? 30);
updateDirty();
}
updateSpamUi();
});
spamInput.addEventListener('input', updateSpamUi);
document.getElementById('spamPresets').addEventListener('click', e => {
const btn = e.target.closest('[data-min]');
if (!btn) return;
spamInput.value = btn.dataset.min;
spamInput.dispatchEvent(new Event('input', { bubbles: true }));
spamInput.dispatchEvent(new Event('change', { bubbles: true }));
});
document.getElementById('aiProfileAddBtn').addEventListener('click', () => openAiProfileForm(null));
document.getElementById('aiProfileSaveBtn').addEventListener('click', saveAiProfile);
document.getElementById('aiProfileTestBtn').addEventListener('click', testAiProfile);
document.getElementById('aiProfileCancelBtn').addEventListener('click', closeAiProfileForm);
document.getElementById('aiProfileModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeAiProfileForm(); });
document.getElementById('aiActiveProfile').addEventListener('change', e => activateAiProfile(e.target.value));
document.getElementById('aiProfilesList').addEventListener('click', e => {
const del = e.target.closest('[data-del-profile]');
if (del) { deleteAiProfile(del.dataset.delProfile, del.dataset.delName, del); return; }
const edit = e.target.closest('[data-edit-profile]');
if (edit) {
fetch(`${API}/api/ai/profiles`, { headers: hdr() })
.then(r => r.json())
.then(data => {
const p = (data.profiles || []).find(x => x.id === edit.dataset.editProfile);
if (p) openAiProfileForm(p);
});
}
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.getElementById('aiProfileModal').classList.contains('open')) closeAiProfileForm();
});
async function saveSettings() {
const v = document.getElementById('spamInterval').value;
if (v === '' || Number(v) < 1) { alert('Введите число от 1'); return; }
const v = spamInput.value;
const n = Number(v);
if (v === '' || !Number.isFinite(n) || Math.round(n) < 1) {
showFieldError('spamInterval', 'Укажите целое число минут (минимум 1)');
spamInput.focus();
showError('Проверьте поле «Интервал ожидания»');
return;
}
clearFieldError('spamInterval');
const trashValue = document.getElementById('trashPurgeDays').value;
const trashDays = Math.round(Number(trashValue));
if (trashValue === '' || !Number.isFinite(trashDays) || trashDays < 1 || trashDays > 365) {
showError('Проверьте поле «Время до безвозвратного удаления» (от 1 до 365 дней)');
return;
}
const btn = document.getElementById('saveBtn');
btn.dataset.loadingText = 'Сохранение...';
setBtnLoading(btn, true);
@@ -158,6 +524,7 @@ async function saveSettings() {
method: 'PUT',
headers: hdrJson(),
body: JSON.stringify({ settings: {
system_name: document.getElementById('systemName').value.trim(),
spam_interval_min: String(Math.round(Number(v))),
footer_left: document.getElementById('footerLeft').value.trim(),
footer_right: document.getElementById('footerRight').value.trim(),
@@ -168,35 +535,67 @@ async function saveSettings() {
share_show_student_names: document.getElementById('shareShowNames').checked ? 'true' : 'false',
share_show_group_photos: document.getElementById('shareShowPhotos').checked ? 'true' : 'false',
cookie_notice_text: document.getElementById('cookieNoticeText').value.trim(),
photo_capture_resolution: document.getElementById('photoResolution').value,
photo_capture_quality: String(document.getElementById('photoQuality').value),
photo_enhance_engine: document.getElementById('photoEngine').value,
camera_enabled: document.getElementById('camEnabled').checked ? 'true' : 'false',
trash_purge_days: String(trashDays),
} })
});
if (res.ok) {
showToast('Настройки сохранены');
logoOriginal = currentLogo;
snapshotDirty();
} else { const e = await res.json(); alert(e.error); }
updateSpamUi();
} else {
const e = await res.json().catch(() => ({}));
showError(e.error || 'Ошибка сохранения');
}
} catch {
showError('Ошибка сети при сохранении');
} finally {
setBtnLoading(btn, false);
}
}
function formatBackupSize(b) {
if (!b || b < 0) return '';
if (b >= 1073741824) return (b / 1073741824).toFixed(2) + ' ГБ';
return (b / 1048576).toFixed(1) + ' МБ';
}
async function downloadBackup() {
const btn = document.getElementById('backupBtn');
const st = document.getElementById('backupStatus');
btn.dataset.loadingText = 'Формирование...';
setBtnLoading(btn, true);
if (st) { st.classList.remove('err'); st.textContent = 'Формирование архива на сервере…'; }
try {
const res = await fetch(`${API}/api/backup`, { headers: hdr() });
if (!res.ok) { const t = await res.text(); alert('Ошибка: ' + t); return; }
const blob = await res.blob();
const cd = res.headers.get('Content-Disposition') || '';
const m = cd.match(/filename="([^"]+)"/);
const name = m ? m[1] : `whatido-backup-${new Date().toISOString().slice(0, 10)}.json.gz`;
const url = URL.createObjectURL(blob);
const res = await fetch(`${API}/api/backup`, { method: 'POST', headers: hdr() });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (st) { st.classList.add('err'); st.textContent = 'Не удалось сформировать бэкап'; }
showError('Ошибка формирования бэкапа: ' + (data.error || res.status));
return;
}
const head = await fetch(data.url, { method: 'HEAD' }).catch(() => null);
if (head && !head.ok) {
if (st) { st.classList.add('err'); st.textContent = 'Ссылка на архив устарела, попробуйте ещё раз'; }
showError('Ошибка скачивания бэкапа');
return;
}
const a = document.createElement('a');
a.href = url;
a.download = name;
a.href = data.url;
a.download = data.filename || 'whatido-backup.tar.gz';
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(url);
showToast('Бэкап скачан');
a.remove();
const size = formatBackupSize(data.size);
if (st) st.textContent = `Архив ${data.filename}${size ? ` (${size})` : ''} — скачивается браузером`;
showToast('Бэкап сформирован' + (size ? ': ' + size : ''));
} catch (err) {
if (st) { st.classList.add('err'); st.textContent = 'Не удалось сформировать бэкап: ' + err.message; }
showError('Ошибка сети при формировании бэкапа');
} finally {
setBtnLoading(btn, false);
}
@@ -220,7 +619,7 @@ document.getElementById('restoreFile').addEventListener('change', async (e) => {
res = await fetch(`${API}/api/restore`, { method: 'POST', headers: hdr(), body: fd });
} catch (err) {
st.textContent = '';
alert('Ошибка сети: ' + err.message);
showError('Ошибка сети: ' + err.message);
e.target.value = '';
return;
} finally {
@@ -234,8 +633,7 @@ document.getElementById('restoreFile').addEventListener('change', async (e) => {
} else {
st.classList.add('err');
st.innerHTML = 'Ошибка восстановления';
const t = await res.text();
alert('Ошибка: ' + t);
showError(await res.text());
}
e.target.value = '';
});
@@ -243,35 +641,37 @@ document.getElementById('restoreFile').addEventListener('change', async (e) => {
async function loadBans() {
const el = document.getElementById('bansList');
const res = await fetch(`${API}/api/bans`, { headers: hdr() });
if (res.status === 403) { document.getElementById('bansCard').style.display = 'none'; return; }
if (res.status === 403) { document.getElementById('sec-bans').style.display = 'none'; return; }
if (!res.ok) { el.innerHTML = '<span class="chip">Не удалось загрузить</span>'; return; }
const bans = await res.json();
if (!bans.length) { el.innerHTML = '<span class="chip">Активных блокировок нет</span>'; return; }
el.innerHTML = bans.map(b => `
<span class="chip" data-chip="${esc(b.ip)}">
<b>${esc(b.ip)}</b> — ${esc(b.reason)} до ${new Date(b.banned_until).toLocaleString('ru')}
<button type="button" data-unban="${esc(b.ip)}" title="Разблокировать" style="background:none;border:none;color:var(--primary);cursor:pointer;font-weight:700">✕</button>
<span class="chip ban-chip" data-chip="${esc(b.ip)}">
<b>${esc(b.ip)}</b>
<span class="chip-sub">${esc(BAN_REASONS[b.reason] || b.reason || '—')} · до ${new Date(b.banned_until).toLocaleString('ru')}</span>
<button type="button" class="unban-btn" data-unban="${esc(b.ip)}" title="Разблокировать" aria-label="Разблокировать ${esc(b.ip)}">✕</button>
</span>`).join('');
}
async function doUnban(ip) {
const res = await fetch(`${API}/api/bans/${encodeURIComponent(ip)}`, { method: 'DELETE', headers: hdr() });
if (res.ok) { showToast('IP разблокирован: ' + ip); loadBans(); }
else showToast('Ошибка разблокировки');
else showError('Ошибка разблокировки');
}
document.getElementById('bansList').addEventListener('click', e => {
const ip = e.target.closest('[data-unban]')?.dataset.unban;
if (!ip) return;
const chip = e.target.closest('[data-chip]');
if (chip.dataset.confirming) return;
const btn = e.target.closest('[data-unban]');
if (!btn) return;
const ip = btn.dataset.unban;
const chip = btn.closest('[data-chip]');
if (!chip || chip.dataset.confirming) return;
chip.dataset.confirming = '1';
chip.style.background = 'rgba(239,68,68,.08)';
e.target.outerHTML = `
<span style="white-space:nowrap">
Убрать из блокировки?
<button type="button" data-unban-yes style="background:none;border:none;color:#16a34a;cursor:pointer;font-weight:700">Да</button>
<button type="button" data-unban-no style="background:none;border:none;color:var(--muted);cursor:pointer;font-weight:700">Нет</button>
chip.classList.add('confirming');
btn.outerHTML = `
<span class="chip-confirm">
Разблокировать?
<button type="button" class="chip-yes" data-unban-yes>Да</button>
<button type="button" class="chip-no" data-unban-no>Нет</button>
</span>`;
chip.querySelector('[data-unban-yes]').addEventListener('click', () => doUnban(ip));
chip.querySelector('[data-unban-no]').addEventListener('click', () => loadBans());
@@ -280,12 +680,109 @@ document.getElementById('bansList').addEventListener('click', e => {
document.getElementById('backupBtn').addEventListener('click', downloadBackup);
document.getElementById('restoreBtn').addEventListener('click', () => document.getElementById('restoreFile').click());
document.getElementById('saveBtn').addEventListener('click', saveSettings);
document.getElementById('resetBtn').addEventListener('click', revertChanges);
const systemRefreshBtn = document.getElementById('systemRefreshBtn');
if (systemRefreshBtn) systemRefreshBtn.addEventListener('click', async () => {
systemRefreshBtn.classList.add('spinning');
await loadSystemInfo();
systemRefreshBtn.classList.remove('spinning');
});
const statsRefreshBtn = document.getElementById('statsRefreshBtn');
if (statsRefreshBtn) statsRefreshBtn.addEventListener('click', async () => {
statsRefreshBtn.classList.add('spinning');
await refreshStats();
statsRefreshBtn.classList.remove('spinning');
});
function logoStatusText(html) {
const st = document.getElementById('logoUploadStatus');
if (st) st.innerHTML = html;
}
document.getElementById('logoUploadBtn').addEventListener('click', () => document.getElementById('logoInput').click());
document.getElementById('logoInput').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const btn = document.getElementById('logoUploadBtn');
if (file.size > 1024 * 1024) {
e.target.value = '';
showError('Логотип не должен превышать 1 МБ');
return;
}
btn.disabled = true;
logoStatusText('<span class="spinner dark"></span> Загрузка...');
try {
const fd = new FormData();
fd.append('logo', file);
const res = await fetch(`${API}/api/settings/logo`, { method: 'POST', headers: hdr(), body: fd });
const d = await res.json().catch(() => ({}));
if (res.ok) {
currentLogo = d.system_logo || '';
renderLogoPreview();
logoStatusText('');
showToast('Логотип обновлён');
} else {
logoStatusText(`<span class="fail">${esc(d.error || 'Ошибка загрузки')}</span>`);
}
} catch {
logoStatusText('<span class="fail">Ошибка сети</span>');
} finally {
btn.disabled = false;
e.target.value = '';
}
});
document.getElementById('logoRemoveBtn').addEventListener('click', async () => {
if (!confirm('Удалить логотип? Название системы сохранится.')) return;
logoStatusText('<span class="spinner dark"></span> Удаление...');
try {
const res = await fetch(`${API}/api/settings/logo`, { method: 'DELETE', headers: hdr() });
if (res.ok) {
currentLogo = '';
logoOriginal = '';
renderLogoPreview();
logoStatusText('');
showToast('Логотип удалён');
} else {
logoStatusText('<span class="fail">Ошибка удаления</span>');
}
} catch {
logoStatusText('<span class="fail">Ошибка сети</span>');
}
});
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault();
const modal = document.getElementById('aiProfileModal');
const saveBtn = document.getElementById('saveBtn');
if (!modal.classList.contains('open') && document.getElementById('saveBar').classList.contains('show') && !saveBtn.disabled) saveSettings();
}
});
window.addEventListener('beforeunload', e => {
if (document.getElementById('saveBar').classList.contains('show')) {
e.preventDefault();
e.returnValue = '';
}
});
(async () => {
if (await requireAdminPage()) {
buildSidebar(document.body.dataset.page);
bindDirty();
loadSettings();
initSectionNav();
updateSpamUi();
const authStatus = document.getElementById('authStatus');
try {
await loadSettings();
if (authStatus) authStatus.classList.add('loaded');
} catch {
if (authStatus) { authStatus.classList.remove('loaded'); authStatus.classList.add('err'); authStatus.textContent = 'Ошибка загрузки настроек'; }
}
loadBans();
}
})();
+45 -2
View File
@@ -239,7 +239,15 @@ function renderShare(data, password) {
const isAnonymized = !data.show_student_names;
const showStudentMessage = !!data.show_student_message;
const showEntryDate = !!data.show_entry_date;
grid.innerHTML = data.entries.map(e => {
const dayKey = e => {
const d = new Date(e.created_at);
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
};
const dayLabel = k => {
const [y,m,d] = k.split('-').map(Number);
return new Date(y, m-1, d).toLocaleDateString('ru-RU', { day:'numeric', month:'long', year:'numeric' });
};
const cardHTML = e => {
const body = [
!isAnonymized ? `<div class="name">${esc(e.student_name)}</div>` : '',
!isAnonymized && e.group_name ? `<div><span class="group">${esc(e.group_name)}</span></div>` : '',
@@ -257,7 +265,18 @@ function renderShare(data, password) {
${showEntryDate ? `<div class="foot">${new Date(e.created_at).toLocaleString('ru',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})}</div>` : ''}
</div>
`;
}).join('');
};
const groups = [];
let lastKey = null;
data.entries.forEach(e => {
const k = dayKey(e);
if (k !== lastKey) { groups.push({ key: k, entries: [] }); lastKey = k; }
groups[groups.length-1].entries.push(e);
});
grid.innerHTML = groups.map(g =>
`<div class="date-sep"><span class="date-chip">${esc(dayLabel(g.key))}</span><span class="date-line"></span></div>` +
g.entries.map(cardHTML).join('')
).join('');
}
document.getElementById('lightbox').addEventListener('click', () => document.getElementById('lightbox').classList.remove('open'));
@@ -290,3 +309,27 @@ document.addEventListener('click', e => {
notice.classList.add('hidden');
});
})();
(async function(){
try {
const res = await fetch('/api/public-settings');
if (!res.ok) return;
const s = await res.json();
const head = document.getElementById('brandHead');
if (!head) return;
const logo = document.getElementById('brandLogo');
const name = document.getElementById('brandName');
let any = false;
if (name && (s.system_name || '').trim()) {
name.textContent = s.system_name;
name.hidden = false;
any = true;
}
if (logo && s.system_logo) {
logo.src = s.system_logo;
logo.hidden = false;
any = true;
}
if (any) head.hidden = false;
} catch (e) {}
})();
+247
View File
@@ -47,6 +47,8 @@ function renderStudents() {
<select class="group-select" id="sgroup-${s.id}"></select>
<div class="student-actions">
<button class="btn-save" data-save-student="${s.id}">Сохранить</button>
<button class="btn-link" data-profile-student="${s.id}" title="Скачать профиль ученика (ZIP: фото и работы)">Профиль</button>
<button class="btn-link" data-profile-data="${s.id}" title="Данные профиля: заполняют разделы страницы профиля в архиве">Данные</button>
<button class="btn-link" data-export-student="${esc(s.name)}" title="Сформировать ZIP-отчёт: фотографии и файлы">Отчёт</button>
<button class="btn-link" data-copy-student="${esc(s.name)}" title="Скопировать ссылку с подстановкой имени">Ссылка</button>
<button class="btn-del" data-del-student="${s.id}" title="Удалить">&times;</button>
@@ -156,6 +158,215 @@ async function submitExport() {
await exportStudent(name, opts);
}
async function exportStudentProfile(id) {
const s = allStudents.find(x => x.id === id);
if (!s) return;
const btn = document.querySelector(`[data-profile-student="${id}"]`);
const label = btn ? btn.textContent : '';
if (btn) { btn.disabled = true; btn.textContent = 'Готовим...'; }
showToast(`Формируем профиль: ${s.name}...`);
try {
const res = await fetch(`${API}/api/export/student?name=${encodeURIComponent(s.name)}`, { headers: hdr() });
if (!res.ok) {
const e = await res.json().catch(() => ({}));
showToast(e.error || 'Не удалось сформировать профиль');
return;
}
const blob = await res.blob();
downloadBlob(blob, `profile_${safeWindowsName(s.name)}_${new Date().toISOString().slice(0, 10)}.zip`);
showToast(`Готово: ${s.name}`);
} catch (err) {
showToast('Ошибка при формировании профиля');
} finally {
if (btn) { btn.disabled = false; btn.textContent = label; }
}
}
let profileDataId = null;
let profilePhotos = [];
function openProfileData(id) {
profileDataId = id;
loadGroupOptions();
loadStudentPhotos(id);
fetch(`${API}/api/students/${id}/profile`, { headers: hdr() })
.then(r => (r.ok ? r.json() : Promise.reject(new Error('load'))))
.then(data => fillProfileForm(data.profile, data.name, data.photo_path))
.catch(() => fillProfileForm(null, '', ''));
document.getElementById('profileDataModal').classList.add('open');
}
function closeProfileData() {
document.getElementById('profileDataModal').classList.remove('open');
profileDataId = null;
}
async function loadStudentPhotos(id) {
try {
const res = await fetch(`${API}/api/students/${id}/photos`, { headers: hdr() });
if (!res.ok) return;
const data = await res.json();
profilePhotos = data.photos || [];
document.getElementById('pfPhoto').value = data.photo_path || '';
renderStudentPhotos();
} catch (err) {
profilePhotos = [];
}
}
function renderStudentPhotos() {
const wrap = document.getElementById('pfPhotos');
if (!wrap) return;
const main = document.getElementById('pfPhoto').value.trim();
if (!profilePhotos.length) {
wrap.innerHTML = '<span class="hint" style="margin:0">Пока нет загруженных фото</span>';
return;
}
wrap.innerHTML = profilePhotos.map(p => `
<div class="pf-photo${p.photo_path === main ? ' main' : ''}" data-pf-set="${p.id}" title="Сделать главным">
<img src="${esc(p.photo_path)}" alt="" loading="lazy">
${p.photo_path === main ? '<span class="pf-badge">Главная</span>' : ''}
<button type="button" class="pf-del" data-pf-del="${p.id}" title="Удалить фото">✕</button>
</div>
`).join('');
}
async function uploadStudentPhoto(file) {
if (!profileDataId || !file) return;
if (!file.type.startsWith('image/')) {
alert('Можно загружать только изображения');
return;
}
showToast('Загружаем фото...');
const fd = new FormData();
fd.append('photo', file);
try {
const res = await fetch(`${API}/api/students/${profileDataId}/photos`, { method: 'POST', headers: hdr(), body: fd });
if (res.ok) {
await loadStudentPhotos(profileDataId);
showToast('Фото загружено');
} else {
const e = await res.json().catch(() => ({}));
alert(e.error || 'Не удалось загрузить фото');
}
} catch (err) {
alert('Ошибка при загрузке фото');
}
}
async function setMainStudentPhoto(pid) {
if (!profileDataId) return;
try {
const res = await fetch(`${API}/api/students/${profileDataId}/photos/${pid}/main`, { method: 'PUT', headers: hdr() });
if (res.ok) {
const r = await res.json();
document.getElementById('pfPhoto').value = r.photo_path || '';
renderStudentPhotos();
showToast('Главное фото обновлено');
} else {
const e = await res.json().catch(() => ({}));
alert(e.error || 'Не удалось выбрать главное фото');
}
} catch (err) {
alert('Ошибка при выборе главного фото');
}
}
async function delStudentPhoto(pid) {
if (!profileDataId) return;
if (!confirm('Удалить это фото?')) return;
try {
const res = await fetch(`${API}/api/students/${profileDataId}/photos/${pid}`, { method: 'DELETE', headers: hdr() });
if (res.ok) {
await loadStudentPhotos(profileDataId);
showToast('Фото удалено');
} else {
const e = await res.json().catch(() => ({}));
alert(e.error || 'Не удалось удалить фото');
}
} catch (err) {
alert('Ошибка при удалении фото');
}
}
function fillProfileForm(profile, name, photoPath) {
const p = profile || {};
const set = (id, v) => { document.getElementById(id).value = v === null || v === undefined ? '' : String(v); };
document.getElementById('profileDataTitle').textContent = name || 'Профиль ученика';
set('pfRole', p.role); set('pfStatus', p.status); set('pfStatusNote', p.status_note);
set('pfCity', p.city); set('pfMentor', p.mentor); set('pfJoined', p.joined);
set('pfBio', p.bio); set('pfQuote', p.quote);
set('pfTags', (p.tags || []).join(', '));
set('pfContacts', (p.contacts || []).map(c => [c.icon || 'link', c.label, c.href || ''].join(' | ')).join('\n'));
set('pfAchievements', (p.achievements || []).join('\n'));
set('pfSkills', (p.skills || []).map(s => [s.group || 'Навыки', s.name, s.level || '', s.value === null || s.value === undefined ? '' : s.value].join(' | ')).join('\n'));
set('pfExperience', (p.experience || []).map(e => [e.title, e.company || '', e.period || '', e.date || '', e.badge || '', e.description || '', (e.tags || []).join(',')].join(' | ')).join('\n'));
set('pfEducation', (p.education || []).map(m => [m.module, m.progress === null || m.progress === undefined ? '' : m.progress, m.grade || '', m.teacher || ''].join(' | ')).join('\n'));
set('pfPhoto', photoPath || '');
}
function collectProfile() {
const val = id => document.getElementById(id).value.trim();
const lines = id => val(id).split('\n').map(s => s.trim()).filter(Boolean);
const cols = line => line.split('|').map(s => s.trim());
const intOrNull = s => (s === '' || s === null || s === undefined ? null : Number(s));
const out = {
role: val('pfRole') || null,
status: val('pfStatus') || null,
status_note: val('pfStatusNote') || null,
city: val('pfCity') || null,
mentor: val('pfMentor') || null,
joined: val('pfJoined') || null,
bio: val('pfBio') || null,
quote: val('pfQuote') || null,
tags: val('pfTags') ? val('pfTags').split(',').map(s => s.trim()).filter(Boolean) : [],
contacts: lines('pfContacts').map(l => {
const c = cols(l);
return { icon: c[0] || 'link', label: c[1] || '', href: c[2] || '' };
}).filter(c => c.label),
achievements: lines('pfAchievements'),
skills: lines('pfSkills').map(l => {
const c = cols(l);
return { group: c[0] || 'Навыки', name: c[1] || '', level: c[2] || null, value: intOrNull(c[3]) };
}).filter(s => s.name),
experience: lines('pfExperience').map(l => {
const c = cols(l);
return {
title: c[0] || '', company: c[1] || null, period: c[2] || null, date: c[3] || null,
badge: c[4] || null, description: c[5] || null,
tags: c[6] ? c[6].split(',').map(s => s.trim()).filter(Boolean) : [],
};
}).filter(e => e.title),
education: lines('pfEducation').map(l => {
const c = cols(l);
return { module: c[0] || '', progress: intOrNull(c[1]), grade: c[2] || null, teacher: c[3] || null };
}).filter(m => m.module),
};
const hasData = Object.keys(out).some(k => (Array.isArray(out[k]) ? out[k].length : out[k]));
return hasData ? out : null;
}
async function saveProfileData(e) {
e.preventDefault();
if (!profileDataId) return;
const body = { profile: collectProfile() };
const photoPath = document.getElementById('pfPhoto').value.trim();
body.photo_path = photoPath || null;
const res = await fetch(`${API}/api/students/${profileDataId}/profile`, {
method: 'PUT',
headers: hdrJson(),
body: JSON.stringify(body),
});
if (res.ok) {
closeProfileData();
loadStudents();
showToast('Данные профиля сохранены');
} else {
const err = await res.json().catch(() => ({}));
alert(err.error || 'Не удалось сохранить данные профиля');
}
}
async function delStudent(id) {
if (!confirm('Удалить ученика из списка? Старые записи останутся.')) return;
await fetch(`${API}/api/students/${id}`, { method: 'DELETE', headers: hdr() });
@@ -318,10 +529,46 @@ document.getElementById('studentList').addEventListener('click', e => {
if (cp) { copyStudentLink(cp.dataset.copyStudent); return; }
const ex = e.target.closest('[data-export-student]');
if (ex) { openExportModal(ex.dataset.exportStudent.trim()); return; }
const pf = e.target.closest('[data-profile-student]');
if (pf) { exportStudentProfile(parseInt(pf.dataset.profileStudent, 10)); return; }
const pd = e.target.closest('[data-profile-data]');
if (pd) { openProfileData(parseInt(pd.dataset.profileData, 10)); return; }
const dl = e.target.closest('[data-del-student]');
if (dl) { delStudent(parseInt(dl.dataset.delStudent, 10)); }
});
document.getElementById('profileDataForm').addEventListener('submit', saveProfileData);
document.getElementById('profileDataCancelBtn').addEventListener('click', closeProfileData);
document.getElementById('pfPhotoUploadBtn').addEventListener('click', () => document.getElementById('pfPhotoFile').click());
document.getElementById('pfPhotoFile').addEventListener('change', e => {
const f = e.target.files && e.target.files[0];
e.target.value = '';
if (f) uploadStudentPhoto(f);
});
document.getElementById('pfPhotos').addEventListener('click', e => {
const del = e.target.closest('[data-pf-del]');
if (del) { delStudentPhoto(parseInt(del.dataset.pfDel, 10)); return; }
const set = e.target.closest('[data-pf-set]');
if (set) setMainStudentPhoto(parseInt(set.dataset.pfSet, 10));
});
document.addEventListener('paste', e => {
if (!profileDataId) return;
if (!document.getElementById('profileDataModal').classList.contains('open')) return;
const items = e.clipboardData && e.clipboardData.items;
if (!items) return;
for (const it of items) {
if (it.type && it.type.startsWith('image/')) {
const f = it.getAsFile();
if (f) {
e.preventDefault();
uploadStudentPhoto(f);
}
return;
}
}
});
document.getElementById('profileDataModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeProfileData(); });
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
+177 -13
View File
@@ -1,7 +1,14 @@
const PAGE_SIZE = 20;
let currentEntries = [];
let currentGroups = [];
let pendingEntries = [];
let pendingGroups = [];
let page = 1;
let totalEntries = 0;
let totalGroups = 0;
let pendingTotalEntries = 0;
let pendingTotalGroups = 0;
let purgeDays = 30;
function isImageFile(name) {
return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
@@ -28,32 +35,129 @@ function entryHTML(e) {
<div class="trash-time">Удалена: ${new Date(e.deleted_at).toLocaleString('ru')}</div>
</div>
<button class="restore-btn" data-restore="${e.id}" title="Восстановить">Восстановить</button>
<button class="del-btn" data-hard="${e.id}" title="Удалить навсегда (вместе с файлами)">&times;</button>
<button class="del-btn" data-hard="${e.id}" title="Пометить на удаление (через ${purgeDays} дней будет удалена безвозвратно)">&times;</button>
</div>
`;
}
function groupHTML(g) {
return `
<div class="card">
${g.cover_path ? `<img src="${thumbSrc(g.cover_path)}" data-img="${API}${g.cover_path}" loading="lazy" decoding="async" alt="">` : ''}
<div class="info">
<div class="name">${esc(g.name)}</div>
${g.branch_name ? `<div class="group">${esc(g.branch_name)}</div>` : ''}
<div class="trash-time">Удалена: ${new Date(g.deleted_at).toLocaleString('ru')}</div>
</div>
<button class="restore-btn" data-restore-group="${g.id}" title="Восстановить">Восстановить</button>
<button class="del-btn" data-hard-group="${g.id}" title="Пометить на удаление (через ${purgeDays} дней будет удалена безвозвратно вместе с записями и фото)">&times;</button>
</div>
`;
}
function pendingEntryHTML(e) {
return `
<div class="card pending">
${e.photo_path ? `<img src="${thumbSrc(e.photo_path)}" data-img="${API}${e.photo_path}" loading="lazy" decoding="async" alt="">` : ''}
<div class="info">
<div class="name">${esc(e.student_name)}</div>
<div class="group">${esc(e.group_name)}</div>
<div class="desc">${esc(e.description)}</div>
${filesHTML(e.files)}
<div class="trash-time warn">Удаление через ${purgeDays} дн.: ${new Date(e.purge_at).toLocaleString('ru')}</div>
</div>
<button class="restore-btn" data-unschedule="${e.id}" title="Вернуть в корзину">В корзину</button>
</div>
`;
}
function pendingGroupHTML(g) {
return `
<div class="card pending">
${g.cover_path ? `<img src="${thumbSrc(g.cover_path)}" data-img="${API}${g.cover_path}" loading="lazy" decoding="async" alt="">` : ''}
<div class="info">
<div class="name">${esc(g.name)}</div>
${g.branch_name ? `<div class="group">${esc(g.branch_name)}</div>` : ''}
<div class="trash-time warn">Удаление через ${purgeDays} дн.: ${new Date(g.purge_at).toLocaleString('ru')}</div>
</div>
<button class="restore-btn" data-unschedule-group="${g.id}" title="Вернуть в корзину">В корзину</button>
</div>
`;
}
function renderGroups() {
const grid = document.getElementById('groupsGrid');
grid.className = 'grid';
grid.innerHTML = currentGroups.length
? currentGroups.map(groupHTML).join('')
: '<div class="empty">Групп в корзине нет</div>';
document.getElementById('groupsInfo').textContent =
totalGroups > 0 ? `— групп в корзине: ${totalGroups}` : '';
}
function renderGrid() {
const grid = document.getElementById('grid');
grid.className = 'grid';
grid.innerHTML = currentEntries.length
? currentEntries.map(entryHTML).join('')
: '<div class="empty">Корзина пуста</div>';
: '<div class="empty">Записей в корзине нет</div>';
document.getElementById('entriesInfo').textContent =
totalEntries > 0 ? `— записей в корзине: ${totalEntries}` : '';
document.getElementById('trashInfo').textContent =
totalEntries > 0 ? `Записей в корзине: ${totalEntries}` : '';
document.getElementById('emptyBtn').disabled = totalEntries === 0;
totalEntries + totalGroups > 0
? `В корзине: записей ${totalEntries}, групп ${totalGroups}`
: 'Корзина пуста';
document.getElementById('emptyBtn').disabled = totalEntries + totalGroups === 0;
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalEntries / PAGE_SIZE)), goPage);
}
function renderPending() {
document.getElementById('pendingGroupsGrid').className = 'grid';
document.getElementById('pendingGroupsGrid').innerHTML = pendingGroups.length
? pendingGroups.map(pendingGroupHTML).join('')
: '<div class="empty">Нет групп, помеченных на удаление</div>';
document.getElementById('pendingGrid').className = 'grid';
document.getElementById('pendingGrid').innerHTML = pendingEntries.length
? pendingEntries.map(pendingEntryHTML).join('')
: '<div class="empty">Нет записей, помеченных на удаление</div>';
document.getElementById('pendingGroupsInfo').textContent =
pendingTotalGroups > 0 ? `— ${pendingTotalGroups}` : '';
document.getElementById('pendingEntriesInfo').textContent =
pendingTotalEntries > 0 ? `— ${pendingTotalEntries}` : '';
const n = pendingTotalEntries + pendingTotalGroups;
document.getElementById('pendingInfo').textContent =
n > 0 ? `— будет удалено: ${n}` : '';
const hint = document.getElementById('pendingToggleHint');
if (hint) {
hint.textContent = n > 0
? `Помечено: записей ${pendingTotalEntries}, групп ${pendingTotalGroups} — безвозвратно удалятся через ${purgeDays} дней`
: 'Данных, помеченных на удаление, нет';
}
document.getElementById('pendingSection').hidden = !document.getElementById('pendingToggle').checked;
}
async function loadTrash() {
const p = new URLSearchParams();
p.set('limit', PAGE_SIZE);
p.set('offset', (page - 1) * PAGE_SIZE);
const res = await fetch(`${API}/api/trash?${p}`, { headers: hdr() });
const data = await res.json();
const [trashRes, pendRes] = await Promise.all([
fetch(`${API}/api/trash?${p}`, { headers: hdr() }),
fetch(`${API}/api/trash/pending`, { headers: hdr() }),
]);
const data = await trashRes.json();
const pend = await pendRes.json();
currentEntries = data.entries;
totalEntries = data.total;
currentGroups = data.groups;
totalGroups = data.total_groups;
pendingEntries = pend.entries;
pendingTotalEntries = pend.total;
pendingGroups = pend.groups;
pendingTotalGroups = pend.total_groups;
if (pend.purge_days > 0) purgeDays = pend.purge_days;
renderGroups();
renderGrid();
renderPending();
}
function goPage(p) { page = p; loadTrash(); }
@@ -65,22 +169,55 @@ async function restoreEntry(id) {
}
async function hardDelete(id) {
if (!confirm('Удалить запись навсегда? Файлы и фото будут удалены безвозвратно.')) return;
await fetch(`${API}/api/entries/${id}/permanent`, { method: 'DELETE', headers: hdr() });
showToast('Запись удалена навсегда');
loadTrash();
if (!confirm(`Пометить запись на удаление? Она будет безвозвратно удалена через ${purgeDays} дней вместе с файлами и фото.`)) return;
const res = await fetch(`${API}/api/entries/${id}/permanent`, { method: 'DELETE', headers: hdr() });
if (res.ok) { showToast(`Запись помечена на удаление через ${purgeDays} дн.`); loadTrash(); }
else { const err = await res.json(); showToast(err.error || 'Ошибка'); }
}
async function restoreGroup(id) {
const res = await fetch(`${API}/api/groups/${id}/restore`, { method: 'PUT', headers: hdr() });
if (res.ok) { showToast('Группа восстановлена'); loadTrash(); }
else { const err = await res.json(); showToast(err.error || 'Ошибка восстановления'); }
}
async function hardDeleteGroup(id, name) {
if (!confirm(`Пометить группу «${name}» на удаление? Записи журнала, фото и файлы группы будут безвозвратно удалены через ${purgeDays} дней, ученики открепятся.`)) return;
const res = await fetch(`${API}/api/groups/${id}/permanent`, { method: 'DELETE', headers: hdr() });
if (res.ok) { showToast(`Группа помечена на удаление через ${purgeDays} дн.`); loadTrash(); }
else { const err = await res.json(); showToast(err.error || 'Ошибка'); }
}
async function unscheduleEntry(id) {
const res = await fetch(`${API}/api/entries/${id}/unschedule`, { method: 'PUT', headers: hdr() });
if (res.ok) { showToast('Запись возвращена в корзину'); loadTrash(); }
else { const err = await res.json(); showToast(err.error || 'Ошибка'); }
}
async function unscheduleGroup(id) {
const res = await fetch(`${API}/api/groups/${id}/unschedule`, { method: 'PUT', headers: hdr() });
if (res.ok) { showToast('Группа возвращена в корзину'); loadTrash(); }
else { const err = await res.json(); showToast(err.error || 'Ошибка'); }
}
async function emptyTrash() {
if (totalEntries === 0) return;
if (!confirm(`Удалить все записи из корзины (${totalEntries})? Файлы будут удалены безвозвратно.`)) return;
const parts = [];
if (totalEntries) parts.push(`записей: ${totalEntries}`);
if (totalGroups) parts.push(`групп: ${totalGroups}`);
if (!parts.length) return;
if (!confirm(`Пометить всё из корзины (${parts.join(', ')}) на удаление? Данные будут безвозвратно удалены через ${purgeDays} дней.`)) return;
const res = await fetch(`${API}/api/trash`, { method: 'DELETE', headers: hdr() });
if (res.ok) { showToast('Корзина очищена'); page = 1; loadTrash(); }
if (res.ok) { showToast(`Всё помечено на удаление через ${purgeDays} дн.`); page = 1; loadTrash(); }
else { const err = await res.json(); alert(err.error || 'Ошибка'); }
}
document.getElementById('emptyBtn').addEventListener('click', emptyTrash);
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
document.getElementById('pendingToggle').addEventListener('change', () => {
const open = document.getElementById('pendingToggle').checked;
document.getElementById('pendingSection').hidden = !open;
if (open) loadTrash();
});
document.getElementById('grid').addEventListener('click', e => {
const card = e.target.closest('.card');
@@ -91,6 +228,33 @@ document.getElementById('grid').addEventListener('click', e => {
if (hd && (e.target === hd || hd.contains(e.target))) { hardDelete(parseInt(hd.dataset.hard, 10)); }
});
document.getElementById('groupsGrid').addEventListener('click', e => {
const card = e.target.closest('.card');
if (!card) return;
const rs = card.querySelector('[data-restore-group]');
if (rs && (e.target === rs || rs.contains(e.target))) { restoreGroup(parseInt(rs.dataset.restoreGroup, 10)); return; }
const hd = card.querySelector('[data-hard-group]');
if (hd && (e.target === hd || hd.contains(e.target))) {
const id = parseInt(hd.dataset.hardGroup, 10);
const g = currentGroups.find(x => String(x.id) === String(id));
hardDeleteGroup(id, g ? g.name : id);
}
});
document.getElementById('pendingGrid').addEventListener('click', e => {
const card = e.target.closest('.card');
if (!card) return;
const us = card.querySelector('[data-unschedule]');
if (us && (e.target === us || us.contains(e.target))) { unscheduleEntry(parseInt(us.dataset.unschedule, 10)); }
});
document.getElementById('pendingGroupsGrid').addEventListener('click', e => {
const card = e.target.closest('.card');
if (!card) return;
const us = card.querySelector('[data-unschedule-group]');
if (us && (e.target === us || us.contains(e.target))) { unscheduleGroup(parseInt(us.dataset.unscheduleGroup, 10)); }
});
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
+209 -2
View File
@@ -1,5 +1,6 @@
const AUTO_REFRESH_MS = 5000;
const RECENT_PAGE_SIZE = 15;
const PHOTO_PAGE_SIZE = 15;
const STATUS_LABELS = {
pending: 'В очереди',
processing: 'Проверяется',
@@ -16,10 +17,40 @@ const STATUS_BADGE = {
};
let state = null;
let photoState = null;
let photoJobRow = null;
let refreshTimer = null;
let busy = false;
let photoBusy = false;
let recentRows = [];
let recentPage = 1;
let photoPage = 1;
let photoTotal = 0;
const PHOTO_ACTION_LABELS = {
ai: 'ИИ-обработка',
enhance: 'Улучшение',
restore: 'Возврат оригинала',
rollback: 'Откат',
};
const PHOTO_STATUS_LABELS = {
pending: 'В очереди',
processing: 'Обрабатывается',
done: 'Готово',
error: 'Ошибка',
rejected: 'Отклонено',
};
const PHOTO_STATUS_BADGE = {
done: 'ai-badge-done',
error: 'ai-badge-error',
processing: 'ai-badge-skipped',
};
function thumbUrl(p) {
if (!p) return '';
if (p.startsWith('/uploads/.originals/')) return `${API}${p}`;
return `${API}/uploads/thumb/${p.split('/').pop()}`;
}
function fmtDur(ms) {
if (ms === null || ms === undefined || ms < 0) return '—';
@@ -190,6 +221,116 @@ function render() {
renderIcons();
}
function renderPhoto() {
if (!photoState) return;
const w = photoState.worker || {};
document.getElementById('photoStateHint').textContent = !photoState.enabled
? 'Обработка фотографий выключена'
: (!photoState.ai_configured ? 'PHOTO_AI_URL не настроен' : (w.processing ? `Обрабатывается задание #${w.current_id ?? ''}` : 'Ожидание очереди'));
document.getElementById('photoToggleBtn').textContent = photoState.enabled ? '📷 Выключить фото-воркер' : '📷 Включить фото-воркер';
const c = photoState.counts || {};
document.getElementById('photoCounts').innerHTML = [
['pending', 'В очереди', c.pending || 0],
['processing', 'Обрабатывается', c.processing || 0],
['done', 'Готово', c.done || 0],
['error', 'Ошибка', c.error || 0],
].map(([cls, label, n]) => `<span class="chip ${cls}">${label}: <b>${n}</b></span>`).join('');
const rows = photoState.recent || [];
renderPhotoPending();
const tbody = document.querySelector('#photoTable tbody');
const empty = document.getElementById('photoEmpty');
const pager = document.getElementById('photoRecentPager');
const maxPage = Math.max(1, Math.ceil(photoTotal / PHOTO_PAGE_SIZE));
if (photoPage > maxPage) photoPage = maxPage;
if (!photoTotal) { tbody.innerHTML = ''; empty.style.display = 'block'; renderPager(pager, photoPage, maxPage, p => { photoPage = p; loadPhoto(); }); return; }
empty.style.display = 'none';
tbody.innerHTML = rows.map(r => {
const before = r.before_path ? `<img class="photo-thumb" loading="lazy" src="${esc(thumbUrl(r.before_path))}" alt="было">` : '<span style="color:var(--muted)">—</span>';
const after = r.after_path ? `<img class="photo-thumb" loading="lazy" src="${esc(thumbUrl(r.after_path))}" alt="стало">` : '<span style="color:var(--muted)">—</span>';
const canRollback = r.status === 'done' && r.before_path && r.action !== 'rollback';
const badge = `<span class="ai-badge ${PHOTO_STATUS_BADGE[r.status] || ''}">${esc(PHOTO_STATUS_LABELS[r.status] || r.status)}</span>`;
return `<tr>
<td style="white-space:nowrap;color:var(--muted)">${fmtTime(r.finished_at)}</td>
<td>#${r.entry_id} ${esc(r.student_name || '')}</td>
<td>${esc(PHOTO_ACTION_LABELS[r.action] || r.action)}</td>
<td>${badge}</td>
<td>${before}</td>
<td>${after}</td>
<td style="color:#ef4444;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(r.error || '')}">${r.error ? esc(r.error) : ''}</td>
<td style="white-space:nowrap">
<button type="button" class="btn-link" data-photo-view="${r.id}">Смотреть</button>
${canRollback ? `<button type="button" class="btn-link" data-photo-rollback="${r.id}" data-photo-entry="${r.entry_id}">↩ Откатить</button>` : ''}
</td>
</tr>`;
}).join('');
renderPager(pager, photoPage, maxPage, p => { photoPage = p; loadPhoto(); });
renderIcons();
}
function renderPhotoPending() {
const rows = photoState.pending || [];
document.getElementById('photoPendingCount').textContent = rows.length;
const tbody = document.querySelector('#photoPendingTable tbody');
const empty = document.getElementById('photoPendingEmpty');
if (!rows.length) { tbody.innerHTML = ''; empty.style.display = 'block'; return; }
empty.style.display = 'none';
tbody.innerHTML = rows.map(r => {
const processing = r.status === 'processing';
const action = esc(PHOTO_ACTION_LABELS[r.action] || r.action);
const badge = `<span class="ai-badge ${processing ? 'ai-badge-skipped' : ''}">${processing ? '⏳ Обрабатывается' : 'В очереди'}</span>`;
return `<tr${processing ? ' style="background:rgba(59,130,246,.08)"' : ''}>
<td>#${r.id}</td>
<td>#${r.entry_id} ${esc(r.student_name || '')} <span style="color:var(--muted)">(${esc(r.group_name || '')})</span></td>
<td>${action} ${badge}</td>
<td style="color:var(--muted);white-space:nowrap">${fmtTime(r.created_at)}</td>
</tr>`;
}).join('');
}
function findPhotoJob(id) {
return (photoState && photoState.recent || []).find(r => r.id === id) || null;
}
function setPhotoJobClip(p) {
const v = Math.min(100, Math.max(0, p));
document.getElementById('photoJobTop').style.clipPath = `inset(0 0 0 ${v}%)`;
document.getElementById('photoJobDivider').style.left = v + '%';
}
function openPhotoJob(r) {
photoJobRow = r;
document.getElementById('photoJobTitle').textContent = `Обработка фото — запись #${r.entry_id} ${r.student_name || ''}`;
document.getElementById('photoJobBefore').src = thumbUrl(r.before_path);
document.getElementById('photoJobAfter').src = r.after_path ? `${API}${r.after_path}` : '';
document.getElementById('photoJobRollbackBtn').style.display = (r.before_path && r.action !== 'rollback') ? '' : 'none';
setPhotoJobClip(50);
document.getElementById('photoJobModal').classList.add('open');
}
async function loadPhoto() {
if (photoBusy) return;
photoBusy = true;
try {
const res = await fetch(`${API}/api/photo-jobs/status?recent_limit=${PHOTO_PAGE_SIZE}&recent_offset=${(photoPage - 1) * PHOTO_PAGE_SIZE}`, { headers: hdr() });
if (!res.ok) return;
photoState = await res.json();
photoTotal = photoState.recent_total || 0;
renderPhoto();
} catch {
} finally {
photoBusy = false;
}
}
async function postPhoto(path_, body) {
const opts = { method: 'POST', headers: body ? hdrJson() : hdr() };
if (body) opts.body = JSON.stringify(body);
const res = await fetch(`${API}${path_}`, opts);
const data = await res.json().catch(() => ({}));
if (!res.ok) { showToast(data.error || 'Ошибка'); return null; }
return data;
}
async function load() {
if (busy) return;
busy = true;
@@ -231,15 +372,28 @@ async function requeueFailed() {
function setAutoRefresh(on) {
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
if (on) refreshTimer = setInterval(load, AUTO_REFRESH_MS);
if (on) refreshTimer = setInterval(() => { load(); loadPhoto(); }, AUTO_REFRESH_MS);
}
document.getElementById('refreshBtn').addEventListener('click', load);
document.getElementById('refreshBtn').addEventListener('click', () => { load(); loadPhoto(); });
document.getElementById('wakeBtn').addEventListener('click', wake);
document.getElementById('toggleBtn').addEventListener('click', toggleEnabled);
document.getElementById('requeueBtn').addEventListener('click', requeueFailed);
document.getElementById('recentFilter').addEventListener('change', () => { recentPage = 1; renderRecent(); });
document.getElementById('autoRefresh').addEventListener('change', e => setAutoRefresh(e.target.checked));
document.getElementById('photoWakeBtn').addEventListener('click', async () => {
if (await postPhoto('/api/photo-jobs/wake')) { showToast('Фото-воркер разбужен'); loadPhoto(); }
});
document.getElementById('photoToggleBtn').addEventListener('click', async () => {
const next = !(photoState && photoState.enabled);
const data = await postPhoto('/api/photo-jobs/enabled', { enabled: next });
if (data) { showToast(next ? 'Обработка фото включена' : 'Обработка фото выключена'); loadPhoto(); }
});
document.getElementById('photoRequeueBtn').addEventListener('click', async () => {
if (!confirm('Вернуть все задания с ошибкой в очередь на повторную обработку?')) return;
const data = await postPhoto('/api/photo-jobs/requeue-failed');
if (data) { showToast(`В очередь возвращено: ${data.count}`); loadPhoto(); }
});
document.getElementById('recentTable').addEventListener('click', e => {
const btn = e.target.closest('.ai-details');
@@ -258,10 +412,63 @@ document.getElementById('aiDetailModal').addEventListener('click', e => {
if (e.target === e.currentTarget) e.currentTarget.classList.remove('open');
});
document.getElementById('photoTable').addEventListener('click', async e => {
const viewBtn = e.target.closest('[data-photo-view]');
if (viewBtn) {
const r = findPhotoJob(parseInt(viewBtn.dataset.photoView, 10));
if (r) openPhotoJob(r);
return;
}
const rb = e.target.closest('[data-photo-rollback]');
if (rb) {
const entryId = rb.dataset.photoEntry;
const jobId = rb.dataset.photoRollback;
if (!confirm('Откатить фотографию записи к версии «было»? Текущая версия сохранится в истории.')) return;
const data = await postPhoto(`/api/entries/${entryId}/photo/jobs/${jobId}/rollback`);
if (data) { showToast('Фотография отката'); loadPhoto(); }
}
});
document.getElementById('photoJobCloseBtn').addEventListener('click', () => {
document.getElementById('photoJobModal').classList.remove('open');
});
document.getElementById('photoJobModal').addEventListener('click', e => {
if (e.target === e.currentTarget) e.currentTarget.classList.remove('open');
});
document.getElementById('photoJobRollbackBtn').addEventListener('click', async () => {
const r = photoJobRow;
if (!r) return;
if (!confirm('Откатить фотографию записи к версии «было»? Текущая версия сохранится в истории.')) return;
const data = await postPhoto(`/api/entries/${r.entry_id}/photo/jobs/${r.id}/rollback`);
if (data) {
showToast('Фотография отката');
document.getElementById('photoJobModal').classList.remove('open');
loadPhoto();
}
});
(function initPhotoJobDrag() {
const area = document.getElementById('photoJobCompare');
let dragging = false;
const move = ev => {
const rect = area.getBoundingClientRect();
setPhotoJobClip(((ev.clientX - rect.left) / rect.width) * 100);
};
area.addEventListener('pointerdown', ev => {
dragging = true;
area.setPointerCapture(ev.pointerId);
move(ev);
});
area.addEventListener('pointermove', ev => { if (dragging) move(ev); });
area.addEventListener('pointerup', () => { dragging = false; });
area.addEventListener('pointercancel', () => { dragging = false; });
})();
(async () => {
if (await requireAdminPage()) {
buildSidebar(document.body.dataset.page);
load();
loadPhoto();
}
})();
+1
View File
@@ -13,6 +13,7 @@
<h2>Ссылки</h2>
<p style="color:var(--muted);font-size:.85rem;margin-bottom:20px;max-width:680px">Каждая ссылка открывает публичную страницу с карточками записей выбранной группы за указанный период — без входа и выбора параметров.</p>
<div class="list" id="linksList"><div class="empty">Загрузка...</div></div>
<div class="pager" id="pager"></div>
</div>
</div>
+8 -2
View File
@@ -9,7 +9,10 @@
:root{--bg:#f5f5f5;--card:#fff;--border:#e5e5e5;--accent:#2563eb;--text:#171717;--muted:#737373;--radius:12px}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100dvh;display:flex;align-items:center;justify-content:center}
.login{max-width:320px;width:100%;text-align:center;background:var(--card);border:1px solid var(--border);border-radius:16px;padding:40px 32px}
.login h1{font-size:1.3rem;font-weight:600;margin-bottom:24px}
.login .brand{display:flex;flex-direction:column;align-items:center;gap:14px;margin-bottom:24px}
.login .brand .logo{height:48px;width:auto;min-width:48px;background:var(--bg);border:1px solid var(--border);display:none;align-items:center;justify-content:center;overflow:hidden}
.login .brand .logo img{max-width:100%;max-height:100%;object-fit:contain}
.login h1{font-size:1.3rem;font-weight:600;margin:0}
.login input{width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:var(--radius);padding:14px 16px;font-size:.95rem;outline:none;margin-bottom:12px;text-align:center}
.login input:focus{border-color:var(--accent)}
.login button{width:100%;background:var(--accent);color:#fff;border:none;border-radius:var(--radius);padding:14px;font-size:1rem;font-weight:600;cursor:pointer}
@@ -22,7 +25,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
</head>
<body>
<div class="login">
<h1>Админ-панель</h1>
<div class="brand">
<div class="logo" id="loginLogo"></div>
<h1 id="loginTitle">Админ-панель</h1>
</div>
<input type="text" id="userInput" placeholder="Логин" autocomplete="username" autofocus>
<input type="password" id="pwdInput" placeholder="Пароль" autocomplete="current-password">
<input type="text" id="hpWebsite" tabindex="-1" autocomplete="off" style="display:none" value="">
+111
View File
@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Темы модулей — Админ-панель</title>
<link rel="stylesheet" href="admin.css">
</head>
<body data-page="modules">
<div class="layout">
<div class="sidebar" id="sidebar"></div>
<div class="main">
<div class="page-head">
<h2>Темы модулей</h2>
<p class="page-sub">Ученик выбирает тему модуля при заполнении формы</p>
</div>
<div class="actions-row" style="margin-bottom:16px">
<button class="btn-primary" id="addModuleBtn" type="button">
<span style="font-size:1.2rem">+</span> Добавить модуль
</button>
<button class="btn-primary ghost" id="batchModuleBtn" type="button">
Пакетное добавление
</button>
<input type="text" id="moduleSearch" class="search-input" placeholder="Поиск по названию..." style="flex:1;min-width:200px;max-width:340px;padding:10px 14px;font-size:.9rem">
</div>
<div class="list" id="moduleList"><div class="empty">Загрузка…</div></div>
<div class="pager" id="pager"></div>
</div>
</div>
<div class="modal-overlay" id="moduleModal">
<div class="edit-modal" style="max-width:480px">
<h3 id="moduleModalTitle">Создать модуль</h3>
<form id="moduleForm" class="settings-stack" style="gap:12px">
<input type="hidden" id="moduleId">
<div class="settings-field">
<label>Название <span style="color:#ef4444">*</span></label>
<input type="text" name="name" class="settings-input" required placeholder="Например: Основы Python" autocomplete="off">
</div>
<div class="settings-field">
<label>Количество занятий</label>
<input type="number" name="lessons_count" id="moduleLessons" class="settings-input" min="0" max="10000" step="1" value="0">
</div>
<div class="settings-field">
<label>Картинка модуля</label>
<div class="module-photo-controls">
<img id="modulePhotoPreview" class="module-photo-preview" alt="" style="display:none">
<div class="module-photo-actions">
<label class="btn-primary ghost module-photo-pick" for="modulePhotoFile">Выбрать файл</label>
<input type="file" id="modulePhotoFile" accept="image/*" style="display:none">
<button type="button" id="modulePhotoDeleteBtn" class="btn-link danger" style="display:none">Удалить</button>
</div>
<span id="modulePhotoNote" class="hint">jpg, png, gif, webp, avif, heic и др., до 10 МБ · или вставьте из буфера (Ctrl+V)</span>
</div>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="moduleCancelBtn">Отмена</button>
<button type="submit" class="btn-primary" id="moduleSubmitBtn">Создать</button>
</div>
</form>
</div>
</div>
<div class="modal-overlay" id="batchModuleModal">
<div class="edit-modal" style="max-width:520px">
<h3>Пакетное добавление модулей</h3>
<form id="batchModuleForm" class="settings-stack" style="gap:12px">
<div class="settings-field">
<label>Модули — каждый с новой строки <span style="color:#ef4444">*</span></label>
<textarea id="batchModuleText" class="settings-input" style="min-height:160px;font-family:monospace" required
placeholder="Основы Python | 10&#10;Введение в базы данных | 8&#10;Разработка интерфейсов 12"></textarea>
<span class="hint">Название может состоять из нескольких слов. Количество занятий отделяйте символом <b>|</b>, <b>;</b>, <b>=</b> или табуляцией; если не указать разделитель — последнее число в строке считается количеством занятий. Количество занятий обязательно для каждой строки.</span>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="batchModuleCancelBtn">Отмена</button>
<button type="submit" class="btn-primary" id="batchModuleSubmitBtn">Добавить</button>
</div>
</form>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script>
<script src="js/modules.js"></script>
<style>
.page-sub{color:var(--muted);font-size:.85rem;margin:0}
.module-item{display:flex;justify-content:space-between;align-items:center;gap:12px}
.module-info{min-width:0;display:flex;flex-direction:column;gap:4px}
.module-name{font-weight:600;font-size:.95rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.module-thumb{width:48px;height:48px;border-radius:8px;object-fit:cover;flex-shrink:0}
.module-meta{font-size:.78rem;color:var(--muted)}
.module-item.module-off .module-name{color:var(--muted)}
.module-badge{display:inline-block;margin-left:8px;padding:1px 8px;border-radius:999px;font-size:.7rem;font-weight:600;background:rgba(239,68,68,.12);color:#ef4444;vertical-align:middle}
.module-item .btn-link.danger{color:#ef4444}
.module-item .btn-link.danger:hover{border-color:#ef4444}
#batchModuleModal .hint{font-size:.78rem;color:var(--muted);line-height:1.5;display:block}
#batchModuleModal .hint b{color:var(--text)}
.module-photo-controls{display:flex;flex-direction:column;gap:10px;align-items:flex-start;width:100%}
.module-photo-preview{width:100%;max-height:180px;object-fit:contain;border:1px solid var(--border);border-radius:10px;background:var(--bg)}
.module-photo-actions{display:flex;align-items:center;gap:8px}
.module-photo-pick{text-align:center;cursor:pointer;margin:0}
.module-photo-actions .btn-link.danger{color:#ef4444;border-color:rgba(239,68,68,.35)}
.module-photo-actions .btn-link.danger:hover{border-color:#ef4444}
#moduleModal .hint{font-size:.78rem;color:var(--muted);line-height:1.5;display:block}
@media (max-width:640px){.module-item{flex-direction:column;align-items:flex-start}}
</style>
</body>
</html>
File diff suppressed because one or more lines are too long
+188
View File
@@ -0,0 +1,188 @@
/* WhatIDo - report page layer on top of report.css (design system from the reference layout). */
@font-face{
font-family:"Inter";
font-style:normal;
font-weight:400;
font-display:swap;
src:url("/fonts/inter-400.woff2") format("woff2");
}
@font-face{
font-family:"Inter";
font-style:normal;
font-weight:500;
font-display:swap;
src:url("/fonts/inter-500-cyr.woff2") format("woff2");
unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
@font-face{
font-family:"Inter";
font-style:normal;
font-weight:500;
font-display:swap;
src:url("/fonts/inter-500-lat.woff2") format("woff2");
unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
:root{
--rep-accent:rgb(118,132,255);
--rep-accent-hover:rgb(89,99,185);
--rep-bg:rgb(21,21,21);
--rep-line:rgba(255,255,255,.1);
--rep-line-soft:rgba(255,255,255,.16);
--rep-muted:rgba(174,176,176,1);
--sf-img-0:url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0nMjUnIGhlaWdodD0nMjUnIHZpZXdCb3g9JzAgMCAyNSAyNScgZmlsbD0nbm9uZScgeG1sbnM9J2h0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnJz48cGF0aCBkPSdNMTUuNSA2LjVMOS41IDEyLjVMMTUuNSAxOC41JyBzdHJva2U9JyM5QzlDOUMnIHN0cm9rZS1saW5lY2FwPSdyb3VuZCcgc3Ryb2tlLWxpbmVqb2luPSdyb3VuZCcvPjwvc3ZnPgo=");
--sf-img-1:url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0nMjUnIGhlaWdodD0nMjUnIHZpZXdCb3g9JzAgMCAyNSAyNScgZmlsbD0nbm9uZScgeG1sbnM9J2h0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnJz48cGF0aCBkPSdNOS41IDE4LjVMMTUuNSAxMi41TDkuNSA2LjUnIHN0cm9rZT0nIzlDOUM5Qycgc3Ryb2tlLWxpbmVjYXA9J3JvdW5kJyBzdHJva2UtbGluZWpvaW49J3JvdW5kJy8+PC9zdmc+Cg==");
--sf-img-7:url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xNS45OTg0IDUuMzkzNDFMMjUuMTkwNiAxNC41ODU2TDI1LjE5MDYgMTQuNTg1NkwyNi42MDQ4IDE1Ljk5OThMMjYuNjA0OCAxNS45OTk4TDI2LjYwNSAxNkwyNS4xOTA3IDE3LjQxNDJMMjUuMTkwNiAxNy40MTRMMTUuOTk4MiAyNi42MDY0TDE0LjU4NCAyNS4xOTIyTDIyLjc4NSAxNi45OTEyTDYuNDk1MjMgMTYuOTkxMkw2LjQ5NTIzIDE1LjAwODRMMjIuNzg0OSAxNS4wMDg0TDE0LjU4NDEgNi44MDc2MkwxNS45OTg0IDUuMzkzNDFaIiBmaWxsPSJ3aGl0ZSIvPgo8L3N2Zz4K");
--sf-img-8:url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xNi4wMDE2IDI2LjYwNjZMNi44MDk0NCAxNy40MTQ0TDYuODA5NDQgMTcuNDE0NEw1LjM5NTIyIDE2LjAwMDJMNS4zOTUyMyAxNi4wMDAyTDUuMzk1MDQgMTZMNi44MDkyNiAxNC41ODU4TDYuODA5NDQgMTQuNTg2TDE2LjAwMTggNS4zOTM1OUwxNy40MTYgNi44MDc4TDkuMjE0OTkgMTUuMDA4OUwyNS41MDQ4IDE1LjAwODhMMjUuNTA0OCAxNi45OTE2TDkuMjE1MDggMTYuOTkxNkwxNy40MTU5IDI1LjE5MjRMMTYuMDAxNiAyNi42MDY2WiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cg==");
--sf-img-9:url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTIiIGhlaWdodD0iMTEiIHZpZXdCb3g9IjAgMCAxMiAxMSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEuMTk5MjIgNC44NTYzNkw0Ljc5OTIyIDkuNzMyNTVMMTAuNzk5MiAxLjE5OTIyIiBzdHJva2U9IndoaXRlIi8+Cjwvc3ZnPgo=");
}
html{scroll-behavior:smooth}
html,body{background-color:var(--rep-bg)}
body{overflow-x:hidden}
::selection{background-color:var(--rep-accent);color:#fff}
.container--u-i80idakgi{opacity:1!important;transform:none!important}
.rep-hidden{display:none!important}
.rep-static{cursor:default;color:var(--rep-muted)}
[data-img]{cursor:zoom-in}
button,a{-webkit-tap-highlight-color:transparent}
.rep-logo-box{width:auto;min-width:44px;height:40px;align-items:center;justify-content:center;overflow:visible}
.rep-logo{font-size:20px;font-weight:500;letter-spacing:-.05em;line-height:1;color:rgb(21,21,21);white-space:nowrap}
.rep-brand-logo{height:40px;width:auto;max-width:min(30vw,200px);object-fit:contain;display:block}
.rep-brand-name{font-size:18px;font-weight:500;letter-spacing:-.05em;line-height:1;color:rgb(21,21,21);white-space:nowrap;max-width:min(26vw,180px);overflow:hidden;text-overflow:ellipsis}
.rep-nav .link-block--u-ij7yf5mm2{column-gap:12px}
.rep-logo-box--footer{height:20px;min-width:0}
.rep-logo--white{font-size:18px;color:#fff}
.rep-overlay{display:none}
.rep-overlay.is-open{display:block}
@media (min-width:992px){
.rep-burger,.rep-menu-close{display:none!important}
}
@media (max-width:991px){
.rep-menu-panel.is-open{display:flex!important;position:fixed;top:0;right:0;bottom:0;left:0;z-index:85;flex-direction:column;align-items:flex-start;background-color:rgb(255,255,255);padding:20px;overflow-y:auto}
.rep-menu-panel.is-open .menu__scroll-wrapper{display:block!important;width:100%;min-width:0;height:auto}
.rep-menu-panel.is-open .menu__wrapper-lvl1{display:block!important;position:static;background-image:none;background-color:transparent;padding:0}
.rep-menu-panel.is-open .menu__list-lvl1{display:flex!important;flex-direction:column;align-items:flex-start;row-gap:16px;padding:50px 10px 0}
.rep-menu-panel.is-open .menu__link-lvl1{font-size:26px;line-height:110%;padding:0}
.rep-menu-close.is-open{display:block!important;position:absolute;top:22px;right:22px}
.rep-burger{display:block}
}
.rep-hero-photo{position:relative;display:flex;align-items:center;justify-content:center}
.rep-monogram{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:clamp(56px,9vw,140px);font-weight:600;letter-spacing:-.06em;color:rgba(255,255,255,.06)}
.rep-hero-tag{color:rgba(255,255,255,.35)}
.rep-hero-lead{white-space:pre-line;word-break:break-word}
.rep-slider{width:100%;max-width:100%;min-width:0}
.rep-slider .blocklist__list{display:flex;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;scroll-snap-type:x mandatory;scrollbar-width:none;-ms-overflow-style:none}
.rep-slider .blocklist__list::-webkit-scrollbar{display:none;width:0;height:0}
.rep-slider .blocklist__item-wrapper{flex:0 0 100%;max-width:100%;scroll-snap-align:start}
@media (min-width:992px){
.rep-slider .blocklist__item-wrapper{flex:0 0 50%}
}
.rep-slider [data-slider-prev],.rep-slider [data-slider-next]{cursor:pointer}
.rep-slider .rep-arrow-off{opacity:.06!important;pointer-events:none}
.rep-work-photo{width:100%;max-width:280px;height:auto;aspect-ratio:1/1;border-radius:55px}
@media (max-width:991px){
.rep-work-photo{max-width:200px}
}
.rep-work-photo .image__img{width:100%;height:100%;object-fit:cover}
.rep-gallery-photo{width:180px;height:180px;max-width:180px;border-radius:50px}
@media (max-width:991px){
.rep-gallery-photo{width:140px;height:140px;max-width:140px;border-radius:40px}
}
.rep-gallery-photo .image__img{width:100%;height:100%;object-fit:cover}
.rep-gallery-caption{color:#fff}
.rep-gallery-date{color:rgba(56,56,56,1);margin-bottom:auto}
.rep-gallery-text{color:#fff}
.rep-stack{flex-direction:column;align-items:flex-start;row-gap:12px}
.rep-tl-desc{color:rgba(255,255,255,.7);max-width:350px;line-height:130%}
.rep-tl-aside{row-gap:12px}
.rep-tl-count{color:rgba(56,56,56,1)}
.rep-files{display:flex;flex-wrap:wrap;gap:8px;margin-top:6px;max-width:350px}
.rep-chip{display:inline-flex;align-items:center;gap:8px;max-width:280px;padding:9px 18px;border:1px solid var(--rep-line-soft);border-radius:100px;font-size:13px;font-weight:500;letter-spacing:-.05em;line-height:100%;color:rgba(255,255,255,.7);text-decoration:none;transition:border-color .1s linear,color .1s linear}
.rep-chip:hover{border-color:rgba(255,255,255,.65);color:#fff}
.rep-chip span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rep-chip--static{cursor:default}
.rep-chip--static:hover{border-color:var(--rep-line-soft);color:rgba(255,255,255,.7)}
.rep-empty{color:rgba(174,176,176,1);font-size:15px;font-weight:500;letter-spacing:-.05em;padding:20px 0}
.rep-file-item{text-decoration:none;color:inherit;cursor:pointer;transition:background-color .15s linear}
.rep-file-item:hover{background-color:rgb(255,255,255)}
.rep-file-icon{display:flex;align-items:center;justify-content:center;width:180px;height:180px;max-width:180px;border-radius:50px;background-color:rgb(255,255,255);border:1px solid rgba(0,0,0,.06);font-size:20px;font-weight:500;letter-spacing:-.05em;color:rgba(21,21,21,.5);text-transform:uppercase}
@media (max-width:991px){
.rep-file-icon{width:140px;height:140px;max-width:140px;border-radius:40px;font-size:16px}
}
.rep-file-title{color:rgb(21,21,21);overflow-wrap:anywhere}
.rep-file-meta{color:var(--rep-muted)}
.rep-file-cta{display:inline-flex;margin-top:12px;padding:9px 18px;border:1px solid rgba(187,189,190,1);border-radius:100px;font-size:13px;font-weight:500;letter-spacing:-.05em;color:rgba(21,21,21,.75);text-decoration:none}
.rep-file-item:hover .rep-file-cta{border-color:rgb(21,21,21);color:rgb(21,21,21)}
.rep-contact{width:auto;max-width:420px;align-items:flex-start;row-gap:16px}
.rep-contact-title{color:#fff;font-size:24px;font-weight:500;letter-spacing:-.05em;line-height:115%}
.rep-contact-note{color:var(--rep-muted);font-size:15px;font-weight:500;letter-spacing:-.05em;line-height:140%}
.rep-contact-btn{width:100%;height:60px;background-color:var(--rep-accent);border-radius:10px;color:#fff}
.rep-contact-btn:hover{background-color:var(--rep-accent-hover)}
.rep-footer-photo{width:180px;height:180px;max-width:180px;border-radius:55px}
@media (max-width:991px){
.rep-footer-photo{width:140px;height:140px;max-width:140px}
}
.rep-footer-photo .image__img{width:100%;height:100%;object-fit:cover}
.rep-countdown{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:60;display:inline-flex;align-items:center;gap:8px;max-width:calc(100% - 32px);padding:12px 22px;border:1px solid var(--rep-line-soft);border-radius:100px;background-color:rgba(21,21,21,.94);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);color:rgba(255,255,255,.8);font-size:14px;font-weight:500;letter-spacing:-.05em;line-height:120%;white-space:nowrap;overflow:hidden}
.rep-countdown strong{color:#fff;font-weight:500}
.rep-countdown.rep-warn{border-color:rgba(250,192,0,.45)}
.rep-countdown.rep-urgent{border-color:rgba(255,79,82,.55)}
.rep-error{display:flex;align-items:center;justify-content:center;min-height:70vh;padding:80px 30px}
.rep-error-inner{display:flex;flex-direction:column;align-items:center;row-gap:20px;max-width:540px;text-align:center}
.rep-error-title{color:#fff;font-size:clamp(32px,4vw,50px);font-weight:500;letter-spacing:-.05em;line-height:100%}
.rep-error-text{color:var(--rep-muted);font-size:15px;font-weight:500;letter-spacing:-.05em;line-height:140%}
.rep-error-btn{width:auto;height:60px;padding:0 30px;background-color:var(--rep-accent);border-radius:10px;color:#fff}
.rep-error-btn:hover{background-color:var(--rep-accent-hover)}
.rep-lightbox{position:fixed;top:0;right:0;bottom:0;left:0;z-index:300;display:none;align-items:center;justify-content:center;padding:30px;background-color:rgba(0,0,0,.9);cursor:zoom-out}
.rep-lightbox.is-open{display:flex}
.rep-lightbox img{max-width:100%;max-height:100%;object-fit:contain;border-radius:10px}
.rep-modal{position:fixed;top:0;right:0;bottom:0;left:0;z-index:400;display:none;align-items:center;justify-content:center;padding:20px;background-color:rgba(0,0,0,.72);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px)}
.rep-modal.is-open{display:flex}
.rep-modal-box{display:flex;flex-direction:column;row-gap:16px;width:100%;max-width:420px;padding:35px;border:1px solid rgba(44,44,44,1);border-radius:20px;background-color:var(--rep-bg)}
.rep-modal-title{color:#fff;font-size:24px;font-weight:500;letter-spacing:-.05em;line-height:115%}
.rep-modal-text{color:var(--rep-muted);font-size:15px;font-weight:500;letter-spacing:-.05em;line-height:140%}
.rep-modal-input{width:100%;height:55px;padding:0 18px;border:1px solid var(--rep-line-soft);border-radius:10px;background-color:transparent;color:#fff;font-size:15px;font-weight:500;letter-spacing:-.05em;outline:none}
.rep-modal-input::placeholder{color:rgba(255,255,255,.35)}
.rep-modal-input:focus{border-color:rgba(255,255,255,.5)}
.rep-modal-btn{width:100%;height:55px;background-color:var(--rep-accent);border-radius:10px;color:#fff}
.rep-modal-btn:hover{background-color:var(--rep-accent-hover)}
.rep-modal-error{color:rgb(255,79,82);font-size:14px;font-weight:500;letter-spacing:-.05em}
@media (max-width:640px){
.rep-countdown{bottom:14px;padding:10px 16px;font-size:13px}
.rep-modal-box{padding:25px}
}
.rep-work-desc{color:rgba(255,255,255,.7);max-width:350px;line-height:130%}
.rep-stack>.text-block-wrap-div{flex-grow:0}
.rep-file-meta .text-block-wrap-div{flex-grow:0}
.rep-about-photo{display:block;width:100%;height:100%}
.rep-about-photo .image__img{width:100%;height:100%;object-fit:cover}
.rep-about-monogram{display:flex;align-items:center;justify-content:center;width:100%;height:100%;font-size:64px;font-weight:600;letter-spacing:-.06em;color:rgba(21,21,21,.12)}
#works.section--u-ibq7nxfzo{background-color:rgb(255,255,255)}
#works .text--u-ihb2xbsz6{color:rgba(21,21,21,1)}
#works .blocklist__item--u-iqn443glf{background-color:rgb(255,255,255)}
#works .div--u-ifxd1g9rk{border-top-color:rgba(228,231,232,1);border-right-color:rgba(228,231,232,1);border-bottom-color:rgba(228,231,232,1);border-left-color:rgba(228,231,232,1)}
#works .text--u-ib8dkw227{color:rgba(220,220,220,1)}
#works .text--u-i8ydj7eyx{color:rgba(21,21,21,1)}
#works .div--u-ipp2467j8{border-top-color:rgba(187,189,190,1);border-right-color:rgba(187,189,190,1);border-bottom-color:rgba(187,189,190,1);border-left-color:rgba(187,189,190,1)}
#works .text--u-iz3df5dls{color:rgba(21,21,21,1)}
#works .div--u-ibren39lw,#works .div--u-ixtm7p8ov,#works .div--u-iidtqgnjo,#works .div--u-iohjzr62g{background-color:rgb(21,21,21)}
#works .rep-work-desc{color:rgba(21,21,21,.7)}
#works .rep-chip{border-color:rgb(187,189,190);color:rgba(21,21,21,.75)}
#works .rep-chip:hover{border-color:rgb(21,21,21);color:rgb(21,21,21)}
+285
View File
@@ -0,0 +1,285 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="theme-color" content="#151515">
<meta name="robots" content="noindex,nofollow">
<title>Отчёт</title>
<link rel="stylesheet" href="/report.css">
<link rel="stylesheet" href="/report.custom.css">
</head>
<body>
<div class="mosaic-wrap">
<div class="root root--u-i8z5xo3zc" id="top">
<section class="section paddings--left-right section--u-i7l3q2nvu rep-nav">
<div class="container container--u-i80idakgi">
<a href="#top" class="link-block link-block--u-ij7yf5mm2">
<div class="image image--u-irhpgss26 rep-logo-box">
<img id="brandLogo" class="rep-brand-logo" alt="" hidden>
<span class="rep-logo" id="brandMark">—</span>
</div>
<span class="rep-brand-name" id="brandName" hidden></span>
</a>
<div class="menu menu--u-i9yrdei10 menu--s2-i9yrdei10">
<div class="menu__fixed-wrapper menu__fixed-wrapper--s2-iek5sxvy1 rep-menu-panel" id="navPanel">
<div class="menu__scroll-wrapper menu__scroll-wrapper--u-ihg46fvlv menu__scroll-wrapper--s2-ihg46fvlv">
<div class="menu__wrapper-lvl1 menu__wrapper-lvl1--u-ic1ljomjg menu__wrapper-lvl1--s2-ic1ljomjg">
<div class="menu__list-lvl1 menu__list-lvl1--u-ioyaxt4m1 menu__list-lvl1--s2-ioyaxt4m1">
<div class="menu__item-lvl1 menu__item-lvl1--u-imeieea6o" data-nav="about">
<a href="#about" class="menu__link-lvl1 menu__link-lvl1--u-i6278jyvl">
<div class="menu__link-text menu__link-text--u-i2qgb1ij1"><span class="text-block-wrap-div">Обо мне</span></div>
</a>
</div>
<div class="menu__item-lvl1 menu__item-lvl1--u-imeieea6o" data-nav="timeline">
<a href="#timeline" class="menu__link-lvl1 menu__link-lvl1--u-i6278jyvl">
<div class="menu__link-text menu__link-text--u-i2qgb1ij1"><span class="text-block-wrap-div">Хроника</span></div>
</a>
</div>
<div class="menu__item-lvl1 menu__item-lvl1--u-imeieea6o" data-nav="works">
<a href="#works" class="menu__link-lvl1 menu__link-lvl1--u-i6278jyvl">
<div class="menu__link-text menu__link-text--u-i2qgb1ij1"><span class="text-block-wrap-div">Работы</span></div>
</a>
</div>
<div class="menu__item-lvl1 menu__item-lvl1--u-imeieea6o" data-nav="gallery">
<a href="#gallery" class="menu__link-lvl1 menu__link-lvl1--u-i6278jyvl">
<div class="menu__link-text menu__link-text--u-i2qgb1ij1"><span class="text-block-wrap-div">Фото</span></div>
</a>
</div>
<div class="menu__item-lvl1 menu__item-lvl1--u-imeieea6o" data-nav="files">
<a href="#files" class="menu__link-lvl1 menu__link-lvl1--u-i6278jyvl">
<div class="menu__link-text menu__link-text--u-i2qgb1ij1"><span class="text-block-wrap-div">Файлы</span></div>
</a>
</div>
</div>
</div>
</div>
<div role="button" class="menu__close-button menu__close-button--u-ib386a3r1 menu__close-button--s2-ib386a3r1 rep-menu-close" id="navClose"></div>
</div>
<div role="button" class="menu__burger-button menu__burger-button--u-ibf8kzqjr menu__burger-button--s2-ibf8kzqjr rep-burger" id="navBurger"></div>
<div class="menu__overlay menu__overlay--u-io05iogd9 rep-overlay" id="navOverlay"></div>
</div>
<a href="#contact" class="link text--link-1 link--u-i2ifdcrc4"><span class="text-block-wrap-div">Контакты</span></a>
</div>
</section>
<div id="errorBox" class="rep-error" style="display:none">
<div class="rep-error-inner">
<div class="rep-error-title">Отчёт недоступен</div>
<div class="rep-error-text" id="errorText"></div>
<a href="/" class="button button--u-i4a1jl1f8 text--button rep-error-btn">
<span class="text-button text-button--u-i4t8wl9m2"><span class="text-block-wrap-div">На главную</span></span>
</a>
</div>
</div>
<main id="content" style="display:none">
<section class="section section--u-imrochvid" id="hero">
<div class="text text--u-ihro87s5g h1" id="heroName"><span class="text-block-wrap-div">—</span></div>
<div class="div div--u-iypw3r3hy"></div>
<div class="div div--u-iac2fpjoy paddings--left-right">
<div class="text text--u-if3tqh9j6 t2" id="heroRole"><span class="text-block-wrap-div">—</span></div>
<div class="text text--u-il0q6mzel t2" id="heroPeriod"><span class="text-block-wrap-div">—</span></div>
</div>
<div class="div div--u-ii07pa6dy">
<div class="image image--u-ivhonc1br">
<img class="image__img" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDExIiBoZWlnaHQ9IjQxMiIgdmlld0JveD0iMCAwIDQxMSA0MTIiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxjaXJjbGUgY3g9IjIwNSIgY3k9IjIwNiIgcj0iMjA0LjUiIHN0cm9rZT0iIzIxMjEyMSIvPgo8cmVjdCB3aWR0aD0iNTgxIiBoZWlnaHQ9IjEiIHRyYW5zZm9ybT0icm90YXRlKDQ1KSIgZmlsbD0iIzIxMjEyMSIvPgo8L3N2Zz4KCg==" alt="">
</div>
<div class="div div--u-ivyxhpbts">
<div class="div div--u-inb3arto0">
<div class="div div--u-iloks34xh">
<div class="image image--u-id09xtq63">
<img class="image__img image__img--s2-ipfbguwr1" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCA0MCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTAgMEwyMCAyMEgwVjBaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMjAgMjBMNDAgLTguNzQyMjhlLTA3TDQwIDIwTDIwIDIwWiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cgo=" alt="">
</div>
<div class="text text--u-is1fxv2no l1 rep-hero-lead" id="heroLead"><span class="text-block-wrap-div"></span></div>
<div class="text text--u-ig2k8b0st l1" id="heroTag"><span class="text-block-wrap-div">[Отчёт]</span></div>
<div class="image image--u-i5hwy71ca">
<img class="image__img image__img--s2-icfu2drw3" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCA0MCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQwIDIwTDIwIC0xLjc0ODQ2ZS0wNkw0MCAwTDQwIDIwWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTIwIDBMLTguNzQyMjhlLTA3IDIwTDAgLTguNzQyMjhlLTA3TDIwIDBaIiBmaWxsPSJ3aGl0ZSIvPgo8L3N2Zz4KCg==" alt="">
</div>
<div class="div div--u-iasmwwdzw"></div>
<div class="div div--u-i9jct2f9m"></div>
<div class="div div--u-is7e9z5nq"></div>
<div class="div div--u-i0k4yxpc8"></div>
</div>
</div>
<div class="image image--u-is0urbrfg rep-hero-photo" id="heroPhotoBox">
<img class="image__img" id="heroPhoto" alt="">
</div>
</div>
<div class="image image--u-i8jhhfeik">
<img class="image__img" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDEwIiBoZWlnaHQ9IjQxMiIgdmlld0JveD0iMCAwIDQxMCA0MTIiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxjaXJjbGUgY3g9IjIwNSIgY3k9IjIwNSIgcj0iMjA0LjUiIHN0cm9rZT0iIzIxMjEyMSIvPgo8cmVjdCB4PSI0MTEiIHk9IjEiIHdpZHRoPSI1ODAiIGhlaWdodD0iMSIgdHJhbnNmb3JtPSJyb3RhdGUoMTM1IDQxMSAxKSIgZmlsbD0iIzIxMjEyMSIvPgo8L3N2Zz4KCg==" alt="">
</div>
</div>
<div class="div div--u-ivqnx6ncb"></div>
<div class="div div--u-ivysvnhdd"></div>
</section>
<section class="section section--u-ije008ww5 paddings--left-right" id="about">
<div class="div div--u-ikil9thol"></div>
<div class="text text--u-ivqsnwblc h2" id="aboutTitle"><span class="text-block-wrap-div">Обо мне</span></div>
<div class="div div--u-isbf1g6de"></div>
<div class="div div--u-iayi3al5a">
<div class="div div--u-i7arpju57"></div>
<div class="div div--u-i6wqm8o03">
<div class="text text--u-iaopidyhq h3" id="aboutLead"><span class="text-block-wrap-div"></span></div>
</div>
<div class="div div--u-isrs3fsi8">
<div class="div div--u-ih80fbo71"></div>
<div class="div div--u-inkx4saax">
<div class="image image--u-iidjge3zd rep-about-photo" id="aboutPhotoBox">
<img class="image__img" id="aboutPhoto" alt="">
</div>
<div class="div div--u-irqublwm9"></div>
<div class="div div--u-ikxc1qjen"></div>
<div class="div div--u-i88rv34ah"></div>
<div class="div div--u-i8l8bxdx6"></div>
</div>
<div class="div div--u-iryjyivpv">
<div class="text text--u-igq5dogo6 t2" id="aboutNote"><span class="text-block-wrap-div"></span></div>
</div>
</div>
<div class="list list--u-i59hjh2ej" id="statsList"></div>
</div>
<a href="#contact" class="button button--u-i4a1jl1f8 text--button">
<span class="text-button text-button--u-i4t8wl9m2"><span class="text-block-wrap-div">Связаться</span></span>
</a>
</section>
<section class="section paddings--left-right section--u-iw83bdcw9" id="timeline">
<div class="text text--u-i6dz2a56i h2"><span class="text-block-wrap-div">Хроника занятий</span></div>
<div class="list list--u-iaxl58kyi" id="timelineList"></div>
</section>
<section class="section section--u-ibq7nxfzo paddings--left-right" id="works">
<div class="text h2 text--u-ihb2xbsz6"><span class="text-block-wrap-div">Работы</span></div>
<div class="div rep-slider" data-slider="works">
<div class="blocklist blocklist--u-ivlioq6ab blocklist--s2-ivlioq6ab">
<div class="blocklist__list-wrapper blocklist__list-wrapper--s2-i4t2ufz23">
<div class="blocklist__list blocklist__list--s2-iou286xss" id="worksTrack"></div>
</div>
<div class="blocklist__controls-wrapper blocklist__controls-wrapper--s2-igcvqnkng">
<div class="blocklist__controls blocklist__controls--u-igz87d7pf">
<div class="blocklist__control-arrows blocklist__control-arrows--u-ihwh9bgqr">
<div role="button" class="blocklist__arrow-prev2 blocklist__arrow-prev2--u-i72uloioe" data-slider-prev></div>
<div role="button" class="blocklist__arrow-next2 blocklist__arrow-next2--u-ivlw85q4f" data-slider-next></div>
</div>
<div class="blocklist__controls-pagination-wrapper"></div>
</div>
</div>
</div>
</div>
</section>
<section class="section paddings--left-right section--u-ip0mjje82" id="files">
<div class="text text--u-i24jt3mcb h2"><span class="text-block-wrap-div">Проектные файлы</span></div>
<div class="list list--u-iix2uh3ie" id="filesList"></div>
</section>
<section class="section section--u-i0y44hvpw paddings--left-right" id="gallery">
<div class="text h2 text--u-ikcqharhc"><span class="text-block-wrap-div">Фото группы</span></div>
<div class="div div--u-ig1bsrybs"></div>
<div class="div div--u-i0kioh3ih"></div>
<div class="div rep-slider" data-slider="gallery">
<div class="blocklist blocklist--u-iohyxn81v blocklist--s2-iohyxn81v">
<div class="blocklist__list-wrapper blocklist__list-wrapper--s2-ijirftpw5">
<div class="blocklist__list blocklist__list--s2-iak70wit9" id="galleryTrack"></div>
</div>
<div class="blocklist__controls-wrapper blocklist__controls-wrapper--s2-ig787iibk">
<div class="blocklist__controls blocklist__controls--u-iau8c9wem">
<div class="blocklist__control-arrows blocklist__control-arrows--u-ihw55qli4">
<div role="button" class="blocklist__arrow-prev2 blocklist__arrow-prev2--u-i8t1smxl7" data-slider-prev></div>
<div role="button" class="blocklist__arrow-next2 blocklist__arrow-next2--u-ixzxe7qta" data-slider-next></div>
</div>
<div class="blocklist__controls-pagination-wrapper"></div>
</div>
</div>
</div>
</div>
</section>
<section class="section section--u-ihbjvn71x" id="contact">
<div class="text text--u-ifdrgto5v h2"><span class="text-block-wrap-div">Связь с нами</span></div>
<div class="div div--u-ivyhwi7sr"></div>
<div class="div div--u-iq5a3c8eo">
<div class="image image--u-ipv2zlpi2">
<img class="image__img" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDExIiBoZWlnaHQ9IjQxMiIgdmlld0JveD0iMCAwIDQxMSA0MTIiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxjaXJjbGUgY3g9IjIwNSIgY3k9IjIwNiIgcj0iMjA0LjUiIHN0cm9rZT0iIzIxMjEyMSIvPgo8cmVjdCB3aWR0aD0iNTgxIiBoZWlnaHQ9IjEiIHRyYW5zZm9ybT0icm90YXRlKDQ1KSIgZmlsbD0iIzIxMjEyMSIvPgo8L3N2Zz4KCg==" alt="">
</div>
<div class="text text--u-ieyliov62 h3" id="contactNote"><span class="text-block-wrap-div"></span></div>
<div class="image image--u-im8xge6of">
<img class="image__img" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDEwIiBoZWlnaHQ9IjQxMiIgdmlld0JveD0iMCAwIDQxMCA0MTIiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxjaXJjbGUgY3g9IjIwNSIgY3k9IjIwNSIgcj0iMjA0LjUiIHN0cm9rZT0iIzIxMjEyMSIvPgo8cmVjdCB4PSI0MTEiIHk9IjEiIHdpZHRoPSI1ODAiIGhlaWdodD0iMSIgdHJhbnNmb3JtPSJyb3RhdGUoMTM1IDQxMSAxKSIgZmlsbD0iIzIxMjEyMSIvPgo8L3N2Zz4KCg==" alt="">
</div>
</div>
<div class="div div--u-ifj6asx15">
<div class="div div--u-i95yztn00"></div>
<div class="div div--u-iqswnal3i"></div>
<div class="image image--u-i9ff2hgvg rep-footer-photo" id="footerPhotoBox">
<img class="image__img" id="footerPhoto" alt="">
</div>
<div class="image image--u-ishqnsp3y">
<img class="image__img" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCA0MCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTAgMEwyMCAyMEgwVjBaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMjAgMjBMNDAgLTguNzQyMjhlLTA3TDQwIDIwTDIwIDIwWiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cgo=" alt="">
</div>
<div class="div div--u-iosm3syjx"></div>
<div class="div div--u-imsajm4rq"></div>
</div>
<div class="div div--u-i0phithov">
<div class="div div--u-i82ybieml">
<a href="#top" class="link-block link-block--u-ic5ntfotz">
<div class="image image--u-iwz9z9ztv rep-logo-box rep-logo-box--footer"><span class="rep-logo rep-logo--white" id="brandMarkFooter">—</span></div>
</a>
<div class="div div--u-iqpqkl8er">
<div class="div div--u-i230b7fin">
<div class="text t2 text--u-iukrw4zbo"><span class="text-block-wrap-div">Меню</span></div>
<a href="#about" class="link text--link-1 link--u-ihn1tumex"><span class="text-block-wrap-div">Обо мне</span></a>
<a href="#timeline" class="link text--link-1 link--u-iw5fg3iez"><span class="text-block-wrap-div">Хроника занятий</span></a>
<a href="#works" class="link text--link-1 link--u-i6sruiw9p" data-nav="works"><span class="text-block-wrap-div">Работы</span></a>
<a href="#files" class="link text--link-1 link--u-ivy8mkxoi" data-nav="files"><span class="text-block-wrap-div">Файлы</span></a>
<a href="#gallery" class="link text--link-1 link--u-i17jh0lsx" data-nav="gallery"><span class="text-block-wrap-div">Фото группы</span></a>
</div>
<div class="div div--u-i3z8k7bsc">
<div class="text t2 text--u-isp07chjo"><span class="text-block-wrap-div">Отчёт</span></div>
<div class="link text--link-1 rep-static"><span class="text-block-wrap-div" id="footGroup"></span></div>
<div class="link text--link-1 rep-static"><span class="text-block-wrap-div" id="footPeriod"></span></div>
<div class="link text--link-1 rep-static"><span class="text-block-wrap-div" id="footCreated"></span></div>
</div>
<div class="div div--u-i94yzqcsn">
<div class="text t2 text--u-ibz851gxs"><span class="text-block-wrap-div">Ссылка</span></div>
<a href="#" class="link text--link-1 link--u-ioxiekbkh" id="footLink" target="_blank" rel="noopener noreferrer" style="display:none"><span class="text-block-wrap-div">Перейти по ссылке</span></a>
<div class="link text--link-1 rep-static"><span class="text-block-wrap-div" id="footExpires"></span></div>
</div>
</div>
</div>
<div class="form form--u-i3ge2e3aw rep-contact">
<div class="rep-contact-title" id="contactLead"></div>
<div class="rep-contact-note" id="contactSub"></div>
<a href="#top" class="button button--u-i4a1jl1f8 text--button rep-contact-btn" id="contactBtn">
<span class="text-button text-button--u-i4t8wl9m2"><span class="text-block-wrap-div">В начало отчёта</span></span>
</a>
</div>
</div>
<div class="div div--u-inush9rz9">
<div class="text t2 text--u-irub9snx4"><span class="text-block-wrap-div" id="footCopy"></span></div>
<div class="link text--link-1 rep-static"><span class="text-block-wrap-div" id="footBrand">WhatIDo</span></div>
</div>
</section>
</main>
</div>
</div>
<div class="rep-countdown" id="countdown" style="display:none"><span class="rep-cd-text"></span></div>
<div class="rep-lightbox" id="lightbox"><img id="lightboxImg" alt=""></div>
<div class="rep-modal" id="passwordModal">
<div class="rep-modal-box">
<div class="rep-modal-title">Ссылка защищена паролем</div>
<div class="rep-modal-text">Введите пароль, чтобы открыть отчёт</div>
<input type="password" id="passwordInput" class="rep-modal-input" placeholder="Пароль" autocomplete="current-password">
<button type="button" id="passwordSubmitBtn" class="button button--u-i4a1jl1f8 text--button rep-modal-btn">
<span class="text-button text-button--u-i4t8wl9m2"><span class="text-block-wrap-div">Войти</span></span>
</button>
<div class="rep-modal-error" id="passwordError" style="display:none">Неверный пароль</div>
</div>
</div>
<script src="/js/report.js"></script>
</body>
</html>
+214 -61
View File
@@ -12,84 +12,180 @@
<div class="main">
<div class="page-head">
<h2>Настройки</h2>
<p class="page-sub">Параметры формы и данные системы</p>
<p class="page-sub">Параметры формы, публичных страниц и данные системы</p>
<span class="page-status" id="authStatus"><span class="spinner dark"></span> Загрузка…</span>
<button class="head-btn" id="reloadBtn" type="button" title="Перезагрузить настройки"><i data-lucide="rotate-ccw"></i> Сбросить</button>
</div>
<div class="settings-layout">
<aside class="settings-nav" id="settingsNav">
<a href="#sec-system" data-nav="sec-system" class="active"><i data-lucide="bar-chart-3"></i> Система</a>
<a href="#sec-brand" data-nav="sec-brand"><i data-lucide="palette"></i> Брендинг</a>
<a href="#sec-antispam" data-nav="sec-antispam"><i data-lucide="shield"></i> Антиспам</a>
<a href="#sec-share" data-nav="sec-share"><i data-lucide="link"></i> Публичные ссылки</a>
<a href="#sec-form" data-nav="sec-form"><i data-lucide="file-text"></i> Форма ответа</a>
<a href="#sec-photo" data-nav="sec-photo"><i data-lucide="camera"></i> Фото</a>
<a href="#sec-ai" data-nav="sec-ai"><i data-lucide="bot"></i> ИИ</a>
<a href="#sec-backup" data-nav="sec-backup"><i data-lucide="database"></i> Бэкапы</a>
<a href="#sec-trash" data-nav="sec-trash"><i data-lucide="trash-2"></i> Корзина</a>
<a href="#sec-bans" data-nav="sec-bans"><i data-lucide="shield-off"></i> Блокировки</a>
</aside>
<div class="settings-stack settings-stack-grid">
<div class="settings-card wide">
<div class="settings-card wide" id="sec-system">
<div class="card-head">
<div class="ic"><i data-lucide="bar-chart-3"></i></div>
<h3>Системная информация</h3>
<button class="icon-btn" id="systemRefreshBtn" type="button" title="Обновить" aria-label="Обновить"><i data-lucide="refresh-cw"></i></button>
<span class="head-live" id="systemLive" title="Данные кэшируются на сервере"></span>
</div>
<div id="systemInfo" class="sys-grid">
<div class="sys-card loading">Загрузка…</div>
</div>
</div>
<div class="settings-card">
<div class="settings-card" id="sec-brand">
<div class="card-head">
<div class="ic"><i data-lucide="palette"></i></div>
<h3>Брендинг</h3>
</div>
<p class="hint">Название и логотип отображаются в админ-панели, на форме ответа ученика, странице входа и в отчёте.</p>
<div class="settings-field">
<label for="systemName">Название системы</label>
<input type="text" id="systemName" class="settings-input" maxlength="60" placeholder="WhatIDo">
</div>
<div class="settings-field">
<label>Логотип</label>
<div class="logo-picker">
<div class="logo-preview" id="logoPreview">
<span id="logoPreviewFallback">—</span>
<img id="logoPreviewImg" alt="Логотип">
</div>
<div class="logo-picker-actions">
<div class="logo-picker-btns">
<button class="btn-primary ghost" id="logoUploadBtn" type="button"><i data-lucide="upload"></i> Загрузить логотип</button>
<button class="btn-primary danger-ghost" id="logoRemoveBtn" type="button" hidden><i data-lucide="trash-2"></i> Удалить</button>
</div>
<input type="file" id="logoInput" accept="image/*" style="display:none">
<div class="logo-upload-status" id="logoUploadStatus"></div>
<span class="hint">Рекомендуется широкое изображение с прозрачным фоном (PNG/WebP), не более 1 МБ</span>
</div>
</div>
</div>
</div>
<div class="settings-card" id="sec-antispam">
<div class="card-head">
<div class="ic"><i data-lucide="shield"></i></div>
<h3>Защита от повторов</h3>
</div>
<p class="hint">Если ученик уже отправил ответ, следующая отправка от него будет отклонена в течение заданного времени. Минимальное значение — 1 минута.</p>
<div class="settings-row">
<div class="settings-field">
<label for="spamInterval">Интервал ожидания</label>
<div class="unit-row">
<input type="number" id="spamInterval" class="settings-input" min="1" max="1440" step="1">
<span class="unit">минут</span>
</div>
<span class="field-error" id="spamIntervalError"></span>
<div class="preset-btns" id="spamPresets">
<button class="preset-btn" type="button" data-min="1">1 мин</button>
<button class="preset-btn" type="button" data-min="15">15 мин</button>
<button class="preset-btn" type="button" data-min="30">30 мин</button>
<button class="preset-btn" type="button" data-min="60">1 час</button>
<button class="preset-btn" type="button" data-min="1440">Сутки</button>
</div>
<span class="hint" id="spamIntervalHint"></span>
</div>
</div>
<div class="settings-card">
<div class="settings-card" id="sec-share">
<div class="card-head">
<div class="ic"><i data-lucide="link"></i></div>
<h3>Страница публичной ссылки /s/&lt;hash&gt;</h3>
</div>
<p class="hint">Управление видимостью элементов на публичных страницах записей.</p>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<label class="toggle">
<input type="checkbox" id="shareShowMessage">
Показать сообщение ученика
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Сообщение ученика</b><small>Текст «Что делала(а)» на странице по ссылке</small></span>
</label>
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<label class="toggle">
<input type="checkbox" id="shareShowDate">
Показать дату в карточке записи
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Дата записи</b><small>Дата в карточке записи</small></span>
</label>
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<label class="toggle">
<input type="checkbox" id="shareShowNames">
Показывать фамилии и имена учеников
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Фамилии и имена</b><small>Имена учеников в галерее работ</small></span>
</label>
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<label class="toggle">
<input type="checkbox" id="shareShowPhotos">
Показывать фото группы
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Фото группы</b><small>Групповое фото в шапке страницы</small></span>
</label>
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<div class="settings-field">
<label for="cookieNoticeText">Уведомление о cookie</label>
<textarea id="cookieNoticeText" class="settings-input" rows="3" style="min-height:80px"></textarea>
</div>
<span class="hint">Пусто — баннер не показывается</span>
</div>
</div>
<div class="settings-card" id="sec-ai">
<div class="card-head">
<div class="ic"><i data-lucide="bot"></i></div>
<h3>Промпт ИИ для исправления текста</h3>
</div>
<p class="hint">Инструкция для нейросети при исправлении ошибок в записи «Что делала(а)».</p>
<label class="toggle">
<input type="checkbox" id="aiAutoCheck">
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Автопроверка новых записей</b><small>Без участия человека — воркер сам обрабатывает очередь</small></span>
</label>
<div class="settings-field">
<label for="aiPrompt">Промпт</label>
<textarea id="aiPrompt" class="settings-input mono" rows="5" spellcheck="false"></textarea>
</div>
<div class="ai-queue" id="aiQueueInfo"></div>
</div>
<div class="settings-card">
<div class="card-head">
<div class="ic"><i data-lucide="boxes"></i></div>
<h3>Профили моделей ИИ</h3>
</div>
<p class="hint">Модель, выбранная по умолчанию, обрабатывает записи учеников. «Нативная» — llama.cpp в Docker. Можно добавить любого провайдера с OpenAI-совместимым API (OpenRouter, OpenAI, Ollama и др.).</p>
<div class="ai-queue" id="aiProfileActive"></div>
<div class="card-foot" style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:8px;font-size:.88rem;min-width:0;max-width:100%">
Модель по умолчанию:
<select id="aiActiveProfile" class="settings-input" style="width:auto;min-width:0;max-width:100%;flex:1 1 200px">
<option value="native">Нативная (llama.cpp)</option>
</select>
</label>
<button class="btn-primary ghost" id="aiProfileAddBtn" type="button"><i data-lucide="plus"></i> Добавить профиль</button>
</div>
<div id="aiProfilesList" class="ai-profiles-list"></div>
</div>
<div class="settings-card" id="sec-backup">
<div class="card-head">
<div class="ic"><i data-lucide="database"></i></div>
<h3>Резервное копирование</h3>
<button class="icon-btn" id="statsRefreshBtn" type="button" title="Обновить статистику" aria-label="Обновить статистику"><i data-lucide="refresh-cw"></i></button>
</div>
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Архив формируется на сервере, скачивание выполняет браузер — при обрыве связи загрузку можно продолжить. Восстановление полностью заменит текущие данные.</p>
<div class="data-stats" id="dataStats"><span class="chip">Загрузка статистики…</span></div>
<div class="card-foot">
<button class="btn-primary ghost" id="backupBtn" type="button"><i data-lucide="download"></i> Скачать бэкап</button>
<button class="btn-primary ghost danger" id="restoreBtn" type="button"><i data-lucide="upload"></i> Восстановить из файла</button>
<input type="file" id="restoreFile" accept=".tar.gz,.gz,application/gzip" style="display:none">
</div>
<div class="restore-status" id="restoreStatus"></div>
<div class="restore-status" id="backupStatus"></div>
</div>
<div class="settings-card" id="sec-form">
<div class="card-head">
<div class="ic"><i data-lucide="file-text"></i></div>
<h3>Футер на форме</h3>
@@ -107,45 +203,64 @@
</div>
</div>
<div class="settings-card">
<div class="settings-card" id="sec-photo">
<div class="card-head">
<div class="ic"><i data-lucide="bot"></i></div>
<h3>Промпт ИИ для исправления текста</h3>
<div class="ic"><i data-lucide="camera"></i></div>
<h3>Фотография с веб-камеры</h3>
</div>
<p class="hint">Инструкция для нейросети при исправлении ошибок в записи «Что делала(а)».</p>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label for="aiPrompt">Промпт</label>
<textarea id="aiPrompt" class="settings-input" rows="4" style="min-height:100px;font-family:monospace;font-size:.85rem"></textarea>
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="aiAutoCheck">
Автоматически проверять новые записи (без участия человека)
<p class="hint">Разрешение и качество снимка на форме ответа ученика. Камера может выдать разрешение, ближайшее к выбранному.</p>
<label class="toggle">
<input type="checkbox" id="camEnabled">
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Кнопка «Камера»</b><small>Показывать кнопку «Камера» на форме ответа. Если выключено, фото делается кликом по превью «Нажми чтобы сделать фото»</small></span>
</label>
<div class="settings-row">
<div class="settings-field">
<label for="photoResolution">Разрешение</label>
<select id="photoResolution" class="settings-input">
<option value="640x480">640 × 480 (по умолчанию)</option>
<option value="1280x720">1280 × 720 (HD)</option>
<option value="1920x1080">1920 × 1080 (Full HD)</option>
<option value="2560x1440">2560 × 1440 (QHD)</option>
</select>
</div>
<div class="settings-field">
<label for="photoQuality">Качество JPEG</label>
<div class="unit-row">
<input type="number" id="photoQuality" class="settings-input" min="0.5" max="1" step="0.01" value="0.92">
<span class="unit">0.5–1.0</span>
</div>
<span class="hint">Больше значение — лучше качество и крупнее файл</span>
</div>
<div class="settings-field">
<label for="photoEngine">Обработка улучшения фото</label>
<select id="photoEngine" class="settings-input">
<option value="auto">Авто (сервер, иначе клиент)</option>
<option value="server">Сервер (sharp)</option>
<option value="client">Клиент (браузер)</option>
</select>
<span class="hint">Серверный режим обрабатывает оригинал без пережатия и быстрее на больших фото</span>
</div>
</div>
<div class="ai-queue" id="aiQueueInfo"></div>
</div>
<div class="settings-card">
<div class="settings-card" id="sec-trash">
<div class="card-head">
<div class="ic"><i data-lucide="database"></i></div>
<h3>Резервное копирование</h3>
<div class="ic"><i data-lucide="trash-2"></i></div>
<h3>Корзина</h3>
</div>
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Восстановление полностью заменит текущие данные.</p>
<div class="data-stats" id="dataStats"><span class="chip">Загрузка статистики…</span></div>
<div class="card-foot">
<button class="btn-primary ghost" id="backupBtn" type="button"><i data-lucide="download"></i> Скачать бэкап</button>
<button class="btn-primary ghost" id="restoreBtn" type="button"><i data-lucide="upload"></i> Восстановить из файла</button>
<input type="file" id="restoreFile" accept=".tar.gz,.gz,application/gzip" style="display:none">
<p class="hint">При очистке корзины или удалении пункта данные не удаляются сразу, а помечаются на удаление и исчезают из корзины. Через указанное количество дней они удаляются автоматически и безвозвратно вместе с файлами. До этого помеченные данные можно вернуть в корзину и восстановить (страница «Корзина»).</p>
<div class="settings-field">
<label for="trashPurgeDays">Время до безвозвратного удаления</label>
<div class="unit-row">
<input type="number" id="trashPurgeDays" class="settings-input" min="1" max="365" step="1">
<span class="unit">дней</span>
</div>
<span class="hint">От 1 до 365 дней</span>
</div>
<div class="restore-status" id="restoreStatus"></div>
</div>
<div class="settings-card" id="bansCard">
<div class="settings-card" id="sec-bans">
<div class="card-head">
<div class="ic"><i data-lucide="shield-off"></i></div>
<h3>Блокировки IP</h3>
@@ -155,12 +270,50 @@
</div>
</div>
</div>
</div>
</div>
<div class="save-bar" id="saveBar">
<span class="hint" id="saveHint" style="display:inline-flex;align-items:center;gap:6px"><i data-lucide="triangle-alert"></i> Есть несохранённые изменения</span>
<button class="btn-primary" id="saveBtn" type="button">Сохранить изменения</button>
<span class="hint" id="saveHint"><i data-lucide="triangle-alert"></i> Есть несохранённые изменения</span>
<span class="save-bar-actions">
<button class="btn-primary ghost" id="resetBtn" type="button"><i data-lucide="rotate-ccw"></i> Отменить</button>
<button class="btn-primary" id="saveBtn" type="button"><i data-lucide="check"></i> Сохранить</button>
</span>
</div>
<div class="modal-overlay" id="aiProfileModal">
<div class="edit-modal" style="max-width:560px">
<h3 id="aiProfileModalTitle">Добавить профиль ИИ</h3>
<div class="settings-field">
<label for="aiProfileName">Название профиля <span style="color:#ef4444">*</span></label>
<input type="text" id="aiProfileName" class="settings-input" placeholder="Например: OpenRouter GPT-4o-mini">
</div>
<div class="settings-field">
<label for="aiProfileModel">Модель <span style="color:#ef4444">*</span></label>
<input type="text" id="aiProfileModel" class="settings-input" placeholder="openai/gpt-4o-mini">
</div>
<div class="settings-field">
<label for="aiProfileBaseUrl">Base URL (OpenAI-совместимый API) <span style="color:#ef4444">*</span></label>
<input type="text" id="aiProfileBaseUrl" class="settings-input" placeholder="https://openrouter.ai/api/v1">
</div>
<div class="settings-field">
<label for="aiProfileApiKey">API-ключ (если требуется)</label>
<input type="password" id="aiProfileApiKey" class="settings-input" placeholder="sk-...">
</div>
<div class="settings-field">
<label for="aiProfileMaxTokens">Max tokens (лимит ответа модели)</label>
<input type="number" id="aiProfileMaxTokens" class="settings-input" min="16" max="32768" placeholder="Авто (1024–4096)">
<span class="hint">Оставьте пустым для авто. Для reasoning-моделей (gpt-oss, deepseek-r1) нужно 1024+ — иначе рассуждения не уместятся и ответ придёт пустым.</span>
</div>
<div class="ai-test-status" id="aiProfileTestStatus"></div>
<div class="actions" style="flex-wrap:wrap">
<button class="btn-primary" id="aiProfileSaveBtn" type="button">Сохранить</button>
<button class="btn-primary ghost" id="aiProfileTestBtn" type="button"><i data-lucide="plug-zap"></i> Проверить</button>
<button class="btn-primary ghost" id="aiProfileCancelBtn" type="button">Отмена</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script>
+11
View File
@@ -10,6 +10,10 @@
:root{--yellow:#F7C90E;--dark:#211E22;--gray:#9C9C9C;--page:#f6f6f6;--card-bg:#fff;--radius:30px}
body{font-family:'Segoe UI',system-ui,-apple-system,Roboto,sans-serif;background:var(--page);color:var(--dark);min-height:100dvh}
.hero{background:var(--yellow);padding:44px 20px 52px;text-align:center;position:relative;overflow:hidden}
.hero .brand{display:flex;align-items:center;justify-content:center;gap:14px;margin-bottom:16px}
.hero .brand[hidden]{display:none}
.hero .brand .brand-logo{height:52px;width:auto;max-width:min(42vw,300px);object-fit:contain;display:block}
.hero .brand .brand-name{font-size:1.05rem;font-weight:800;letter-spacing:-.02em;color:var(--dark);opacity:.8}
.hero h1{font-size:clamp(1.6rem,4.5vw,2.9rem);font-weight:800;line-height:1.05;letter-spacing:-.02em;color:var(--dark);max-width:900px;margin:0 auto}
.hero .chips{margin-top:20px;display:flex;gap:10px;justify-content:center;flex-wrap:wrap}
.hero .chip{background:#fff;color:var(--dark);border-radius:999px;padding:8px 18px;font-size:.88rem;font-weight:600;box-shadow:5px 5px 10px rgba(216,173,0,.25)}
@@ -38,6 +42,9 @@ body{font-family:'Segoe UI',system-ui,-apple-system,Roboto,sans-serif;background
.card .files a.ftype-presentation .ficon{background:#F6E4E8}
.card .files a.ftype-other .ficon{background:#EFEFEF}
.card .foot{margin-top:auto;padding:8px 6px 2px;font-size:.78rem;color:var(--gray)}
.date-sep{grid-column:1/-1;display:flex;align-items:center;gap:14px;margin:8px 0 -8px}
.date-sep .date-chip{background:var(--dark);color:#fff;font-size:.82rem;font-weight:700;padding:7px 16px;border-radius:999px;letter-spacing:.01em}
.date-sep .date-line{flex:1;height:1px;background:#E3E3E3}
.empty{grid-column:1/-1;background:#fff;border-radius:var(--radius);padding:48px 24px;text-align:center;color:var(--gray);box-shadow:0 1px 3px rgba(20,30,40,.06)}
.empty .big{font-size:2rem;margin-bottom:10px}
.loading{grid-column:1/-1;text-align:center;color:var(--gray);padding:40px}
@@ -85,6 +92,10 @@ body{font-family:'Segoe UI',system-ui,-apple-system,Roboto,sans-serif;background
</head>
<body>
<section class="hero">
<div class="brand" id="brandHead" hidden>
<img class="brand-logo" id="brandLogo" alt="" hidden>
<span class="brand-name" id="brandName" hidden></span>
</div>
<h1 id="title">Загрузка...</h1>
<div class="chips" id="chips"></div>
</section>
+79
View File
@@ -130,6 +130,85 @@
</div>
</div>
<div class="modal-overlay" id="profileDataModal">
<div class="edit-modal" style="max-width:900px;width:96%;max-height:92dvh;overflow-y:auto">
<h3>Данные профиля: <span id="profileDataTitle" style="color:var(--accent)"></span></h3>
<form id="profileDataForm" style="display:grid;grid-template-columns:1fr 1fr;gap:12px 16px">
<div class="settings-field">
<label>Роль / направление</label>
<input type="text" id="pfRole" class="settings-input" placeholder="Например: Ученик направления «Frontend-разработка»">
</div>
<div class="settings-field">
<label>Статус</label>
<input type="text" id="pfStatus" class="settings-input" placeholder="Активно учится">
</div>
<div class="settings-field">
<label>Примечание к статусу</label>
<input type="text" id="pfStatusNote" class="settings-input" placeholder="учится с сентября 2022">
</div>
<div class="settings-field">
<label>Город</label>
<input type="text" id="pfCity" class="settings-input">
</div>
<div class="settings-field">
<label>Наставник</label>
<input type="text" id="pfMentor" class="settings-input">
</div>
<div class="settings-field">
<label>В академии с</label>
<input type="text" id="pfJoined" class="settings-input" placeholder="В академии с сентября 2022">
</div>
<div class="settings-field" style="grid-column:1/-1">
<label>О себе</label>
<textarea id="pfBio" class="settings-input" style="min-height:70px" placeholder="Короткий рассказ ученика о себе"></textarea>
</div>
<div class="settings-field">
<label>Цитата</label>
<input type="text" id="pfQuote" class="settings-input" placeholder="«Мой принцип: сначала понять, потом написать»">
</div>
<div class="settings-field">
<label>Теги (через запятую)</label>
<input type="text" id="pfTags" class="settings-input" placeholder="HTML, CSS, JavaScript">
</div>
<div class="settings-field">
<label>Контакты — строка: Иконка | Подпись | Ссылка</label>
<textarea id="pfContacts" class="settings-input" style="min-height:64px" placeholder="mail | maria@example.com | mailto:maria@example.com&#10;send | @nickname | https://t.me/nickname"></textarea>
<span class="hint">Иконки: mail, send, phone, github, globe, link, at-sign</span>
</div>
<div class="settings-field">
<label>Достижения — по одному в строке</label>
<textarea id="pfAchievements" class="settings-input" style="min-height:64px"></textarea>
</div>
<div class="settings-field">
<label>Навыки — строка: Группа | Навык | Уровень | %</label>
<textarea id="pfSkills" class="settings-input" style="min-height:64px" placeholder="Вёрстка | HTML5 · семантика | Изучен | 95"></textarea>
</div>
<div class="settings-field">
<label>Опыт работы — строка: Заголовок | Организация | Период | Дата | Бейдж | Описание | Теги (через запятую)</label>
<textarea id="pfExperience" class="settings-input" style="min-height:64px" placeholder="Хакатон Digital City | Академия | Март 2024 | 03.2024 | Хакатон | Прототип карты заявок, 2 место | Vue 3, Leaflet"></textarea>
</div>
<div class="settings-field">
<label>Прогресс по модулям — строка: Тема модуля | Прогресс % | Оценка | Преподаватель</label>
<textarea id="pfEducation" class="settings-input" style="min-height:64px" placeholder="JavaScript: основы языка | 100 | Оценка 4,7 | Игорь Дёмин"></textarea>
</div>
<div class="settings-field" style="grid-column:1/-1">
<label>Фотографии ученика</label>
<input type="hidden" id="pfPhoto">
<div class="pf-photos" id="pfPhotos"></div>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:6px">
<input type="file" id="pfPhotoFile" accept="image/*" hidden>
<button type="button" class="btn-primary ghost" id="pfPhotoUploadBtn">Загрузить фото</button>
<span class="hint">Клик по фото — сделать главным. Можно вставить из буфера обмена (Ctrl+V)</span>
</div>
</div>
<div style="grid-column:1/-1;display:flex;justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="profileDataCancelBtn">Отмена</button>
<button type="submit" class="btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script>
+20
View File
@@ -15,9 +15,29 @@
<span class="trash-muted" id="trashInfo"></span>
<button class="danger-btn" id="emptyBtn" type="button">Очистить корзину</button>
</div>
<label class="toggle pending-toggle" id="pendingToggleRow">
<input type="checkbox" id="pendingToggle">
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Показать помеченные на удаление</b><small id="pendingToggleHint">Данные не удаляются сразу: они помечаются и исчезают из корзины. Здесь их можно вернуть в корзину и восстановить</small></span>
</label>
<div class="trash-section" id="pendingSection" hidden>
<h3>Помечены на удаление <span class="trash-muted" id="pendingInfo"></span></h3>
<p class="trash-muted">Эти данные будут удалены автоматически и безвозвратно вместе с файлами. Чтобы отменить удаление, нажмите «В корзину» — данные вернутся в корзину, откуда их можно восстановить.</p>
<h4>Группы <span class="trash-muted" id="pendingGroupsInfo"></span></h4>
<div class="grid" id="pendingGroupsGrid"><div class="empty">Загрузка...</div></div>
<h4>Записи журнала <span class="trash-muted" id="pendingEntriesInfo"></span></h4>
<div class="grid" id="pendingGrid"><div class="empty">Загрузка...</div></div>
</div>
<div class="trash-section" id="groupsSection">
<h3>Группы <span class="trash-muted" id="groupsInfo"></span></h3>
<div class="grid" id="groupsGrid"><div class="empty">Загрузка...</div></div>
</div>
<div class="trash-section">
<h3>Записи журнала <span class="trash-muted" id="entriesInfo"></span></h3>
<div class="grid" id="grid"><div class="empty">Загрузка...</div></div>
<div class="pager" id="pager"></div>
</div>
</div>
</div>
<div class="modal-overlay" id="imgModal">
+72
View File
@@ -5,6 +5,21 @@
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Воркер ИИ — Админ-панель</title>
<link rel="stylesheet" href="admin.css">
<style>
.enhance-compare { position: relative; border-radius: 10px; overflow: hidden; background: var(--bg); border: 1px solid var(--border); user-select: none; touch-action: none; cursor: ew-resize; }
.enhance-stage { transform-origin: 0 0; }
.enhance-stage > img { display: block; width: 100%; height: auto; }
.enhance-top { position: absolute; inset: 0; overflow: hidden; }
.enhance-top .enhance-stage { position: absolute; inset: 0; }
.enhance-top .enhance-stage > img { height: 100%; object-fit: fill; }
.enhance-divider { position: absolute; top: 0; bottom: 0; left: 50%; width: 32px; transform: translateX(-50%); cursor: ew-resize; }
.enhance-divider::before { content: ''; position: absolute; top: 0; bottom: 0; left: 50%; width: 2px; transform: translateX(-50%); background: #fff; box-shadow: 0 0 6px rgba(0,0,0,.6); pointer-events: none; }
.enhance-handle { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 34px; height: 34px; border-radius: 50%; background: #fff; color: #333; display: flex; align-items: center; justify-content: center; font-size: 1rem; box-shadow: 0 2px 8px rgba(0,0,0,.4); pointer-events: none; }
.enhance-badge { position: absolute; top: 10px; padding: 3px 10px; border-radius: 999px; background: rgba(0,0,0,.55); color: #fff; font-size: .72rem; pointer-events: none; }
.enhance-badge.left { left: 10px; }
.enhance-badge.right { right: 10px; }
.photo-thumb { width: 56px; height: 56px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); display: block; }
</style>
</head>
<body data-page="worker">
<div class="layout">
@@ -24,6 +39,8 @@
<button class="btn-link" id="refreshBtn" type="button">Обновить</button>
<button class="btn-link" id="wakeBtn" type="button" title="Запустить обработку очереди немедленно">Разбудить</button>
<button class="btn-link" id="requeueBtn" type="button" title="Вернуть все записи с ошибкой в очередь">Перепроверить ошибки</button>
<button class="btn-link" id="photoWakeBtn" type="button" title="Разбудить фото-воркер">📷 Разбудить фото</button>
<button class="btn-link" id="photoToggleBtn" type="button">📷 …</button>
<button class="btn-link" id="toggleBtn" type="button">…</button>
</div>
@@ -78,6 +95,39 @@
<div class="pager" id="recentPager"></div>
</div>
<div class="settings-card wide">
<div class="card-head">
<div class="ic"><i data-lucide="image"></i></div>
<h3>Обработка фотографий</h3>
</div>
<div class="actions-row" style="margin-bottom:10px">
<span class="hint" id="photoStateHint"></span>
<span style="flex:1"></span>
<button class="btn-link" id="photoRequeueBtn" type="button" title="Вернуть задания с ошибкой в очередь">Повторить ошибки фото</button>
</div>
<div class="worker-counts" id="photoCounts" style="margin-bottom:10px"></div>
<p class="hint">В очереди (<span id="photoPendingCount">0</span>)</p>
<div class="audit-wrap" style="margin-bottom:14px">
<table class="audit-table" id="photoPendingTable">
<thead>
<tr><th>Задание</th><th>Запись</th><th>Действие</th><th>Поставлено</th></tr>
</thead>
<tbody></tbody>
</table>
<div class="empty" id="photoPendingEmpty" style="display:none">Очередь пуста.</div>
</div>
<div class="audit-wrap">
<table class="audit-table" id="photoTable">
<thead>
<tr><th>Время</th><th>Запись</th><th>Действие</th><th>Статус</th><th>Было</th><th>Стало</th><th>Ошибка</th><th></th></tr>
</thead>
<tbody></tbody>
</table>
<div class="empty" id="photoEmpty" style="display:none">Обработок фотографий пока нет.</div>
</div>
<div class="pager" id="photoRecentPager"></div>
</div>
<div class="settings-card wide" id="errorsCard" style="display:none">
<div class="card-head">
<div class="ic"><i data-lucide="triangle-alert"></i></div>
@@ -112,6 +162,28 @@
</div>
</div>
</div>
<div class="modal-overlay" id="photoJobModal">
<div class="edit-modal" style="max-width:900px">
<h3 id="photoJobTitle">Обработка фотографии</h3>
<div class="enhance-compare" id="photoJobCompare">
<div class="enhance-stage">
<img id="photoJobBefore" alt="Было">
</div>
<div class="enhance-top" id="photoJobTop" style="clip-path:inset(0 0 0 50%)">
<div class="enhance-stage enhance-stage-fill">
<img id="photoJobAfter" alt="Стало">
</div>
</div>
<div class="enhance-divider" id="photoJobDivider"><div class="enhance-handle">⇔</div></div>
<div class="enhance-badge left">Было</div>
<div class="enhance-badge right">Стало</div>
</div>
<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" id="photoJobRollbackBtn">↩ Откатить к версии «Было»</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script>
+432
View File
@@ -0,0 +1,432 @@
const { createClient } = require('redis');
const INCR_EXPIRE_LUA = `
local n = redis.call('INCR', KEYS[1])
if n == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
return n
`;
const RATE_LIMIT_DEC_LUA = `
local hits = redis.call('DECR', KEYS[1])
if hits < 0 then
redis.call('SET', KEYS[1], '0', 'PX', ARGV[1])
hits = 0
end
return hits
`;
const SCAN_BATCH = 200;
function createMemoryBackend() {
const store = new Map();
const listeners = new Map();
function live(key) {
const e = store.get(key);
if (!e) return null;
if (e.exp && e.exp <= Date.now()) {
store.delete(key);
return null;
}
return e;
}
return {
name: 'memory',
async get(key) {
const e = live(key);
return e ? e.value : undefined;
},
async set(key, value, ttlMs) {
store.set(key, { value, exp: ttlMs ? Date.now() + ttlMs : 0 });
},
async del(key) {
store.delete(key);
},
async dropPrefix(prefix) {
for (const key of store.keys()) if (key.startsWith(prefix)) store.delete(key);
},
async clear() {
store.clear();
},
async incr(key, ttlMs) {
const e = live(key);
if (!e) {
store.set(key, { value: 1, exp: ttlMs ? Date.now() + ttlMs : 0 });
return 1;
}
e.value = (typeof e.value === 'number' ? e.value : 0) + 1;
return e.value;
},
async rateLimitDec(key, ttlMs) {
const e = live(key);
if (!e) {
store.set(key, { value: 0, exp: Date.now() + ttlMs });
return 0;
}
e.value = Math.max(0, (typeof e.value === 'number' ? e.value : 0) - 1);
return e.value;
},
async publish(channel, payload) {
const subs = listeners.get(channel);
if (!subs || !subs.size) return 0;
for (const fn of [...subs]) {
try { fn(payload); } catch (e) { console.error('Cache listener failed:', e.message); }
}
return subs.size;
},
on(channel, fn) {
if (!listeners.has(channel)) listeners.set(channel, new Set());
const set = listeners.get(channel);
set.add(fn);
return () => { set.delete(fn); };
},
sweep() {
const now = Date.now();
for (const [key, e] of store) if (e.exp && e.exp <= now) store.delete(key);
},
};
}
function createRedis({ url, prefix } = {}) {
const namespace = `${prefix && String(prefix).trim() ? String(prefix).trim() : 'whatido'}:`;
const memory = createMemoryBackend();
const handlers = new Map();
const subscribed = new Set();
const stats = { hits: 0, misses: 0, writes: 0, drops: 0, errors: 0, publishes: 0, fallbackOps: 0 };
const CONNECT_TIMEOUT_MS = Math.max(500, parseInt(process.env.REDIS_CONNECT_TIMEOUT_MS || '5000', 10) || 5000);
let client = null;
let sub = null;
let connecting = null;
let warned = false;
let sweeper = null;
let closed = false;
function enabled() {
return typeof url === 'string' && url.trim() !== '';
}
function ready() {
return Boolean(client && client.isReady);
}
function noteError(where, err) {
if (closed) return;
stats.errors += 1;
if (warned) return;
warned = true;
console.error(`Redis ${where}: ${err && err.message ? err.message : err} — используется in-memory`);
}
function withTimeout(promise, ms, label) {
let timer = null;
const guard = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timeout ${ms}ms`)), ms);
if (timer.unref) timer.unref();
});
promise.catch(() => {});
return Promise.race([promise, guard]).finally(() => { if (timer) clearTimeout(timer); });
}
const fullKey = key => namespace + key;
async function via(fn, fallback) {
if (!ready()) {
stats.fallbackOps += 1;
return fallback();
}
try {
return await fn(client);
} catch (e) {
noteError('command', e);
stats.fallbackOps += 1;
return fallback();
}
}
async function scanDelete(client, target, isGlob) {
let cursor = '0';
do {
const res = await client.scan(cursor, { MATCH: (isGlob ? target : target + '*'), COUNT: SCAN_BATCH });
cursor = String(res.cursor);
if (res.keys.length) await client.del(res.keys);
} while (cursor !== '0');
}
function deliver(channel, message) {
const set = handlers.get(channel);
if (!set) return;
for (const fn of [...set]) {
try { fn(message); } catch (e) { console.error('Redis handler failed:', e.message); }
}
}
async function subscribeChannel(channel) {
if (!sub || !sub.isOpen || subscribed.has(channel)) return;
try {
await sub.subscribe(channel, message => deliver(channel, message));
subscribed.add(channel);
} catch (e) {
noteError('subscribe', e);
}
}
async function resubscribeAll() {
subscribed.clear();
for (const channel of handlers.keys()) await subscribeChannel(channel);
}
const api = {
isEnabled: enabled,
isReady: ready,
namespace,
stats: () => ({ ...stats, enabled: enabled(), ready: ready(), namespace }),
async info() {
const base = { ...stats, enabled: enabled(), ready: ready(), namespace };
if (!ready()) return { ...base, driver: 'memory', used_memory_bytes: null, used_memory_human: null, keys: null };
try {
const [raw, dbsize] = await Promise.all([client.info('memory'), client.dbSize()]);
let used = null;
let human = null;
for (const line of String(raw || '').split('\n')) {
if (line.startsWith('used_memory:')) used = parseInt(line.slice(13).trim(), 10) || null;
else if (line.startsWith('used_memory_human:')) human = line.slice(19).trim();
}
return {
...base,
driver: 'redis',
used_memory_bytes: used,
used_memory_human: human,
keys: Number(dbsize) || 0,
};
} catch (e) {
noteError('info', e);
return { ...base, driver: 'redis', used_memory_bytes: null, used_memory_human: null, keys: null };
}
},
async get(key) {
const raw = await via(
c => c.get(fullKey(key)),
() => memory.get(fullKey(key))
);
if (raw === null || raw === undefined) {
stats.misses += 1;
return undefined;
}
stats.hits += 1;
try {
return JSON.parse(raw);
} catch (e) {
return raw;
}
},
async set(key, value, ttlMs) {
const payload = JSON.stringify(value === undefined ? null : value);
stats.writes += 1;
return via(
c => (ttlMs ? c.set(fullKey(key), payload, { PX: ttlMs }) : c.set(fullKey(key), payload)),
() => memory.set(fullKey(key), value, ttlMs)
);
},
async del(key) {
return via(
c => c.del(fullKey(key)),
() => memory.del(fullKey(key))
);
},
async dropPrefix(keyPrefix) {
const target = fullKey(keyPrefix);
stats.drops += 1;
return via(
c => scanDelete(c, target),
() => memory.dropPrefix(target)
);
},
async dropMatch(pattern) {
const target = fullKey(pattern);
stats.drops += 1;
return via(
c => scanDelete(c, target, true),
() => memory.dropPrefix(target.split('*')[0])
);
},
async clear() {
stats.drops += 1;
return via(
c => scanDelete(c, namespace),
() => memory.clear()
);
},
async wrap(key, ttlMs, fn) {
const hit = await api.get(key);
if (hit !== undefined) return hit;
const value = await fn();
await api.set(key, value, ttlMs);
return value;
},
async incr(key, ttlMs) {
return via(
c => c.eval(INCR_EXPIRE_LUA, { keys: [fullKey(key)], arguments: [String(ttlMs || 0)] }),
() => memory.incr(fullKey(key), ttlMs)
);
},
rateLimitStore(id, defaultWindowMs) {
let windowMs = defaultWindowMs || 60 * 1000;
const base = fullKey(`rl:${id}:`);
const bucketFor = key => {
const bucket = Math.floor(Date.now() / windowMs);
return { key: `${base}${key}:${bucket}`, resetTime: (bucket + 1) * windowMs };
};
return {
async init(options) {
if (options && Number.isFinite(options.windowMs) && options.windowMs > 0) {
windowMs = options.windowMs;
}
},
async increment(key) {
const { key: redisKey, resetTime } = bucketFor(key);
const ttlMs = windowMs + Math.ceil(windowMs / 10);
const hits = await via(
c => c.eval(INCR_EXPIRE_LUA, { keys: [redisKey], arguments: [String(ttlMs)] }),
() => memory.incr(redisKey, ttlMs)
);
return { totalHits: Number(hits), resetTime: new Date(resetTime) };
},
async decrement(key) {
const { key: redisKey, resetTime } = bucketFor(key);
const ttlMs = Math.max(1, resetTime - Date.now());
await via(
c => c.eval(RATE_LIMIT_DEC_LUA, { keys: [redisKey], arguments: [String(ttlMs)] }),
() => memory.rateLimitDec(redisKey, ttlMs)
);
},
async resetKey(key) {
const targets = [];
for (let i = 0; i < 2; i += 1) {
targets.push(`${base}${key}:${Math.floor(Date.now() / windowMs) - i}`);
}
return via(
c => c.del(targets),
async () => { for (const k of targets) await memory.del(k); }
);
},
async resetAll() {
return via(
c => scanDelete(c, base),
() => memory.dropPrefix(base)
);
},
};
},
async publish(channel, payload) {
stats.publishes += 1;
const message = typeof payload === 'string' ? payload : JSON.stringify(payload);
if (!ready()) {
stats.fallbackOps += 1;
return memory.publish(channel, message);
}
try {
await client.publish(channel, message);
return 1;
} catch (e) {
noteError('publish', e);
stats.fallbackOps += 1;
return memory.publish(channel, message);
}
},
on(channel, fn) {
if (!handlers.has(channel)) handlers.set(channel, new Set());
handlers.get(channel).add(fn);
memory.on(channel, fn);
if (sub && sub.isOpen) subscribeChannel(channel);
return () => {
const set = handlers.get(channel);
if (set) set.delete(fn);
};
},
async connect() {
if (!enabled()) {
console.log('Redis: REDIS_URL не задан, используется in-memory кэш');
return false;
}
if (connecting) return connecting;
if (ready()) return true;
closed = false;
connecting = (async () => {
if (!sweeper) {
sweeper = setInterval(() => memory.sweep(), 60 * 1000);
sweeper.unref();
}
try {
client = createClient({
url,
socket: { reconnectStrategy: retries => Math.min(200 + retries * 200, 5000) },
});
client.on('error', e => noteError('connection', e));
client.on('ready', () => {
warned = false;
ensureSubscriber().catch(e => noteError('subscriber', e));
});
await withTimeout(client.connect(), CONNECT_TIMEOUT_MS, 'connect');
await ensureSubscriber();
const pong = await withTimeout(client.ping(), CONNECT_TIMEOUT_MS, 'ping');
console.log(`Redis connected (${pong}), namespace=${namespace}`);
return true;
} catch (e) {
noteError('connect', e);
return false;
} finally {
connecting = null;
}
})();
return connecting;
},
async close() {
closed = true;
if (sweeper) {
clearInterval(sweeper);
sweeper = null;
}
const pending = [sub, client].filter(Boolean);
sub = null;
client = null;
subscribed.clear();
await Promise.all(pending.map(async c => {
try { await withTimeout(c.quit(), 2000, 'quit'); } catch (e) {
try { c.destroy(); } catch (err) {}
}
}));
},
};
async function ensureSubscriber() {
if (closed || !ready()) return;
if (!sub) {
sub = client.duplicate();
sub.on('error', e => noteError('subscriber', e));
sub.on('ready', () => { resubscribeAll().catch(e => noteError('subscribe', e)); });
}
if (!sub.isOpen) await withTimeout(sub.connect(), CONNECT_TIMEOUT_MS, 'subscriber connect');
if (sub.isReady) await resubscribeAll();
}
return api;
}
module.exports = { createRedis, createMemoryBackend };
+148
View File
@@ -0,0 +1,148 @@
const assert = require('assert');
const { createRedis } = require('./redis');
function loadEnv() {
const fs = require('fs');
const path = require('path');
const file = path.join(__dirname, '.env');
if (!fs.existsSync(file)) return;
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2];
}
}
async function main() {
loadEnv();
const url = process.env.REDIS_URL || `redis://:${process.env.REDIS_PASSWORD || ''}@127.0.0.1:6379`;
const prefix = 'test-' + Date.now();
const cache = createRedis({ url, prefix });
const connected = await cache.connect();
console.log('connect:', connected, 'ready:', cache.isReady());
assert.strictEqual(connected, true, 'must connect');
assert.strictEqual(cache.isReady(), true, 'must be ready');
await cache.set('setting:foo', 'bar', 60000);
assert.strictEqual(await cache.get('setting:foo'), 'bar', 'string roundtrip');
await cache.set('obj', { a: 1, b: [2, 3], c: null }, 60000);
assert.deepStrictEqual(await cache.get('obj'), { a: 1, b: [2, 3], c: null }, 'json roundtrip');
assert.strictEqual(await cache.get('missing'), undefined, 'miss returns undefined');
await cache.set('setting:ttl', 'x', 120);
await new Promise(r => setTimeout(r, 250));
assert.strictEqual(await cache.get('setting:ttl'), undefined, 'ttl expiry');
await cache.set('groups:1', 'g1', 60000);
await cache.set('groups:2', 'g2', 60000);
await cache.set('students:1', 's1', 60000);
await cache.dropPrefix('groups:');
assert.strictEqual(await cache.get('groups:1'), undefined, 'dropPrefix removes groups:1');
assert.strictEqual(await cache.get('groups:2'), undefined, 'dropPrefix removes groups:2');
assert.strictEqual(await cache.get('students:1'), 's1', 'dropPrefix keeps students:1');
await cache.set('fail:login:1.2.3.4', 3, 60000);
await cache.set('fail:login:5.6.7.8', 9, 60000);
await cache.dropMatch('fail:*:1.2.3.4');
assert.strictEqual(await cache.get('fail:login:1.2.3.4'), undefined, 'dropMatch suffix removes target');
assert.strictEqual(await cache.get('fail:login:5.6.7.8'), 9, 'dropMatch keeps other ip');
const c1 = await cache.incr('counter:a', 60000);
const c2 = await cache.incr('counter:a', 60000);
assert.strictEqual(c1, 1, 'incr first = 1');
assert.strictEqual(c2, 2, 'incr second = 2');
await cache.incr('counter:short', 120);
await new Promise(r => setTimeout(r, 250));
assert.strictEqual(await cache.incr('counter:short', 60000), 1, 'incr ttl set on first call');
const hits = [];
const received = new Promise(resolve => {
cache.on('test:chan', msg => { hits.push(msg); resolve(msg); });
});
await new Promise(r => setTimeout(r, 200));
await cache.publish('test:chan', { hello: 'world' });
const got = await Promise.race([received, new Promise(r => setTimeout(() => r('TIMEOUT'), 3000))]);
assert.notStrictEqual(got, 'TIMEOUT', 'pubsub must deliver');
assert.deepStrictEqual(JSON.parse(got), { hello: 'world' }, 'pubsub payload');
assert.strictEqual(hits.length, 1, 'pubsub delivered exactly once (no double delivery)');
let wraps = 0;
const v1 = await cache.wrap('wrap:key', 60000, async () => { wraps += 1; return 'computed'; });
const v2 = await cache.wrap('wrap:key', 60000, async () => { wraps += 1; return 'other'; });
assert.strictEqual(v1, 'computed', 'wrap returns computed');
assert.strictEqual(v2, 'computed', 'wrap returns cached');
assert.strictEqual(wraps, 1, 'wrap computed once');
const store = cache.rateLimitStore('test', 60000);
await store.init({ windowMs: 60000 });
const r1 = await store.increment('ip1');
const r2 = await store.increment('ip1');
const r3 = await store.increment('ip2');
assert.strictEqual(r1.totalHits, 1, 'rl first');
assert.strictEqual(r2.totalHits, 2, 'rl second');
assert.strictEqual(r3.totalHits, 1, 'rl separate key');
assert.ok(r2.resetTime > Date.now(), 'rl resetTime in future');
assert.ok(r2.resetTime instanceof Date, 'rl resetTime is a Date');
assert.strictEqual(r1.resetTime.getTime(), r2.resetTime.getTime(), 'rl same window');
const ttl = await cache.info();
await store.decrement('ip1');
assert.strictEqual((await store.increment('ip1')).totalHits, 2, 'decrement then increment');
await store.resetKey('ip1');
assert.strictEqual((await store.increment('ip1')).totalHits, 1, 'resetKey clears');
await store.resetAll();
assert.strictEqual((await store.increment('ip1')).totalHits, 1, 'resetAll clears');
await cache.del('obj');
assert.strictEqual(await cache.get('obj'), undefined, 'del');
const info = await cache.info();
console.log('info:', JSON.stringify(info));
assert.strictEqual(info.driver, 'redis', 'info driver is redis');
assert.ok(info.used_memory_bytes > 0, 'info has memory');
await cache.clear();
assert.strictEqual(await cache.get('students:1'), undefined, 'clear removes all');
const other = createRedis({ url, prefix: prefix + '-other' });
await other.connect();
await other.set('iso', 'yes', 60000);
assert.strictEqual(await cache.get('iso'), undefined, 'namespaces are isolated');
await other.close();
await cache.close();
assert.strictEqual(cache.isReady(), false, 'closed');
const offline = createRedis({ url: 'redis://127.0.0.1:1/', prefix: 'off-' + Date.now() });
const okConn = await offline.connect();
assert.strictEqual(okConn, false, 'bad url must not throw');
await offline.set('k', 'v', 1000);
assert.strictEqual(await offline.get('k'), 'v', 'fallback set/get works');
await offline.set('groups:a', 1, 1000);
await offline.set('students:a', 1, 1000);
await offline.dropPrefix('groups:');
assert.strictEqual(await offline.get('groups:a'), undefined, 'fallback dropPrefix');
assert.strictEqual(await offline.get('students:a'), 1, 'fallback dropPrefix isolation');
assert.strictEqual(await offline.incr('c', 1000), 1, 'fallback incr');
assert.strictEqual(await offline.incr('c', 1000), 2, 'fallback incr 2');
const offStore = offline.rateLimitStore('t', 60000);
assert.strictEqual((await offStore.increment('k1')).totalHits, 1, 'fallback rl');
assert.strictEqual((await offStore.increment('k1')).totalHits, 2, 'fallback rl 2');
let localHit = null;
const localRecv = new Promise(r => { offline.on('c', m => { localHit = m; r(m); }); });
await offline.publish('c', { x: 1 });
assert.deepStrictEqual(JSON.parse(await Promise.race([localRecv, new Promise(r => setTimeout(() => r('T'), 2000))])), { x: 1 }, 'fallback local pubsub');
assert.deepStrictEqual(JSON.parse(localHit), { x: 1 }, 'fallback local payload');
await offline.close();
const disabled = createRedis({});
assert.strictEqual(await disabled.connect(), false, 'disabled connect false');
await disabled.set('a', 1, 1000);
assert.strictEqual(await disabled.get('a'), 1, 'disabled memory works');
await disabled.close();
console.log('\nALL REDIS TESTS PASSED');
process.exit(0);
}
main().catch(e => { console.error('FAILED:', e.message); console.error(e.stack); process.exit(1); });
+1473
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

+5 -2
View File
@@ -10,10 +10,13 @@ mkdir -p backups
echo ">> Экспорт БД"
docker compose exec -T db pg_dump -U app -d whereldo | gzip > backups/db.sql.gz
echo ">> Копирование фотографий"
echo ">> Экспорт файлов из хранилища"
rm -rf backups/_uploads
mkdir -p backups/_uploads
docker cp "whatido-app-1:/app/uploads/." "backups/_uploads/"
docker compose exec -T app rm -rf /tmp/whatido-export
docker compose exec -T app node scripts/storage-sync.js export /tmp/whatido-export
docker cp "whatido-app-1:/tmp/whatido-export/." "backups/_uploads/"
docker compose exec -T app rm -rf /tmp/whatido-export
rm -rf backups/_uploads/.thumbs
echo ">> Сборка архива"
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Обновление кода на сервере: pull ветки, сборка образа с версией коммита, перезапуск app.
# Использование: ./scripts/deploy.sh [ветка] (по умолчанию master либо $DEPLOY_BRANCH)
set -euo pipefail
cd "$(dirname "$0")/.."
BRANCH="${1:-${DEPLOY_BRANCH:-master}}"
if docker compose version >/dev/null 2>&1; then
DC="docker compose"
else
DC="docker-compose"
fi
echo ">> Рабочая копия"
if [ -n "$(git status --porcelain --untracked-files=no -- . ':(exclude)public/version.json')" ]; then
git status --short
echo "Есть незакоммиченные изменения. Задайте DEPLOY_ALLOW_DIRTY=1, чтобы продолжить."
[ "${DEPLOY_ALLOW_DIRTY:-0}" = "1" ] || exit 1
fi
echo ">> Обновление ветки $BRANCH"
git fetch origin --prune
git checkout "$BRANCH"
git pull --ff-only origin "$BRANCH"
GIT_COMMIT="$(git rev-parse HEAD)"
GIT_COMMIT_DATE="$(git log -1 --format=%cI)"
export GIT_COMMIT GIT_COMMIT_DATE
echo ">> Сборка: $GIT_COMMIT ($GIT_COMMIT_DATE)"
$DC build --build-arg "GIT_COMMIT=$GIT_COMMIT" --build-arg "GIT_COMMIT_DATE=$GIT_COMMIT_DATE" app
$DC up -d --force-recreate app
echo ">> Проверка"
$DC exec -T app cat /app/public/version.json
HOST_MD5="$(md5sum server.js | cut -d' ' -f1)"
APP_MD5="$($DC exec -T app md5sum /app/server.js | tr -d '\r' | cut -d' ' -f1)"
if [ "$HOST_MD5" = "$APP_MD5" ]; then
echo "server.js в контейнере совпадает с рабочей копией: $APP_MD5"
else
echo "ВНИМАНИЕ: server.js в контейнере ($APP_MD5) не совпадает с рабочей копией ($HOST_MD5)"
fi
if command -v curl >/dev/null 2>&1; then
echo ">> /version.json"
for _ in $(seq 1 20); do
curl -skf --max-time 3 https://127.0.0.1:3443/version.json >/dev/null 2>&1 && break
sleep 1
done
curl -sk --max-time 5 https://127.0.0.1:3443/version.json || echo "Не удалось получить /version.json (приложение ещё поднимается или образ собран без аргументов версии)"
echo
fi
$DC ps
+18
View File
@@ -0,0 +1,18 @@
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
function sh(cmd) {
try { return execSync(cmd, { encoding: 'utf8' }).trim(); } catch { return ''; }
}
const out = {
full: sh('git rev-parse HEAD'),
short: sh('git rev-parse --short HEAD'),
date: sh('git log -1 --format=%cI')
};
fs.writeFileSync(
path.join(__dirname, '..', 'public', 'version.json'),
JSON.stringify(out, null, 2)
);
+128
View File
@@ -0,0 +1,128 @@
const path = require('path');
const { createStorage } = require('../storage');
const SKIP_PREFIXES = ['.thumbs/', '.cache/'];
function parseArgs(argv) {
const opts = { dryRun: false, deleteLocal: false, verifyOnly: false, concurrency: 3, prefix: null, help: false };
for (const arg of argv.slice(2)) {
if (arg === '--dry-run') opts.dryRun = true;
else if (arg === '--delete-local') opts.deleteLocal = true;
else if (arg === '--keep-local') opts.deleteLocal = false;
else if (arg === '--verify-only') opts.verifyOnly = true;
else if (arg.startsWith('--concurrency=')) opts.concurrency = Math.max(1, parseInt(arg.split('=')[1], 10) || 1);
else if (arg.startsWith('--prefix=')) opts.prefix = arg.split('=')[1].replace(/^\/+|\/+$/g, '');
else if (arg === '--help' || arg === '-h') opts.help = true;
}
return opts;
}
function usage() {
console.log(`Миграция uploads/ -> S3 (MinIO).
Использование:
node scripts/migrate-to-s3.js [ключи]
Ключи:
--dry-run показать, что будет загружено, ничего не менять
--verify-only только проверить наличие объектов в S3
--delete-local удалить локальные файлы после успешной проверки
--keep-local оставить локальные файлы (по умолчанию)
--concurrency=N параллельных загрузок (по умолчанию 3)
--prefix=PREFIX префикс ключей внутри бакета
Переменные окружения: STORAGE_DRIVER=s3, S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY.`);
}
async function runPool(items, limit, worker) {
const queue = items.slice();
const results = { ok: 0, skip: 0, fail: 0 };
const runners = Array.from({ length: Math.min(limit, queue.length) }, async () => {
while (queue.length) {
const item = queue.shift();
const res = await worker(item);
if (res === 'ok') results.ok++;
else if (res === 'skip') results.skip++;
else results.fail++;
}
});
await Promise.all(runners);
return results;
}
async function main() {
const opts = parseArgs(process.argv);
if (opts.help) return usage();
const dir = path.join(__dirname, '..', 'uploads');
const source = createStorage({ dir, driver: 'local' });
const target = createStorage({ driver: 's3', prefix: opts.prefix || undefined });
if (!target.isRemote()) {
console.error('STORAGE_DRIVER не s3: задайте STORAGE_DRIVER=s3 и переменные S3_*.');
process.exit(2);
}
if (!target.bucket) {
console.error('Не задан S3_BUCKET.');
process.exit(2);
}
const all = await source.listAll('');
const objects = all.filter(o => !SKIP_PREFIXES.some(p => o.key.startsWith(p)));
const totalBytes = objects.reduce((sum, o) => sum + (o.size || 0), 0);
if (!opts.dryRun) await target.ensureBucket();
console.log(`Локально: ${objects.length} файлов, ${(totalBytes / 1048576).toFixed(2)} МБ`);
console.log(`Бакет: ${target.bucket}${opts.prefix ? ' префикс ' + opts.prefix : ''} (${process.env.S3_ENDPOINT || 'endpoint по умолчанию'})`);
if (opts.dryRun) console.log('Режим --dry-run: изменения не применяются.');
const failures = [];
const result = await runPool(objects, opts.concurrency, async (obj) => {
const localPath = source.localFile(obj.key);
if (!localPath) {
failures.push(`${obj.key}: некорректный путь`);
return 'fail';
}
try {
const remote = await target.head(obj.key);
if (remote && remote.size === obj.size) {
if (opts.deleteLocal && !opts.dryRun) source.unlinkLocalOnly(obj.key);
return 'skip';
}
if (opts.verifyOnly) {
failures.push(`${obj.key}: нет объекта в S3`);
return 'fail';
}
if (opts.dryRun) {
console.log(` + ${obj.key} (${obj.size} Б)`);
return 'ok';
}
await target.putFile(obj.key, localPath);
const check = await target.head(obj.key);
if (!check || check.size !== obj.size) {
failures.push(`${obj.key}: размер не совпал после загрузки`);
return 'fail';
}
if (opts.deleteLocal) source.unlinkLocalOnly(obj.key);
return 'ok';
} catch (err) {
failures.push(`${obj.key}: ${err.message}`);
return 'fail';
}
});
const usage = await target.usage().catch(() => null);
console.log(`Загружено: ${result.ok}, уже было: ${result.skip}, ошибок: ${result.fail}`);
if (usage) console.log(`В бакете: ${usage.count} объектов, ${(usage.size_bytes / 1048576).toFixed(2)} МБ`);
if (failures.length) {
console.error('Ошибки:');
for (const f of failures.slice(0, 50)) console.error(' - ' + f);
if (failures.length > 50) console.error(` ... ещё ${failures.length - 50}`);
process.exit(1);
}
if (!opts.deleteLocal && !opts.dryRun) {
console.log('Локальные файлы оставлены. Удалить после проверки: node scripts/migrate-to-s3.js --delete-local');
}
}
main().catch(err => {
console.error('Миграция не выполнена:', err.message);
process.exit(1);
});
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
ROOT="$(git rev-parse --show-toplevel)"
node "$ROOT/scripts/gen-version.js"
+11 -2
View File
@@ -28,8 +28,17 @@ echo ">> Восстановление БД (чистая схема)"
docker compose exec -T db psql -U app -d whereldo -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;' >/dev/null
gzip -dc "$TMP/db.sql.gz" | docker compose exec -T db psql -U app -d whereldo
echo ">> Восстановление фотографий"
docker cp "$TMP/_uploads/." "whatido-app-1:/app/uploads/"
echo ">> Загрузка файлов в хранилище"
docker compose start app
for _ in $(seq 1 30); do
docker compose exec -T app true >/dev/null 2>&1 && break
sleep 1
done
docker compose exec -T app rm -rf /tmp/whatido-import
docker compose exec -T app mkdir -p /tmp/whatido-import
docker cp "$TMP/_uploads/." "whatido-app-1:/tmp/whatido-import/"
docker compose exec -T app node scripts/storage-sync.js import /tmp/whatido-import
docker compose exec -T app rm -rf /tmp/whatido-import
echo ">> Запуск приложения"
docker compose start app
+39
View File
@@ -0,0 +1,39 @@
const path = require('path');
const { createStorage } = require('../storage');
function usage() {
console.log(`Экспорт/импорт файлов хранилища (для scripts/backup.sh и scripts/restore.sh).
Использование:
node scripts/storage-sync.js export <каталог> выгрузить все объекты в каталог
node scripts/storage-sync.js import <каталог> загрузить все файлы из каталога в хранилище
Работает с текущим драйвером из переменных окружения (local — ./uploads, s3 — бакет MinIO/S3).`);
}
async function main() {
const [, , mode, dirArg] = process.argv;
if (!mode || !dirArg || mode === '--help' || mode === '-h') {
usage();
return process.exit(mode ? 0 : 2);
}
const dir = path.resolve(dirArg);
const storage = createStorage({ dir: path.join(__dirname, '..', 'uploads') });
if (mode === 'export') {
const count = await storage.downloadAll(dir);
console.log(`Экспортировано объектов: ${count} -> ${dir}`);
return;
}
if (mode === 'import') {
const count = await storage.uploadTree(dir);
console.log(`Импортировано файлов: ${count} <- ${dir}`);
return;
}
usage();
process.exit(2);
}
main().catch(err => {
console.error('storage-sync failed:', err.message);
process.exit(1);
});
+2351 -697
View File
File diff suppressed because it is too large Load Diff
+543
View File
@@ -0,0 +1,543 @@
const fs = require('fs');
const path = require('path');
const { pipeline } = require('stream/promises');
const {
S3Client,
PutObjectCommand,
GetObjectCommand,
HeadObjectCommand,
DeleteObjectCommand,
CopyObjectCommand,
ListObjectsV2Command,
HeadBucketCommand,
CreateBucketCommand,
} = require('@aws-sdk/client-s3');
const MIME_TYPES = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.jfif': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.bmp': 'image/bmp',
'.avif': 'image/avif',
'.ico': 'image/x-icon',
'.heic': 'image/heic',
'.heif': 'image/heif',
'.pdf': 'application/pdf',
'.txt': 'text/plain; charset=utf-8',
'.md': 'text/markdown; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.htm': 'text/html; charset=utf-8',
'.zip': 'application/zip',
'.rar': 'application/vnd.rar',
'.7z': 'application/x-7z-compressed',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
};
const SAFE_SEGMENT = /^[\w,.()-]+$/;
const NON_OBJECT_PREFIXES = ['.thumbs/', '.cache/'];
function mimeFor(name) {
const m = String(name || '').toLowerCase().match(/\.[a-z0-9]+$/);
return (m && MIME_TYPES[m[0]]) || 'application/octet-stream';
}
function normalizeKey(input) {
if (typeof input !== 'string' || !input) return null;
let key = input.replace(/\\/g, '/').replace(/^\/+/, '');
if (key.startsWith('uploads/')) key = key.slice('uploads/'.length);
if (!key || key.length > 255 || key.includes('..')) return null;
const segments = key.split('/');
if (segments.some(seg => !SAFE_SEGMENT.test(seg))) return null;
return key;
}
function isObjectKey(key) {
return !NON_OBJECT_PREFIXES.some(p => key.startsWith(p));
}
function createStorage(options = {}) {
const driver = options.driver || process.env.STORAGE_DRIVER || 'local';
const dir = path.resolve(options.dir || path.join(__dirname, 'uploads'));
const cacheDir = path.resolve(options.cacheDir || path.join(dir, '.cache'));
const localFallback = options.localFallback !== undefined
? !!options.localFallback
: process.env.STORAGE_LOCAL_FALLBACK !== '0';
const keepLocal = options.keepLocal !== undefined
? !!options.keepLocal
: process.env.STORAGE_KEEP_LOCAL === '1';
const bucket = options.bucket || process.env.S3_BUCKET || '';
const prefix = String(options.prefix !== undefined ? options.prefix : process.env.S3_PREFIX || '').replace(/^\/+|\/+$/g, '');
const cacheMaxAgeMs = Math.max(0, parseInt(options.cacheMaxAgeHours || process.env.STORAGE_CACHE_MAX_AGE_HOURS || '168', 10) || 0) * 3600 * 1000;
const remote = driver === 's3';
const client = remote
? new S3Client({
region: options.region || process.env.S3_REGION || 'us-east-1',
endpoint: options.endpoint || process.env.S3_ENDPOINT || undefined,
forcePathStyle: options.forcePathStyle !== undefined
? !!options.forcePathStyle
: process.env.S3_FORCE_PATH_STYLE !== '0',
credentials: {
accessKeyId: options.accessKey || process.env.S3_ACCESS_KEY || '',
secretAccessKey: options.secretKey || process.env.S3_SECRET_KEY || '',
},
})
: null;
function isRemote() {
return remote;
}
function objectKey(key) {
const k = normalizeKey(key);
if (!k) return null;
return prefix ? `${prefix}/${k}` : k;
}
function localFile(key) {
const k = normalizeKey(key);
if (!k) return null;
const abs = path.resolve(dir, ...k.split('/'));
if (abs !== dir && !abs.startsWith(dir + path.sep)) return null;
return abs;
}
function cacheFile(key) {
const k = normalizeKey(key);
if (!k) return null;
return path.join(cacheDir, ...k.split('/'));
}
function keyFromPath(p) {
return normalizeKey(p);
}
function localExists(key) {
const fp = localFile(key);
if (!fp) return false;
try {
return fs.statSync(fp).isFile();
} catch {
return false;
}
}
function unlinkLocalOnly(key) {
for (const fp of [localFile(key), cacheFile(key)]) {
if (!fp) continue;
try {
if (fs.existsSync(fp)) fs.unlinkSync(fp);
} catch {}
}
}
function isMissingError(err) {
if (!err) return false;
const code = err.name || err.Code || err.code;
const status = err.$metadata && err.$metadata.httpStatusCode;
return code === 'NoSuchKey' || code === 'NotFound' || code === 'ENOENT' || status === 404;
}
async function put(key, body, opts = {}) {
const objKey = objectKey(key);
if (!objKey) return false;
if (!remote) {
const fp = localFile(key);
if (!fp) return false;
fs.mkdirSync(path.dirname(fp), { recursive: true });
if (Buffer.isBuffer(body) || typeof body === 'string') fs.writeFileSync(fp, body);
else await pipeline(body, fs.createWriteStream(fp));
return true;
}
await client.send(new PutObjectCommand({
Bucket: bucket,
Key: objKey,
Body: body,
ContentType: opts.contentType || mimeFor(key),
ContentLength: opts.contentLength,
}));
return true;
}
async function putFile(key, localPath, opts = {}) {
const objKey = objectKey(key);
if (!objKey) return false;
let size = null;
try {
size = fs.statSync(localPath).size;
} catch {
return false;
}
if (!remote) {
const fp = localFile(key);
if (!fp) return false;
fs.mkdirSync(path.dirname(fp), { recursive: true });
if (path.resolve(localPath) !== fp) fs.copyFileSync(localPath, fp);
return true;
}
await client.send(new PutObjectCommand({
Bucket: bucket,
Key: objKey,
Body: fs.createReadStream(localPath),
ContentLength: size,
ContentType: opts.contentType || mimeFor(key),
}));
return true;
}
async function head(key) {
const objKey = objectKey(key);
if (!objKey) return null;
if (!remote) {
const fp = localFile(key);
if (!fp) return null;
try {
const st = fs.statSync(fp);
if (!st.isFile()) return null;
return { key: normalizeKey(key), size: st.size, contentType: mimeFor(key), etag: null };
} catch {
return null;
}
}
try {
const out = await client.send(new HeadObjectCommand({ Bucket: bucket, Key: objKey }));
return {
key: normalizeKey(key),
size: out.ContentLength || 0,
contentType: out.ContentType || mimeFor(key),
etag: out.ETag ? out.ETag.replace(/"/g, '') : null,
};
} catch (err) {
if (isMissingError(err)) return null;
throw err;
}
}
async function exists(key) {
if (localFallback && localExists(key)) return true;
return !!(await head(key));
}
async function sizeOf(key) {
const info = await head(key);
return info ? info.size : 0;
}
async function getStream(key) {
const objKey = objectKey(key);
if (!objKey) return null;
if (!remote) {
const fp = localFile(key);
if (!fp || !fs.existsSync(fp)) return null;
return { stream: fs.createReadStream(fp), contentLength: fs.statSync(fp).size, contentType: mimeFor(key) };
}
try {
const out = await client.send(new GetObjectCommand({ Bucket: bucket, Key: objKey }));
return { stream: out.Body, contentLength: out.ContentLength || 0, contentType: out.ContentType || mimeFor(key) };
} catch (err) {
if (isMissingError(err)) return null;
throw err;
}
}
async function getBuffer(key) {
const fp = await localize(key);
if (fp) return fs.readFileSync(fp);
return null;
}
async function del(key) {
const k = normalizeKey(key);
if (!k) return false;
unlinkLocalOnly(k);
if (!remote) return true;
const objKey = objectKey(k);
try {
await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: objKey }));
} catch (err) {
if (!isMissingError(err)) throw err;
}
return true;
}
async function copyObject(srcKey, dstKey) {
const src = normalizeKey(srcKey);
const dst = normalizeKey(dstKey);
if (!src || !dst) return false;
if (!remote) {
const from = localFile(src);
const to = localFile(dst);
if (!from || !to || !fs.existsSync(from)) return false;
fs.mkdirSync(path.dirname(to), { recursive: true });
fs.copyFileSync(from, to);
return true;
}
try {
await client.send(new CopyObjectCommand({
Bucket: bucket,
Key: objectKey(dst),
CopySource: `${bucket}/${objectKey(src)}`,
ContentType: mimeFor(dst),
MetadataDirective: 'REPLACE',
}));
return true;
} catch (err) {
if (isMissingError(err)) return false;
throw err;
}
}
async function listAll(listPrefix = '') {
const out = [];
if (!remote) {
const base = listPrefix ? localFile(listPrefix) : dir;
if (!base || !fs.existsSync(base)) return out;
const walk = (abs, rel) => {
for (const name of fs.readdirSync(abs)) {
const childAbs = path.join(abs, name);
const childRel = rel ? `${rel}/${name}` : name;
let st;
try {
st = fs.statSync(childAbs);
} catch {
continue;
}
if (st.isDirectory()) walk(childAbs, childRel);
else out.push({ key: childRel, size: st.size });
}
};
walk(base, listPrefix ? normalizeKey(listPrefix) || '' : '');
return out;
}
let token = null;
const fullPrefix = prefix ? `${prefix}/${listPrefix}` : listPrefix;
do {
const res = await client.send(new ListObjectsV2Command({
Bucket: bucket,
Prefix: fullPrefix || undefined,
ContinuationToken: token || undefined,
}));
for (const item of res.Contents || []) {
const key = prefix ? String(item.Key).slice(prefix.length + 1) : String(item.Key);
out.push({ key, size: item.Size || 0 });
}
token = res.IsTruncated ? res.NextContinuationToken : null;
} while (token);
return out;
}
async function localize(key, opts = {}) {
const k = normalizeKey(key);
if (!k) return null;
if (localExists(k)) return localFile(k);
if (!remote) return null;
const cf = cacheFile(k);
if (cf && fs.existsSync(cf)) {
const info = await head(k);
if (info && info.size === fs.statSync(cf).size) return cf;
try { fs.unlinkSync(cf); } catch {}
}
const source = await getStream(k);
if (!source) return null;
fs.mkdirSync(path.dirname(cf), { recursive: true });
const tmp = `${cf}.${process.pid}.${Date.now()}.tmp`;
await pipeline(source.stream, fs.createWriteStream(tmp));
if (opts.useCache === false) return tmp;
fs.renameSync(tmp, cf);
return cf;
}
async function persist(key, localPath) {
const k = normalizeKey(key);
if (!k) return false;
const src = path.resolve(localPath);
if (!fs.existsSync(src)) return false;
if (!remote) return true;
const info = await head(k);
if (info && info.size === fs.statSync(src).size) {
if (!keepLocal) unlinkLocalOnly(k);
return true;
}
await putFile(k, src);
if (!keepLocal) {
try { fs.unlinkSync(src); } catch {}
const cf = cacheFile(k);
if (cf && fs.existsSync(cf)) {
try { fs.unlinkSync(cf); } catch {}
}
}
return true;
}
async function streamTo(res, key, opts = {}) {
const k = normalizeKey(key);
if (!k) return false;
if (localFallback && localExists(k)) {
const fp = localFile(k);
if (opts.cacheControl) res.setHeader('Cache-Control', opts.cacheControl);
if (opts.download) {
res.download(fp, opts.name || path.basename(fp), opts.callback || (() => {}));
return true;
}
res.setHeader('Content-Type', opts.contentType || mimeFor(k));
res.sendFile(fp);
return true;
}
const source = await getStream(k);
if (!source) return false;
if (opts.cacheControl) res.setHeader('Cache-Control', opts.cacheControl);
res.setHeader('Content-Type', opts.contentType || source.contentType || mimeFor(k));
if (source.contentLength) res.setHeader('Content-Length', String(source.contentLength));
if (opts.download) {
const name = opts.name || path.basename(k);
const ascii = name.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, '');
res.setHeader('Content-Disposition', `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`);
}
await pipeline(source.stream, res);
return true;
}
async function downloadAll(destDir, opts = {}) {
fs.mkdirSync(destDir, { recursive: true });
const root = path.resolve(destDir);
const objects = await listAll('');
let count = 0;
for (const obj of objects) {
if (!isObjectKey(obj.key)) continue;
const k = normalizeKey(obj.key);
if (!k) continue;
const target = path.resolve(root, ...k.split('/'));
if (!target.startsWith(root + path.sep)) continue;
const source = await getStream(k);
if (!source) continue;
fs.mkdirSync(path.dirname(target), { recursive: true });
await pipeline(source.stream, fs.createWriteStream(target));
count++;
}
if (opts.pruneThumbs !== false) {
const thumbs = path.join(root, '.thumbs');
if (fs.existsSync(thumbs)) fs.rmSync(thumbs, { recursive: true, force: true });
}
return count;
}
async function uploadTree(srcDir) {
const root = path.resolve(srcDir);
if (!fs.existsSync(root)) return 0;
let count = 0;
const walk = async (abs, rel) => {
for (const name of fs.readdirSync(abs)) {
const childAbs = path.join(abs, name);
const childRel = rel ? `${rel}/${name}` : name;
let st;
try {
st = fs.statSync(childAbs);
} catch {
continue;
}
if (st.isDirectory()) {
if (name === '.thumbs' || name === '.cache') continue;
await walk(childAbs, childRel);
continue;
}
const k = normalizeKey(childRel);
if (!k) continue;
await putFile(k, childAbs);
count++;
}
};
await walk(root, '');
return count;
}
async function ensureBucket() {
if (!remote || !bucket) return false;
try {
await client.send(new HeadBucketCommand({ Bucket: bucket }));
return true;
} catch (err) {
const status = err && err.$metadata && err.$metadata.httpStatusCode;
const name = err && (err.name || err.Code);
if (status && status !== 404 && name !== 'NotFound' && name !== 'NoSuchBucket') throw err;
}
try {
await client.send(new CreateBucketCommand({ Bucket: bucket }));
return true;
} catch (err) {
const name = err && (err.name || err.Code);
if (name === 'BucketAlreadyOwnedByYou' || name === 'BucketAlreadyExists') return true;
throw err;
}
}
async function usage() {
const objects = (await listAll('')).filter(o => isObjectKey(o.key));
return {
count: objects.length,
size_bytes: objects.reduce((sum, o) => sum + (o.size || 0), 0),
bucket: remote ? bucket : null,
prefix: prefix || null,
};
}
function pruneCache(now = Date.now()) {
if (!cacheMaxAgeMs || !fs.existsSync(cacheDir)) return 0;
let removed = 0;
const walk = (abs) => {
for (const name of fs.readdirSync(abs)) {
const childAbs = path.join(abs, name);
let st;
try {
st = fs.statSync(childAbs);
} catch {
continue;
}
if (st.isDirectory()) {
walk(childAbs);
continue;
}
if (now - st.mtimeMs > cacheMaxAgeMs) {
try { fs.unlinkSync(childAbs); removed++; } catch {}
}
}
};
walk(cacheDir);
return removed;
}
return {
bucket,
cacheDir,
isRemote,
keyFromPath,
localFile,
unlinkLocalOnly,
normalizeKey,
put,
putFile,
head,
exists,
sizeOf,
getStream,
getBuffer,
del,
copyObject,
listAll,
localize,
persist,
streamTo,
downloadAll,
uploadTree,
ensureBucket,
usage,
pruneCache,
};
}
module.exports = { createStorage, mimeFor, normalizeKey };
+1395
View File
File diff suppressed because it is too large Load Diff
+1209
View File
File diff suppressed because it is too large Load Diff
+78
View File
@@ -0,0 +1,78 @@
# TODO: профиль ученика + ZIP-архив
## Задача
1. На `/students.html` у каждого ученика — кнопка **«Профиль»**, которая одним нажатием
отдаёт ZIP-архив со всеми данными ученика (фото, работы/файлы, записи журнала).
2. Сгенерированная страница `index.html` внутри архива должна быть оформлена
**полностью по дизайну `student-profile.html`** (тёмная/светлая тема, токены,
топбар, hero, статистика, образование, опыт, навыки, проекты, уроки, «о себе», футер).
3. Если в БД нет данных для заполнения блоков этой страницы — страница не должна
ломаться или показывать выдуманные данные: блоки рендерятся из того, что есть,
пустые разделы получают аккуратный empty-state, а **данные можно добавить позже**
(без правок кода).
## План работ
- [x] Разведка: `server.js` (экспорт `/api/export/student`, `createZipWriter`,
`renderStudentReport`), `public/students.html` + `public/js/students.js`,
`db/init.sql` + `db/migration.sql`, дизайн-макет `student-profile.html`.
- [x] `todo.md` (этот файл).
- [x] БД: у ученика появляются поля для будущего заполнения профиля —
`students.profile JSONB` и `students.photo_path` (`db/init.sql`, `db/migration.sql`).
- [x] Бэкап/восстановление: новые поля сохраняются и восстанавливаются
(`normalizeRestoreData`, `INSERT INTO students`).
- [x] API: `GET/PUT /api/students/:id/profile` (requireAuth + проверка филиала,
валидация структуры профиля, аудит `student.profile.update`).
- [x] Новый модуль `student-report.js`: ZIP-writer (перенесён из `server.js`) +
`renderStudentReport()` — страница профиля ученика по дизайну
`student-profile.html` (самодостаточный HTML+CSS+JS, Lucide через CDN с
fallback-заглушками, лайтбокс, ленты, тема, печать, анимации).
- [x] `server.js`: вызов рендера через модуль, убрать старый рендер из `server.js`,
расширить выборки экспорта (модули, статус ИИ, филиал, `students.profile`).
- [x] `public/students.html` + `public/js/students.js`: кнопка **«Профиль»**
(прямая выгрузка ZIP) и кнопка **«Данные»** (модальное окно заполнения данных
профиля: роль, статус, город, наставник, о себе, цитата, теги, контакты,
достижения, навыки, опыт, прогресс по модулям).
- [x] Архив: `index.html` + `photos/` + `files/` + `data.json` (метаданные профиля).
- [x] Проверка: `node --check`, генерация тестового ZIP и разбор `zipfile`
(структура + наличие разделов профиля в `index.html`).
## Решения и допущения
- «index.html» из задачи — это `index.html` внутри ZIP-архива (страница профиля
ученика). Именно он теперь оформлен по `student-profile.html`. Публичный
`public/index.html` (форма отправки записи) не переделывался: это отдельная
страница, и в задаче про неё данных нет.
- Кнопка называется «Профиль» (в задаче опечатка «Профаил»), подпись в подсказке:
«Скачать профиль ученика (ZIP: фото и работы)».
- Дизайн-макет использует Lucide-иконки через CDN; в архиве оставлен тот же подход,
потому что он полностью офлайн-деградирует (класс `no-icons`), как и в макете.
- Данные, которых нет в БД (опыт, навыки, контакты, достижения, прогресс по модулям,
фото), рендерятся из полей `students.profile`; пока их нет — показывается
empty-state с подсказкой, а заполнить их можно в админке («Данные» у ученика).
- В `renderStudentReport` добавлен раздел «Фотографии» (лента в стиле макета) —
без него фото из архива негде было бы посмотреть; ленты и лайтбокс — в стиле
`student-profile.html`.
## Как применить на существующей базе
```bash
docker compose exec -T db psql -U app -d whereldo \
-c "ALTER TABLE students ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);" \
-c "ALTER TABLE students ADD COLUMN IF NOT EXISTS profile JSONB;"
docker compose up -d --build app
```
(`db/migration.sql` уже содержит эти же ALTER-команды; на чистой базе `init.sql`
создаёт поля сразу.)
## Как проверить
```bash
# 1. Админка → «Ученики»: у каждого ученика кнопки «Профиль» и «Данные»
# 2. «Данные» → заполнить роль/био/навыки/контакты/достижения/опыт → Сохранить
# 3. «Профиль» → скачивается profile_<имя>_<дата>.zip:
# index.html (страница профиля по student-profile.html), photos/, files/, data.json
```
+307 -9
View File
@@ -3,8 +3,245 @@ const IDLE_MAX_MS = 60000;
const REQUEST_TIMEOUT_MS = parseInt(process.env.AI_REQUEST_TIMEOUT_MS || '120000', 10);
const MAX_INPUT_CHARS = 2000;
const MIN_TEXT_CHARS = 4;
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const { textDiff, FIELD_LABELS } = require('./diff');
const PHOTO_MAX_ATTEMPTS = 3;
const PHOTO_AI_TIMEOUT_MS = 300000;
function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultPrompt, model }) {
function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntries, sharp, photoAiUrl, uploadsDir, storage, bus }) {
const AI_URL = photoAiUrl || process.env.PHOTO_AI_URL || '';
const IDLE_MIN = 2000;
const IDLE_MAX = 30000;
let started = false;
let stopped = false;
let idleMs = IDLE_MIN;
let wake = null;
let processing = false;
let currentId = null;
let startedAt = null;
const stats = { jobs: 0, done: 0, errors: 0, last_at: null, last_error: null };
const CONFIG = {
idle_min_ms: IDLE_MIN,
idle_max_ms: IDLE_MAX,
ai_timeout_ms: PHOTO_AI_TIMEOUT_MS,
max_attempts: PHOTO_MAX_ATTEMPTS,
ai_url: AI_URL,
};
function sleep(ms) {
return new Promise((resolve) => {
const t = setTimeout(() => { wake = null; resolve(); }, ms);
wake = () => { clearTimeout(t); wake = null; resolve(); };
});
}
function wakeLocal() {
if (wake) wake();
}
function notify() {
wakeLocal();
if (bus) bus.publish();
}
if (bus) bus.subscribe(wakeLocal);
async function isEnabled() {
const v = await getSetting('photo_worker_enabled', 'true');
return String(v) !== 'false';
}
async function claimNext() {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(
`SELECT j.id, j.entry_id, j.action, j.params
FROM photo_jobs j
WHERE j.status = 'pending'
ORDER BY j.id ASC LIMIT 1 FOR UPDATE SKIP LOCKED`
);
if (!rows.length) {
await client.query('COMMIT');
return null;
}
await client.query(`UPDATE photo_jobs SET 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 enhanceWithSharp(srcKey, params) {
if (!sharp) throw new Error('sharp недоступен на сервере');
const p = params || {};
const clamp = (v, min, max, def) => {
const n = parseFloat(v);
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : def;
};
const brightness = clamp(p.brightness, 10, 300, 100);
const contrast = clamp(p.contrast, 10, 300, 100);
const saturate = clamp(p.saturate, 0, 300, 100);
const sharpAmt = clamp(p.sharp, 0, 100, 0);
const denoise = clamp(p.denoise, 0, 100, 0);
const srcPath = await storage.localize(srcKey);
if (!srcPath) throw new Error('Файл фото не найден');
let pipeline = sharp(srcPath).rotate();
if (denoise > 0) pipeline = pipeline.median(denoise > 70 ? 5 : 3);
pipeline = pipeline.modulate({ brightness: brightness / 100, saturation: saturate / 100 });
if (contrast !== 100) {
const a = contrast / 100;
pipeline = pipeline.linear(a, 128 * (1 - a));
}
if (sharpAmt > 0) {
pipeline = pipeline.sharpen({ sigma: 0.5 + (sharpAmt / 100) * 1.5, m1: 0, m2: 1 + sharpAmt / 50 });
}
const newName = crypto.randomBytes(12).toString('hex') + '.jpg';
const outPath = path.join(uploadsDir, newName);
await pipeline.jpeg({ quality: 92, mozjpeg: true }).toFile(outPath);
await storage.persist(newName, outPath);
return `/uploads/${newName}`;
}
async function runAiEnhance(srcKey) {
if (!AI_URL) throw new Error('PHOTO_AI_URL не настроен');
const buf = await storage.getBuffer(srcKey);
if (!buf) throw new Error('Файл фото не найден');
const fd = new FormData();
fd.append('image', new Blob([buf], { type: 'image/jpeg' }), 'photo.jpg');
fd.append('scale', '2');
const resp = await fetch(AI_URL.replace(/\/+$/, '') + '/enhance', {
method: 'POST',
body: fd,
signal: AbortSignal.timeout(PHOTO_AI_TIMEOUT_MS),
});
if (!resp.ok) throw new Error('AI service error: ' + resp.status);
const out = Buffer.from(await resp.arrayBuffer());
const newName = crypto.randomBytes(12).toString('hex') + '.jpg';
const outPath = path.join(uploadsDir, newName);
fs.writeFileSync(outPath, out);
await storage.persist(newName, outPath);
return `/uploads/${newName}`;
}
async function applyResult(job, newPath) {
await pool.query(
`UPDATE photo_jobs SET status = 'done', after_path = $1, error = NULL, finished_at = now() WHERE id = $2`,
[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 });
}
async function processOne(job) {
stats.jobs++;
const { rows } = await pool.query('SELECT photo_path FROM entries WHERE id = $1', [job.entry_id]);
if (!rows.length || !rows[0].photo_path) {
await pool.query(
`UPDATE photo_jobs SET status = 'error', error = 'У записи нет фото', finished_at = now() WHERE id = $1`,
[job.id]
);
stats.errors++;
stats.last_at = new Date().toISOString();
stats.last_error = 'У записи нет фото';
return true;
}
const photoPath = rows[0].photo_path;
try {
const newPath = job.action === 'ai' ? await runAiEnhance(photoPath) : await enhanceWithSharp(photoPath, job.params);
await applyResult(job, newPath);
stats.done++;
stats.last_at = new Date().toISOString();
stats.last_error = null;
return true;
} catch (e) {
const message = (e && e.message ? e.message : 'error').slice(0, 500);
stats.last_at = new Date().toISOString();
stats.last_error = message;
const { rows: cur } = await pool.query('SELECT attempts FROM photo_jobs WHERE id = $1', [job.id]);
const tries = ((cur[0] && cur[0].attempts) || 0) + 1;
if (tries < PHOTO_MAX_ATTEMPTS) {
await pool.query(`UPDATE photo_jobs SET status = 'pending', attempts = $1, error = $2 WHERE id = $3`, [tries, message, job.id]);
if (logAudit) await logAudit(null, 'photo.job.retry', { entry_id: job.entry_id, job_id: job.id, attempt: tries, error: message });
return false;
}
await pool.query(
`UPDATE photo_jobs SET status = 'error', attempts = $1, error = $2, finished_at = now() WHERE id = $3`,
[tries, message, job.id]
);
stats.errors++;
if (logAudit) await logAudit(null, 'photo.job.error', { entry_id: job.entry_id, job_id: job.id, error: message });
return true;
}
}
async function loop() {
while (!stopped) {
let job = null;
try {
if (!(await isEnabled())) {
await sleep(IDLE_MAX);
continue;
}
job = await claimNext();
} catch (e) {
console.error('Photo worker claim error:', e);
await sleep(IDLE_MAX);
continue;
}
if (!job) {
await sleep(idleMs);
idleMs = Math.min(idleMs * 2, IDLE_MAX);
continue;
}
idleMs = IDLE_MIN;
processing = true;
currentId = job.id;
let ok = true;
try {
ok = await processOne(job);
} catch (e) {
console.error('Photo worker process error:', e);
} finally {
processing = false;
currentId = null;
}
if (!ok) await sleep(IDLE_MAX);
}
}
async function resetStale() {
await pool.query(`UPDATE photo_jobs SET status = 'pending' WHERE status = 'processing'`);
}
function start() {
if (started) return;
started = true;
startedAt = Date.now();
resetStale()
.catch((e) => console.error('Photo worker reset error:', e))
.finally(() => { loop().catch((e) => console.error('Photo worker loop error:', e)); });
}
function getInfo() {
return {
started_at: startedAt ? new Date(startedAt).toISOString() : null,
uptime_ms: startedAt ? Date.now() - startedAt : 0,
processing,
current_id: currentId,
config: CONFIG,
...stats,
};
}
return { start, notify, getInfo };
}
function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultPrompt, model, bus }) {
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 || process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.';
@@ -37,10 +274,17 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm
});
}
function notify() {
function wakeLocal() {
if (wake) wake();
}
function notify() {
wakeLocal();
if (bus) bus.publish();
}
if (bus) bus.subscribe(wakeLocal);
async function isEnabled() {
const v = await getSetting('ai_autocheck_enabled', 'true');
return String(v) !== 'false';
@@ -70,27 +314,70 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm
}
}
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) {
let b = String(base || '').trim().replace(/\/+$/, '');
if (!/^https?:\/\//i.test(b)) return null;
if (!/\/v1$/i.test(b)) b += '/v1';
return b;
}
async function callModel(text, 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, text.length * 2 + 512));
} else {
url = `${AI_URL.replace(/\/+$/, '')}/v1/chat/completions`;
model = MODEL;
maxTokens = Math.min(256, Math.max(128, text.length + 64));
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const res = await fetch(`${AI_URL}/v1/chat/completions`, {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers,
signal: controller.signal,
body: JSON.stringify({
model: MODEL,
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: text },
],
temperature: 0.1,
max_tokens: Math.min(512, Math.max(64, text.length + 32)),
max_tokens: maxTokens,
}),
});
if (!res.ok) throw new Error(`AI service error: ${res.status}`);
const data = await res.json();
return (data.choices?.[0]?.message?.content || '').trim();
const choice = data.choices && data.choices[0];
const content = (choice && choice.message && choice.message.content || '').trim();
if (!content) {
throw new Error(`Модель вернула пустой ответ (finish_reason=${choice && choice.finish_reason || 'unknown'})`);
}
return content;
} finally {
clearTimeout(timer);
}
@@ -125,7 +412,18 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm
if (changed) stats.corrected++; else stats.unchanged++;
stats.last_at = new Date().toISOString();
stats.last_error = null;
if (logAudit) await logAudit(null, 'entry.ai.auto-check', { id: row.id, changed });
if (logAudit) {
const d = textDiff(String(row.description ?? ''), changed ? checked : original);
await logAudit(null, 'entry.ai.auto-check', {
id: row.id,
source: 'ai',
changed,
fields: changed ? ['description'] : [],
changes: changed
? [{ field: 'description', label: FIELD_LABELS.description, stats: d.stats, diff: d.segments, truncated: d.truncated }]
: []
});
}
return true;
} catch (e) {
const message = (e && e.message ? e.message : 'error').slice(0, 500);
@@ -215,4 +513,4 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm
return { start, notify, getStats, getInfo };
}
module.exports = { createEntryAutoChecker };
module.exports = { createEntryAutoChecker, createPhotoEnhanceWorker };