Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c63a46d24 | ||
|
|
7367d66ac3 | ||
|
|
e8c15cc425 | ||
|
|
88dbff0136 | ||
|
|
00fbf41efb | ||
|
|
1a25ce7170 | ||
|
|
403574fe79 | ||
|
|
31542de33b | ||
|
|
2afe676969 | ||
|
|
f31b8deea2 | ||
|
|
4d3df1de37 | ||
|
|
3ecf87146a | ||
|
|
eea42eb704 | ||
|
|
54cf5bbfa4 | ||
|
|
19676c9b64 | ||
|
|
87541a5ce8 | ||
|
|
4b620d3e59 | ||
|
|
cb0e68d04a | ||
|
|
0e38a280d7 | ||
|
|
e4d58d6525 | ||
|
|
ddd49707ae | ||
|
|
8bf54fb95d | ||
|
|
e6c69c8a78 | ||
|
|
e4704c2dc6 | ||
|
|
a72600af13 | ||
|
|
2e1d36bc4d | ||
|
|
d943b77f58 | ||
|
|
a95af7daa7 | ||
|
|
69d46a0e5f | ||
|
|
16aba3efb0 | ||
|
|
cb3f010cd9 | ||
|
|
30cf04b0cf | ||
|
|
f2d465e0c4 | ||
|
|
e435b4ad43 | ||
|
|
218c3f825d | ||
|
|
4623358f21 | ||
|
|
5e2533b876 | ||
|
|
1ef81f9d1c | ||
|
|
72eeb5cf9b | ||
|
|
d7d4cc1133 | ||
|
|
d3dd922e32 | ||
|
|
12a527b3ee | ||
|
|
e6291a0235 | ||
|
|
d434732f41 | ||
|
|
12c612eccd | ||
|
|
5874e2b619 | ||
|
|
881b5b4c67 | ||
|
|
1145be02f3 | ||
|
|
27a2238082 | ||
|
|
dec8790a3a | ||
|
|
3a345cbefd | ||
|
|
c4fd31cd53 | ||
|
|
049df61e55 | ||
|
|
57e48a9d4f | ||
|
|
a93e37a6b1 | ||
|
|
0b4243fe91 | ||
|
|
66f7fdc1a8 | ||
|
|
d7c793c67a | ||
|
|
efe2a42d1d | ||
|
|
bbfde902a5 | ||
|
|
cc9a9e6c2d | ||
|
|
2784084c68 | ||
|
|
e8337c2845 | ||
|
|
6cfb13310d | ||
|
|
2c8beedc4b |
@@ -4,6 +4,14 @@ DB_PASSWORD=случайная-длинная-строка
|
|||||||
# Лимит размера загружаемого на восстановление бэкапа, МБ (по умолчанию 500)
|
# Лимит размера загружаемого на восстановление бэкапа, МБ (по умолчанию 500)
|
||||||
BACKUP_UPLOAD_LIMIT_MB=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 (нужен для закрытых/приватных репозиториев моделей)
|
# Hugging Face token (нужен для закрытых/приватных репозиториев моделей)
|
||||||
HUGGINGFACE_TOKEN=
|
HUGGINGFACE_TOKEN=
|
||||||
# Имя GGUF-файла модели для text-corrector (скачивается с Hugging Face, если отсутствует)
|
# Имя GGUF-файла модели для text-corrector (скачивается с Hugging Face, если отсутствует)
|
||||||
@@ -27,3 +35,63 @@ CLOUDFLARE_TUNNEL_URL=http://app:3003
|
|||||||
# Максимальное ожидание WireGuard handshake перед fallback на прямой запуск
|
# Максимальное ожидание WireGuard handshake перед fallback на прямой запуск
|
||||||
# туннеля без VPN (сек). Если VPN-провайдер не отвечает — сайт всё равно поднимется.
|
# туннеля без VPN (сек). Если VPN-провайдер не отвечает — сайт всё равно поднимется.
|
||||||
WG_HANDSHAKE_TIMEOUT=60
|
WG_HANDSHAKE_TIMEOUT=60
|
||||||
|
|
||||||
|
# === ИИ-улучшение фото (контейнер photo-ai, Real-ESRGAN + GFPGAN) ===
|
||||||
|
# По умолчанию фото-ИИ включено: сервис photo-ai поднимается вместе со стеком
|
||||||
|
# и работает на CPU. Оставьте значение пустым, чтобы выключить сервис —
|
||||||
|
# тогда кнопка «🤖 ИИ» скрыта, а /api/entries/:id/photo/enhance-ai отвечает 503.
|
||||||
|
# Ручные проверки сервиса идут по loopback-порту 127.0.0.1:8081 (наружу не публикуется):
|
||||||
|
# curl http://127.0.0.1:8081/health
|
||||||
|
PHOTO_AI_URL=http://photo-ai:8080
|
||||||
|
# Максимум пикселей входного изображения, более крупный вход уменьшается.
|
||||||
|
PHOTO_AI_MAX_PIXELS=4000000
|
||||||
|
# Устройство инференса: auto (CUDA, если контейнеру выдан GPU, иначе CPU), cuda, cpu.
|
||||||
|
# Явный cuda без доступной CUDA не роняет сервис: WARN в лог и работа на CPU.
|
||||||
|
# GPU-вариант собирается оверрайдом (отдельный образ whatido-photo-ai:cu126, GPU через CDI):
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build photo-ai
|
||||||
|
# Подробности установки NVIDIA Container Toolkit — в README, раздел «Запуск на NVIDIA GPU».
|
||||||
|
PHOTO_AI_DEVICE=auto
|
||||||
|
# Размер тайла инференса (0 — без тайлов). Меньше тайл — меньше памяти, медленнее.
|
||||||
|
# На GPU с 4 ГБ VRAM держите 256: при нехватке сервис сам пройдёт лестницу тайлов и уйдёт на CPU.
|
||||||
|
PHOTO_AI_TILE=256
|
||||||
|
# Модель восстановления лиц: gfpgan (по умолчанию) или codeformer.
|
||||||
|
# codeformer доступен только если модуль вендорен в photo-ai/vendor.
|
||||||
|
PHOTO_AI_FACE_MODEL=gfpgan
|
||||||
|
# Предзагрузка всех моделей при старте (1) или ленивая загрузка по требованию (0).
|
||||||
|
PHOTO_AI_LOAD_ALL=0
|
||||||
|
# Качество JPEG результата (70..100).
|
||||||
|
PHOTO_AI_JPEG_QUALITY=92
|
||||||
|
# Таймаут заданий с восстановлением лиц на стороне приложения (мс).
|
||||||
|
PHOTO_AI_FACE_TIMEOUT_MS=600000
|
||||||
|
# Сколько раз воркер повторит задание, если photo-ai недоступен (503/обрыв сети),
|
||||||
|
# не увеличивая attempts; после исчерпания — одна честная ошибка в задании.
|
||||||
|
PHOTO_AI_SOFT_MAX_RETRIES=60
|
||||||
|
# Пауза перед мягким повтором и её потолок (мс), задержка растёт вдвое до потолка.
|
||||||
|
PHOTO_AI_SOFT_BACKOFF_MS=10000
|
||||||
|
PHOTO_AI_SOFT_BACKOFF_MAX_MS=300000
|
||||||
|
|
||||||
|
# === Хранилище файлов (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
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ backups/
|
|||||||
*.sql.gz
|
*.sql.gz
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
|
|
||||||
|
# --- Generated build artifacts ---
|
||||||
|
public/version.json
|
||||||
|
|
||||||
# --- Node ---
|
# --- Node ---
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
**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)
|
- **Stack**: Node.js 20 + Express, PostgreSQL 16, Redis 7, Docker Compose, S3-совместимое хранилище файлов, Tailscale (Serve/Funnel)
|
||||||
- **Architecture**: Single Express server (`server.js`) + static frontend in `public/`
|
- **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 + tailscale), bind-mounted uploads, named volume for Postgres data
|
- **Deployment**: Docker Compose (app + db + s3 + tailscale), bind-mounted uploads, named volumes for Postgres and S3 data
|
||||||
- **Auth**: Admin-only via `X-Admin-Token` header (value = `ADMIN_PASSWORD` env var). No user sessions.
|
- **Auth**: сессии в БД. `POST /api/auth/login` (bcrypt) → токен в заголовке `X-Auth-Token`. Роли: `admin` и не-admin, ограниченные филиалами (`user_branches`). `ADMIN_PASSWORD` используется **только** для автосоздания первого админа в пустой БД — это не механизм авторизации API
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -34,13 +34,50 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
|
|||||||
### 3. File Uploads
|
### 3. File Uploads
|
||||||
- **Multer configs**: `upload` (images only), `adminUpload` (wider allowed ext), `uploadBackup` (restore)
|
- **Multer configs**: `upload` (images only), `adminUpload` (wider allowed ext), `uploadBackup` (restore)
|
||||||
- **Limits**: 10 MB/file, 30 MB total per entry
|
- **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`
|
- **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`
|
||||||
|
|
||||||
|
### 3c. Уведомления (`server.js`, `worker.js`, `public/`)
|
||||||
|
- **Каталог событий** — только `NOTIFY_TYPES` в `server.js` (тип → `label`, `hint`, `icon`, `level`, `enabled` по умолчанию, `admin`); фронтенд берёт список из `GET /api/notifications/meta`, дублировать каталог в HTML нельзя
|
||||||
|
- **Таблицы**: `notifications` (событие, `admin_only`, `branch_id`) + `notification_reads` (прочтение на пользователя). Изменения схемы — в `db/init.sql` и `db/migration.sql` и в `ensureNotificationsTable()`
|
||||||
|
- **Настройки**: `notify_enabled` (общий), `notify_retention_days` (1–365), `notify_<тип>` (точки типа заменяются на `_`, см. `notifySettingKey`). Значения только `'true'` / `'false'` — `PUT /api/settings` это валидирует
|
||||||
|
- **Создание события** — только через `pushNotification()` / `notifyEntry()`; они сами проверяют переключатели и при выключенном типе возвращают `null`. `notifyEntry` подставляет `{student}` и `{group}` и определяет филиал по группе записи
|
||||||
|
- **Хук в фото-воркере называется `notifyEvent`** — имя `notify` внутри `createPhotoEnhanceWorker` уже занято будильником воркера (`photoWorker.notify()` из `POST /api/photo-jobs/wake`), объявление функции перекрыло бы параметр
|
||||||
|
- **Видимость**: админ видит всё; остальные — `admin_only = false` и `branch_id IS NULL` или филиал из `user_branches`
|
||||||
|
- **Доставка**: запись в БД → `cache.publish('whatido:notifications', row)` → SSE `GET /api/notifications/stream` (клиенты фильтруются по `notificationVisible`). Redis недоступен — работает in-memory pub/sub
|
||||||
|
- **Очистка**: `purgeOldNotifications()` при старте и раз в час по `notify_retention_days`; чтения удаляются каскадом
|
||||||
|
- **Новое событие добавляется вместе с**: записью в `NOTIFY_TYPES`, строками `INSERT INTO settings` в `db/init.sql` + `db/migration.sql`, вызовом `pushNotification`/`notifyEntry` в точке события и парой `icon` из Lucide
|
||||||
|
- **Аудит**: удаление/очистка уведомлений логируется (`notifications.delete`, `notifications.clear`)
|
||||||
|
|
||||||
### 4. API Patterns
|
### 4. API Patterns
|
||||||
- **Admin routes**: `requireAdmin` middleware (checks `X-Admin-Token`)
|
- **Middleware**: `requireAuth` — читает `X-Auth-Token`, 401 без валидной активной сессии. `requireAdmin` — самодостаточный (внутри вызывает `requireAuth`, если `req.user` ещё нет), 403 при `role !== 'admin'`. `optionalAuth` — для публичных страниц с персонализацией
|
||||||
- **Public routes**: `apiLimiter` (300/15min), `entryLimiter` (10/15min), `fileLimiter` (300/15min)
|
- **Филиалы**: `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
|
- **Responses**: JSON, `{ error: 'message' }` on failure, data directly on success
|
||||||
- **Pagination**: `limit` / `offset` query params, return `{ items, total }` or `{ entries, total }`
|
- **Pagination**: `limit` / `offset` query params, return `{ items, total }` or `{ entries, total }`
|
||||||
- **Filters**: `group_id`, `date_from`, `date_to`, `student_name`, `search`, `deleted`
|
- **Filters**: `group_id`, `date_from`, `date_to`, `student_name`, `search`, `deleted`
|
||||||
@@ -48,16 +85,18 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
|
|||||||
### 5. Frontend (public/)
|
### 5. Frontend (public/)
|
||||||
- Vanilla HTML/CSS/JS, no build step
|
- Vanilla HTML/CSS/JS, no build step
|
||||||
- Each page = single HTML file + shared `admin.js` / `admin.css`
|
- 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
|
- Share pages (`share.html`, `links.html`) work without auth
|
||||||
|
|
||||||
### 6. Docker / Compose
|
### 6. Docker / Compose
|
||||||
- **Dockerfile**: Node 20 Alpine, installs deps, generates self-signed TLS cert
|
- **Dockerfile**: Node 22 Alpine, installs deps, generates self-signed TLS cert
|
||||||
- **docker-compose.yml**: 3 services (db, app, tailscale)
|
- **docker-compose.yml**: сервисы `db`, `app`, `redis`, `s3` (+ опционально `tailscale`, `cloudflared`, `text-corrector`, `photo-ai`)
|
||||||
- `db`: postgres:16-alpine, healthcheck, init.sql mounted
|
- `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
|
- `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)
|
- `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)
|
- **Port 443 on host** must be free (tailscale listens directly)
|
||||||
|
|
||||||
### 7. Tailscale Publication
|
### 7. Tailscale Publication
|
||||||
@@ -102,6 +141,15 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
|
|||||||
- Update Multer `fileFilter` functions
|
- Update Multer `fileFilter` functions
|
||||||
- Keep `MAX_TOTAL_UPLOAD_BYTES` and per-file limit in sync
|
- 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
|
## Testing & Verification
|
||||||
@@ -115,21 +163,68 @@ docker compose up -d --build
|
|||||||
# Check logs
|
# Check logs
|
||||||
docker compose logs -f app
|
docker compose logs -f app
|
||||||
|
|
||||||
# Test API (replace TOKEN)
|
# Test API (replace LOGIN/PASS; X-Admin-Token больше не работает)
|
||||||
curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
|
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
|
# Run backup/restore scripts
|
||||||
./scripts/backup.sh
|
./scripts/backup.sh
|
||||||
./scripts/restore.sh backups/whatido-backup-<date>.tar.gz
|
./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`).
|
||||||
|
|
||||||
|
`GET /api/system-info` also returns a `stack` block (built by `getStackInfo()`, outside the
|
||||||
|
response cache so versions and load stay fresh): `app` (Node, PID, RSS/heap, uptime, version from
|
||||||
|
`package.json` + `public/version.json`), `deps` (installed versions of the main packages), `runtime`
|
||||||
|
(OS from `/etc/os-release`, kernel, arch, CPU count/model, loadavg, memory, container detection),
|
||||||
|
`database` (PostgreSQL version, host, pool counters), `cache` (driver, version, ready, keys, memory,
|
||||||
|
hits/misses, fallback ops) and `storage` (driver, endpoint, bucket). Hosts come from `URL.hostname`
|
||||||
|
only — credentials from `DATABASE_URL`/`REDIS_URL` must never reach the payload. The «Статус стека»
|
||||||
|
block on `public/settings.html` renders exactly this payload.
|
||||||
|
|
||||||
|
`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)
|
## Security Checklist (before any change)
|
||||||
- [ ] No SQL interpolation — only `$1`, `$2`...
|
- [ ] No SQL interpolation — only `$1`, `$2`...
|
||||||
- [ ] Upload path validation via `isSafeUploadPath` / `safeUnlink`
|
- [ ] Upload path validation via `isSafeUploadPath` / `safeUnlink`
|
||||||
|
- [ ] File I/O через `storage.*`, ключи объектов не выходят за пределы бакета/`uploads/`
|
||||||
- [ ] Rate limiter on new public routes
|
- [ ] Rate limiter on new public routes
|
||||||
- [ ] Admin routes behind `requireAdmin`
|
- [ ] 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
|
- [ ] No secrets in code — only via env vars
|
||||||
- [ ] Helmet headers present (already global)
|
- [ ] Helmet headers present (already global)
|
||||||
- [ ] CORS disabled (no `cors` middleware)
|
- [ ] CORS disabled (no `cors` middleware)
|
||||||
@@ -141,15 +236,24 @@ curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
|
|||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `server.js` | Entire backend (Express, routes, DB, uploads, backup) |
|
| `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/init.sql` | Initial schema (runs on fresh DB) |
|
||||||
| `db/migration.sql` | Idempotent migrations for existing DBs |
|
| `db/migration.sql` | Idempotent migrations for existing DBs |
|
||||||
| `docker-compose.yml` | Service definitions (app, db, 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 |
|
| `Dockerfile` | App image build |
|
||||||
| `public/*.html` | Frontend pages |
|
| `public/*.html` | Frontend pages |
|
||||||
| `public/admin.js` | Shared frontend logic |
|
| `public/admin.js` | Shared frontend logic |
|
||||||
| `scripts/backup.sh` | Host-level backup script |
|
| `scripts/backup.sh` | Host-level backup script (DB dump + storage export) |
|
||||||
| `scripts/restore.sh` | Host-level restore script |
|
| `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 |
|
| `start-tailscale.sh` | Tailscale container entrypoint |
|
||||||
| `.env.example` | Env var template |
|
| `.env.example` | Env var template |
|
||||||
|
|
||||||
@@ -159,6 +263,14 @@ curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
|
|||||||
|
|
||||||
- ❌ Add dependencies without updating `package.json` and rebuilding
|
- ❌ Add dependencies without updating `package.json` and rebuilding
|
||||||
- ❌ Write files outside `uploads/` or `certs/`
|
- ❌ 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/`
|
- ❌ Commit `.env`, `certs/`, `uploads/`, `backups/`, `node_modules/`
|
||||||
- ❌ Expose DB port (5432) outside docker network
|
- ❌ Expose DB port (5432) outside docker network
|
||||||
- ❌ Use `eval`, `Function` constructor, or dynamic code execution
|
- ❌ Use `eval`, `Function` constructor, or dynamic code execution
|
||||||
@@ -172,12 +284,24 @@ curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
|
|||||||
# Full rebuild
|
# Full rebuild
|
||||||
docker compose down && docker compose up -d --build
|
docker compose down && docker compose up -d --build
|
||||||
|
|
||||||
|
# Обновление на сервере (pull master + сборка образа с версией коммита + перезапуск app)
|
||||||
|
./scripts/deploy.sh
|
||||||
|
|
||||||
# App logs
|
# App logs
|
||||||
docker compose logs -f app
|
docker compose logs -f app
|
||||||
|
|
||||||
# DB shell
|
# DB shell
|
||||||
docker compose exec db psql -U app -d whereldo
|
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
|
# Tailscale status
|
||||||
docker exec -it whatido-tailscale-1 tailscale status
|
docker exec -it whatido-tailscale-1 tailscale status
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -1,16 +1,19 @@
|
|||||||
FROM node:20-alpine
|
FROM node:22-alpine
|
||||||
ENV TZ=Europe/Moscow
|
ENV TZ=Europe/Moscow
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm install --omit=dev
|
RUN npm install --omit=dev
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN mkdir -p uploads certs
|
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; \
|
RUN set -e; \
|
||||||
V="v$(cut -d. -f1-2 /etc/alpine-release)"; \
|
V="v$(cut -d. -f1-2 /etc/alpine-release)"; \
|
||||||
try_repo() { \
|
try_repo() { \
|
||||||
printf "%s/%s/main\n%s/%s/community\n" "$1" "$V" "$1" "$V" > /etc/apk/repositories; \
|
printf "%s/%s/main\n%s/%s/community\n" "$1" "$V" "$1" "$V" > /etc/apk/repositories; \
|
||||||
echo "Trying apk mirror: $1"; \
|
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" && \
|
if ! try_repo "https://dl-cdn.alpinelinux.org/alpine" && \
|
||||||
! try_repo "https://mirrors.edge.kernel.org/alpine" && \
|
! try_repo "https://mirrors.edge.kernel.org/alpine" && \
|
||||||
|
|||||||
@@ -0,0 +1,553 @@
|
|||||||
|
# План: Улучшение лиц на фотографиях (Real-ESRGAN + GFPGAN/CodeFormer) с автовыбором GPU/CPU
|
||||||
|
|
||||||
|
Статус: **план, реализация не начата**. Документ описывает целевую архитектуру, изменения по файлам,
|
||||||
|
порядок внедрения и критерии приёмки. Реализацию начинать после знакомства с этим файлом.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Постановка задачи
|
||||||
|
|
||||||
|
Сейчас система улучшает фотографии одной моделью `RealESRGAN_x2plus` (x2, CPU, `half=False`,
|
||||||
|
`device='cpu'`, захардкожено в `photo-ai/app.py:44`). Модель хорошо восстанавливает текстуры
|
||||||
|
(одежда, фон, бумага), но **лица** при апскейле часто получают артефакты, «пластиковую» кожу и
|
||||||
|
искажённые черты, потому что у `RealESRGAN_x2plus` нет приора на структуру лица.
|
||||||
|
|
||||||
|
Нужно:
|
||||||
|
|
||||||
|
1. Добавить модели восстановления **лиц** — GFPGAN v1.4 и/или CodeFormer (оба — face restoration
|
||||||
|
с prior-сетью, работают в связке с `RealESRGANer` как `bg_upsampler`).
|
||||||
|
2. Дать пользователю **выбор модели** для обработки: универсальная x2, face-модель, комбинация
|
||||||
|
(фон + лица), быстрая VGG-модель `realesr-general-x4v3`.
|
||||||
|
3. Поддержать работу **на GPU и на CPU** с **автоматическим выбором** устройства: есть рабочий
|
||||||
|
CUDA — используем GPU, нет — молча и без падений уходим на CPU.
|
||||||
|
4. Не допустить простоя GPU-контейнера: пока модели грузятся — сервис отвечает `503`, воркер
|
||||||
|
ждёт, а не теряет задания.
|
||||||
|
5. Не сломать существующие контракты: `photo_jobs`, `/enhance`, `PHOTO_AI_URL`, автономную работу
|
||||||
|
без `photo-ai` (`PHOTO_AI_URL` пустой → кнопка «ИИ» недоступна).
|
||||||
|
|
||||||
|
### Ограничения окружения (проверено на текущем хосте)
|
||||||
|
|
||||||
|
| Параметр | Значение | Следствие |
|
||||||
|
|----------|----------|-----------|
|
||||||
|
| GPU | `NVIDIA GeForce RTX 3050 ...`, 4096 MiB VRAM, драйвер 615.71.09, CUDA UMD 13.4 | GPU-режим реален, но 4 ГБ VRAM — тесно, нужен `tile` и запас |
|
||||||
|
| `/dev/dri` | `card1`, `card2`, `renderD128`, `renderD129` | iGPU тоже виден, но torch будет использовать CUDA |
|
||||||
|
| `nvidia-ctk` | **не установлен** | нужен NVIDIA Container Toolkit на хосте, иначе `--gpus` не заработает |
|
||||||
|
| Runtime Docker | только `runc` (нет `nvidia`) | требуется установка toolkit + `docker compose` override |
|
||||||
|
| CPU | Intel i5-12500H, 16 потоков | CPU-режим приемлем как fallback, но медленный |
|
||||||
|
| RAM | 15 GiB (занято ~9 ГБ), swap 31 GiB | CPU-модели + torch требуют ~2–3 ГБ; следить за OOM |
|
||||||
|
| Диск | 59 ГБ свободно на `/home` | веса: x2plus 64 МБ + GFPGAN 333 МБ + CodeFormer 360 МБ + wdn 64 МБ — ок |
|
||||||
|
| Docker / Compose | 29.8.1 / 5.5.1 | поддерживают `deploy.resources.reservations.devices` (Compose v5) |
|
||||||
|
|
||||||
|
Важно: **GPU-режим не должен быть обязательным условием запуска**. Если toolkit не установлен,
|
||||||
|
compose-файл с `devices` не поднимется — поэтому GPU выносим в отдельный override-файл.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Что уже есть (точки интеграции)
|
||||||
|
|
||||||
|
| Место | Что делает | Что меняем |
|
||||||
|
|-------|-----------|-----------|
|
||||||
|
| `photo-ai/app.py` | FastAPI, `POST /enhance` (`image`, `scale`), одна модель, `device='cpu'` | Расширяем до реестра моделей + автовыбор device + `POST /enhance` с `model`/`face` |
|
||||||
|
| `photo-ai/Dockerfile` | `python:3.10-slim`, torch CPU-only, правка `basicsr/data/degradations.py` | Разделяем на CPU-базу и GPU-базу (`ARG`), добавляем `gfpgan`, `facexlib` |
|
||||||
|
| `docker-compose.yml` (`photo-ai`) | build `./photo-ai`, `MODEL_PATH`, `MAX_INPUT_PIXELS`, том `photo-ai-models` | Добавляем env `PHOTO_AI_DEVICE`, `PHOTO_AI_FACE_MODEL`, `PHOTO_AI_TILE`, healthcheck |
|
||||||
|
| `worker.js` → `createPhotoEnhanceWorker` | `runAiEnhance(srcKey)` шлёт `image` + `scale=2`, ждёт `image/jpeg` (таймаут 300 с, 3 попытки) | Передаём `model`/`face`/`strength` из `job.params`, разбираем JSON-ответ, 503 ждёт без траты попыток |
|
||||||
|
| `server.js` → `POST /api/entries/:id/photo/enhance-ai` | `INSERT INTO photo_jobs (entry_id, action, status, params) VALUES ($1,'ai','pending',NULL)` | Принимаем `model`/`face`/`face_model`/`strength`, валидируем, пишем в `params` (колонка уже есть) |
|
||||||
|
| `server.js` → `GET /api/photo-jobs/status` | `ai_configured`, `ai_url`, `worker`, `counts` | Добавляем `service.device`, `service.models`, `service.face_models`, `service.ready` |
|
||||||
|
| `server.js` → `PHOTO_JOB_ACTIONS` | белый список действий, `action VARCHAR(20)` | Новые действия `ai_face`, `ai_upscale` в двух местах (валидация + restore) |
|
||||||
|
| `public/js/journal.js` (кнопка «🤖 ИИ») | Ставит задание `action='ai'` | Выбор модели: универсальная / быстрая / лица / обе |
|
||||||
|
| `public/js/worker.js` + `worker.html` | Таблица заданий, `PHOTO_ACTION_LABELS`, сравнение «Было/Стало» | Новые метки, показ модели/устройства/времени обработки |
|
||||||
|
| `settings.html` / `settings.js` | `photo_enhance_engine` (`auto`/`server`/`client`) | Новые настройки: `photo_ai_face_mode`, `photo_ai_device_pref` |
|
||||||
|
|
||||||
|
Текущее поведение, которое **сохраняем байт-в-байт**:
|
||||||
|
`/enhance` с `scale=2` без указания модели → та же картинка, что и сегодня (x2plus, JPEG q92).
|
||||||
|
Это нужно, чтобы старые `photo_jobs` со `params = NULL` и все закешированные превью не изменились.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Модели: что именно добавляем
|
||||||
|
|
||||||
|
### 3.1 Каталог моделей
|
||||||
|
|
||||||
|
| Ключ | Класс | Веса | Размер | Назначение |
|
||||||
|
|------|-------|------|--------|-----------|
|
||||||
|
| `x2plus` | `RRDBNet(scale=2)` | `RealESRGAN_x2plus.pth` | 64 МБ | **текущая**, универсальный апскейл x2, дефолт |
|
||||||
|
| `general-x4v3` | `SRVGGNetCompact(upscale=4)` | `realesr-general-x4v3.pth` | 4.7 МБ | быстрый апскейл x4 + денойз через DNI (`realesr-general-wdn-x4v3.pth`) |
|
||||||
|
| `animevideo-v3` | `SRVGGNetCompact(upscale=4)` | `realesr-animevideov3.pth` | 2.4 МБ | быстрый x4, для скриншотов/иллюстраций |
|
||||||
|
| `gfpgan` | `GFPGANer(arch='clean', channel_multiplier=2)` | `GFPGANv1.4.pth` | 333 МБ | **восстановление лиц**, `bg_upsampler` = выбранный ESRGAN |
|
||||||
|
| `codeformer` | `CodeFormer` | `codeformer.pth` + `detection_Resnet50_Final.pth` + `parsing_parsenet.pth` | 360 МБ + ~110 МБ | **восстановление лиц**, регулируемая сила `fidelity_weight` (`-w`), лучше на сильных искажениях |
|
||||||
|
|
||||||
|
Рекомендация: **GFPGAN v1.4 как основная face-модель** (меньше весов, стабильнее на 4 ГБ VRAM,
|
||||||
|
это модель по умолчанию в апстриме `inference_realesrgan.py` через `--face_enhance`), CodeFormer —
|
||||||
|
опционально, как альтернатива с регулируемой силой. На CPU CodeFormer практически
|
||||||
|
неработоспособен по времени (facexlib + parsing) — оставляем его «только GPU, если включён явно».
|
||||||
|
|
||||||
|
### 3.2 Режимы обработки (`face_mode`)
|
||||||
|
|
||||||
|
| `face_mode` | Что происходит | Модель | Когда использовать |
|
||||||
|
|-------------|----------------|--------|--------------------|
|
||||||
|
| `off` | только апскейл фона, лицо не трогается | ESRGAN | текущее поведение, фон/текстуры |
|
||||||
|
| `face` | апскейл фона + **только лица** вставлены восстановленными (paste-back) | ESRGAN + GFPGAN/CodeFormer | портреты, крупные лица |
|
||||||
|
| `all` | как `face`, плюс мягкий денойз фона | ESRGAN(+wdn) + GFPGAN | зашумлённые снимки с веб-камеры 640×480 |
|
||||||
|
|
||||||
|
Технически GFPGAN работает так: детектирует лица (facexlib/RetinaFace), кропает → восстанавливает
|
||||||
|
→ paste-back в альбом. Если лиц не найдено — возвращает чистый результат `bg_upsampler`: это
|
||||||
|
безопасный no-op и не должно считаться ошибкой.
|
||||||
|
|
||||||
|
### 3.3 Почему face-модель нельзя ставить «всегда»
|
||||||
|
|
||||||
|
1. **Скорость.** Детекция + face-restore + paste-back даёт +40…150 % ко времени. На CPU это
|
||||||
|
разница между ~40 с и ~90 с на фото 640×480.
|
||||||
|
2. **Гарантий нет.** GFPGAN «дорисовывает» лица по приору: на сильно замытых, боковых или
|
||||||
|
закрытых лицах он может сделать человека похожим на другого. Для журнала посещаемости ошибка
|
||||||
|
идентификации недопустима, поэтому face-режим — **явный выбор пользователя**, а не молчаливый
|
||||||
|
дефолт.
|
||||||
|
3. **VRAM.** GFPGAN + x2plus одновременно держат два графа на GPU; на 4 ГБ нужен `tile <= 256`.
|
||||||
|
|
||||||
|
### 3.4 Точные URL весов (скачиваются при первом запуске)
|
||||||
|
|
||||||
|
```
|
||||||
|
https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth
|
||||||
|
https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-general-x4v3.pth
|
||||||
|
https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-general-wdn-x4v3.pth
|
||||||
|
https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-animevideov3.pth
|
||||||
|
https://github.com/TencentARC/GFPGAN/releases/download/v1.3.0/GFPGANv1.4.pth
|
||||||
|
https://github.com/sczhou/CodeFormer/releases/download/v0.1.0/codeformer.pth
|
||||||
|
```
|
||||||
|
|
||||||
|
Каждый файл кладём в `/models/weights/<name>.pth` (том `photo-ai-models`), загрузка через
|
||||||
|
`.tmp` + `os.replace` и проверку минимального размера — иначе оборванная закачка оставит «битые»
|
||||||
|
веса, и сервис будет падать при загрузке.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Автоматический выбор GPU/CPU
|
||||||
|
|
||||||
|
### 4.1 Приоритет выбора (в `app.py` при старте)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. PHOTO_AI_DEVICE=cuda|cpu|auto (env, дефолт auto)
|
||||||
|
2. если auto:
|
||||||
|
a. torch.cuda.is_available() and torch.cuda.device_count() > 0
|
||||||
|
-> device = 'cuda:0', half = True (fp16 быстрее и экономит VRAM)
|
||||||
|
b. иначе если torch.backends.mps.is_available()
|
||||||
|
-> device = 'mps', half = False (Apple Silicon, на случай dev-машины)
|
||||||
|
c. иначе -> device = 'cpu', half = False
|
||||||
|
3. если явно cuda, но cuda недоступна -> НЕ падать: WARN в лог и уйти на cpu
|
||||||
|
(контейнер обязан подниматься даже без GPU — требование отказоустойчивости)
|
||||||
|
4. явно cpu — всегда cpu, даже если GPU есть (для отладки и воспроизводимости)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Прогрев, ленивая загрузка и деградация
|
||||||
|
|
||||||
|
- `load_model()` вызывается в `startup` (сейчас через `asyncio.to_thread`), модели грузятся
|
||||||
|
**лениво по требованию** и кешируются в `MODELS` dict под `threading.Lock`. Первый запрос к
|
||||||
|
новой модели оплачивает её загрузку (x2plus 64 МБ грузится быстрее, чем GFPGAN 333 МБ).
|
||||||
|
- Пока нужная модель не готова, `/enhance` возвращает `503` + `Retry-After: 5`, а `/health`
|
||||||
|
отдаёт `{"ok": true, "ready": false, "loading": ["gfpgan"]}`. Воркер на `503` **не** тратит
|
||||||
|
попытку из `PHOTO_MAX_ATTEMPTS`: он ждёт и повторяет (см. §5.4).
|
||||||
|
- `CUDA out of memory` при инференсе — ловим `RuntimeError`, уменьшаем `tile` вдвое
|
||||||
|
(256 → 128 → 64) и повторяем **один раз**; если снова OOM — переключаемся на `cpu`,
|
||||||
|
инвалидируем модель и повторяем. Если и на CPU не получилось — `500` с понятным текстом.
|
||||||
|
- Прогрев (warm-up) на синтетическом шуме 64×64 сразу после загрузки: убирает «первый запрос в
|
||||||
|
3 раза дольше» и немедленно выявляет OOM.
|
||||||
|
|
||||||
|
### 4.3 Что показывать оператору
|
||||||
|
|
||||||
|
`GET /health` (расширяем, обратно совместимо: поле `ok` остаётся):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"ready": true,
|
||||||
|
"device": "cuda:0",
|
||||||
|
"device_name": "NVIDIA GeForce RTX 3050 ...",
|
||||||
|
"half": true,
|
||||||
|
"tile": 256,
|
||||||
|
"driver": "615.71.09",
|
||||||
|
"cuda": "13.4",
|
||||||
|
"vram_total_mb": 4096,
|
||||||
|
"vram_free_mb": 3780,
|
||||||
|
"models": ["x2plus", "general-x4v3", "animevideo-v3"],
|
||||||
|
"face_models": ["gfpgan", "codeformer"],
|
||||||
|
"loaded": ["x2plus", "gfpgan"],
|
||||||
|
"max_pixels": 4000000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`server.js` проксирует это в `GET /api/photo-jobs/status` → `service` и в блок «Статус стека»
|
||||||
|
(`getStackInfo()`, `public/settings.html`), чтобы было видно, на чём реально считает фото-ИИ.
|
||||||
|
|
||||||
|
### 4.4 Docker: как отдать GPU контейнеру
|
||||||
|
|
||||||
|
Два обязательных шага на хосте (сейчас **не выполнены** — `nvidia-ctk` отсутствует):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. NVIDIA Container Toolkit
|
||||||
|
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | \
|
||||||
|
sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
|
||||||
|
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \
|
||||||
|
sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | \
|
||||||
|
sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
|
||||||
|
sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit
|
||||||
|
sudo nvidia-ctk runtime configure --runtime=docker && sudo systemctl restart docker
|
||||||
|
|
||||||
|
# 2. Проверка
|
||||||
|
docker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi
|
||||||
|
```
|
||||||
|
|
||||||
|
Compose делим на два файла, чтобы GPU был **опцией, а не требованием** (по образцу
|
||||||
|
существующего `docker-compose.minio.yml`):
|
||||||
|
|
||||||
|
- `docker-compose.yml` — `photo-ai` без GPU (CPU-образ, как сейчас). Стек поднимается на любой
|
||||||
|
машине.
|
||||||
|
- `docker-compose.gpu.yml` (новый) — override:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
photo-ai:
|
||||||
|
build:
|
||||||
|
context: ./photo-ai
|
||||||
|
args:
|
||||||
|
TORCH_VARIANT: cu124
|
||||||
|
environment:
|
||||||
|
PHOTO_AI_DEVICE: cuda
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: 1
|
||||||
|
capabilities: [gpu]
|
||||||
|
```
|
||||||
|
|
||||||
|
Запуск GPU-режима:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build photo-ai
|
||||||
|
```
|
||||||
|
|
||||||
|
Если `photo-ai` уже запущен в CPU-режиме — сначала `docker compose stop photo-ai`, затем команда
|
||||||
|
выше (иначе Compose ругается на конфликт конфигурации сервиса).
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Изменения по файлам
|
||||||
|
|
||||||
|
### 5.1 `photo-ai/app.py` (переписываем, ~250–300 строк)
|
||||||
|
|
||||||
|
Новая структура:
|
||||||
|
|
||||||
|
```
|
||||||
|
ENV: PHOTO_AI_DEVICE, PHOTO_AI_FACE_MODEL, PHOTO_AI_TILE, PHOTO_AI_MAX_PIXELS,
|
||||||
|
PHOTO_AI_MODELS_DIR, PHOTO_AI_LOAD_ALL, PHOTO_AI_WARMUP, PHOTO_AI_JPEG_QUALITY
|
||||||
|
pick_device() -> (device, half, device_name) # §4.1
|
||||||
|
MODEL_REGISTRY = { 'x2plus': {...}, 'general-x4v3': {...}, 'animevideo-v3': {...} }
|
||||||
|
FACE_REGISTRY = { 'gfpgan': {...}, 'codeformer': {...} }
|
||||||
|
class ModelPool: # Lock, dict, lazy load, OOM-ретрай
|
||||||
|
get_esrgan(name) -> RealESRGANer
|
||||||
|
get_face(name, bg_upsampler) -> GFPGANer | CodeFormer
|
||||||
|
encode_jpeg(out, quality)
|
||||||
|
@app.get('/health') # расширенный контракт §4.3
|
||||||
|
@app.get('/models') # список моделей + устройство
|
||||||
|
@app.post('/enhance') # image, scale, model, face, face_model, strength
|
||||||
|
```
|
||||||
|
|
||||||
|
Ключевые детали реализации:
|
||||||
|
|
||||||
|
- `POST /enhance` принимает те же `image` и `scale`, плюс новые необязательные поля:
|
||||||
|
`model` (дефолт `x2plus`), `face` (`off`|`face`|`all`, дефолт `off`),
|
||||||
|
`face_model` (`gfpgan`|`codeformer`), `strength` (0.0–1.0, только CodeFormer, дефолт 0.7),
|
||||||
|
`jpeg_quality` (70–100, дефолт 92). Неизвестная модель → `400` со списком допустимых.
|
||||||
|
- Полная обратная совместимость: без `model`/`face` → путь «x2plus, JPEG q92», как сегодня.
|
||||||
|
- `face != off` → `face_enhancer.enhance(img, has_aligned=False, only_center_face=False,
|
||||||
|
paste_back=True)`; в ответ добавляем `faces_found`.
|
||||||
|
- **Два формата ответа**: JSON (`{ok, image_base64, model, face, face_model, faces_found,
|
||||||
|
device, elapsed_ms, warnings}`) при `Accept: application/json` — новый путь для воркера;
|
||||||
|
сырой `image/jpeg` без заголовка — текущее поведение (совместимость с ручными `curl`).
|
||||||
|
- `upsampler.enhance()` уже делает тайлинг сам — `tile`/`tile_pad=10` остаются, `tile` берём из env.
|
||||||
|
- `half=True` только при CUDA; `dni_weight` — только для `general-x4v3` при `denoise_strength != 1`.
|
||||||
|
- Расширения определяем по имени файла, а не по `UploadFile.content_type` (браузер и `FormData`
|
||||||
|
в `worker.js` шлют `image/jpeg` для любого исходника — сейчас это уже так, сохраняем).
|
||||||
|
|
||||||
|
### 5.2 `photo-ai/Dockerfile`
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM python:3.10-slim
|
||||||
|
ARG TORCH_VARIANT=cpu # cpu | cu124
|
||||||
|
ARG TORCH_INDEX=https://download.pytorch.org/whl/${TORCH_VARIANT}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `TORCH_VARIANT=cpu` → `torch torchvision --index-url .../whl/cpu` (как сейчас);
|
||||||
|
`cu124` → тот же `pip` с `.../whl/cu124`. Образ один, вариант — аргумент сборки.
|
||||||
|
- Добавить `gfpgan==1.3.8` и `facexlib==0.3.0` (CodeFormer — из TencentARC, пакет/вендоринг).
|
||||||
|
**Проверить доступность пакетов в зеркале pip заранее** — это риск сборки, если недоступны,
|
||||||
|
вендорим исходники в `photo-ai/vendor/` и копируем каталог в образ.
|
||||||
|
- `libgl1 libglib2.0-0` уже есть — facexlib их требует.
|
||||||
|
- Патч `basicsr/data/degradations.py` (`torchvision.transforms.functional_tensor` → `functional`)
|
||||||
|
**сохранить** — без него basicsr падает на torch >= 2.0.
|
||||||
|
- `ENV PHOTO_AI_MODELS_DIR=/models`; старый `MODEL_PATH` продолжаем читать как алиас, чтобы
|
||||||
|
существующий `.env`/том не сломался.
|
||||||
|
- Размер образа: CPU ~1.2 ГБ → ~2.5 ГБ; cu124 ~6–7 ГБ. Учитывать при `--build`.
|
||||||
|
- Тома: `photo-ai-models` уже есть — все веса в `/models/weights/*.pth`.
|
||||||
|
|
||||||
|
### 5.3 `docker-compose.yml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
photo-ai:
|
||||||
|
environment:
|
||||||
|
PHOTO_AI_DEVICE: ${PHOTO_AI_DEVICE:-auto}
|
||||||
|
PHOTO_AI_FACE_MODEL: ${PHOTO_AI_FACE_MODEL:-gfpgan}
|
||||||
|
PHOTO_AI_TILE: ${PHOTO_AI_TILE:-256}
|
||||||
|
PHOTO_AI_MAX_PIXELS: ${PHOTO_AI_MAX_PIXELS:-4000000}
|
||||||
|
PHOTO_AI_LOAD_ALL: ${PHOTO_AI_LOAD_ALL:-0}
|
||||||
|
PHOTO_AI_JPEG_QUALITY: ${PHOTO_AI_JPEG_QUALITY:-92}
|
||||||
|
MODEL_PATH: /models/RealESRGAN_x2plus.pth
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3).status==200 else 1)\""]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 300s
|
||||||
|
```
|
||||||
|
|
||||||
|
`start_period: 300s` — загрузка x2plus + GFPGAN на CPU занимает минуты; короткий период даст
|
||||||
|
`unhealthy` на старте. `PHOTO_AI_LOAD_ALL=1` предзагружает все модели (для GPU-сервера с запасом
|
||||||
|
RAM), по умолчанию `0` — ленивая загрузка.
|
||||||
|
|
||||||
|
### 5.4 `worker.js` (функция `createPhotoEnhanceWorker`)
|
||||||
|
|
||||||
|
- `runAiEnhance(srcKey, params)`:
|
||||||
|
- читает `params.model`, `params.face`, `params.face_model`, `params.strength`;
|
||||||
|
- отправляет их вместе с `image` и `scale`; ставит `Accept: application/json`, разбирает
|
||||||
|
JSON-ответ, декодирует base64 в буфер;
|
||||||
|
- если сервис вернул `image/jpeg` (старая версия photo-ai) — работает как сейчас, без ошибок;
|
||||||
|
- `503` (`Retry-After`) обрабатывает отдельно: `sleep` 10 с, `return false` — **без** инкремента
|
||||||
|
`attempts` (мягкий повтор, задание не сгорает);
|
||||||
|
- `AbortError` от `AbortSignal.timeout(PHOTO_AI_TIMEOUT_MS)` → сообщение
|
||||||
|
«ИИ-сервис не ответил за N с» (уже ошибка с попытками);
|
||||||
|
- в аудит пишет модель/устройство/время: `logAudit(null, 'photo.job.preview', { ..., model,
|
||||||
|
face, device, elapsed_ms })`.
|
||||||
|
- `processOne`: `job.action === 'ai' || job.action === 'ai_face'` → AI-путь; `enhance` → sharp.
|
||||||
|
Если `params` пусты, `action='ai_face'` даёт дефолт `face='face'`.
|
||||||
|
- `CONFIG` воркера дополняем `face_model`, `default_model`, `face_timeout_ms` — они попадают в
|
||||||
|
`GET /api/photo-jobs/status.worker.config`.
|
||||||
|
- Для face-режима на CPU вводим отдельный, больший таймаут: `PHOTO_AI_FACE_TIMEOUT_MS`
|
||||||
|
(дефолт 600 000) вместо 300 000.
|
||||||
|
|
||||||
|
|
||||||
|
### 5.5 `server.js`
|
||||||
|
|
||||||
|
1. `POST /api/entries/:id/photo/enhance-ai` (строка 5114) — тело
|
||||||
|
`{ model, face, face_model, strength }`:
|
||||||
|
- `model` ∈ `['x2plus', 'general-x4v3', 'animevideo-v3']`;
|
||||||
|
- `face` ∈ `['off', 'face', 'all']`;
|
||||||
|
- `face_model` ∈ `['gfpgan', 'codeformer']`;
|
||||||
|
- `strength` — число 0…1, только для CodeFormer;
|
||||||
|
- `params = JSON.stringify({ model, face, face_model, strength })` в `photo_jobs.params`;
|
||||||
|
- `action = face === 'off' ? 'ai' : 'ai_face'`;
|
||||||
|
- `400` при невалидных значениях; пустое тело → сегодняшнее поведение (`params = NULL`,
|
||||||
|
`action = 'ai'`). Валидация — инлайн-хелперами (`optInt`) и явными списками, как в
|
||||||
|
`PUT /api/settings`.
|
||||||
|
2. `PHOTO_JOB_ACTIONS` — добавить `'ai_face'`, `'ai_upscale'`. Схема не меняется
|
||||||
|
(`action VARCHAR(20)`), но белый список используется в двух местах: `ensurePhotoJobsTable()`
|
||||||
|
(~1238–1256) и при разборе restore-данных (~2259) — обновить **оба**.
|
||||||
|
3. `GET /api/photo-jobs/status` (строка 5782) — прокинуть `service.device`,
|
||||||
|
`service.device_name`, `service.ready`, `service.vram_free_mb`, `service.models`,
|
||||||
|
`service.face_models` из `GET /health` photo-ai (таймаут 5 с, по образцу `aiHealthCheck()`).
|
||||||
|
Запрос делать без падения: photo-ai недоступен → `service = { reachable: false }`.
|
||||||
|
4. Новый `GET /api/photo-ai/health` (`requireAdmin`) — прямой прокси `/health` для оператора.
|
||||||
|
В `getStackInfo()` добавить блок `photo_ai` (`device`, `device_name`, `vram_total_mb`,
|
||||||
|
`models`) — рендерится в «Статус стека» на `public/settings.html`.
|
||||||
|
5. Настройки — валидация рядом со строкой 1710:
|
||||||
|
- `photo_ai_face_mode` ∈ `['off', 'face', 'all']`, дефолт `off`;
|
||||||
|
- `photo_ai_device_pref` ∈ `['auto', 'cuda', 'cpu']`, дефолт `auto` — **только для UI и
|
||||||
|
документации**: фактическое устройство определяет контейнер через env, UI показывает,
|
||||||
|
совпадает ли желаемое с фактическим (если нет — подсветить).
|
||||||
|
- дефолты дописать в `db/init.sql`, `db/migration.sql` (`INSERT ... ON CONFLICT DO NOTHING`)
|
||||||
|
и в `keys`/`defaults` `/api/public-settings` (строка 1663–1664);
|
||||||
|
- `photo_ai_enabled` там уже отдаётся (строка 1677) — сохранить.
|
||||||
|
6. `worker.js` вызывается с новыми параметрами: в `createPhotoEnhanceWorker({...})` (строка 6045)
|
||||||
|
добавить `faceTimeoutMs: PHOTO_AI_FACE_TIMEOUT_MS`, `defaultFaceModel: PHOTO_AI_FACE_MODEL`.
|
||||||
|
7. Предупреждения сервиса (`warnings`, например «вход меньше 320×320») сохранять в аудит, чтобы
|
||||||
|
оператор понимал, что face-режим не сработал не из-за ошибки.
|
||||||
|
|
||||||
|
### 5.6 Фронтенд
|
||||||
|
|
||||||
|
- `public/js/journal.js` (кнопка «🤖 ИИ», ~строка 620) — рядом dropdown: «Универсально (x2)»,
|
||||||
|
«Быстро (x4)», «Лица (GFPGAN)», «Лица + фон». Значение уходит в
|
||||||
|
`POST /api/entries/:id/photo/enhance-ai` телом `{ model, face, face_model }`. Дефолт — из
|
||||||
|
`photo_ai_face_mode` (публичные настройки уже читаются на этой странице).
|
||||||
|
- `public/js/worker.js` — `PHOTO_ACTION_LABELS` дополнить (`ai_face`: «ИИ + лица»,
|
||||||
|
`ai_upscale`: «ИИ-апскейл»); в модалке сравнения «Было/Стало» показать `model`, `device`,
|
||||||
|
`faces_found`, `elapsed_ms`. Новых колонок в БД не нужно — данные берём из `params` и аудита.
|
||||||
|
- `public/settings.html` + `public/js/settings.js` — блок «Фото-ИИ»: селект face-режима, селект
|
||||||
|
устройства («желаемое» + строка «фактическое»), read-only статус (`device_name`, VRAM, список
|
||||||
|
моделей) из `/api/photo-jobs/status`. Новые поля добавить в `DIRTY_FIELDS` (строка 1
|
||||||
|
`settings.js`) и в сборку payload (строка ~739).
|
||||||
|
- `public/js/audit.js` — новые коды действий аудита прописать в словарь меток, иначе в UI будет
|
||||||
|
сырой код (`photo.job.preview` уже есть, при добавлении `photo.job.ai` — добавить и там).
|
||||||
|
|
||||||
|
### 5.7 Документация и тесты
|
||||||
|
|
||||||
|
- `AGENTS.md` — раздел про photo-ai: реестр моделей, device-политика, новые env, GPU-override,
|
||||||
|
обновлённый контракт `/enhance` и `/health`.
|
||||||
|
- `README.md` — установка NVIDIA Container Toolkit, команда запуска с `docker-compose.gpu.yml`,
|
||||||
|
проверка `GET /api/photo-jobs/status` → `service.device`.
|
||||||
|
- `.env.example` — новые переменные с комментариями (см. §7).
|
||||||
|
- `api.smoketest.js` — добавить проверки:
|
||||||
|
- `POST /api/entries/:id/photo/enhance-ai` с `model: 'нет такой'` → `400`;
|
||||||
|
- `face: 'face'` без `PHOTO_AI_URL` → `503`;
|
||||||
|
- `GET /api/photo-jobs/status` содержит `service` (или `ai_configured: false`).
|
||||||
|
Это соответствует правилу из `AGENTS.md`: контракт авторизации и API фиксируется в smoke-тесте
|
||||||
|
в том же изменении.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Порядок внедрения (этапы)
|
||||||
|
|
||||||
|
Каждый этап заканчивается проверяемым результатом и не ломает предыдущий.
|
||||||
|
|
||||||
|
### Этап 0. Подготовка (0.5 дня)
|
||||||
|
- Установить NVIDIA Container Toolkit, проверить `docker run --gpus all ... nvidia-smi`.
|
||||||
|
- Зафиксировать текущее состояние `.env` (`PHOTO_AI_URL=` пусто или `http://photo-ai:8080`).
|
||||||
|
- Сохранить эталон «до»: 3–5 фото (портрет, групповое, без лиц, зашумлённое), прогнать
|
||||||
|
`curl -F image=@photo.jpg -F scale=2 http://localhost:8080/enhance`.
|
||||||
|
|
||||||
|
**Приёмка:** `nvidia-smi` внутри контейнера видит RTX 3050; эталонные JPEG сохранены для
|
||||||
|
сравнения на следующих этапах.
|
||||||
|
|
||||||
|
### Этап 1. `app.py`: реестр моделей + автовыбор device (1–1.5 дня)
|
||||||
|
- `pick_device()`, `ModelPool`, расширенный `/health`, ленивая загрузка x2plus, warm-up.
|
||||||
|
- Старый `/enhance` по поведению не меняется (проверить визуально и по размеру файла).
|
||||||
|
|
||||||
|
**Приёмка:** `/health` отдаёт `device`, `device_name`, `half`; `PHOTO_AI_DEVICE=cpu` при рабочей
|
||||||
|
CUDA даёт `cpu`; `PHOTO_AI_DEVICE=cuda` без toolkit даёт `cpu` + WARN, сервис поднимается.
|
||||||
|
|
||||||
|
### Этап 2. Dockerfile + compose (0.5–1 день)
|
||||||
|
- `ARG TORCH_VARIANT`, `docker-compose.gpu.yml`, healthcheck, новые env, алиас `MODEL_PATH`.
|
||||||
|
|
||||||
|
**Приёмка:** CPU- и GPU-сборка стартуют; `/api/photo-jobs/status` показывает
|
||||||
|
`service.device = "cuda:0"` в GPU-режиме и `"cpu"` в CPU-режиме; при пустом `PHOTO_AI_URL`
|
||||||
|
приложение полностью работает без photo-ai.
|
||||||
|
|
||||||
|
### Этап 3. GFPGAN — face-режим (1–2 дня)
|
||||||
|
- `FACE_REGISTRY`, поля `face`/`face_model`/`strength` в `/enhance`, JSON-ответ, `faces_found`,
|
||||||
|
OOM-ретрай и fallback на CPU.
|
||||||
|
|
||||||
|
**Приёмка:** на тестовом портрете 640×480 в режиме `face` лицо резче, `faces_found = 1`;
|
||||||
|
на фото без лиц — `faces_found = 0` и результат не хуже, чем `x2plus`; искусственный OOM
|
||||||
|
(`tile=1024` на 4 ГБ) деградирует до CPU без падения сервиса.
|
||||||
|
|
||||||
|
### Этап 4. Воркер и API (1 день)
|
||||||
|
- `worker.js`: `params` → модель/face, JSON-разбор, `503`-ожидание без траты попыток, аудит.
|
||||||
|
- `server.js`: валидация `params`, `action = 'ai_face'`, `service` в статусе, новые настройки.
|
||||||
|
|
||||||
|
**Приёмка:** задание через API с `face='face'` доходит до `done`, параметры сохранены в
|
||||||
|
`photo_jobs.params`, в аудите — модель/устройство/время; остановка photo-ai на лету даёт задание,
|
||||||
|
которое дообработается после возврата сервиса (без `error`).
|
||||||
|
|
||||||
|
### Этап 5. Фронтенд (1 день)
|
||||||
|
- Выбор модели в журнале, статус устройства в настройках, новые метки в воркере.
|
||||||
|
|
||||||
|
**Приёмка:** из журнала доступны все 4 варианта; после постановки видно «В очереди», затем
|
||||||
|
сравнение «Было/Стало»; в настройках показано фактическое устройство и VRAM.
|
||||||
|
|
||||||
|
### Этап 6. Docs + smoke (0.5 дня)
|
||||||
|
- `AGENTS.md`, `README.md`, `.env.example`, `api.smoketest.js`.
|
||||||
|
|
||||||
|
**Приёмка:** `node api.smoketest.js` проходит; тесты валидации возвращают `400`; документация
|
||||||
|
совпадает с кодом.
|
||||||
|
|
||||||
|
**Итого:** ~5–7 рабочих дней. Этапы 1–3 дают ценность уже без фронтенда (ручные вызовы `curl`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Новые переменные окружения
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# === Фото-ИИ (Real-ESRGAN + восстановление лиц) ===
|
||||||
|
# Адрес сервиса; пусто = фото-ИИ выключен (кнопка «ИИ» недоступна)
|
||||||
|
PHOTO_AI_URL=
|
||||||
|
# Максимум входных пикселей (даунскейл перед обработкой)
|
||||||
|
PHOTO_AI_MAX_PIXELS=4000000
|
||||||
|
# Устройство: auto | cuda | cpu. auto = CUDA, если доступна, иначе CPU
|
||||||
|
PHOTO_AI_DEVICE=auto
|
||||||
|
# Тайл для апскейла: меньше = меньше VRAM, но медленнее (256 на 4 ГБ, 512+ при 8 ГБ+)
|
||||||
|
PHOTO_AI_TILE=256
|
||||||
|
# Модель лиц: gfpgan | codeformer | none
|
||||||
|
PHOTO_AI_FACE_MODEL=gfpgan
|
||||||
|
# Предзагружать все модели при старте (1 — да, нужно больше RAM/VRAM)
|
||||||
|
PHOTO_AI_LOAD_ALL=0
|
||||||
|
# Режим лиц по умолчанию для UI: off | face | all
|
||||||
|
PHOTO_AI_FACE_MODE=off
|
||||||
|
# Качество JPEG результата (70–100)
|
||||||
|
PHOTO_AI_JPEG_QUALITY=92
|
||||||
|
# Отдельный таймаут для face-режима, мс (на CPU медленно)
|
||||||
|
PHOTO_AI_FACE_TIMEOUT_MS=600000
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Риски и как их закрываем
|
||||||
|
|
||||||
|
| Риск | Вероятность | Митигация |
|
||||||
|
|------|-------------|-----------|
|
||||||
|
| `nvidia-container-toolkit` не установлен / политика хоста запрещает | средняя | GPU — отдельный override-файл; CPU-путь остаётся дефолтом и полностью рабочим; в `/health` видно фактическое устройство |
|
||||||
|
| 4 ГБ VRAM не хватает для GFPGAN + x2plus | высокая | `tile=256` по умолчанию, авто-снижение до 128/64 при OOM, `half=True` только на CUDA, при повторном OOM — fallback на CPU |
|
||||||
|
| GFPGAN «портит» лица (артефакты идентичности) | средняя | face-режим только по явному выбору; результат попадает в `photo_jobs.after_path` и **не применяется автоматически** (нужно нажать «Применить»), история и «Вернуть оригинал» сохраняются |
|
||||||
|
| Долгая загрузка весов (333 МБ) на первом запросе | высокая | ленивая загрузка + `503`/`Retry-After` вместо ошибки; `PHOTO_AI_LOAD_ALL=1` для прогрева; том `photo-ai-models` сохраняет веса между перезапусками; `start_period: 300s` в healthcheck |
|
||||||
|
| Сборка ломается на `pip install gfpgan/facexlib` (пакета нет в зеркале) | средняя | **проверить доступность пакетов до начала работ**; при отсутствии — вендорить исходники в `photo-ai/vendor/` и копировать каталог в образ |
|
||||||
|
| Рост образа до ~6–7 ГБ (cu124) | средняя | CPU-образ по умолчанию, GPU-образ собирается отдельно; на `/home` свободно 59 ГБ |
|
||||||
|
| CPU-инференс с GFPGAN медленнее таймаута воркера | средняя | отдельный `PHOTO_AI_FACE_TIMEOUT_MS` (600 с) для face-режима; в UI предупреждение «на CPU медленно»; для слабых машин — `off` |
|
||||||
|
| Регресс текущего `/enhance` | низкая | контракт по умолчанию не меняется; эталонное сравнение на этапах 1–2; старые `photo_jobs.params = NULL` продолжают работать |
|
||||||
|
| Нехватка RAM (15 ГБ, занято ~9 ГБ) при загрузке моделей на CPU | средняя | `PHOTO_AI_LOAD_ALL=0`, кеш моделей с вытеснением (LRU, максимум 2), контроль через `docker stats` |
|
||||||
|
| Воркер зацикливается на «вечно недоступном» сервисе | низкая | при `503`/`unreachable` — экспоненциальный `sleep`, лимит мягких повторов (например 60) → затем одна честная ошибка с понятным текстом |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Критерии готовности
|
||||||
|
|
||||||
|
1. `photo-ai` стартует и на CPU-хосте, и с CUDA, определяя устройство автоматически; `/health`
|
||||||
|
сообщает фактическое устройство, имя GPU, VRAM, доступные и загруженные модели.
|
||||||
|
2. Существующий сценарий «🤖 ИИ» без параметров даёт результат, эквивалентный текущему.
|
||||||
|
3. Доступны режимы: универсальный x2, быстрый x4, только лица (GFPGAN), лица + фон.
|
||||||
|
4. Задание с face-моделью проходит полный цикл `pending → processing → done`; результат виден в
|
||||||
|
сравнении «Было/Стало», применим вручную и откатывается.
|
||||||
|
5. Отсутствие GPU, отсутствие `nvidia-ctk` и недоступный `photo-ai` **не** ломают приложение:
|
||||||
|
`PHOTO_AI_URL=` пусто → кнопка «ИИ» недоступна; сервис упал → задания пережидают (`503`), не
|
||||||
|
сжигая попытки; пустой `.env` полностью совместим.
|
||||||
|
6. `node api.smoketest.js` проходит; `AGENTS.md`, `README.md`, `.env.example` описывают новые
|
||||||
|
переменные и GPU-запуск.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Быстрые команды для проверки после реализации
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Статус устройства и моделей
|
||||||
|
docker compose exec -T app node -e "fetch(process.env.PHOTO_AI_URL+'/health').then(r=>r.json()).then(console.log)"
|
||||||
|
curl -s -H "X-Auth-Token: $TOKEN" http://localhost:3003/api/photo-jobs/status | python3 -m json.tool
|
||||||
|
|
||||||
|
# Прямой вызов с лицами (ручная проверка)
|
||||||
|
curl -s -X POST http://localhost:8080/enhance \
|
||||||
|
-F image=@photo.jpg -F scale=2 -F model=x2plus -F face=face -F face_model=gfpgan \
|
||||||
|
-H 'Accept: application/json' | python3 -c "import json,sys,base64; d=json.load(sys.stdin); open('out.jpg','wb').write(base64.b64decode(d['image_base64'])); print({k:v for k,v in d.items() if k!='image_base64'})"
|
||||||
|
|
||||||
|
# CPU-режим принудительно
|
||||||
|
docker compose stop photo-ai
|
||||||
|
PHOTO_AI_DEVICE=cpu docker compose -f docker-compose.yml up -d --build photo-ai
|
||||||
|
|
||||||
|
# GPU-режим
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build photo-ai
|
||||||
|
|
||||||
|
# Проверка обратной совместимости (без model/face — как раньше)
|
||||||
|
curl -s -X POST http://localhost:8080/enhance -F image=@photo.jpg -F scale=2 -o old_behavior.jpg
|
||||||
|
|
||||||
|
# Смоук
|
||||||
|
node api.smoketest.js
|
||||||
|
```
|
||||||
|
|
||||||
@@ -182,7 +182,9 @@ branches
|
|||||||
| GET | `/api/share/:token` | Public | Share page data |
|
| GET | `/api/share/:token` | Public | Share page data |
|
||||||
| GET | `/api/links` | Admin | List share links |
|
| GET | `/api/links` | Admin | List share links |
|
||||||
| POST/PUT/DELETE | `/api/links` | Admin | CRUD 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 |
|
| POST | `/api/restore` | Admin | Upload & restore backup |
|
||||||
| GET | `/api/dashboard` | Admin | Dashboard data |
|
| GET | `/api/dashboard` | Admin | Dashboard data |
|
||||||
| GET | `/api/stats` | Admin | Stats cards |
|
| GET | `/api/stats` | Admin | Stats cards |
|
||||||
|
|||||||
@@ -12,7 +12,9 @@
|
|||||||
- **Share-ссылки** — публичные страницы-витрины с выбором группы / воспитанника / диапазона дат
|
- **Share-ссылки** — публичные страницы-витрины с выбором группы / воспитанника / диапазона дат
|
||||||
- **Дашборд** — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников
|
- **Дашборд** — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников
|
||||||
- **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz`
|
- **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz`
|
||||||
- **Настройки** — тексты футера, анти-спам интервал
|
- **Хранилище файлов** — локальный каталог `uploads/` или S3-совместимый сервис (`s3`: SeaweedFS, либо MinIO через оверрайд), перенос файлов скриптом миграции
|
||||||
|
- **Настройки** — тексты футера, анти-спам интервал, системная информация (объёмы БД и хранилища) и «Статус стека»: версии Node.js/Express/PostgreSQL/Redis, состояние сервисов, ОС, CPU, память и аптаймы (`GET /api/system-info` → `stack`)
|
||||||
|
- **Уведомления** — системные события (новые записи журнала, обработка фото нейросетью, ошибки авто-проверки текста, блокировки IP, бэкапы) собираются в «колокольчике» и на странице «Уведомления»; набор событий включается/выключается в «Настройках» → «Уведомления»
|
||||||
- **Публикация через Tailscale** — приложение открывается по постоянному адресу `https://whatido.<tailnet>.ts.net` без проброса портов, внешнего IP и reverse-proxy
|
- **Публикация через Tailscale** — приложение открывается по постоянному адресу `https://whatido.<tailnet>.ts.net` без проброса портов, внешнего IP и reverse-proxy
|
||||||
|
|
||||||
## Технологии
|
## Технологии
|
||||||
@@ -20,6 +22,8 @@
|
|||||||
- Node.js + Express
|
- Node.js + Express
|
||||||
- PostgreSQL (pg)
|
- PostgreSQL (pg)
|
||||||
- Multer (загрузка файлов), Tar (бэкапы)
|
- Multer (загрузка файлов), Tar (бэкапы)
|
||||||
|
- S3-совместимое хранилище (AWS SDK v3): сервис `s3` (SeaweedFS / MinIO)
|
||||||
|
- Redis: кэш, rate limit, баны IP, кэш сессий, pub/sub (SSE и воркеры)
|
||||||
- Lucide (иконки UI)
|
- Lucide (иконки UI)
|
||||||
- Docker / Docker Compose
|
- Docker / Docker Compose
|
||||||
- Tailscale (Serve / Funnel) — публикация по HTTPS
|
- Tailscale (Serve / Funnel) — публикация по HTTPS
|
||||||
@@ -48,6 +52,7 @@ docker compose up -d --build
|
|||||||
- **HTTP** `http://localhost:3003` — редирект на HTTPS
|
- **HTTP** `http://localhost:3003` — редирект на HTTPS
|
||||||
- **HTTPS** `https://localhost:3443` — приложение (самоподписанный сертификат, примите предупреждение браузера)
|
- **HTTPS** `https://localhost:3443` — приложение (самоподписанный сертификат, примите предупреждение браузера)
|
||||||
- **PostgreSQL** — доступен только внутри docker-сети (наружу не публикуется)
|
- **PostgreSQL** — доступен только внутри docker-сети (наружу не публикуется)
|
||||||
|
- **Redis** — `127.0.0.1:6379` на хосте (только loopback), внутри сети — `redis:6379`
|
||||||
|
|
||||||
Управление:
|
Управление:
|
||||||
|
|
||||||
@@ -59,6 +64,35 @@ docker compose down # остановка (данные сохраняю
|
|||||||
|
|
||||||
> Приложение **не запустится** без `ADMIN_PASSWORD` (защита от пароля по умолчанию).
|
> Приложение **не запустится** без `ADMIN_PASSWORD` (защита от пароля по умолчанию).
|
||||||
> `DB_PASSWORD` задаёт пароль пользователя `app` в PostgreSQL.
|
> `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,20 +100,105 @@ docker compose down # остановка (данные сохраняю
|
|||||||
|
|
||||||
| Переменная | По умолчанию | Назначение |
|
| Переменная | По умолчанию | Назначение |
|
||||||
|------------------|--------------------|-------------------------------------|
|
|------------------|--------------------|-------------------------------------|
|
||||||
| `ADMIN_PASSWORD` | — (обязательно) | Пароль администратора (X-Admin-Token). Без него сервер не стартует |
|
| `ADMIN_PASSWORD` | — (обязательно) | Пароль первого администратора, создаётся в пустой БД. Не является способом авторизации в API |
|
||||||
|
| `ADMIN_USERNAME` | `admin` | Логин первого администратора |
|
||||||
| `DB_PASSWORD` | — (обязательно) | Пароль пользователя `app` в PostgreSQL |
|
| `DB_PASSWORD` | — (обязательно) | Пароль пользователя `app` в PostgreSQL |
|
||||||
|
| `REDIS_PASSWORD` | — (обязательно) | Пароль Redis (`--requirepass`) |
|
||||||
|
| `REDIS_PREFIX` | `whatido` | Префикс ключей Redis — свой для каждого инстанса |
|
||||||
|
| `REDIS_MAXMEMORY` | `256mb` | Лимит памяти Redis, при переполнении вытесняется LRU |
|
||||||
|
|
||||||
Пример `.env` (в репозитории — `.env.example`):
|
Пример `.env` (в репозитории — `.env.example`):
|
||||||
|
|
||||||
```
|
```
|
||||||
ADMIN_PASSWORD=сложный-пароль
|
ADMIN_PASSWORD=сложный-пароль
|
||||||
DB_PASSWORD=случайная-длинная-строка
|
DB_PASSWORD=случайная-длинная-строка
|
||||||
|
REDIS_PASSWORD=случайная-длинная-строка
|
||||||
```
|
```
|
||||||
|
|
||||||
`DB_PASSWORD` подставляется в `docker-compose.yml` в `POSTGRES_PASSWORD` и `DATABASE_URL`. Если БД уже была инициализирована ранее, значение `DB_PASSWORD` должно совпадать с фактическим паролем пользователя `app` в БД (иначе приложение не подключится).
|
`DB_PASSWORD` подставляется в `docker-compose.yml` в `POSTGRES_PASSWORD` и `DATABASE_URL`. Если БД уже была инициализирована ранее, значение `DB_PASSWORD` должно совпадать с фактическим паролем пользователя `app` в БД (иначе приложение не подключится).
|
||||||
|
|
||||||
Имя узла Tailscale задаётся в `docker-compose.yml` (`tailscale.hostname`, по умолчанию `whatido`).
|
Имя узла Tailscale задаётся в `docker-compose.yml` (`tailscale.hostname`, по умолчанию `whatido`).
|
||||||
|
|
||||||
|
## ИИ-улучшение фото (photo-ai)
|
||||||
|
|
||||||
|
Сервис `photo-ai` (Real-ESRGAN + GFPGAN) поднимается вместе со стеком и **включён по умолчанию**:
|
||||||
|
`PHOTO_AI_URL` в `docker-compose.yml` равен `http://photo-ai:8080`, кнопка «🤖 ИИ» активна,
|
||||||
|
а задания обрабатывает фоновый воркер. Базовая сборка работает на CPU, GPU не требуется.
|
||||||
|
|
||||||
|
| Переменная | По умолчанию | Назначение |
|
||||||
|
|---|---|---|
|
||||||
|
| `PHOTO_AI_URL` | `http://photo-ai:8080` | Адрес сервиса. **Пустое значение = сервис выключен**: кнопка «🤖 ИИ» скрыта, `POST /api/entries/:id/photo/enhance-ai` отвечает `503`, приложение при этом полностью работоспособно |
|
||||||
|
| `PHOTO_AI_MAX_PIXELS` | `4000000` | Максимум пикселей входного изображения, вход большего размера уменьшается |
|
||||||
|
| `PHOTO_AI_DEVICE` | `auto` | Устройство инференса: `auto` (CUDA, если контейнеру выдан GPU, иначе CPU), `cuda`, `cpu`. Явный `cuda` без CUDA не роняет сервис: WARN в лог и работа на CPU |
|
||||||
|
| `PHOTO_AI_TILE` | `256` | Размер тайла инференса (`0` — без тайлов): меньше тайл — меньше памяти, но медленнее |
|
||||||
|
| `PHOTO_AI_FACE_MODEL` | `gfpgan` | Модель восстановления лиц: `gfpgan`; `codeformer` доступен только при вендоринге модуля в `photo-ai/vendor` |
|
||||||
|
| `PHOTO_AI_LOAD_ALL` | `0` | Загружать все модели при старте (`1`) или лениво по требованию (`0`) |
|
||||||
|
| `PHOTO_AI_JPEG_QUALITY` | `92` | Качество JPEG результата, 70..100 |
|
||||||
|
| `PHOTO_AI_FACE_TIMEOUT_MS` | `600000` | Таймаут заданий с восстановлением лиц (мс) |
|
||||||
|
| `PHOTO_AI_SOFT_MAX_RETRIES` | `60` | Сколько раз задание ждёт недоступный сервис, не увеличивая счётчик попыток; после исчерпания — честная ошибка |
|
||||||
|
| `PHOTO_AI_SOFT_BACKOFF_MS` | `10000` | Первая пауза перед мягким повтором |
|
||||||
|
| `PHOTO_AI_SOFT_BACKOFF_MAX_MS` | `300000` | Потолок паузы (задержка растёт вдвое) |
|
||||||
|
|
||||||
|
### Запуск на NVIDIA GPU
|
||||||
|
|
||||||
|
GPU не обязателен: без него сервис работает на CPU. Чтобы включить GPU-вариант, нужен драйвер NVIDIA
|
||||||
|
и NVIDIA Container Toolkit.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Toolkit (один раз, требует sudo)
|
||||||
|
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
|
||||||
|
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
|
||||||
|
| sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
|
||||||
|
| sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
|
||||||
|
sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit
|
||||||
|
|
||||||
|
# 2. GPU-образ (для устройств с поддержкой CDI спеку генерирует сам toolkit)
|
||||||
|
sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml
|
||||||
|
sudo systemctl restart docker
|
||||||
|
|
||||||
|
# 3. Запуск
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build photo-ai
|
||||||
|
curl http://127.0.0.1:8081/health
|
||||||
|
```
|
||||||
|
|
||||||
|
`docker-compose.gpu.yml` собирает отдельный тег `whatido-photo-ai:cu126` (torch из индекса `cu126` —
|
||||||
|
те же версии, что и в CPU-образе, отличаются только CUDA-библиотеки), поэтому CPU-образ
|
||||||
|
`whatido-photo-ai:latest` не перетирается и переключение обратно — обычный `docker compose up -d photo-ai`.
|
||||||
|
|
||||||
|
GPU выдаётся контейнеру через CDI (`device_ids: nvidia.com/gpu=all`), поэтому править
|
||||||
|
`/etc/docker/daemon.json` и перезапускать демон не нужно. Если nvidia-runtime уже зарегистрирован в
|
||||||
|
демоне (`nvidia-ctk runtime configure --runtime=docker`), в оверрайде можно заменить это на
|
||||||
|
классическое резервирование `driver: nvidia, count: 1` — результат тот же.
|
||||||
|
|
||||||
|
Проверка результата: в `/health` должны быть `device: cuda:0`, `half: true`, непустые
|
||||||
|
`vram_total_mb`/`vram_free_mb`. На 4 ГБ (например, RTX 3050 Laptop) реально держатся одновременно
|
||||||
|
`x2plus` и `gfpgan`: `vram_free_mb` после двух моделей — около 100–300 МБ, поэтому `PHOTO_AI_TILE`
|
||||||
|
оставьте небольшим (`256`), а `PHOTO_AI_LOAD_ALL=1` на 4 ГБ лучше не включать — предзагрузка всех
|
||||||
|
моделей подряд исчерпает VRAM. Если памяти не хватило, сервис сам проходит лестницу тайлов
|
||||||
|
(`PHOTO_AI_TILE` → /2 → /4), затем переключается на CPU и возвращает результат с предупреждением
|
||||||
|
в `warnings` — задание при этом не падает.
|
||||||
|
|
||||||
|
Порт `8081` на `127.0.0.1` — только loopback хоста, наружу ничего не публикуется (хостовый `8080` занят
|
||||||
|
`text-corrector`). Ручные проверки сервиса:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8081/health
|
||||||
|
curl -F "image=@photo.jpg" -F "scale=2" http://127.0.0.1:8081/enhance -o out.jpg
|
||||||
|
curl -H "Accept: application/json" -F "image=@photo.jpg" -F "face=face" http://127.0.0.1:8081/enhance \
|
||||||
|
| python3 -c "import json,sys; d=json.load(sys.stdin); print({k: d[k] for k in ('device','faces_found','elapsed_ms','warnings')})"
|
||||||
|
```
|
||||||
|
|
||||||
|
Состояние сервиса и воркера — в `GET /api/photo-jobs/status` (admin): `service` отдаёт health фото-сервиса
|
||||||
|
(`configured`, `reachable`, `device`, `device_name`, `half`, `tile`, `vram_total_mb`, `vram_free_mb`,
|
||||||
|
`models`, `face_models`, `loaded`, `latency_ms`, `error`), `ai_configured` — задан ли `PHOTO_AI_URL`,
|
||||||
|
`worker` — состояние очереди и конфигурация воркера.
|
||||||
|
|
||||||
|
Параметры `/enhance`: `image` (файл), `scale` (`2`..`4`), `model` (`x2plus`|`general-x4v3`|`animevideo-v3`),
|
||||||
|
`face` (`off`|`face`|`all`), `face_model` (`gfpgan`|`codeformer`), `strength` (`0..1`, только CodeFormer),
|
||||||
|
`jpeg_quality` (`70..100`). По умолчанию отдаётся сырой `image/jpeg`; заголовок
|
||||||
|
`Accept: application/json` переключает на JSON с `image_base64`, `faces_found`, `device`, `elapsed_ms`
|
||||||
|
и `warnings` (малое разрешение входа, лица не найдены, не хватило памяти, CodeFormer на CPU).
|
||||||
|
|
||||||
## Публичный доступ через Tailscale
|
## Публичный доступ через Tailscale
|
||||||
|
|
||||||
Стек не требует внешнего IP и проброса портов: контейнер `tailscale` запускается с `network_mode: host`, входит в вашу tailnet-сеть и через **Serve** открывает приложение внутри tailnet, а через **Funnel** — в публичном интернете.
|
Стек не требует внешнего IP и проброса портов: контейнер `tailscale` запускается с `network_mode: host`, входит в вашу tailnet-сеть и через **Serve** открывает приложение внутри tailnet, а через **Funnel** — в публичном интернете.
|
||||||
@@ -212,20 +331,186 @@ docker compose exec cloudflared wg show # есть handshake — VPN подн
|
|||||||
- `group_photos` — фотохроника групп
|
- `group_photos` — фотохроника групп
|
||||||
- `share_links` — публичные ссылки-витрины
|
- `share_links` — публичные ссылки-витрины
|
||||||
- `settings` — пары ключ/значение (анти-спам интервал, футер)
|
- `settings` — пары ключ/значение (анти-спам интервал, футер)
|
||||||
|
- `audit_log` — журнал действий (`action`, `target` JSONB, `ip`, `user_id`)
|
||||||
|
|
||||||
Схема инициализируется при первом запуске из `db/init.sql`; миграции существующей БД — в `db/migration.sql`.
|
Схема инициализируется при первом запуске из `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`) и в блоке «Кэш» на странице
|
||||||
|
Настроек → Стек (там же — «нет связи — в памяти», если Redis не отвечает).
|
||||||
|
|
||||||
|
### Команды
|
||||||
|
|
||||||
|
```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 (нужен запущенный стек)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Уведомления
|
||||||
|
|
||||||
|
Система уведомлений — журнал событий (`notifications`) с отметками прочтения на пользователя (`notification_reads`) плюс каталог типов событий `NOTIFY_TYPES` в `server.js`.
|
||||||
|
|
||||||
|
| Тип | Событие | Кому видно |
|
||||||
|
|-----|---------|------------|
|
||||||
|
| `entry.new` | новая запись в журнале (форма ученика или ручное добавление) | филиал группы |
|
||||||
|
| `entry.ai.corrected` | ИИ исправил текст (по умолчанию выключено) | филиал группы |
|
||||||
|
| `entry.ai.error` | авто-проверка текста не удалась | филиал группы |
|
||||||
|
| `photo.job.done` | фото обработано нейросетью или сервером | филиал группы |
|
||||||
|
| `photo.job.error` | очередь обработки фото исчерпала попытки | филиал группы |
|
||||||
|
| `ip.ban` | IP отправлен в бан (авто или вручную) | только админ |
|
||||||
|
| `backup.restore` | восстановление из бэкапа | только админ |
|
||||||
|
| `backup.create` | создан архив бэкапа (по умолчанию выключено) | только админ |
|
||||||
|
|
||||||
|
Где видно: «колокольчик» в боковом меню (панель последних событий, бейдж непрочитанных, опциональные уведомления браузера) и страница `notifications.html` (фильтр «непрочитанные», отметка «прочитано», удаление и полная очистка для админа). Новые события приходят в реальном времени по SSE (`GET /api/notifications/stream`), транспорт — Redis pub/sub с in-memory fallback.
|
||||||
|
|
||||||
|
Что настраивается в «Настройках» → «Уведомления» (ключи таблицы `settings`): общий выключатель `notify_enabled`, срок хранения `notify_retention_days` (1–365 дней, старые уведомления удаляются ежечасно) и отдельный переключатель `notify_<тип>` для каждого события. Там же кнопка тестового уведомления.
|
||||||
|
|
||||||
|
Видимость: администратор видит все уведомления, остальные — только события своего филиала (или без филиала) и никогда — события с пометкой `admin_only`.
|
||||||
|
|
||||||
## Бэкапы
|
## Бэкапы
|
||||||
|
|
||||||
|
|
||||||
В админке (Настройки → Бэкап) можно:
|
В админке (Настройки → Бэкап) можно:
|
||||||
|
|
||||||
- Скачать полный бэкап — `tar.gz`, содержащий `data.json` (все таблицы) и `uploads/`
|
- Скачать полный бэкап — `tar.gz`, содержащий `data.json` (все таблицы) и `uploads/`
|
||||||
- Восстановить из файла бэкапа
|
- Восстановить из файла бэкапа
|
||||||
|
|
||||||
|
Архив формируется на сервере (`POST /api/backup`) и скачивается браузером по одноразовой ссылке (`GET /api/backup/<token>`, действует 30 минут) — загрузку можно возобновить при обрыве связи. Совместимый эндпоинт `GET /api/backup` отдаёт тот же архив сразу.
|
||||||
|
|
||||||
Также доступны скрипты на хосте:
|
Также доступны скрипты на хосте:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -233,9 +518,13 @@ docker compose exec cloudflared wg show # есть handshake — VPN подн
|
|||||||
./scripts/restore.sh # восстановление из архива
|
./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 запрещены.
|
- **CORS отключён** — кросс-доменные запросы к API запрещены.
|
||||||
- **Rate limiting** по IP на публичные роуты: `POST /api/entries` — 10 запросов / 15 мин, загрузка файлов и share-ссылки — 300 / 15 мин.
|
- **Rate limiting** по IP на публичные роуты: `POST /api/entries` — 10 запросов / 15 мин, загрузка файлов и share-ссылки — 300 / 15 мин.
|
||||||
- **Загрузки** ограничены: 30 МБ суммарно на запись, 10 МБ на файл; заблокированы опасные расширения (`.html`, `.js`, `.svg`, `.xml`, `.exe` и др.); SVG не отдаётся inline.
|
- **Загрузки** ограничены: 30 МБ суммарно на запись, 10 МБ на файл; заблокированы опасные расширения (`.html`, `.js`, `.svg`, `.xml`, `.exe` и др.); SVG не отдаётся inline.
|
||||||
@@ -261,24 +550,57 @@ docker compose exec cloudflared wg show # есть handshake — VPN подн
|
|||||||
| `GET/POST/PUT/DELETE` | `/api/share/...`, `/api/links` | Публичные ссылки |
|
| `GET/POST/PUT/DELETE` | `/api/share/...`, `/api/links` | Публичные ссылки |
|
||||||
| `GET` | `/api/backup` | Скачать бэкап |
|
| `GET` | `/api/backup` | Скачать бэкап |
|
||||||
| `POST` | `/api/restore` | Восстановить из бэкапа |
|
| `POST` | `/api/restore` | Восстановить из бэкапа |
|
||||||
|
| `GET` | `/api/notifications` | Уведомления пользователя (`limit`, `offset`, `unread=1`) |
|
||||||
|
| `GET` | `/api/notifications/stream` | SSE-поток уведомлений (заголовок `X-Auth-Token` или `?token=`) |
|
||||||
|
| `GET` | `/api/notifications/meta` | Каталог типов событий и текущие переключатели (admin) |
|
||||||
|
| `POST` | `/api/notifications/:id/read`, `/api/notifications/read-all` | Отметить прочитанным |
|
||||||
|
| `POST` | `/api/notifications/test` | Тестовое уведомление (admin) |
|
||||||
|
| `DELETE` | `/api/notifications/:id`, `/api/notifications` | Удалить уведомление / очистить все (admin) |
|
||||||
| `GET` | `/api/dashboard`, `/api/stats` | Статистика |
|
| `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 # шаблон переменных окружения
|
├── .env.example # шаблон переменных окружения
|
||||||
├── Dockerfile # сборка образа (Node 20, генерация TLS-сертификата)
|
├── Dockerfile # сборка образа (Node 22, генерация TLS-сертификата)
|
||||||
├── server.js # Express-приложение
|
├── 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 не хранится)
|
├── certs/ # cert.pem приложения (монтируется в tailscale, в git не хранится)
|
||||||
├── db/
|
├── db/
|
||||||
│ ├── init.sql # схема при первом запуске
|
│ ├── init.sql # схема при первом запуске
|
||||||
│ └── migration.sql # миграции существующей БД
|
│ └── migration.sql # миграции существующей БД
|
||||||
├── public/ # статика (HTML/CSS/JS админки и витрин)
|
├── public/ # статика (HTML/CSS/JS админки и витрин)
|
||||||
├── scripts/ # вспомогательные скрипты
|
├── scripts/ # вспомогательные скрипты (backup/restore/deploy, migrate-to-s3, storage-sync)
|
||||||
├── uploads/ # загруженные файлы (bind-монт, вне git)
|
├── uploads/ # локальные файлы и кэш миниатюр (bind-монт, вне git)
|
||||||
└── backups/ # локальные бэкапы
|
└── backups/ # локальные бэкапы
|
||||||
```
|
```
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
# TODO: фото-ИИ с восстановлением лиц (Real-ESRGAN + GFPGAN/CodeFormer), авто-выбор GPU/CPU
|
||||||
|
|
||||||
|
Источник требований: `PLAN_PHOTO_FACE_AI.md`. Этот файл — **исполняемый чек-лист для агента**,
|
||||||
|
который пишет код. План не дублируется: здесь только задачи, якоря в коде, контракты и приёмка.
|
||||||
|
|
||||||
|
Перед стартом агент обязан прочитать `AGENTS.md` (правила проекта) и `PLAN_PHOTO_FACE_AI.md` целиком.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Жёсткие инварианты (нарушение = регресс, работа не принята)
|
||||||
|
|
||||||
|
- [x] **I1. Обратная совместимость `/enhance`.** Запрос только с `image` + `scale=2` (без `model`/`face`)
|
||||||
|
обязан давать тот же JPEG, что и до изменений: `x2plus`, `half=False` на CPU, `IMWRITE_JPEG_QUALITY=92`.
|
||||||
|
Проверяется сравнением с эталоном из Stage 0. Эталон снят (см. «Журнал раздела 0»), прогон
|
||||||
|
`node backups/photo-face-ai-baseline/capture.js <label> && node backups/photo-face-ai-baseline/verify.js <label>`
|
||||||
|
даёт байт-в-байт совпадение; повторная проверка обязательна на приёмке Stage 1.
|
||||||
|
- [x] **I2. Старые задания.** `photo_jobs` со `params IS NULL` и `action='ai'` продолжают обрабатываться
|
||||||
|
воркером как раньше (дефолты подставляются на стороне воркера/сервиса).
|
||||||
|
- [x] **I3. photo-ai не обязателен.** Пустой `PHOTO_AI_URL` → кнопка «🤖 ИИ» скрыта
|
||||||
|
(`public/js/journal.js:718`), `POST /api/entries/:id/photo/enhance-ai` → `503`
|
||||||
|
(`server.js:5115`), приложение полностью работоспособно.
|
||||||
|
- [x] **I4. GPU не обязателен.** Нет `nvidia-ctk` / нет CUDA → контейнер поднимается, `PHOTO_AI_DEVICE=cuda`
|
||||||
|
даёт WARN в лог и молчаливый откат на CPU. Падение `/health` из-за отсутствия GPU недопустимо.
|
||||||
|
Проверено на этом хосте: `nvidia-ctk` **отсутствует**, nvidia-runtime в `docker info` нет, `photo-ai`
|
||||||
|
поднялся и `/health` → `{"ok":true}` (GPU недоступен → сервис не падает). Переменная
|
||||||
|
`PHOTO_AI_DEVICE` появится только в Stage 1 — проверка `cuda` → WARN + CPU откат переносится
|
||||||
|
на приёмку Stage 1, сам GPU-режим на этом хосте не проверяется (стоп-условие: нужен
|
||||||
|
`nvidia-container-toolkit`, ставить без разрешения оператора нельзя).
|
||||||
|
- [x] **I5. Воркер не сжигает попытки на «мягких» ошибках.** `503` / недоступный сервис → задание возвращается
|
||||||
|
в `pending` **без** инкремента `attempts`; лимит мягких повторов (например 60) → одна честная ошибка.
|
||||||
|
- [x] **I6. Никаких новых колонок в БД.** Метаданные обработки (модель, устройство, время, `faces_found`)
|
||||||
|
живут в `photo_jobs.params` (JSONB уже есть) и в `audit_log.target`.
|
||||||
|
- [x] **I7. Код без комментариев** (правило `AGENTS.md`), CommonJS в Node-части, параметризованный SQL,
|
||||||
|
никаких прямых `fs.*` по `uploads/` (только `storage.*`).
|
||||||
|
|
||||||
|
### Журнал раздела 0 (проверено 2026-09-28)
|
||||||
|
|
||||||
|
Что сделано, кроме проверки: в `worker.js` устранены два нарушения инвариантов — прямой
|
||||||
|
`fs.writeFileSync` в `uploads/` вместо `storage.put` (I7) и сжигание `attempts` на `503`/недоступности
|
||||||
|
(I5). В `server.js` флаг `photo_ai_enabled` вынесен из кэша `public-settings` (I3). Новые переменные
|
||||||
|
`PHOTO_AI_SOFT_*` описаны в `.env.example`.
|
||||||
|
|
||||||
|
| Инвариант | Как проверено | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| I1 | `capture.js` → `before/`, повтор `repeat1/`, прогон после правок `after-section0/`; `verify.js` сравнивает sha256 | 5/5 байт-в-байт, `/health` `{"ok":true}` |
|
||||||
|
| I2 | запись `INSERT INTO photo_jobs (entry_id, action, status, params) VALUES (385,'ai','pending',NULL)` | `done`, `attempts=0`, результат отдаётся из S3 (200) |
|
||||||
|
| I3 | `docker compose -f docker-compose.yml -f no-photo-ai.yml up -d app` (`PHOTO_AI_URL: ""`) | `photo_ai_enabled=false`, `enhance-ai` → 503, 8 маршрутов API живы |
|
||||||
|
| I4 | `backups/photo-face-ai-baseline/env.txt` | `nvidia-ctk` NOT_FOUND, `nvidia-container-runtime` NOT_FOUND, runtimes `runc`/`io.containerd.runc.v2`, `photo-ai` Up, `/health` ok |
|
||||||
|
| I5 | `photo-ai` остановлен → задание ждало 70 с → вернулось `pending` при `attempts=0`; после `docker compose start photo-ai` → `done` при `attempts=0`. Лимит проверен прогоном с `PHOTO_AI_SOFT_MAX_RETRIES=2` | мягкий повтор не тратит попытки; после лимита `status=error`, `attempts=0`, текст «мягкие повторы исчерпаны (2)» |
|
||||||
|
| I6 | `git status db/` пуст, `information_schema.columns` для `photo_jobs` — те же 12 колонок; метаданные повторов в `audit_log.target` (`soft_attempt`, `soft_limit`) | без изменений схемы |
|
||||||
|
| I7 | `node --check worker.js server.js api.smoketest.js public/js/audit.js`; `grep` по `worker.js` — ни `require('fs')`, ни `require('path')`, ни `uploadsDir`; в диффе нет строк с комментариями; все SQL — на `$1/$2` | чисто |
|
||||||
|
|
||||||
|
Эталон и скрипты — в `backups/photo-face-ai-baseline/` (вне git): `env.txt`, `inputs/`, `inputs.json`,
|
||||||
|
`before/`, `repeat1/`, `after-section0/`, `capture.js`, `verify.js`, `make-inputs.js`.
|
||||||
|
Тестовые задания (id 90–94), их аудит, уведомления и 3 объекта результата в S3 удалены.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Что уже есть (сверено в коде, не перепроверять)
|
||||||
|
|
||||||
|
| Место | Сейчас | Что меняем |
|
||||||
|
|---|---|---|
|
||||||
|
| `photo-ai/app.py:15–73` | одна модель, `MODEL_PATH`, `MAX_INPUT_PIXELS`, `lock`+`upsampler`, `/health` → `{ok}`, `/enhance(image, scale)` | реестр моделей, `pick_device()`, `ModelPool`, расширенные `/health` и `/models` |
|
||||||
|
| `photo-ai/Dockerfile` | `python:3.10-slim`, `torch --index-url .../whl/cpu`, `realesrgan==0.3.0`, патч `basicsr/data/degradations.py` | `ARG TORCH_VARIANT`, `gfpgan`/`facexlib`, вендоринг CodeFormer |
|
||||||
|
| `docker-compose.yml:194–204` | `photo-ai`: `MODEL_PATH`, `MAX_INPUT_PIXELS`, том `photo-ai-models`, `ports: 127.0.0.1:8081:8080` (D1) | новые env + `healthcheck` |
|
||||||
|
| `docker-compose.yml:208` | том `photo-ai-models` | без изменений |
|
||||||
|
| `server.js:1841` | `PHOTO_AI_URL` | + константы `PHOTO_AI_FACE_MODEL`, `PHOTO_AI_FACE_TIMEOUT_MS` |
|
||||||
|
| `server.js:1237–1256` | `ensurePhotoJobsTable()`: колонка `action VARCHAR(20)`, `params JSONB`, CHECK на action **нет** | миграция схемы не требуется |
|
||||||
|
| `server.js:2254` | `PHOTO_JOB_ACTIONS = new Set(['ai','enhance','restore','rollback'])` | + `'ai_face'`, `'ai_upscale'` |
|
||||||
|
| `server.js:5114–5136` | `POST .../enhance-ai`: без тела → `action='ai'`, `params=NULL` | приём/валидация `{model, face, face_model, strength}` |
|
||||||
|
| `server.js:5782–5837` | `GET /api/photo-jobs/status`: `service = await aiHealthCheck()` вычисляется, но **в ответ не попадает** | вернуть `service` (это баг-дыра, см. D2) |
|
||||||
|
| `server.js:769–826` | `getStackInfo()`: `app/deps/runtime/database/cache/storage` | + блок `photo_ai` |
|
||||||
|
| `server.js:1663–1664` | `keys`/`defaults` для `/api/public-settings` | + `photo_ai_face_mode`, `photo_ai_face_model`, `photo_ai_device_pref` |
|
||||||
|
| `server.js:1710` | валидация `photo_enhance_engine` в `PUT /api/settings` | + валидация трёх новых ключей |
|
||||||
|
| `server.js:6045–6056` | `createPhotoEnhanceWorker({...})` | + `faceTimeoutMs`, `defaultFaceModel` |
|
||||||
|
| `worker.js:11` | `PHOTO_AI_TIMEOUT_MS = 300000` (хардкод) | читать env, добавить face-таймаут |
|
||||||
|
| `worker.js:35–41` | `CONFIG` воркера | + `face_model`, `default_model`, `face_timeout_ms` |
|
||||||
|
| `worker.js:122–141` | `runAiEnhance(srcKey)`: `image` + `scale=2`, ждёт сырой `image/jpeg` | проброс `params`, `Accept: application/json`, разбор JSON, `503` без траты попыток |
|
||||||
|
| `worker.js:176` | `job.action === 'ai' ? runAiEnhance : enhanceWithSharp` | + `'ai_face'`, `'ai_upscale'` |
|
||||||
|
| `worker.js:186–192` | инкремент `attempts` и `photo.job.retry` | не инкрементить при `503`/недоступности |
|
||||||
|
| `public/js/journal.js:606–615` | `loadEnhanceEngine()` читает `/api/public-settings` | + чтение дефолта face-режима |
|
||||||
|
| `public/js/journal.js:617–671` | `runPhotoAi()`: `confirm()` + POST без тела | + селект модели, тело `{model, face, face_model}` |
|
||||||
|
| `public/js/journal.js:718` | показ кнопки по `photo_ai_enabled` | без изменений (I3) |
|
||||||
|
| `public/js/worker.js:30–35` | `PHOTO_ACTION_LABELS` | + `ai_face`, `ai_upscale` |
|
||||||
|
| `public/js/worker.js:300–308` | `openPhotoJob(r)` — модалка «Было/Стало» | + `model`/`device`/`faces_found`/`elapsed_ms` |
|
||||||
|
| `public/js/settings.js:1` | `DIRTY_FIELDS` | + новые поля |
|
||||||
|
| `public/js/settings.js:217–218, 739` | загрузка/сохранение `photo_enhance_engine` | + face-режим, face-модель, желаемое устройство |
|
||||||
|
| `public/js/settings.js:372–477` | `renderStackInfo()` — 5 карточек | + карточка «Фото-ИИ» |
|
||||||
|
| `public/settings.html:221–260` | карточка `#sec-photo` (камера) | + блок «Фото-ИИ» (или новая карточка) |
|
||||||
|
| `public/js/audit.js:34–48` | словарь `photo.*` | + новые коды аудита |
|
||||||
|
| `db/init.sql:242`, `db/migration.sql:234` | `INSERT ... 'photo_worker_enabled'` | + дефолты новых настроек |
|
||||||
|
| `.env.example:39–41` | `PHOTO_AI_URL`, `PHOTO_AI_MAX_PIXELS` | + остальные переменные |
|
||||||
|
| `photo-ai` в compose | **порт не проброшен**; `text-corrector` занимает `8080:8080` | см. D1 — ручные `curl` из плана не сработают |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Расхождения плана с кодом — решить ДО правок
|
||||||
|
|
||||||
|
Все шесть расхождений закрыты 2026-09-28 (решения ниже, правки кода — в своих этапах).
|
||||||
|
|
||||||
|
- [x] **D1. Порт для ручных проверок.** `photo-ai` не имеет `ports`/`expose`, а хостовый `8080` занят
|
||||||
|
`text-corrector`. Команды вида `curl http://localhost:8080/enhance` из §10 плана попадут
|
||||||
|
в `text-corrector`. Решение выбрано одно: пробросить `ports: ["127.0.0.1:8081:8080"]` в сервис
|
||||||
|
`photo-ai` — только loopback, наружу (`0.0.0.0`) ничего не публикуется, хостовый `8080`
|
||||||
|
(`text-corrector`) не затрагивается. **Правка применена 2026-09-28:** порт добавлен в
|
||||||
|
`docker-compose.yml`, способ отражён в `README.md` (раздел «ИИ-улучшение фото») и `.env.example`.
|
||||||
|
Проверено: `docker compose up -d photo-ai` → `curl http://127.0.0.1:8081/health` → `{"ok":true}`,
|
||||||
|
`ss -tulpn` → `LISTEN 127.0.0.1:8081` (не `0.0.0.0`).
|
||||||
|
|
||||||
|
- [x] **D2. `service` в статусе фото-воркера.** `server.js:5825` вызывает `aiHealthCheck()` (это health
|
||||||
|
**текстового** ИИ), результат не возвращается. Решение: отдельная функция, общий `aiHealthCheck()`
|
||||||
|
не переиспользуем и из `/api/photo-jobs/status` убираем.
|
||||||
|
- `photoAiHealth()` рядом с `aiHealthCheck()`: `GET ${PHOTO_AI_URL}/health`, таймаут 5 с, без throw;
|
||||||
|
пустой `PHOTO_AI_URL` → `{ configured: false, reachable: false, latency_ms: 0, error: 'PHOTO_AI_URL не настроен' }`,
|
||||||
|
недоступность/таймаут → `{ configured: true, reachable: false, latency_ms, error }`.
|
||||||
|
- `service` в ответе = `{ configured, reachable, latency_ms, error }` + passthrough полей photo-ai
|
||||||
|
(`ok, ready, device, device_name, half, tile, driver, cuda, vram_total_mb, vram_free_mb, models,
|
||||||
|
face_models, loaded, loading, max_pixels`). Первые четыре — тот же контракт, который уже читает
|
||||||
|
фронт текстового ИИ (`public/js/worker.js:89–116`: `reachable`, `latency_ms`, `error`),
|
||||||
|
поэтому карточки переиспользуются без правок.
|
||||||
|
- Вызов уходит в тот же `Promise.all`, что и запросы к БД, — иначе статус получает лишние 5 с;
|
||||||
|
HTTP 200 в любом случае, недоступный сервис — не ошибка API.
|
||||||
|
- Прямой прокси для оператора — `GET /api/photo-ai/health` (`requireAdmin`), отдельным пунктом Stage 4.
|
||||||
|
- **Правка применена 2026-09-28:** `photoAiHealth()` в `server.js` рядом с `aiHealthCheck()`,
|
||||||
|
вызов ушёл в `Promise.all` запроса `/api/photo-jobs/status`, `service` возвращается.
|
||||||
|
Проверено на живом стеке: сервис поднят → `{"ok":true,"configured":true,"reachable":true,"latency_ms":3,"error":null}`;
|
||||||
|
`docker compose stop photo-ai` → HTTP 200 и `{"configured":true,"reachable":false,"latency_ms":3661,"error":"fetch failed"}`
|
||||||
|
(без исключения); после `start` → снова `reachable: true`. Контракт зафиксирован в
|
||||||
|
`api.smoketest.js` (два новых assert'а).
|
||||||
|
|
||||||
|
- [x] **D3. Где живёт белый список действий.** План говорит про два места (`ensurePhotoJobsTable()` и restore).
|
||||||
|
По факту `PHOTO_JOB_ACTIONS` используется **только** в нормализации restore-данных (`server.js:2254`,
|
||||||
|
единственное применение — `server.js:2259`; `grep` по репозиторию больше нигде), а в
|
||||||
|
`ensurePhotoJobsTable()` (`server.js:1237–1254`) CHECK-ограничения на `action` нет:
|
||||||
|
`action VARCHAR(20) NOT NULL DEFAULT 'ai'` — новые значения (`ai_face`, `ai_upscale`) помещаются.
|
||||||
|
Решение: обновить одно место.
|
||||||
|
- Набор выносится на уровень модуля (рядом с `PHOTO_AI_URL`, `server.js:1841`), а не внутрь
|
||||||
|
функции restore; используется в restore-нормализации **и** в валидации
|
||||||
|
`POST /api/entries/:id/photo/enhance-ai` (Stage 4) — забыть маршрут нельзя.
|
||||||
|
- CHECK в БД **не добавляем** (I6: никаких изменений схемы, миграция не нужна). Сверка списка
|
||||||
|
с фактическими `action`, которые пишет код, — `grep` на приёмке этапа.
|
||||||
|
- `ai_upscale` в список попадает сразу, хотя маршрута, его создающего, пока нет: список —
|
||||||
|
это допустимые значения для restore, а не реестр маршрутов.
|
||||||
|
|
||||||
|
- [x] **D4. Доступность пакетов.** Проверено 2026-09-28 в работающем контейнере `photo-ai`
|
||||||
|
(`pip download gfpgan==1.3.8 facexlib==0.3.0 --no-deps` — оба колеса с PyPI, 52 и 59 КБ;
|
||||||
|
`pip list` в образе: `gfpgan 1.3.8`, `facexlib 0.3.0`, `basicsr 1.4.2`, `realesrgan 0.3.0`,
|
||||||
|
`filterpy 1.4.5`, `numba 0.67.0`, `lmdb 2.3.0`, `scipy 1.15.3`; `import gfpgan, facexlib` — ок).
|
||||||
|
Решение: **вендорить не нужно**, пакеты есть на PyPI и уже стоят в образе — `realesrgan==0.3.0`
|
||||||
|
тянет `gfpgan>=1.3.5` и `facexlib>=0.2.5` транзитивно. CodeFormer официальным pip-пакетом
|
||||||
|
не распространяется (см. D5).
|
||||||
|
- Stage 2 фиксирует версии явно (`gfpgan==1.3.8 facexlib==0.3.0`) и убирает dev-зависимости
|
||||||
|
gfpgan из рантайма (`tb-nightly`, `yapf`): ставить gfpgan с `--no-deps` и перечислить
|
||||||
|
реальные зависимости явно.
|
||||||
|
- **Найденное расхождение (Stage 2):** пин `numpy<2` в `photo-ai/Dockerfile` не действует — в образе
|
||||||
|
`numpy 2.2.6`, потому что пин живёт в отдельном вызове `pip install`, который следующие установки
|
||||||
|
не учитывают; там же одновременно стоят `opencv-python 5.0.0.93` (через gfpgan) и
|
||||||
|
`opencv-python-headless 5.0.0.93`. До Stage 2 numpy не трогаем (Stage 1 меряет I1 на текущем
|
||||||
|
2.2.6); в Stage 2 пин либо переносится в один общий вызов `pip install`, либо фиксируется
|
||||||
|
фактическая версия — с обязательной перепроверкой эталона I1 после любого изменения numpy.
|
||||||
|
- **Закрыто в Stage 2:** требования `numpy<2` и `opencv-python-headless 5.0.0.93` несовместимы
|
||||||
|
(opencv 5 требует numpy ≥ 2), поэтому зафиксирована фактическая версия `numpy==2.2.6` в том же
|
||||||
|
вызове `pip install`, что и остальные зависимости, а `opencv-python` (не-headless) больше не
|
||||||
|
устанавливается вовсе. Эталон I1 перепроверен — 5/5 байт-в-байт (см. «Журнал раздела 2»).
|
||||||
|
- Веса (проверено HEAD): `x2plus` v0.2.1 — 200; `general-x4v3` и `animevideov3` v0.2.5.0 — 200;
|
||||||
|
`codeformer.pth` v0.1.0 — 200. GFPGAN: берём `GFPGANv1.4.pth` из релиза `v1.3.0` (200, `arch='clean'`,
|
||||||
|
`channel_multiplier=2` — как у официального `inference_gfpgan.py -v 1.4`); `GFPGANCleanv1-NoCE-C2.pth`
|
||||||
|
в релизах `v1.3.8`/`v1.3.4`/`v1.3.0` отсутствует (404), в `v0.2.0` есть (200) — как запасной вариант.
|
||||||
|
- **Учтётся в Stage 1:** `GFPGANer` жёстко передаёт facexlib `model_rootpath='gfpgan/weights'`
|
||||||
|
(относительный путь → каталог образа, не том `/models`), поэтому веса детектора/парсера facexlib
|
||||||
|
сейчас скачиваются мимо тома и теряются при пересборке. Свой `FaceRestoreHelper`/каталог
|
||||||
|
`/models/weights` — обязательное требование этапа.
|
||||||
|
|
||||||
|
- [x] **D5. CodeFormer — опционален по умолчанию.** Проверено 2026-09-28: на PyPI есть только
|
||||||
|
сторонняя обёртка `codeformer 0.0.11` (`github.com/rohitkhatri/codeformer`, тянет `lpips`) —
|
||||||
|
это не официальный `sczhou/CodeFormer`, использовать его не будем. Официальные веса доступны.
|
||||||
|
Решение: официальный модуль вендорится в `photo-ai/vendor/codeformer/` **только если** face-режим
|
||||||
|
CodeFormer реально понадобится; в рамках текущего плана не вендорим, дефолт
|
||||||
|
`PHOTO_AI_FACE_MODEL=gfpgan` (Stage 1–3), чтобы дефолтный путь работал без вендоринга.
|
||||||
|
- `FACE_REGISTRY` всегда содержит обе записи, но запись `codeformer` активна только если
|
||||||
|
`import codeformer` (с `photo-ai/vendor` в `sys.path`) успешен.
|
||||||
|
- Нет модуля → запись не попадает в `face_models` в `/health` и в список `/models`, UI её не показывает,
|
||||||
|
`POST /enhance` с `face_model=codeformer` → `400` с текстом «CodeFormer не установлен в образ,
|
||||||
|
доступен gfpgan». Сборка и CPU-режим не падают.
|
||||||
|
- `strength` валиден только для CodeFormer: при `face_model=gfpgan` и `strength`, отличном от 0.7,
|
||||||
|
→ `400` с пояснением, иначе параметр молча игнорировался бы.
|
||||||
|
- Веса CodeFormer качаются тем же загрузчиком в `/models/weights/codeformer.pth`.
|
||||||
|
|
||||||
|
- [x] **D6. Поведение без `photo-ai` в compose.** Сейчас `PHOTO_AI_URL` по умолчанию
|
||||||
|
`http://photo-ai:8080` (`docker-compose.yml:79`) — то есть «ИИ» включён по умолчанию.
|
||||||
|
Решение: дефолт **не меняем** — фото-ИИ включено из коробки и работает на CPU; выключается
|
||||||
|
только явно (`PHOTO_AI_URL=` в `.env` → кнопка «🤖 ИИ» скрыта, `enhance-ai` → 503, I3).
|
||||||
|
Заодно исправлен найденный рассинхрон: `.env.example` содержал `PHOTO_AI_URL=` (пусто) с комментарием
|
||||||
|
«пусто = контейнер photo-ai», то есть инструкция «скопируй `.env.example`» молча выключала ИИ-фото.
|
||||||
|
В этом же изменении `.env.example` приведён к дефолту compose с явным описанием обоих состояний;
|
||||||
|
блок про photo-ai в `README` (включая GPU-запуск) появится в Stage 6 с той же формулировкой.
|
||||||
|
Текущий `.env` переменной не содержит → на этом хосте действует дефолт compose (включено).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Stage 0. Подготовка и эталон «до» (обязательно до любых правок кода)
|
||||||
|
|
||||||
|
- [x] Зафиксировать окружение: `docker --version`, `docker compose version`, `nvidia-smi`,
|
||||||
|
наличие/отсутствие `nvidia-ctk`, `docker info | grep -i runtime`.
|
||||||
|
- [x] Если `nvidia-ctk` нет — зафиксировать это как «GPU-режим не проверяем на этом хосте»,
|
||||||
|
**не** пытаться ставить системные пакеты без явного разрешения оператора.
|
||||||
|
- [x] Сохранить текущее состояние `.env` (`PHOTO_AI_URL` пусто или задан).
|
||||||
|
- [x] Поднять текущий стек как есть: `docker compose up -d --build`.
|
||||||
|
- [x] Снять эталон «до» на 3–5 фото (портрет, групповое, без лиц, зашумлённое 640×480):
|
||||||
|
результат `/enhance` с `scale=2` без других параметров + `/health`. Сохранить файлы и
|
||||||
|
размеры в `backups/photo-face-ai-baseline/` (вне git).
|
||||||
|
- [x] **Приёмка:** эталон сохранён, `curl /health` отвечает, текущий сценарий «🤖 ИИ» даёт `done`.
|
||||||
|
|
||||||
|
Stage 0 закрыт 2026-09-28, журнал проверок — «Журнал раздела 0» выше. Повторная сверка при закрытии:
|
||||||
|
`node backups/photo-face-ai-baseline/verify.js after-section0` → 5/5 `MATCH` (I1 байт-в-байт),
|
||||||
|
`photo-ai /health` → `{"ok":true}` из контейнера `app`, сценарий «🤖 ИИ» → `done` (I2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Stage 1. `photo-ai/app.py`: реестр моделей + авто-выбор устройства
|
||||||
|
|
||||||
|
- [x] `pick_device() -> (device, half, device_name)` по §4.1 плана: env → cuda → mps → cpu;
|
||||||
|
`half=True` только на CUDA; явный `cuda` без CUDA = WARN + cpu; явный `cpu` = всегда cpu.
|
||||||
|
- [x] `MODEL_REGISTRY`: `x2plus` (`RRDBNet(scale=2)`), `general-x4v3` (`SRVGGNetCompact(upscale=4)` + `wdn`),
|
||||||
|
`animevideo-v3` (`SRVGGNetCompact(upscale=4)`).
|
||||||
|
- [x] `FACE_REGISTRY`: `gfpgan` (`GFPGANer(arch='clean', channel_multiplier=2, upscale=2, bg_upsampler=…)`),
|
||||||
|
`codeformer` (за `D5`).
|
||||||
|
- [x] Загрузка весов: список URL из §3.4 плана, каталог `${PHOTO_AI_MODELS_DIR:-/models}/weights/`,
|
||||||
|
скачивание в `.tmp` → `os.replace`, проверка минимального размера, кэш в томе.
|
||||||
|
`MODEL_PATH` читается как алиас для `x2plus` (существующий `.env`/том не ломается).
|
||||||
|
- [x] `class ModelPool`: `threading.Lock`, ленивая загрузка по требованию, кеш, LRU с лимитом 2,
|
||||||
|
`PHOTO_AI_LOAD_ALL=1` — предзагрузка, прогрев на синтетическом шуме 64×64 после загрузки.
|
||||||
|
- [x] OOM-деградация: `RuntimeError` с CUDA OOM → `tile` пополам (256→128→64), один ретрай;
|
||||||
|
повтор → инвалидация модели, переход на CPU, ещё одна попытка; финал — `500` с понятным текстом.
|
||||||
|
- [x] `GET /health` — контракт §4.3 плана (поле `ok` сохраняется, добавляются `ready`, `device`,
|
||||||
|
`device_name`, `half`, `tile`, `driver`, `cuda`, `vram_total_mb`, `vram_free_mb`, `models`,
|
||||||
|
`face_models`, `loaded`, `loading`, `max_pixels`).
|
||||||
|
- [x] `GET /models` — список моделей, face-моделей, устройство, дефолты.
|
||||||
|
- [x] `POST /enhance`: поля `image`, `scale`, `model` (дефолт `x2plus`), `face` (`off|face|all`, дефолт `off`),
|
||||||
|
`face_model` (`gfpgan|codeformer`), `strength` (0..1, только CodeFormer, дефолт 0.7),
|
||||||
|
`jpeg_quality` (70..100, дефолт 92). Неизвестная модель → `400` со списком допустимых.
|
||||||
|
Модель не готова → `503` + `Retry-After: 5`.
|
||||||
|
- [x] Два формата ответа: сырой `image/jpeg` по умолчанию (совместимость) и JSON при
|
||||||
|
`Accept: application/json`: `{ok, image_base64, model, face, face_model, faces_found, device,
|
||||||
|
elapsed_ms, warnings}`. При `face != off` — `enhance(..., has_aligned=False, only_center_face=False,
|
||||||
|
paste_back=True)`; лица не найдены — не ошибка, `faces_found: 0` + чистый `bg_upsampler`.
|
||||||
|
- [x] Расширение выходного файла — по имени файла, не по `content_type` (воркер шлёт `image/jpeg` для всего).
|
||||||
|
- [x] **Проверка I1:** эталон из Stage 0 воспроизводится (сравнить размер/содержимое, `node --check`-эквивалент
|
||||||
|
для Python — `python -c "import ast;ast.parse(open('photo-ai/app.py').read())"`).
|
||||||
|
- [x] **Приёмка:** `/health` отдаёт `device`/`device_name`/`half`; `PHOTO_AI_DEVICE=cpu` при рабочей CUDA →
|
||||||
|
`cpu`; `PHOTO_AI_DEVICE=cuda` без CUDA → `cpu` + WARN, сервис поднялся; `PHOTO_AI_DEVICE=auto` без
|
||||||
|
CUDA → `cpu`.
|
||||||
|
|
||||||
|
### Журнал раздела 1 (проверено 2026-09-29)
|
||||||
|
|
||||||
|
Изменён только `photo-ai/app.py` (схема БД, воркер, `server.js`, compose, Dockerfile, фронтенд и `.env.example`
|
||||||
|
не тронуты — они в Stage 2…6).
|
||||||
|
|
||||||
|
| Проверка | Как | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| I1 | `capture.js after-stage1` + `verify.js after-stage1` | 5/5 `MATCH` байт-в-байт; повторно после двух правок `is_oom`/`BASE_TILE` — снова 5/5 |
|
||||||
|
| I2 | `INSERT INTO photo_jobs (entry_id, action, params, status) VALUES (390,'ai',NULL,'pending')` | `done`, `attempts=0`, `after_path` в S3; вход 939×875 PNG → 1878×1750 JPEG (ровно x2) |
|
||||||
|
| I4 | override-файлы с `PHOTO_AI_DEVICE=cuda\|cpu\|auto\|bogus` | `cuda` → WARN «CUDA недоступна — работаю на CPU» + `device=cpu`; `cpu`/`auto` → `cpu`; `bogus` → WARN об неизвестном значении и `auto`; сервис поднялся во всех случаях |
|
||||||
|
| I5 | 503 во время стартовой предзагрузки | `503` + `Retry-After: 5`; `worker.js:19` `isSoftStatus` относит `status >= 500` к мягким, попытка не тратится |
|
||||||
|
| I7 | `ast.parse`, `grep` на комментарии, неиспользуемые импорты | чисто, комментариев 0 |
|
||||||
|
| Реестр | `general-x4v3` (360×548 → 1440×2192), `animevideo-v3` | 200, веса скачаны в том, повторный запрос из кэша |
|
||||||
|
| LRU | последовательно `general-x4v3` → `animevideo-v3` → `face=all` | `loaded` держится ровно 2 записи, вытесняется самая старая (`x2plus` → `general-x4v3` → `animevideo-v3` → `gfpgan`) |
|
||||||
|
| Лица | `face=face` и `face=all` на реальных фото (800×450 и 1400×788) | `faces_found: 10`, 47 с / 42 с на CPU, `warnings: []` |
|
||||||
|
| Лица, их нет | `nofaces.jpg` 300×200 | `ok: true`, `faces_found: 0`, warning «лица не найдены, фон обработан апскейлом», 1.8 с — не ошибка |
|
||||||
|
| `jpeg_quality` | 70 / 92 / 100 и 30 / 101 / `abc` | 165935 / 327022 / 929904 байт, повтор 70 → те же байты; вне диапазона `400` с текстом «должен быть 70..100», нечисловое — `422` от pydantic |
|
||||||
|
| Валидация | неизвестные `model`/`face`/`face_model`, `strength` с gfpgan и вне 0..1, битое изображение | `400` с перечнем допустимых значений / `400 bad image` |
|
||||||
|
| OOM-лестница | подмена `process_image` в контейнере: OOM на 0/1/2/всех попытках | тайлы `[256]`, `[256,128]`, `[256,128,64]`, `[256,128,64]`; при полном провале `500` «не хватило памяти даже при tile=64: пересмотрите PHOTO_AI_TILE или PHOTO_AI_MAX_PIXELS» |
|
||||||
|
| Деградация | повторный вызов `degrade_to_cpu` | выполняется один раз (идемпотентно), `half=False`, предупреждение в ответе |
|
||||||
|
|
||||||
|
Решения и находки, которые нужно знать дальше:
|
||||||
|
|
||||||
|
- **GPU на хосте есть** — `nvidia-smi` работает, драйвер `615.71.09`, CUDA UMD 13.4. Не хватает только
|
||||||
|
`nvidia-container-toolkit`; его установка — решение оператора (в условиях задачи это стоп-условие),
|
||||||
|
поэтому ветка CUDA осталась непроверенной. Всё, что связано с `cuda`/`half`/`vram_*`, в Stage 1
|
||||||
|
покрыто только кодом и unit-проверками, а не прогоном.
|
||||||
|
- **`animevideo-v3`** — имя реестра взято по формулировке этого чек-листа (в плане §3.4 встречается
|
||||||
|
`animevideov3`, это имя файла весов). Ключ используется в API, при несовпадении с планом поправить
|
||||||
|
и то, и другое одним изменением.
|
||||||
|
- **`image_ext`** добавлен в JSON-ответ сверх полей, перечисленных в чек-листе: без него клиент
|
||||||
|
не может угадать формат (`.jpeg` нормализуется в `.jpg` ради байт-в-байтности, `.png`/`.webp`
|
||||||
|
не меняются).
|
||||||
|
- **D4 (веса facexlib в томе)**: `face_helper()` пишет в `${PHOTO_AI_MODELS_DIR}/weights` через
|
||||||
|
`model_rootpath`, а встроенный помощник `GFPGANer` ищет `gfpgan/weights` относительно cwd.
|
||||||
|
`link_default_facexlib_dir()` при первом же построении face-модели заменяет этот каталог
|
||||||
|
символической ссылкой на том — `/app/gfpgan/weights -> /models/weights`. Второй детектор не
|
||||||
|
создаётся, 195 МБ дубля нет (проверено `readlink`).
|
||||||
|
- **Ветка CodeFormer написана, но не проверена** — модуль не вендорен (Stage 2). Проверен только
|
||||||
|
путь отказа: `face_model=codeformer` → `400` «модель лиц codeformer не установлена в образ,
|
||||||
|
доступен gfpgan», и `strength` с gfpgan → `400`. Сам `build_face_codeformer` нужно прогнать после
|
||||||
|
того, как пакет появится в образе.
|
||||||
|
- **`tile` не залипает.** После OOM `state['tile']` восстанавливается на `PHOTO_AI_TILE` в `finally`
|
||||||
|
(`BASE_TILE`), иначе одна тяжёлая картинка навсегда замедляла бы сервис в 16 раз. `degrade_to_cpu`
|
||||||
|
больше не сбрасывает тайл на дефолт — операторское значение сохраняется.
|
||||||
|
- **`is_oom` расширен** на сообщения CPU-аллокатора PyTorch (`not enough memory`, `alloc_cpu`,
|
||||||
|
`can't allocate memory`): без этого исчерпание RAM на CPU (единственный тестируемый режим)
|
||||||
|
возвращало `500` с текстом «ошибка модели: …» вместо лестницы тайлов и подсказки про
|
||||||
|
`PHOTO_AI_TILE`/`PHOTO_AI_MAX_PIXELS`.
|
||||||
|
- **`portrait.jpg` — плохой тестовый вход**: на нём детектор facexlib не находит лица (порог
|
||||||
|
`get_face_landmarks_5` — 0.97, на реальных фото скор 0.999+). Ранние «0 лиц» в проверках были
|
||||||
|
ошибкой тест-скрипта (`len(h.cropped_faces)` заполняется только `align_warp_face()`), а не
|
||||||
|
поломкой детектора. Годится любой портрет из `uploads/` (4032×2268 и ещё 7 файлов).
|
||||||
|
- **`.env.example` не правился**: новые переменные ещё не подставляются в compose (Stage 2),
|
||||||
|
документировать их сейчас — задокументировать неработающее.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Stage 2. `photo-ai/Dockerfile` + compose
|
||||||
|
|
||||||
|
- [x] `ARG TORCH_VARIANT=cpu` и `ARG TORCH_INDEX=https://download.pytorch.org/whl/${TORCH_VARIANT}`;
|
||||||
|
один образ, `cu124` — вариант сборки.
|
||||||
|
- [x] Сохранить патч `basicsr/data/degradations.py` (`functional_tensor` → `functional`) — без него basicsr
|
||||||
|
падает на torch ≥ 2.0. Не «упрощать» Dockerfile без проверки.
|
||||||
|
- [x] Сохранить `libgl1 libglib2.0-0` (нужны facexlib), `numpy<2`, `opencv-python-headless`.
|
||||||
|
- [x] Добавить `gfpgan`/`facexlib` (или вендоринг по `D4`), вендоренный CodeFormer копировать в образ
|
||||||
|
при наличии (`D5`).
|
||||||
|
- [x] `ENV PHOTO_AI_MODELS_DIR=/models`; `MODEL_PATH` остаётся валидным алиасом.
|
||||||
|
- [x] `docker-compose.yml`, сервис `photo-ai`: env `PHOTO_AI_DEVICE`, `PHOTO_AI_FACE_MODEL`, `PHOTO_AI_TILE`,
|
||||||
|
`PHOTO_AI_MAX_PIXELS`, `PHOTO_AI_LOAD_ALL`, `PHOTO_AI_JPEG_QUALITY`; `healthcheck` с
|
||||||
|
`start_period: 300s`; порт по `D1` уже проброшен на loopback — сохранить.
|
||||||
|
- [x] Новый `docker-compose.gpu.yml` (по образцу `docker-compose.minio.yml`): `build.args.TORCH_VARIANT=cu124`,
|
||||||
|
`PHOTO_AI_DEVICE=cuda`, `deploy.resources.reservations.devices` (`driver: nvidia`, `count: 1`).
|
||||||
|
Без него стек поднимается на любой машине.
|
||||||
|
- [x] Сервису `app` добавить env `PHOTO_AI_FACE_MODEL` и `PHOTO_AI_FACE_TIMEOUT_MS`.
|
||||||
|
- [x] **Приёмка:** CPU-сборка стартует; `/api/photo-jobs/status` показывает `service.device`;
|
||||||
|
при пустом `PHOTO_AI_URL` приложение работает полностью (I3); `docker compose -f docker-compose.yml
|
||||||
|
-f docker-compose.gpu.yml config` валиден (сам GPU-пуск — только если toolkit есть).
|
||||||
|
|
||||||
|
### Журнал раздела 2 (проверено 2026-09-29)
|
||||||
|
|
||||||
|
Изменены `photo-ai/Dockerfile`, `docker-compose.yml`, добавлены `docker-compose.gpu.yml` и
|
||||||
|
`photo-ai/vendor/.gitkeep`, а также `.env.example` и таблица переменных в `README.md` — Stage 1 отложил
|
||||||
|
их именно на Stage 2, потому что раньше compose эти переменные не подставлял. `app.py`, `worker.js`,
|
||||||
|
`server.js`, схема БД и фронтенд не тронуты — они в Stage 3…6.
|
||||||
|
|
||||||
|
| Проверка | Как | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| CPU-сборка | `docker compose build photo-ai` (слои pip с нуля) | `EXIT=0`, образ `whatido-photo-ai:latest` **2.63 ГБ** (план оценивал ~2.5 ГБ) |
|
||||||
|
| Состав пакетов | `docker exec photo-ai pip list` | `gfpgan 1.3.8`, `facexlib 0.3.0`, `basicsr 1.4.2`, `realesrgan 0.3.0`, `numpy 2.2.6`, `torch 2.14.0+cpu`, `matplotlib 3.10.9`; `opencv-python-headless 5.0.0.93` — **единственный** cv2; `tb-nightly` и `yapf` отсутствуют |
|
||||||
|
| Импорты | `python -c "import basicsr, gfpgan, facexlib, realesrgan, cv2"` | ок; cv2 `GUI: NONE` (до этого активной была не-headless сборка с `GUI: QT5`) |
|
||||||
|
| Патч basicsr | лог сборки | `basicsr patch applied to /usr/local/lib/python3.10/site-packages/basicsr/data/degradations.py` |
|
||||||
|
| **I1** | `capture.js after-stage2` + `verify.js after-stage2` | **5/5 `MATCH` байт-в-байт** (`795a519b9a9c70f6`, `fe2496f7ef798456`, `fce1949260590855`, `1b52a49d690ca8d7`, `e0e6e480f4799bc0`) — смена cv2 на headless и явный пин numpy результат не изменили |
|
||||||
|
| `service.device` | `GET /api/photo-jobs/status` (admin) | 200; `service` = `ok/ready/device=cpu/device_name/half/tile/driver/cuda/vram_total_mb/vram_free_mb/models/face_models/loaded/loading/max_pixels` + `configured/reachable/latency_ms/error` |
|
||||||
|
| healthcheck | `docker inspect photo-ai` | `healthy` после `start_period: 300s`; та же команда вручную в контейнере → exit 0 |
|
||||||
|
| env контейнера | `docker inspect photo-ai` | `PHOTO_AI_DEVICE=auto`, `PHOTO_AI_TILE=256`, `PHOTO_AI_FACE_MODEL=gfpgan`, `PHOTO_AI_LOAD_ALL=0`, `PHOTO_AI_JPEG_QUALITY=92`, `PHOTO_AI_MAX_PIXELS=4000000`, `PHOTO_AI_MODELS_DIR=/models`, `MODEL_PATH=/models/RealESRGAN_x2plus.pth` |
|
||||||
|
| env `app` | `docker compose exec app node -e ...` | `PHOTO_AI_URL=http://photo-ai:8080`, `PHOTO_AI_FACE_MODEL=gfpgan`, `PHOTO_AI_FACE_TIMEOUT_MS=600000` |
|
||||||
|
| I3 | override с `PHOTO_AI_URL: ""` (файл в `/tmp`, вне репозитория) | `photo_ai_enabled=false`, `POST /api/entries/1/photo/enhance-ai` → `503` «ИИ-обработка фото не настроена», 8 маршрутов API → 200; возврат к базовой конфигурации → `photo_ai_enabled=true` |
|
||||||
|
| I4 (ветка cuda) | `PHOTO_AI_DEVICE=cuda` на новом образе | WARN «PHOTO_AI_DEVICE=cuda, но CUDA недоступна — работаю на CPU», `/health` 200 и `device=cpu` |
|
||||||
|
| Compose | `docker compose config -q` — база, `+docker-compose.minio.yml`, `+docker-compose.gpu.yml` | три конфигурации валидны; GPU-оверрайд даёт `TORCH_VARIANT=cu124`, `PHOTO_AI_DEVICE=cuda` и `deploy.resources.reservations.devices[driver=nvidia, count=1, capabilities=[gpu]]` |
|
||||||
|
| GPU-пуск | `up -d photo-ai` с оверрайдом, когда `nvidia-ctk` есть на хосте | **сделан 2026-09-29**, отдельный журнал «GPU-прогон» в разделе 6: `device=cuda:0`, `half=true`, `vram_total_mb=3822`. Изначально был стоп-условием (toolkit отсутствовал), условие снято |
|
||||||
|
|
||||||
|
Решения и находки, которые нужно знать дальше:
|
||||||
|
|
||||||
|
- **`numpy<2` был невыполним.** `opencv-python-headless 5.0.0.93` требует numpy ≥ 2, поэтому пин заменён
|
||||||
|
на фактическую версию `numpy==2.2.6` и перенесён в тот же вызов `pip install`, что и остальные
|
||||||
|
зависимости (раньше пин стоял отдельным вызовом, и следующие установки его игнорировали). I1 после
|
||||||
|
изменения numpy перепроверен — байт-в-байт.
|
||||||
|
- **`--no-deps` для `basicsr`/`realesrgan`/`gfpgan`/`facexlib`** убирает dev-зависимости gfpgan
|
||||||
|
(`tb-nightly`, `yapf`). Проверено, что в рантайме они не нужны: `tensorboard` в basicsr импортируется
|
||||||
|
только лениво внутри `init_tb_logger`/`read_data_from_tensorboard`, `yapf` не импортируется вовсе,
|
||||||
|
а `matplotlib` приходит как зависимость `filterpy` (требование facexlib) и в образе остался.
|
||||||
|
- **`opencv-python` (не-headless) больше не ставится.** Раньше он приходил транзитивно через gfpgan и
|
||||||
|
перезаписывал headless-сборку (активным был cv2 с `GUI: QT5`). Теперь cv2 один, headless, и JPEG-байты
|
||||||
|
совпали с эталоном — GUI-обвязка на результат не влияла.
|
||||||
|
- **`libgl1 libglib2.0-0` оставлены** по требованию плана (facexlib), хотя при headless cv2 они нужны
|
||||||
|
меньше: экономия здесь не стоит риска незамеченной регрессии.
|
||||||
|
- **`photo-ai/vendor/.gitkeep`** — каталог вендоринга существует в репозитории, поэтому
|
||||||
|
`COPY vendor/ ./vendor/` не падает на сборке без CodeFormer, а положенный туда модуль подхватывается
|
||||||
|
`sys.path` в `app.py` без правок Dockerfile (`D5`).
|
||||||
|
- **env `PHOTO_AI_FACE_MODEL`/`PHOTO_AI_FACE_TIMEOUT_MS` у `app`** добавлены по чек-листу Stage 2;
|
||||||
|
`PHOTO_AI_FACE_MODEL` уже используется `app.py` как дефолт face-модели, воркер начнёт читать оба
|
||||||
|
в Stage 4 (сейчас `ai_timeout_ms` в `worker.config` всё ещё 300000 — это ожидаемо).
|
||||||
|
- **`TORCH_VARIANT` в `docker-compose.gpu.yml` захардкожен**, а не берётся из `.env`: иначе
|
||||||
|
`TORCH_VARIANT=cpu` в `.env` молча собирал бы GPU-конфигурацию без CUDA. Значение изменено
|
||||||
|
`cu124 → cu126` при GPU-прогоне — причина в журнале раздела 3.
|
||||||
|
- **`MAX_INPUT_PIXELS` в compose заменён на канонический `PHOTO_AI_MAX_PIXELS`**; `app.py` по-прежнему
|
||||||
|
читает старое имя как алиас, так что существующий `.env` не ломается. Порт `8081` на loopback (`D1`)
|
||||||
|
сохранён.
|
||||||
|
- **`.env.example`/`README.md`** описывают ровно те переменные, которые подставляет compose; блок про
|
||||||
|
GPU-запуск и установку NVIDIA Container Toolkit добавлен в Stage 3 вместе с проверкой GPU-оверрайда
|
||||||
|
(решение `D6`), остальные документы — за Stage 6.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Stage 3. Face-режим (GFPGAN) в `app.py`
|
||||||
|
|
||||||
|
- [x] `face=off` — только ESRGAN (текущее поведение). — **сделано в Stage 1** (журнал раздела 1)
|
||||||
|
- [x] `face=face` — ESRGAN-фон + GFPGAN `paste_back`. — **сделано в Stage 1**
|
||||||
|
- [x] `face=all` — ESRGAN(+`wdn` для `general-x4v3`) + мягкий денойз фона + лица. — **сделано в Stage 1**
|
||||||
|
- [x] `strength` → `fidelity_weight` CodeFormer, `-w`; вне диапазона → `400`. — **сделано в Stage 1**
|
||||||
|
(ветка CodeFormer написана, но не проверена: модуль не вендорен, `D5`; проверен путь отказа)
|
||||||
|
- [x] Предупреждения (`warnings`: вход меньше 320×320, лиц не найдено, CodeFormer на CPU) в JSON-ответе.
|
||||||
|
Первые два были в Stage 1; **вход меньше 320×320 и CodeFormer на CPU добавлены при приёмке
|
||||||
|
Stage 3** — их не было в коде
|
||||||
|
- [x] **Приёмка:** портрет 640×480 в `face` → `faces_found=6`, лицо резче; фото без лиц → `faces_found=0`
|
||||||
|
и результат не хуже `x2plus`; искусственный OOM (`PHOTO_AI_TILE=2048` на 4 ГБ) деградирует до CPU
|
||||||
|
без падения сервиса.
|
||||||
|
|
||||||
|
### Журнал раздела 3 (проверено 2026-09-29)
|
||||||
|
|
||||||
|
Изменён `photo-ai/app.py` (лестница OOM на CUDA + два предупреждения) и `docker-compose.gpu.yml`
|
||||||
|
(GPU-оверрайд: cu126 + CDI). `worker.js`, `server.js`, схема БД и фронтенд не тронуты — они в Stage 4…6.
|
||||||
|
Проверки шли на работающем GPU (RTX 3050 Laptop, 4096 МБ, драйвер `615.71.09`, CUDA 12.6) — см.
|
||||||
|
журнал GPU-прогона в разделе 2.
|
||||||
|
|
||||||
|
| Проверка | Как | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| **Главная находка** | `PHOTO_AI_TILE=2048`, `x2plus face=all` на 4032×2268 | **до правки**: `500 Internal Server Error`, в логе `UnboundLocalError: local variable 'output_tile' referenced before assignment` (`realesrgan/utils.py:179`); **после**: `200`, `warnings: ["не хватило памяти при tile=2048"]`, 28.3 с |
|
||||||
|
| Причина | чтение кода `realesrgan/utils.py` и `gfpgan/utils.py` | обе библиотеки **проглатывают** `RuntimeError` в своих `try/except` вокруг вызова сети (`except RuntimeError as error: print('Error', error)`) и идут дальше, поэтому `output_tile`/`restored_face` остаётся неприсвоенным. Для `run_guarded` это был уже не `RuntimeError` → ни лестницы тайлов, ни деградации на CPU, ни внятного текста |
|
||||||
|
| Как починено | `guard_forward()` в `app.py` оборачивает `forward` сетей, построенных нами (`RealESRGANer.model`, `restorer.gfpgan`, CodeFormer `net`): `RuntimeError` с признаком OOM превращается в `TileOOM` | `TileOOM` не наследует `RuntimeError`, поэтому проглатывающие `except` его пропускают; `is_oom` и `run_guarded` (обе точки) ловят `TileOOM` явно |
|
||||||
|
| Лестница тайлов на GPU | инъекция `TileOOM('CUDA out of memory')` вместо `process_image` | `tile=2048 → 1024 → 512`, затем `degrade_to_cpu` и повтор: `warnings` = 4 пункта, `device: cpu`, `half: false`; при повторе с уже-CPU — честная `500` «не хватило памяти даже при tile=512: пересмотрите PHOTO_AI_TILE или PHOTO_AI_MAX_PIXELS» |
|
||||||
|
| `x2plus face=all`, tile=2048 | полное фото | `200`, 6 лиц, единственное предупреждение — про тайл; сервис не упал, следующие запросы проходят |
|
||||||
|
| 640×480, три режима | портрет, приведённый к 640×480 | `off` 1.0 с / `face` 3.8 с / `all` 2.2 с, `faces_found=6`, `device=cuda:0` |
|
||||||
|
| Фото без лиц | синтетический фон 800×600 | `face` → `faces_found=0`, warning «лица не найдены, фон обработан апскейлом», 0.8 с; байты результата **равны** `face=off` (39861) — без деградации качества |
|
||||||
|
| Вход меньше 320×320 | 256×256 | два предупреждения: «вход меньше 320×320 — лица могут не найтись» + «лица не найдены» |
|
||||||
|
| `strength` | `0.5` с gfpgan / `1.4` | `400` «strength применяется только к CodeFormer, для gfpgan оставьте 0.7» / `400` «strength должен быть 0..1» |
|
||||||
|
| CodeFormer | `face_model=codeformer` | `400` «модель лиц codeformer не установлена в образ, доступен gfpgan» (`D5`) |
|
||||||
|
| **I1** | 6 сценариев (`jpg`/`.jpeg`/`.png`+q70/`.webp`+q100/`x4v3`/`animevideo-v3`) на новом и на Stage 2 образе, оба на CPU | **6/6 `MATCH` байт-в-байт** (`420757881a31f12dec174e4873e260dd`, `cdbf59d1eb460d26a3b83af2e0543d82`, `654928b60ec43dbd41c6644121041a75`, `557e76d79869230058e1acf75017c670`, `9d3d731c8858bacbba1ee88bf3f373e5`) |
|
||||||
|
| I7 | `ast.parse`, поиск комментариев | чисто, комментариев 0 |
|
||||||
|
|
||||||
|
Решения и находки, которые нужно знать дальше:
|
||||||
|
|
||||||
|
- **Лестница OOM не работала ни на одном GPU** — только на CPU, где OOM приходит из нашего кода и
|
||||||
|
не проглатывается. Это наш главный аргумент за то, что GPU-ветку надо было прогнать, а не собрать.
|
||||||
|
- **`guard_forward` вешается на экземпляр** (`model.forward`), идемпотентен по флагу
|
||||||
|
`_photo_ai_guarded` и не трогает класс — обёртка не накапливается при пересборке пула. Сетей,
|
||||||
|
которые строит не мы (retinaface в facexlib, ESRGANer внутри GFPGANer), обёртка не покрывает: там
|
||||||
|
OOM уходит нашим же `except RuntimeError`, и это правильно.
|
||||||
|
- **Проглатывание в gfpgan** (`except RuntimeError` вокруг `self.gfpgan(...)`) было даже опаснее
|
||||||
|
тихого: при OOM лицо молча оставалось исходным, задание завершалось «успешно» без единого
|
||||||
|
признака в `warnings`. Теперь такой случай уходит в лестницу тайлов, а если не помогло — в
|
||||||
|
деградацию на CPU.
|
||||||
|
- **4 ГБ VRAM — это про `PHOTO_AI_LOAD_ALL` и `PHOTO_AI_TILE`, а не про лестницу.** `x2plus` и `gfpgan`
|
||||||
|
влезают (свободно ~100–300 МБ), `general-x4v3` втрое тяжелее. Проверено: после `x4v3 face=all`
|
||||||
|
поднимается `tile=2048`, но дефолтные 256 проходят без единого предупреждения.
|
||||||
|
- **`tile` по-прежнему не залипает** — после OOM в `finally` восстанавливается `PHOTO_AI_TILE`, и
|
||||||
|
`degrade_to_cpu` больше не сбрасывает операторское значение.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Журнал GPU-прогона (закрывает строку «GPU-пуск» раздела 2)
|
||||||
|
|
||||||
|
Стоп-условие снято: `nvidia-ctk` и спека `/etc/cdi/nvidia.yaml` появились на хосте, поэтому GPU-оверрейд
|
||||||
|
переведён с классического резервирования (`driver: nvidia, count: 1`) на CDI (`device_ids:
|
||||||
|
nvidia.com/gpu=all`) — так демон перезапускать не нужно, а `/etc/docker/daemon.json` на хосте нет.
|
||||||
|
|
||||||
|
| Проверка | Как | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| Сборка GPU-образа | `docker compose -f docker-compose.yml -f docker-compose.gpu.yml build photo-ai` | `whatido-photo-ai:cu126`, 12 ГБ; `torch 2.14.0+cu126`, `cuda: 12.6` — те же версии, что и в CPU-образе |
|
||||||
|
| Старт | оверрайд + `up -d photo-ai` | `healthy`, в лог `устройство: cuda:0 (NVIDIA GeForce RTX 3050 Laptop GPU), half=True` |
|
||||||
|
| `/health` | curl | `device: cuda:0`, `half: true`, `driver: 615.71.09`, `cuda: 12.6`, `vram_total_mb: 3822`, `ready: true` |
|
||||||
|
| Обратная совместимость | базовый `docker compose up -d photo-ai` без оверрайда | сервис возвращается на CPU, `PHOTO_AI_DEVICE=auto` |
|
||||||
|
|
||||||
|
Решения, которые нужно знать дальше:
|
||||||
|
|
||||||
|
- **`TORCH_VARIANT` в оверрайде захардкожен (`cu126`), а не берётся из `.env`:** `TORCH_VARIANT=cpu`
|
||||||
|
в `.env` иначе молча собрал бы GPU-конфигурацию без CUDA. Причина смены `cu124 → cu126`: в индексе
|
||||||
|
`cu124` последний torch — `2.6.0`, а `cu126` даёт ровно `2.14.0`/`0.29.0`, как CPU-образ.
|
||||||
|
- **GPU-образ тегируется отдельно** (`whatido-photo-ai:cu126`), поэтому сборка GPU-варианта не
|
||||||
|
перетирает `whatido-photo-ai:latest` и откат на CPU — обычный `docker compose up -d photo-ai`.
|
||||||
|
- **Ветка CUDA в плане остаётся непроверенной ровно в одном месте** — печать realesных GPU-байтов:
|
||||||
|
на CPU/CUDA результаты x2 совпадают побайтно, но `half=True` на CUDA считает в fp16, поэтому
|
||||||
|
байты CPU и GPU для одной картинки не равны (это не регресс I1: эталон снимается на CPU).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Stage 4. `worker.js` + `server.js`
|
||||||
|
|
||||||
|
### 7.1 `worker.js` (`createPhotoEnhanceWorker`)
|
||||||
|
|
||||||
|
- [ ] Таймауты из env: `PHOTO_AI_TIMEOUT_MS` (дефолт 300000), `PHOTO_AI_FACE_TIMEOUT_MS` (дефолт 600000)
|
||||||
|
для `face != 'off'`. Таймаут выбирается на основе `params`, а не глобально.
|
||||||
|
- [ ] `runAiEnhance(srcKey, params)`: отправляет `image`, `scale`, `model`, `face`, `face_model`, `strength`
|
||||||
|
из `params` (дефолты при пустых `params`); ставит `Accept: application/json`; принимает и JSON
|
||||||
|
(base64 → буфер), и сырой `image/jpeg` (старый photo-ai) без ошибки.
|
||||||
|
- [ ] `503` / `Retry-After` / сетевая недоступность → `sleep` c экспоненциальной задержкой, `return false`,
|
||||||
|
**`attempts` не инкрементится**; лимит мягких повторов (например 60) → одна честная ошибка
|
||||||
|
с понятным текстом (I5).
|
||||||
|
- [ ] `AbortError` от `AbortSignal.timeout` → сообщение «ИИ-сервис не ответил за N с» — это уже
|
||||||
|
«жёсткая» ошибка с попытками.
|
||||||
|
- [ ] `processOne`: `action` ∈ {`ai`, `ai_face`, `ai_upscale`} → AI-путь; `enhance` → sharp.
|
||||||
|
Пустые `params` при `ai_face` → `face='face'`.
|
||||||
|
- [ ] `applyResult`: в `logAudit(..., 'photo.job.preview', {...})` добавить `model`, `face`, `face_model`,
|
||||||
|
`device`, `elapsed_ms`, `faces_found`, `warnings`. Текст уведомления `photo.job.done` — по факту
|
||||||
|
режима (`job.action === 'ai_face'` → «Фото обработано нейросетью с восстановлением лиц»).
|
||||||
|
- [ ] `CONFIG` воркера: + `face_model`, `default_model`, `face_timeout_ms` (попадают в
|
||||||
|
`GET /api/photo-jobs/status` → `worker.config`).
|
||||||
|
|
||||||
|
### 7.2 `server.js`
|
||||||
|
|
||||||
|
- [ ] `POST /api/entries/:id/photo/enhance-ai` (`server.js:5114`): принять `{model, face, face_model, strength}`;
|
||||||
|
валидация инлайн-хелперами и явными списками (как `PUT /api/settings`), невалидное → `400`.
|
||||||
|
`params = JSON.stringify({model, face, face_model, strength})`; `action = face === 'off' ? 'ai' : 'ai_face'`.
|
||||||
|
**Пустое тело → сегодняшнее поведение** (`params = NULL`, `action='ai'`) — I2.
|
||||||
|
- [ ] `PHOTO_JOB_ACTIONS` (`server.js:2254`): + `'ai_face'`, `'ai_upscale'` (см. `D3`). Схема
|
||||||
|
`action VARCHAR(20)` вмещает новые значения — миграция не нужна.
|
||||||
|
- [x] `photoAiHealth()` (см. `D2`) + проксирование в `GET /api/photo-jobs/status` → `service`
|
||||||
|
(`reachable`, `device`, `device_name`, `ready`, `vram_total_mb`, `vram_free_mb`, `models`, `face_models`,
|
||||||
|
`loaded`). Недоступен → `{reachable:false}`, ответ 200. — **сделано 2026-09-28** (контракт и проверка
|
||||||
|
в `D2`; поля `device`/`device_name`/`vram_*` появятся вместе с расширенным `/health` в Stage 1)
|
||||||
|
- [ ] `GET /api/photo-ai/health` (`requireAdmin`) — прямой прокси `/health` photo-ai для оператора.
|
||||||
|
- [ ] `getStackInfo()` (`server.js:769`) — блок `photo_ai` (`engine: 'Real-ESRGAN + GFPGAN'`, `driver`,
|
||||||
|
`device`, `device_name`, `vram_total_mb`, `models`). Без credentials, только hostname.
|
||||||
|
- [ ] Настройки (`PUT /api/settings`, рядом `server.js:1710`): `photo_ai_face_mode` ∈ `off|face|all` (дефолт `off`),
|
||||||
|
`photo_ai_face_model` ∈ `gfpgan|codeformer` (дефолт `gfpgan`), `photo_ai_device_pref` ∈ `auto|cuda|cpu`
|
||||||
|
(дефолт `auto`, только для UI/доков — фактическое устройство задаёт контейнер).
|
||||||
|
- [ ] Дефолты новых настроек: `db/init.sql`, `db/migration.sql` (`INSERT ... ON CONFLICT DO NOTHING`),
|
||||||
|
`keys`/`defaults` в `/api/public-settings` (`server.js:1663–1664`). `photo_ai_enabled` (строка 1677)
|
||||||
|
сохранить.
|
||||||
|
- [ ] `createPhotoEnhanceWorker({...})` (`server.js:6045`): + `faceTimeoutMs: PHOTO_AI_FACE_TIMEOUT_MS`,
|
||||||
|
`defaultFaceModel: PHOTO_AI_FACE_MODEL`.
|
||||||
|
- [ ] `warnings` от photo-ai сохранять в аудит/уведомление, чтобы оператор видел, что face-режим не
|
||||||
|
сработал не из-за ошибки.
|
||||||
|
- [ ] **Приёмка:** задание с `face='face'` проходит `pending → processing → done`; `params` сохранены;
|
||||||
|
аудит содержит модель/устройство/время; остановка photo-ai на лету → задание дорабатывается после
|
||||||
|
возврата сервиса **без** `error` (I5); результат применяется и откатывается как раньше.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Stage 5. Фронтенд
|
||||||
|
|
||||||
|
- [ ] `public/journal.html` + `public/js/journal.js`: рядом с «🤖 ИИ» селект режима
|
||||||
|
(«Универсально (x2)», «Быстро (x4)», «Лица (GFPGAN)», «Лица + фон»); значение уходит в
|
||||||
|
`POST .../enhance-ai` телом `{model, face, face_model}`. Дефолт — из `photo_ai_face_mode`
|
||||||
|
(`/api/public-settings`, `loadEnhanceEngine`); при `device === 'cpu'` — подсказка «на CPU медленно».
|
||||||
|
Кнопка по-прежнему скрыта при `photo_ai_enabled === 'false'` (I3).
|
||||||
|
- [ ] `public/js/worker.js`: `PHOTO_ACTION_LABELS` + `ai_face: 'ИИ + лица'`, `ai_upscale: 'ИИ-апскейл'`;
|
||||||
|
в модалке сравнения — `model`, `device`, `faces_found`, `elapsed_ms` (данные из `params`/аудита, I6);
|
||||||
|
строка состояния учитывает `service.reachable === false` и `service.device`.
|
||||||
|
- [ ] `public/settings.html` + `public/js/settings.js`: блок «Фото-ИИ» — селект face-режима, селект
|
||||||
|
face-модели, селект желаемого устройства + строка «фактическое: …» с подсветкой расхождения;
|
||||||
|
read-only статус (`device_name`, VRAM, список моделей) из `/api/photo-jobs/status`.
|
||||||
|
Новые id внести в `DIRTY_FIELDS` (`settings.js:1`) и в payload (`settings.js:739`).
|
||||||
|
- [ ] `public/js/settings.js:372` `renderStackInfo()` — карточка «Фото-ИИ» из `stack.photo_ai`.
|
||||||
|
- [ ] `public/js/audit.js` — метки для новых кодов аудита (иначе в UI будет сырой код).
|
||||||
|
- [ ] **Приёмка:** из журнала доступны все 4 режима; после постановки видно «В очереди», затем сравнение
|
||||||
|
«Было/Стало» с моделью/устройством; в настройках видно фактическое устройство и VRAM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Stage 6. Документация и тесты
|
||||||
|
|
||||||
|
- [ ] `AGENTS.md`: раздел про photo-ai (реестр моделей, device-политика, новые env, GPU-override,
|
||||||
|
контракт `/enhance` и `/health`), строки в таблице «File Map» для `photo-ai/app.py`,
|
||||||
|
`photo-ai/Dockerfile`, `docker-compose.gpu.yml`.
|
||||||
|
- [ ] `README.md`: установка NVIDIA Container Toolkit, запуск с `docker-compose.gpu.yml`,
|
||||||
|
проверка `GET /api/photo-jobs/status` → `service.device`, ручной вызов `/enhance` (с учётом `D1`).
|
||||||
|
- [ ] `.env.example`: все переменные из §7 плана с комментариями.
|
||||||
|
- [ ] `api.smoketest.js` (по правилу `AGENTS.md` — контракт API фиксируется в том же изменении):
|
||||||
|
- `POST /api/entries/:id/photo/enhance-ai` с `model: 'нет такой'` → `400`;
|
||||||
|
- `face: 'face'` без `PHOTO_AI_URL` → `503` (условно: если `ai_configured === false`);
|
||||||
|
- `GET /api/photo-jobs/status` содержит ключ `service`.
|
||||||
|
- [ ] **Приёмка:** `node api.smoketest.js` проходит; `node --check server.js`, `node --check worker.js`,
|
||||||
|
`python -c "import ast;ast.parse(...)"` для `app.py`; `docker compose config` валиден для обоих
|
||||||
|
compose-файлов; документация совпадает с кодом.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Новые переменные окружения (итог)
|
||||||
|
|
||||||
|
| Переменная | Умолчание | Где используется |
|
||||||
|
|---|---|---|
|
||||||
|
| `PHOTO_AI_URL` | `http://photo-ai:8080` (compose), пусто = выкл. | `server.js`, воркер |
|
||||||
|
| `PHOTO_AI_MAX_PIXELS` | `4000000` | `app.py` (алиас `MAX_INPUT_PIXELS`) |
|
||||||
|
| `PHOTO_AI_DEVICE` | `auto` | `app.py` (`auto|cuda|cpu`) |
|
||||||
|
| `PHOTO_AI_TILE` | `256` | `app.py` |
|
||||||
|
| `PHOTO_AI_FACE_MODEL` | `gfpgan` | `app.py`, дефолт воркера/UI |
|
||||||
|
| `PHOTO_AI_LOAD_ALL` | `0` | `app.py` |
|
||||||
|
| `PHOTO_AI_FACE_MODE` | `off` | дефолт UI |
|
||||||
|
| `PHOTO_AI_JPEG_QUALITY` | `92` | `app.py` |
|
||||||
|
| `PHOTO_AI_TIMEOUT_MS` | `300000` | `worker.js` (сейчас хардкод) |
|
||||||
|
| `PHOTO_AI_FACE_TIMEOUT_MS` | `600000` | `worker.js` |
|
||||||
|
| `PHOTO_AI_SOFT_MAX_RETRIES` | `60` | `worker.js` — лимит мягких повторов (I5) |
|
||||||
|
| `PHOTO_AI_SOFT_BACKOFF_MS` | `10000` | `worker.js` — первая пауза мягкого повтора |
|
||||||
|
| `PHOTO_AI_SOFT_BACKOFF_MAX_MS` | `300000` | `worker.js` — потолок паузы |
|
||||||
|
| `PHOTO_AI_MODELS_DIR` | `/models` | `app.py` (внутри контейнера) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Порядок и правила выполнения
|
||||||
|
|
||||||
|
- [ ] Этапы выполняются строго 0 → 1 → 2 → 3 → 4 → 5 → 6. Каждый этап закрывается своей приёмкой
|
||||||
|
**до** начала следующего.
|
||||||
|
- [ ] Каждый завершённый чекбокс отмечать (`- [x]`) в этом файле по мере выполнения.
|
||||||
|
- [ ] Изменения в `AGENTS.md`/`.env.example`/`README.md` делать **в том же** изменении, что и код,
|
||||||
|
который они описывают (правило `AGENTS.md` про рассинхрон документации).
|
||||||
|
- [ ] Коммит на этап, а не на весь план: `app.py`+`Dockerfile`+compose → воркер/сервер → фронт → доки/тесты.
|
||||||
|
- [ ] Стоп-условия (сообщить оператору, не «чинить» самостоятельно): требуется `sudo` на хосте;
|
||||||
|
требуется установка `nvidia-container-toolkit`; нашёлся конфликт миграции БД; текущий
|
||||||
|
`/enhance` перестал давать эталонный результат (I1).
|
||||||
|
|
||||||
|
## 12. Готово, когда
|
||||||
|
|
||||||
|
- [ ] `photo-ai` стартует на CPU и на CUDA, устройство выбирается автоматически, `/health` показывает
|
||||||
|
фактическое устройство, имя GPU, VRAM, доступные и загруженные модели.
|
||||||
|
- [ ] Сценарий «🤖 ИИ» без параметров даёт результат, эквивалентный текущему.
|
||||||
|
- [ ] Доступны режимы: универсальный x2, быстрый x4, только лица (GFPGAN), лица + фон.
|
||||||
|
- [ ] Face-задание проходит полный цикл, результат виден в сравнении, применяется и откатывается.
|
||||||
|
- [ ] Отсутствие GPU, отсутствие `nvidia-ctk` и недоступный `photo-ai` не ломают приложение.
|
||||||
|
- [ ] `node api.smoketest.js` проходит; `AGENTS.md`, `README.md`, `.env.example` описывают новые
|
||||||
|
переменные и GPU-запуск.
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
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 stack = info.data && info.data.stack;
|
||||||
|
ok('system-info reports stack', Boolean(stack && stack.app && stack.runtime && stack.database && stack.cache && stack.storage), stack);
|
||||||
|
ok('stack reports node and postgres version', Boolean(stack && stack.app.node && stack.database.version), stack && { node: stack.app.node, pg: stack.database.version });
|
||||||
|
ok('stack never leaks credentials', !/:\/\/[^"@/]*@/.test(JSON.stringify(stack)), stack && stack.database.host, stack && stack.cache.host);
|
||||||
|
|
||||||
|
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 notifyNoAuth = await api('/api/notifications');
|
||||||
|
ok('notifications: список без токена -> 401', notifyNoAuth.status === 401, notifyNoAuth.status);
|
||||||
|
const notifyList = await api('/api/notifications?limit=5', { token });
|
||||||
|
ok('notifications: список -> 200', notifyList.status === 200 && Array.isArray(notifyList.data.items) && typeof notifyList.data.unread === 'number', notifyList.status);
|
||||||
|
const notifyMeta = await api('/api/notifications/meta', { token });
|
||||||
|
ok('notifications: meta перечисляет типы событий', notifyMeta.status === 200 && Array.isArray(notifyMeta.data.types) && notifyMeta.data.types.length > 0, notifyMeta.status);
|
||||||
|
ok('notifications: meta содержит тип ip.ban', Boolean((notifyMeta.data.types || []).find(t => t.type === 'ip.ban')), (notifyMeta.data.types || []).map(t => t.type));
|
||||||
|
const notifyCreate = await api('/api/notifications/test', { token, method: 'POST' });
|
||||||
|
ok('notifications: тестовое уведомление создано', notifyCreate.status === 200 && notifyCreate.data.id > 0 && notifyCreate.data.delivered === true, notifyCreate.data);
|
||||||
|
const notifyUnread = await api('/api/notifications?unread=1', { token });
|
||||||
|
ok('notifications: непрочитанные растут', notifyUnread.data.unread >= 1, notifyUnread.data.unread);
|
||||||
|
const notifyRead = await api('/api/notifications/' + notifyCreate.data.id + '/read', { token, method: 'POST' });
|
||||||
|
ok('notifications: отметить уведомление прочитанным', notifyRead.status === 200, notifyRead.status);
|
||||||
|
const notifyReadAll = await api('/api/notifications/read-all', { token, method: 'POST' });
|
||||||
|
ok('notifications: отметить всё прочитанным', notifyReadAll.status === 200 && notifyReadAll.data.unread === 0, notifyReadAll.data);
|
||||||
|
const notifyStream = await fetch(BASE + '/api/notifications/stream?token=' + encodeURIComponent(token));
|
||||||
|
ok('notifications: SSE открывается', notifyStream.status === 200, notifyStream.status);
|
||||||
|
if (notifyStream.status === 200) {
|
||||||
|
const reader = notifyStream.body.getReader();
|
||||||
|
const first = await reader.read();
|
||||||
|
const text = new TextDecoder().decode(first.value || new Uint8Array());
|
||||||
|
ok('notifications: SSE отдаёт ready-кадр', text.includes('event: ready') || text.includes(':ok'), JSON.stringify(text.slice(0, 60)));
|
||||||
|
reader.cancel().catch(() => {});
|
||||||
|
}
|
||||||
|
const notifyOff = await api('/api/settings', { token, method: 'PUT', body: { settings: { notify_system_test: 'false' } } });
|
||||||
|
const notifySuppressed = await api('/api/notifications/test', { token, method: 'POST' });
|
||||||
|
ok('notifications: выключенный тип не создаётся', notifyOff.status === 200 && notifySuppressed.status === 200 && notifySuppressed.data.id === null, notifySuppressed.data);
|
||||||
|
const notifyOn = await api('/api/settings', { token, method: 'PUT', body: { settings: { notify_system_test: 'true' } } });
|
||||||
|
ok('notifications: тип включается обратно', notifyOn.status === 200, notifyOn.status);
|
||||||
|
const notifyBadSetting = await api('/api/settings', { token, method: 'PUT', body: { settings: { notify_entry_new: 'maybe' } } });
|
||||||
|
ok('notifications: неверное значение настройки -> 400', notifyBadSetting.status === 400, notifyBadSetting.status);
|
||||||
|
const notifyClearNoAuth = await api('/api/notifications', { method: 'DELETE' });
|
||||||
|
ok('notifications: очистка без токена -> 401', notifyClearNoAuth.status === 401, notifyClearNoAuth.status);
|
||||||
|
const notifyDel = await api('/api/notifications/' + notifyCreate.data.id, { token, method: 'DELETE' });
|
||||||
|
ok('notifications: удаление уведомления', notifyDel.status === 200, notifyDel.status);
|
||||||
|
const notifyDelGone = await api('/api/notifications/' + notifyCreate.data.id + '/read', { token, method: 'POST' });
|
||||||
|
ok('notifications: удалённое уведомление -> 404', notifyDelGone.status === 404, notifyDelGone.status);
|
||||||
|
|
||||||
|
// Фото-ИИ: photo_ai_enabled обязан совпадать с ai_configured — оба выводятся из
|
||||||
|
// PHOTO_AI_URL, и флаг не попадает в кэш public-settings, иначе после перезапуска
|
||||||
|
// с пустым PHOTO_AI_URL кнопка «🤖 ИИ» висела бы до истечения кэша (I3).
|
||||||
|
// enhance-ai проверяем на несуществующей записи: 503 без фото-ИИ и 404 с фото-ИИ,
|
||||||
|
// чтобы дымовой тест не создавал реальных заданий фото-воркеру.
|
||||||
|
const photoStatus = await api('/api/photo-jobs/status', { token });
|
||||||
|
ok('photo-jobs/status -> 200', photoStatus.status === 200, photoStatus.status);
|
||||||
|
ok('photo_ai_enabled согласован с ai_configured', pub.data.photo_ai_enabled === String(!!photoStatus.data.ai_configured), {
|
||||||
|
photo_ai_enabled: pub.data.photo_ai_enabled,
|
||||||
|
ai_configured: photoStatus.data.ai_configured,
|
||||||
|
});
|
||||||
|
const workerCfg = photoStatus.data.worker && photoStatus.data.worker.config;
|
||||||
|
ok('worker.config содержит лимит мягких повторов', Boolean(workerCfg && workerCfg.soft_max_retries > 0), workerCfg);
|
||||||
|
// service обязан быть health фото-сервиса, а не текстового ИИ: configured совпадает
|
||||||
|
// с ai_configured, reachable — булево, а при выключенном photo-ai сервис недоступен.
|
||||||
|
const photoSvc = photoStatus.data.service;
|
||||||
|
ok('photo-jobs/status -> service от фото-сервиса', Boolean(photoSvc) && photoSvc.configured === photoStatus.data.ai_configured && typeof photoSvc.reachable === 'boolean', {
|
||||||
|
service: photoSvc,
|
||||||
|
ai_configured: photoStatus.data.ai_configured,
|
||||||
|
});
|
||||||
|
ok('service: без photo-ai reachable=false', photoStatus.data.ai_configured === false ? photoSvc.reachable === false : typeof photoSvc.latency_ms === 'number', {
|
||||||
|
ai_configured: photoStatus.data.ai_configured,
|
||||||
|
reachable: photoSvc.reachable,
|
||||||
|
});
|
||||||
|
ok('worker.config.ai_url соответствует наличию фото-ИИ', Boolean(workerCfg) && workerCfg.ai_url === photoStatus.data.ai_url, {
|
||||||
|
worker_ai_url: workerCfg && workerCfg.ai_url,
|
||||||
|
ai_url: photoStatus.data.ai_url,
|
||||||
|
});
|
||||||
|
const enhanceAi = await api('/api/entries/99999999/photo/enhance-ai', { token, method: 'POST' });
|
||||||
|
ok('enhance-ai: 503 без photo-ai либо 404 с photo-ai (запись не существует)', (photoStatus.data.ai_configured === false && enhanceAi.status === 503) || (photoStatus.data.ai_configured === true && enhanceAi.status === 404), {
|
||||||
|
ai_configured: photoStatus.data.ai_configured,
|
||||||
|
status: enhanceAi.status,
|
||||||
|
body: enhanceAi.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
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); });
|
||||||
+128
-1
@@ -21,20 +21,52 @@ CREATE TABLE IF NOT EXISTS groups (
|
|||||||
day_of_week INT,
|
day_of_week INT,
|
||||||
time_start TIME,
|
time_start TIME,
|
||||||
time_end 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 (
|
CREATE TABLE IF NOT EXISTS students (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
name VARCHAR(150) NOT NULL UNIQUE,
|
name VARCHAR(150) NOT NULL UNIQUE,
|
||||||
group_id INT REFERENCES groups(id),
|
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()
|
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 (
|
CREATE TABLE IF NOT EXISTS entries (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
student_name VARCHAR(150) NOT NULL,
|
student_name VARCHAR(150) NOT NULL,
|
||||||
group_id INT NOT NULL REFERENCES groups(id),
|
group_id INT NOT NULL REFERENCES groups(id),
|
||||||
|
module_id INT REFERENCES modules(id) ON DELETE SET NULL,
|
||||||
description TEXT NOT NULL,
|
description TEXT NOT NULL,
|
||||||
description_original TEXT,
|
description_original TEXT,
|
||||||
description_ai TEXT,
|
description_ai TEXT,
|
||||||
@@ -42,7 +74,9 @@ CREATE TABLE IF NOT EXISTS entries (
|
|||||||
ai_checked_at TIMESTAMPTZ,
|
ai_checked_at TIMESTAMPTZ,
|
||||||
ai_error TEXT,
|
ai_error TEXT,
|
||||||
photo_path VARCHAR(255),
|
photo_path VARCHAR(255),
|
||||||
|
photo_original_path VARCHAR(255),
|
||||||
deleted_at TIMESTAMPTZ,
|
deleted_at TIMESTAMPTZ,
|
||||||
|
purge_at TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ DEFAULT now()
|
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_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_checked_at TIMESTAMPTZ;
|
||||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_error TEXT;
|
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_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 (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
@@ -75,6 +127,10 @@ INSERT INTO settings (key, value) VALUES ('share_show_group_photos', 'true')
|
|||||||
ON CONFLICT (key) DO NOTHING;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.')
|
INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.')
|
||||||
ON CONFLICT (key) DO NOTHING;
|
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 (
|
CREATE TABLE IF NOT EXISTS share_links (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -146,6 +202,8 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
created_at TIMESTAMPTZ DEFAULT now()
|
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 (
|
CREATE TABLE IF NOT EXISTS user_branches (
|
||||||
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
branch_id INT NOT NULL REFERENCES branches(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_token ON sessions(token);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS 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 (
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
user_id INT REFERENCES users(id) ON DELETE SET NULL,
|
user_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
@@ -173,3 +252,51 @@ CREATE TABLE IF NOT EXISTS audit_log (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
level VARCHAR(20) NOT NULL DEFAULT 'info',
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
body TEXT,
|
||||||
|
link VARCHAR(255),
|
||||||
|
target JSONB,
|
||||||
|
admin_only BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_branch_id ON notifications(branch_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_reads (
|
||||||
|
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
notification_id INT NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
|
||||||
|
read_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
PRIMARY KEY (user_id, notification_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_reads_user ON notification_reads(user_id);
|
||||||
|
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_enabled', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_retention_days', '30')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_entry_new', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_entry_ai_corrected', 'false')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_entry_ai_error', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_photo_job_done', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_photo_job_error', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_ip_ban', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_backup_restore', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_backup_create', 'false')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_system_test', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|||||||
@@ -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 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 (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
key TEXT PRIMARY KEY,
|
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);
|
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 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_original TEXT;
|
||||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_ai TEXT;
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_ai TEXT;
|
||||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'pending';
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_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-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.')
|
INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.')
|
||||||
ON CONFLICT (key) DO NOTHING;
|
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 $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NOT EXISTS (SELECT 1 FROM settings WHERE key = 'ai_autocheck_migrated') THEN
|
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_start TIME;
|
||||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS time_end 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 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 (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -152,6 +163,8 @@ CREATE TABLE IF NOT EXISTS user_branches (
|
|||||||
PRIMARY KEY (user_id, branch_id)
|
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 (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@@ -184,3 +197,101 @@ FROM (
|
|||||||
FROM entry_photos ORDER BY entry_id, sort_order, id
|
FROM entry_photos ORDER BY entry_id, sort_order, id
|
||||||
) p
|
) p
|
||||||
WHERE e.photo_path IS NULL AND p.entry_id = e.id;
|
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);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
level VARCHAR(20) NOT NULL DEFAULT 'info',
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
body TEXT,
|
||||||
|
link VARCHAR(255),
|
||||||
|
target JSONB,
|
||||||
|
admin_only BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_branch_id ON notifications(branch_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_reads (
|
||||||
|
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
notification_id INT NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
|
||||||
|
read_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
PRIMARY KEY (user_id, notification_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_reads_user ON notification_reads(user_id);
|
||||||
|
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_enabled', 'true') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_retention_days', '30') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_entry_new', 'true') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_entry_ai_corrected', 'false') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_entry_ai_error', 'true') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_photo_job_done', 'true') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_photo_job_error', 'true') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_ip_ban', 'true') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_backup_restore', 'true') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_backup_create', 'false') ON CONFLICT (key) DO NOTHING;
|
||||||
|
INSERT INTO settings (key, value) VALUES ('notify_system_test', 'true') ON CONFLICT (key) DO NOTHING;
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Переопределение photo-ai для работы на NVIDIA GPU.
|
||||||
|
# Использование:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build photo-ai
|
||||||
|
#
|
||||||
|
# Требуется драйвер NVIDIA и NVIDIA Container Toolkit. GPU выдаётся контейнеру через CDI
|
||||||
|
# (device_ids ниже соответствует спеку /etc/cdi/nvidia.yaml): этот путь не требует правки
|
||||||
|
# /etc/docker/daemon.json и перезапуска демона. Если nvidia-runtime зарегистрирован в демоне
|
||||||
|
# (nvidia-ctk runtime configure --runtime=docker), вместо CDI можно вернуть классический вид
|
||||||
|
# резервирования: driver: nvidia, count: 1 — результат тот же.
|
||||||
|
# TORCH_VARIANT=cu126, а не cu124: в индексе cu124 последний torch — 2.6.0, а cu126 даёт ровно
|
||||||
|
# те же torch 2.14.0 / torchvision 0.29.0, что и CPU-образ, поэтому варианты сборки отличаются
|
||||||
|
# только CUDA-библиотеками.
|
||||||
|
# Образ тегируется отдельно (whatido-photo-ai:cu126), чтобы сборка GPU-варианта не перетирала
|
||||||
|
# CPU-образ whatido-photo-ai:latest.
|
||||||
|
# PHOTO_AI_DEVICE=cuda при недоступной CUDA не роняет сервис: app.py пишет WARN и работает
|
||||||
|
# на CPU, /health при этом отвечает 200.
|
||||||
|
services:
|
||||||
|
photo-ai:
|
||||||
|
image: whatido-photo-ai:cu126
|
||||||
|
build:
|
||||||
|
args:
|
||||||
|
TORCH_VARIANT: cu126
|
||||||
|
environment:
|
||||||
|
PHOTO_AI_DEVICE: ${PHOTO_AI_DEVICE:-cuda}
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: cdi
|
||||||
|
device_ids:
|
||||||
|
- nvidia.com/gpu=all
|
||||||
|
capabilities: [gpu]
|
||||||
@@ -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
|
||||||
+129
-16
@@ -3,6 +3,7 @@ name: whatido
|
|||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: whereldo
|
POSTGRES_DB: whereldo
|
||||||
POSTGRES_USER: app
|
POSTGRES_USER: app
|
||||||
@@ -17,10 +18,48 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 10
|
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:
|
app:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
network: host
|
network: host
|
||||||
|
args:
|
||||||
|
GIT_COMMIT: ${GIT_COMMIT:-}
|
||||||
|
GIT_COMMIT_DATE: ${GIT_COMMIT_DATE:-}
|
||||||
|
restart: unless-stopped
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
expose:
|
expose:
|
||||||
- "3003"
|
- "3003"
|
||||||
- "3443"
|
- "3443"
|
||||||
@@ -29,20 +68,61 @@ services:
|
|||||||
- "3443:3443"
|
- "3443:3443"
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/whereldo
|
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_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
BACKUP_UPLOAD_LIMIT_MB: ${BACKUP_UPLOAD_LIMIT_MB:-500}
|
BACKUP_UPLOAD_LIMIT_MB: ${BACKUP_UPLOAD_LIMIT_MB:-500}
|
||||||
AI_MODEL: ${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf}
|
AI_MODEL: ${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf}
|
||||||
AI_PROMPT: ${AI_PROMPT:-}
|
AI_PROMPT: ${AI_PROMPT:-}
|
||||||
AI_REQUEST_TIMEOUT_MS: ${AI_REQUEST_TIMEOUT_MS:-120000}
|
AI_REQUEST_TIMEOUT_MS: ${AI_REQUEST_TIMEOUT_MS:-120000}
|
||||||
|
PHOTO_AI_URL: ${PHOTO_AI_URL:-http://photo-ai:8080}
|
||||||
|
PHOTO_AI_FACE_MODEL: ${PHOTO_AI_FACE_MODEL:-gfpgan}
|
||||||
|
PHOTO_AI_FACE_TIMEOUT_MS: ${PHOTO_AI_FACE_TIMEOUT_MS:-600000}
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
TZ: Europe/Moscow
|
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:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
- ./uploads:/app/uploads
|
- ./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) без проброса портов.
|
# Публикация через Tailscale (Serve / Funnel) без проброса портов.
|
||||||
# Приложение доступно по https://whatido.<tailnet>.ts.net
|
# Приложение доступно по https://whatido.<tailnet>.ts.net
|
||||||
# tailscale:
|
# tailscale:
|
||||||
@@ -72,22 +152,22 @@ services:
|
|||||||
# если в wg/wg0.conf лежит конфиг — контейнер сначала поднимает VPN и
|
# если в wg/wg0.conf лежит конфиг — контейнер сначала поднимает VPN и
|
||||||
# только потом запускает туннель (исход Cloudflare через VPN). Без конфига
|
# только потом запускает туннель (исход Cloudflare через VPN). Без конфига
|
||||||
# туннель стартует сразу, как в базовой схеме.
|
# туннель стартует сразу, как в базовой схеме.
|
||||||
cloudflared:
|
# cloudflared:
|
||||||
build:
|
# build:
|
||||||
context: .
|
# context: .
|
||||||
dockerfile: Dockerfile.cloudflared
|
# dockerfile: Dockerfile.cloudflared
|
||||||
restart: unless-stopped
|
# restart: unless-stopped
|
||||||
cap_add:
|
# cap_add:
|
||||||
- NET_ADMIN
|
# - NET_ADMIN
|
||||||
privileged: true
|
# privileged: true
|
||||||
volumes:
|
# volumes:
|
||||||
- ./wg:/etc/wireguard:ro
|
# - ./wg:/etc/wireguard:ro
|
||||||
environment:
|
# environment:
|
||||||
TZ: Europe/Moscow
|
# TZ: Europe/Moscow
|
||||||
CLOUDFLARE_TUNNEL_URL: ${CLOUDFLARE_TUNNEL_URL:-http://app:3003}
|
# CLOUDFLARE_TUNNEL_URL: ${CLOUDFLARE_TUNNEL_URL:-http://app:3003}
|
||||||
WG_HANDSHAKE_TIMEOUT: ${WG_HANDSHAKE_TIMEOUT:-60}
|
# WG_HANDSHAKE_TIMEOUT: ${WG_HANDSHAKE_TIMEOUT:-60}
|
||||||
depends_on:
|
# depends_on:
|
||||||
- app
|
# - app
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -111,7 +191,40 @@ services:
|
|||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
|
||||||
|
|
||||||
|
# ИИ-улучшение фотографий (Real-ESRGAN: апскейл, денойз, восстановление лиц GFPGAN).
|
||||||
|
# Устройство выбирается автоматически (PHOTO_AI_DEVICE=auto): CUDA, если контейнеру
|
||||||
|
# выдан GPU, иначе CPU. Запуск на GPU — через docker-compose.gpu.yml.
|
||||||
|
# Поднимается вместе со стеком; если не нужен — PHOTO_AI_URL пустой в .env.
|
||||||
|
# Порт 8081 пробрасывается только на loopback хоста — наружу ничего не публикуется,
|
||||||
|
# хостовый 8080 уже занят text-corrector. Ручные проверки: curl http://127.0.0.1:8081/health
|
||||||
|
photo-ai:
|
||||||
|
build: ./photo-ai
|
||||||
|
container_name: photo-ai
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MODEL_PATH: /models/RealESRGAN_x2plus.pth
|
||||||
|
PHOTO_AI_MODELS_DIR: /models
|
||||||
|
PHOTO_AI_MAX_PIXELS: ${PHOTO_AI_MAX_PIXELS:-4000000}
|
||||||
|
PHOTO_AI_DEVICE: ${PHOTO_AI_DEVICE:-auto}
|
||||||
|
PHOTO_AI_TILE: ${PHOTO_AI_TILE:-256}
|
||||||
|
PHOTO_AI_FACE_MODEL: ${PHOTO_AI_FACE_MODEL:-gfpgan}
|
||||||
|
PHOTO_AI_LOAD_ALL: ${PHOTO_AI_LOAD_ALL:-0}
|
||||||
|
PHOTO_AI_JPEG_QUALITY: ${PHOTO_AI_JPEG_QUALITY:-92}
|
||||||
|
TZ: Europe/Moscow
|
||||||
|
volumes:
|
||||||
|
- photo-ai-models:/models
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8081:8080"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import sys, urllib.request; r = urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=5); sys.exit(0 if r.status == 200 else 1)"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 300s
|
||||||
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
photo-ai-models:
|
||||||
|
redis-data:
|
||||||
|
s3-data:
|
||||||
|
|||||||
Generated
+495
-2
@@ -8,6 +8,7 @@
|
|||||||
"name": "whereldo",
|
"name": "whereldo",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.1141.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"express-rate-limit": "^8.7.0",
|
"express-rate-limit": "^8.7.0",
|
||||||
@@ -16,10 +17,319 @@
|
|||||||
"lucide": "^1.44.0",
|
"lucide": "^1.44.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"pg": "^8.13.0",
|
"pg": "^8.13.0",
|
||||||
|
"redis": "^5.12.1",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"tar": "^7.4.3"
|
"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": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.11.3",
|
"version": "1.11.3",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||||
@@ -654,6 +964,159 @@
|
|||||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/abbrev": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
|
||||||
@@ -807,6 +1270,12 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"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": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.18",
|
"version": "1.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||||
@@ -881,6 +1350,15 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/color-support": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
||||||
@@ -2067,6 +2545,22 @@
|
|||||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/rimraf": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||||
@@ -2402,8 +2896,7 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD",
|
"license": "0BSD"
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/type-is": {
|
"node_modules/type-is": {
|
||||||
"version": "1.6.18",
|
"version": "1.6.18",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"start": "node server.js"
|
"start": "node server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.1141.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"express-rate-limit": "^8.7.0",
|
"express-rate-limit": "^8.7.0",
|
||||||
@@ -14,6 +15,7 @@
|
|||||||
"lucide": "^1.44.0",
|
"lucide": "^1.44.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"pg": "^8.13.0",
|
"pg": "^8.13.0",
|
||||||
|
"redis": "^5.12.1",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"tar": "^7.4.3"
|
"tar": "^7.4.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
FROM python:3.10-slim
|
||||||
|
|
||||||
|
ARG TORCH_VARIANT=cpu
|
||||||
|
ARG TORCH_INDEX=https://download.pytorch.org/whl/${TORCH_VARIANT}
|
||||||
|
|
||||||
|
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.2.6"
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir torch torchvision --index-url ${TORCH_INDEX}
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir --no-deps basicsr==1.4.2 realesrgan==0.3.0 gfpgan==1.3.8 facexlib==0.3.0 && \
|
||||||
|
pip install --no-cache-dir "numpy==2.2.6" "opencv-python-headless==5.0.0.93" addict future lmdb Pillow pyyaml \
|
||||||
|
requests scikit-image scipy tqdm filterpy numba fastapi "uvicorn[standard]" python-multipart
|
||||||
|
|
||||||
|
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 ./
|
||||||
|
COPY vendor/ ./vendor/
|
||||||
|
|
||||||
|
ENV PHOTO_AI_MODELS_DIR=/models
|
||||||
|
ENV MODEL_PATH=/models/RealESRGAN_x2plus.pth
|
||||||
|
|
||||||
|
VOLUME /models
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||||
+879
@@ -0,0 +1,879 @@
|
|||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import importlib.util
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
from collections import OrderedDict
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
from basicsr.archs.rrdbnet_arch import RRDBNet
|
||||||
|
from basicsr.archs.srvgg_arch import SRVGGNetCompact
|
||||||
|
from fastapi import FastAPI, File, Form, Request, UploadFile
|
||||||
|
from fastapi.responses import JSONResponse, Response
|
||||||
|
from realesrgan import RealESRGANer
|
||||||
|
|
||||||
|
VENDOR_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'vendor')
|
||||||
|
if os.path.isdir(VENDOR_DIR) and VENDOR_DIR not in sys.path:
|
||||||
|
sys.path.append(VENDOR_DIR)
|
||||||
|
|
||||||
|
log = logging.getLogger('photo-ai')
|
||||||
|
if not log.handlers:
|
||||||
|
handler = logging.StreamHandler(sys.stderr)
|
||||||
|
handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s %(name)s %(message)s'))
|
||||||
|
log.addHandler(handler)
|
||||||
|
log.setLevel(logging.INFO)
|
||||||
|
log.propagate = False
|
||||||
|
|
||||||
|
FACE_MODES = ('off', 'face', 'all')
|
||||||
|
DEFAULT_MODEL = 'x2plus'
|
||||||
|
DEFAULT_STRENGTH = 0.7
|
||||||
|
MIN_TILE = 64
|
||||||
|
MIN_FACE_SIDE = 320
|
||||||
|
POOL_LIMIT = 2
|
||||||
|
WARMUP_SIZE = 64
|
||||||
|
RETRY_AFTER_SEC = 5
|
||||||
|
|
||||||
|
|
||||||
|
def env_text(name, default=''):
|
||||||
|
value = os.environ.get(name)
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
value = value.strip()
|
||||||
|
return value or default
|
||||||
|
|
||||||
|
|
||||||
|
def env_flag(name, default):
|
||||||
|
value = env_text(name).lower()
|
||||||
|
if not value:
|
||||||
|
return default
|
||||||
|
return value not in ('0', 'false', 'no', 'off')
|
||||||
|
|
||||||
|
|
||||||
|
def env_int(name, default):
|
||||||
|
value = env_text(name)
|
||||||
|
if not value:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except ValueError:
|
||||||
|
log.warning('%s=%r не число, беру %s', name, value, default)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def clamp(value, low, high):
|
||||||
|
return max(low, min(high, value))
|
||||||
|
|
||||||
|
|
||||||
|
DEVICE_PREF = env_text('PHOTO_AI_DEVICE', 'auto').lower()
|
||||||
|
if DEVICE_PREF not in ('auto', 'cuda', 'cpu', 'mps'):
|
||||||
|
log.warning('PHOTO_AI_DEVICE=%r неизвестно, беру auto', DEVICE_PREF)
|
||||||
|
DEVICE_PREF = 'auto'
|
||||||
|
MODELS_DIR = env_text('PHOTO_AI_MODELS_DIR', '/models')
|
||||||
|
WEIGHTS_DIR = os.path.join(MODELS_DIR, 'weights')
|
||||||
|
LEGACY_MODEL_PATH = env_text('MODEL_PATH')
|
||||||
|
MAX_PIXELS = max(env_int('PHOTO_AI_MAX_PIXELS', env_int('MAX_INPUT_PIXELS', 4000000)), 1)
|
||||||
|
BASE_TILE = max(env_int('PHOTO_AI_TILE', 256), 0)
|
||||||
|
TILE_PAD = 10
|
||||||
|
PRE_PAD = 0
|
||||||
|
LOAD_ALL = env_flag('PHOTO_AI_LOAD_ALL', False)
|
||||||
|
WARMUP = env_flag('PHOTO_AI_WARMUP', True)
|
||||||
|
DEFAULT_FACE_MODEL = env_text('PHOTO_AI_FACE_MODEL', 'gfpgan').lower()
|
||||||
|
DEFAULT_JPEG_QUALITY = clamp(env_int('PHOTO_AI_JPEG_QUALITY', 92), 70, 100)
|
||||||
|
|
||||||
|
OUTPUT_FORMATS = {
|
||||||
|
'jpg': ('jpg', 'image/jpeg', [int(cv2.IMWRITE_JPEG_QUALITY)]),
|
||||||
|
'png': ('png', 'image/png', [int(cv2.IMWRITE_PNG_COMPRESSION), 3]),
|
||||||
|
'webp': ('webp', 'image/webp', [int(cv2.IMWRITE_WEBP_QUALITY), 92]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def rrdb_x2():
|
||||||
|
return RRDBNet(num_in_ch=3, num_out_ch=3, scale=2, num_feat=64, num_block=23, num_grow_ch=32)
|
||||||
|
|
||||||
|
|
||||||
|
def srvgg(num_conv):
|
||||||
|
return lambda: SRVGGNetCompact(num_in_ch=3, num_out_ch=3, num_feat=64, num_conv=num_conv,
|
||||||
|
upscale=4, act_type='prelu')
|
||||||
|
|
||||||
|
|
||||||
|
MODEL_REGISTRY = OrderedDict([
|
||||||
|
('x2plus', {
|
||||||
|
'scale': 2,
|
||||||
|
'arch': rrdb_x2,
|
||||||
|
'file': 'RealESRGAN_x2plus.pth',
|
||||||
|
'url': 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth',
|
||||||
|
'min_bytes': 60_000_000,
|
||||||
|
'alias': True,
|
||||||
|
'denoise': False,
|
||||||
|
'title': 'Универсальный апскейл x2, дефолт',
|
||||||
|
}),
|
||||||
|
('general-x4v3', {
|
||||||
|
'scale': 4,
|
||||||
|
'arch': srvgg(32),
|
||||||
|
'file': 'realesr-general-x4v3.pth',
|
||||||
|
'url': 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-general-x4v3.pth',
|
||||||
|
'min_bytes': 2_000_000,
|
||||||
|
'alias': False,
|
||||||
|
'denoise': True,
|
||||||
|
'title': 'Быстрый апскейл x4 с денойзом',
|
||||||
|
'dni': {
|
||||||
|
'file': 'realesr-general-wdn-x4v3.pth',
|
||||||
|
'url': 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-general-wdn-x4v3.pth',
|
||||||
|
'min_bytes': 2_000_000,
|
||||||
|
'weight': 0.5,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
('animevideo-v3', {
|
||||||
|
'scale': 4,
|
||||||
|
'arch': srvgg(16),
|
||||||
|
'file': 'realesr-animevideov3.pth',
|
||||||
|
'url': 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-animevideov3.pth',
|
||||||
|
'min_bytes': 1_000_000,
|
||||||
|
'alias': False,
|
||||||
|
'denoise': False,
|
||||||
|
'title': 'Быстрый апскейл x4 для скриншотов и иллюстраций',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
FACE_REGISTRY = OrderedDict([
|
||||||
|
('gfpgan', {
|
||||||
|
'module': 'gfpgan',
|
||||||
|
'file': 'GFPGANv1.4.pth',
|
||||||
|
'url': 'https://github.com/TencentARC/GFPGAN/releases/download/v1.3.0/GFPGANv1.4.pth',
|
||||||
|
'min_bytes': 300_000_000,
|
||||||
|
'arch': 'clean',
|
||||||
|
'channel_multiplier': 2,
|
||||||
|
'strength': False,
|
||||||
|
'title': 'GFPGAN v1.4, восстановление лиц, дефолт',
|
||||||
|
}),
|
||||||
|
('codeformer', {
|
||||||
|
'module': 'codeformer',
|
||||||
|
'file': 'codeformer.pth',
|
||||||
|
'url': 'https://github.com/sczhou/CodeFormer/releases/download/v0.1.0/codeformer.pth',
|
||||||
|
'min_bytes': 300_000_000,
|
||||||
|
'strength': True,
|
||||||
|
'title': 'CodeFormer, восстановление лиц с регулируемой силой',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
FACEXLIB_WEIGHTS = OrderedDict([
|
||||||
|
('detection_Resnet50_Final.pth', {
|
||||||
|
'url': 'https://github.com/xinntao/facexlib/releases/download/v0.1.0/detection_Resnet50_Final.pth',
|
||||||
|
'min_bytes': 90_000_000,
|
||||||
|
}),
|
||||||
|
('parsing_parsenet.pth', {
|
||||||
|
'url': 'https://github.com/xinntao/facexlib/releases/download/v0.2.2/parsing_parsenet.pth',
|
||||||
|
'min_bytes': 70_000_000,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def cuda_ready():
|
||||||
|
try:
|
||||||
|
return bool(torch.cuda.is_available()) and torch.cuda.device_count() > 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def mps_ready():
|
||||||
|
try:
|
||||||
|
return bool(torch.backends.mps.is_available())
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def cpu_device_name():
|
||||||
|
return 'CPU (' + (platform.machine() or 'unknown') + ')'
|
||||||
|
|
||||||
|
|
||||||
|
def pick_device():
|
||||||
|
if DEVICE_PREF == 'cpu':
|
||||||
|
return 'cpu', False, cpu_device_name()
|
||||||
|
if DEVICE_PREF == 'mps':
|
||||||
|
if mps_ready():
|
||||||
|
return 'mps', False, 'Apple Silicon (MPS)'
|
||||||
|
log.warning('PHOTO_AI_DEVICE=mps, но MPS недоступен — работаю на CPU')
|
||||||
|
return 'cpu', False, cpu_device_name()
|
||||||
|
if DEVICE_PREF == 'cuda':
|
||||||
|
if not cuda_ready():
|
||||||
|
log.warning('PHOTO_AI_DEVICE=cuda, но CUDA недоступна — работаю на CPU')
|
||||||
|
return 'cpu', False, cpu_device_name()
|
||||||
|
return 'cuda:0', True, torch.cuda.get_device_name(0)
|
||||||
|
if cuda_ready():
|
||||||
|
return 'cuda:0', True, torch.cuda.get_device_name(0)
|
||||||
|
if mps_ready():
|
||||||
|
return 'mps', False, 'Apple Silicon (MPS)'
|
||||||
|
return 'cpu', False, cpu_device_name()
|
||||||
|
|
||||||
|
|
||||||
|
state = {'device': 'cpu', 'half': False, 'device_name': 'CPU', 'tile': BASE_TILE, 'degraded': False}
|
||||||
|
state['device'], state['half'], state['device_name'] = pick_device()
|
||||||
|
INFER_LOCK = threading.RLock()
|
||||||
|
|
||||||
|
|
||||||
|
class EnhanceError(Exception):
|
||||||
|
def __init__(self, message, status_code=500):
|
||||||
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
|
||||||
|
class ModelNotReady(EnhanceError):
|
||||||
|
def __init__(self, label):
|
||||||
|
super().__init__('модель %s ещё загружается, повторите позже' % label, 503)
|
||||||
|
self.label = label
|
||||||
|
|
||||||
|
|
||||||
|
class TileOOM(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def error_response(status_code, message, headers=None):
|
||||||
|
return JSONResponse(status_code=status_code, content={'ok': False, 'error': message},
|
||||||
|
headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
def is_oom(err):
|
||||||
|
if isinstance(err, (torch.cuda.OutOfMemoryError, TileOOM)):
|
||||||
|
return True
|
||||||
|
text = str(err).lower()
|
||||||
|
return any(mark in text for mark in ('out of memory', 'not enough memory',
|
||||||
|
'alloc_cpu', "can't allocate memory"))
|
||||||
|
|
||||||
|
|
||||||
|
def guard_forward(model):
|
||||||
|
if getattr(model, '_photo_ai_guarded', False):
|
||||||
|
return model
|
||||||
|
forward = model.forward
|
||||||
|
|
||||||
|
def guarded(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
return forward(*args, **kwargs)
|
||||||
|
except RuntimeError as err:
|
||||||
|
if not is_oom(err):
|
||||||
|
raise
|
||||||
|
raise TileOOM(str(err)) from err
|
||||||
|
|
||||||
|
model.forward = guarded
|
||||||
|
model._photo_ai_guarded = True
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
def tile_ladder(base):
|
||||||
|
if base <= 0:
|
||||||
|
return [base]
|
||||||
|
ladder = []
|
||||||
|
for tile in (base, base // 2, base // 4):
|
||||||
|
if tile >= MIN_TILE and (not ladder or ladder[-1] != tile):
|
||||||
|
ladder.append(tile)
|
||||||
|
return ladder or [base]
|
||||||
|
|
||||||
|
|
||||||
|
def file_ok(path, min_bytes):
|
||||||
|
return os.path.isfile(path) and os.path.getsize(path) >= min_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def link_or_copy(src, dst):
|
||||||
|
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||||
|
try:
|
||||||
|
os.link(src, dst)
|
||||||
|
except OSError:
|
||||||
|
shutil.copyfile(src, dst)
|
||||||
|
|
||||||
|
|
||||||
|
def download_weight(url, target, min_bytes):
|
||||||
|
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||||
|
tmp = target + '.tmp'
|
||||||
|
log.info('качаю веса %s -> %s', url, target)
|
||||||
|
try:
|
||||||
|
urllib.request.urlretrieve(url, tmp)
|
||||||
|
except Exception as err:
|
||||||
|
if os.path.exists(tmp):
|
||||||
|
os.unlink(tmp)
|
||||||
|
raise EnhanceError('не удалось скачать веса %s: %s' % (os.path.basename(target), err), 500) from err
|
||||||
|
size = os.path.getsize(tmp) if os.path.isfile(tmp) else 0
|
||||||
|
if size < min_bytes:
|
||||||
|
os.unlink(tmp)
|
||||||
|
raise EnhanceError('веса %s повреждены: %d байт, минимум %d'
|
||||||
|
% (os.path.basename(target), size, min_bytes), 500)
|
||||||
|
os.replace(tmp, target)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_weight(name, spec, alias=''):
|
||||||
|
target = os.path.join(WEIGHTS_DIR, name)
|
||||||
|
if file_ok(target, spec['min_bytes']):
|
||||||
|
return target
|
||||||
|
if os.path.isfile(target):
|
||||||
|
log.warning('удаляю битые веса %s (%d байт)', target, os.path.getsize(target))
|
||||||
|
os.unlink(target)
|
||||||
|
if alias and file_ok(alias, spec['min_bytes']):
|
||||||
|
link_or_copy(alias, target)
|
||||||
|
log.info('веса %s взяты из существующего файла %s', name, alias)
|
||||||
|
return target
|
||||||
|
download_weight(spec['url'], target, spec['min_bytes'])
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_facexlib_weights():
|
||||||
|
for name, spec in FACEXLIB_WEIGHTS.items():
|
||||||
|
ensure_weight(name, spec)
|
||||||
|
|
||||||
|
|
||||||
|
def upsamplers_of(obj):
|
||||||
|
found = []
|
||||||
|
if isinstance(obj, RealESRGANer):
|
||||||
|
found.append(obj)
|
||||||
|
inner = getattr(obj, 'upsampler', None)
|
||||||
|
if isinstance(inner, RealESRGANer):
|
||||||
|
found.append(inner)
|
||||||
|
bg = getattr(obj, 'bg_upsampler', None)
|
||||||
|
if isinstance(bg, RealESRGANer):
|
||||||
|
found.append(bg)
|
||||||
|
elif isinstance(getattr(bg, 'upsampler', None), RealESRGANer):
|
||||||
|
found.append(bg.upsampler)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def apply_tile(obj, tile):
|
||||||
|
for upsampler in upsamplers_of(obj):
|
||||||
|
upsampler.tile_size = tile
|
||||||
|
|
||||||
|
|
||||||
|
def warm_up(runner):
|
||||||
|
if not WARMUP:
|
||||||
|
return
|
||||||
|
noise = np.random.default_rng(0).integers(0, 256, (WARMUP_SIZE, WARMUP_SIZE, 3), dtype=np.uint8)
|
||||||
|
with INFER_LOCK:
|
||||||
|
runner.enhance(noise)
|
||||||
|
|
||||||
|
|
||||||
|
class Upscaler:
|
||||||
|
def __init__(self, upsampler, outscale):
|
||||||
|
self.upsampler = upsampler
|
||||||
|
self.outscale = outscale
|
||||||
|
|
||||||
|
def enhance(self, img, outscale=None):
|
||||||
|
output, _mode = self.upsampler.enhance(img, outscale=outscale or self.outscale)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
class FaceRunner:
|
||||||
|
def __init__(self, label, outscale, bg, restore):
|
||||||
|
self.label = label
|
||||||
|
self.outscale = outscale
|
||||||
|
self.bg_upsampler = bg
|
||||||
|
self.restore = restore
|
||||||
|
|
||||||
|
def enhance(self, img, strength=DEFAULT_STRENGTH):
|
||||||
|
output, faces_found = self.restore(img, strength)
|
||||||
|
return output, faces_found
|
||||||
|
|
||||||
|
|
||||||
|
class ModelPool:
|
||||||
|
def __init__(self, limit=POOL_LIMIT):
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.entries = OrderedDict()
|
||||||
|
self.loading = OrderedDict()
|
||||||
|
self.in_use = {}
|
||||||
|
self.limit = limit
|
||||||
|
|
||||||
|
def get(self, key, label, factory):
|
||||||
|
with self.lock:
|
||||||
|
entry = self.entries.get(key)
|
||||||
|
if entry is not None:
|
||||||
|
self.entries.move_to_end(key)
|
||||||
|
self.in_use[key] = self.in_use.get(key, 0) + 1
|
||||||
|
return entry['obj']
|
||||||
|
if key in self.loading:
|
||||||
|
raise ModelNotReady(label)
|
||||||
|
self.loading[key] = label
|
||||||
|
try:
|
||||||
|
obj = factory()
|
||||||
|
warm_up(obj)
|
||||||
|
except BaseException:
|
||||||
|
with self.lock:
|
||||||
|
self.loading.pop(key, None)
|
||||||
|
raise
|
||||||
|
with self.lock:
|
||||||
|
self.loading.pop(key, None)
|
||||||
|
self.entries[key] = {'obj': obj, 'label': label}
|
||||||
|
self.in_use[key] = self.in_use.get(key, 0) + 1
|
||||||
|
apply_tile(obj, state['tile'])
|
||||||
|
self.evict_locked()
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def release(self, key):
|
||||||
|
with self.lock:
|
||||||
|
if self.in_use.get(key):
|
||||||
|
self.in_use[key] -= 1
|
||||||
|
|
||||||
|
def evict_locked(self):
|
||||||
|
while len(self.entries) > self.limit:
|
||||||
|
for key in list(self.entries):
|
||||||
|
if not self.in_use.get(key):
|
||||||
|
self.entries.pop(key, None)
|
||||||
|
self.in_use.pop(key, None)
|
||||||
|
log.info('выгружаю из кэша модель %s (LRU, лимит %d)', key, self.limit)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def acquire(self, key, label, factory):
|
||||||
|
obj = self.get(key, label, factory)
|
||||||
|
try:
|
||||||
|
yield obj
|
||||||
|
finally:
|
||||||
|
self.release(key)
|
||||||
|
|
||||||
|
def contains(self, key):
|
||||||
|
with self.lock:
|
||||||
|
return key in self.entries
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
with self.lock:
|
||||||
|
self.entries.clear()
|
||||||
|
self.in_use.clear()
|
||||||
|
|
||||||
|
def set_tile(self, tile):
|
||||||
|
with self.lock:
|
||||||
|
for entry in self.entries.values():
|
||||||
|
apply_tile(entry['obj'], tile)
|
||||||
|
|
||||||
|
def loaded_labels(self):
|
||||||
|
with self.lock:
|
||||||
|
labels = []
|
||||||
|
for entry in self.entries.values():
|
||||||
|
if entry['label'] not in labels:
|
||||||
|
labels.append(entry['label'])
|
||||||
|
return labels
|
||||||
|
|
||||||
|
def loading_labels(self):
|
||||||
|
with self.lock:
|
||||||
|
return list(self.loading.values())
|
||||||
|
|
||||||
|
|
||||||
|
pool = ModelPool()
|
||||||
|
|
||||||
|
|
||||||
|
def build_esrgan(name, denoise):
|
||||||
|
spec = MODEL_REGISTRY[name]
|
||||||
|
alias = LEGACY_MODEL_PATH if spec['alias'] else ''
|
||||||
|
path = ensure_weight(spec['file'], spec, alias)
|
||||||
|
model_path = path
|
||||||
|
dni_weight = None
|
||||||
|
if denoise and spec.get('dni'):
|
||||||
|
dni_spec = spec['dni']
|
||||||
|
dni_path = ensure_weight(dni_spec['file'], dni_spec)
|
||||||
|
weight = float(dni_spec.get('weight', 0.5))
|
||||||
|
model_path = [path, dni_path]
|
||||||
|
dni_weight = (1.0 - weight, weight)
|
||||||
|
upsampler = RealESRGANer(
|
||||||
|
scale=spec['scale'],
|
||||||
|
model_path=model_path,
|
||||||
|
dni_weight=dni_weight,
|
||||||
|
model=guard_forward(spec['arch']()),
|
||||||
|
tile=state['tile'],
|
||||||
|
tile_pad=TILE_PAD,
|
||||||
|
pre_pad=PRE_PAD,
|
||||||
|
half=state['half'],
|
||||||
|
device=torch.device(state['device']),
|
||||||
|
)
|
||||||
|
return Upscaler(upsampler, spec['scale'])
|
||||||
|
|
||||||
|
|
||||||
|
def face_helper(outscale):
|
||||||
|
from facexlib.utils.face_restoration_helper import FaceRestoreHelper
|
||||||
|
return FaceRestoreHelper(
|
||||||
|
upscale_factor=outscale,
|
||||||
|
face_size=512,
|
||||||
|
crop_ratio=(1, 1),
|
||||||
|
det_model='retinaface_resnet50',
|
||||||
|
save_ext='png',
|
||||||
|
use_parse=True,
|
||||||
|
device=torch.device(state['device']),
|
||||||
|
model_rootpath=WEIGHTS_DIR,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def link_default_facexlib_dir():
|
||||||
|
default_dir = os.path.abspath('gfpgan/weights')
|
||||||
|
try:
|
||||||
|
if os.path.realpath(default_dir) == os.path.realpath(WEIGHTS_DIR):
|
||||||
|
return
|
||||||
|
if os.path.isdir(default_dir) and not os.path.islink(default_dir):
|
||||||
|
shutil.rmtree(default_dir, ignore_errors=True)
|
||||||
|
os.makedirs(os.path.dirname(default_dir), exist_ok=True)
|
||||||
|
if not os.path.lexists(default_dir):
|
||||||
|
os.symlink(WEIGHTS_DIR, default_dir)
|
||||||
|
log.info('каталог facexlib %s смотрит в том моделей', default_dir)
|
||||||
|
except OSError as err:
|
||||||
|
log.warning('не удалось направить %s в %s: %s', default_dir, WEIGHTS_DIR, err)
|
||||||
|
|
||||||
|
|
||||||
|
def build_face_gfpgan(spec, outscale, bg):
|
||||||
|
from gfpgan import GFPGANer
|
||||||
|
link_default_facexlib_dir()
|
||||||
|
ensure_facexlib_weights()
|
||||||
|
path = ensure_weight(spec['file'], spec)
|
||||||
|
restorer = GFPGANer(
|
||||||
|
model_path=path,
|
||||||
|
upscale=outscale,
|
||||||
|
arch=spec['arch'],
|
||||||
|
channel_multiplier=spec['channel_multiplier'],
|
||||||
|
bg_upsampler=bg.upsampler,
|
||||||
|
device=torch.device(state['device']),
|
||||||
|
)
|
||||||
|
guard_forward(restorer.gfpgan)
|
||||||
|
|
||||||
|
def restore(img, strength):
|
||||||
|
cropped, _restored, output = restorer.enhance(img, has_aligned=False, only_center_face=False,
|
||||||
|
paste_back=True)
|
||||||
|
if output is None:
|
||||||
|
output = bg.enhance(img, outscale)
|
||||||
|
return output, len(cropped)
|
||||||
|
|
||||||
|
return FaceRunner('gfpgan', outscale, bg, restore)
|
||||||
|
|
||||||
|
|
||||||
|
def build_face_codeformer(spec, outscale, bg):
|
||||||
|
from codeformer import CodeFormer
|
||||||
|
from gfpgan.utils import img2tensor, tensor2img
|
||||||
|
from torchvision.transforms.functional import normalize
|
||||||
|
ensure_facexlib_weights()
|
||||||
|
path = ensure_weight(spec['file'], spec)
|
||||||
|
net = CodeFormer(dim_embd=512, codebook_size=1024, n_head=8, n_layers=9,
|
||||||
|
connect_list=['32', '64', '128', '256'], device=state['device'], fp16=False)
|
||||||
|
net.load_state_dict(torch.load(path, map_location=lambda storage, loc: storage))
|
||||||
|
net.eval()
|
||||||
|
guard_forward(net)
|
||||||
|
helper = face_helper(outscale)
|
||||||
|
|
||||||
|
def restore(img, strength):
|
||||||
|
helper.clean_all()
|
||||||
|
helper.read_image(img)
|
||||||
|
helper.get_face_landmarks_5(only_center_face=False, eye_dist_threshold=5)
|
||||||
|
helper.align_warp_face()
|
||||||
|
for cropped in helper.cropped_faces:
|
||||||
|
tensor = img2tensor(cropped / 255., bgr2rgb=True, float32=True)
|
||||||
|
normalize(tensor, (0.5, 0.5, 0.5), (0.5, 0.5, 0.5), inplace=True)
|
||||||
|
tensor = tensor.unsqueeze(0).to(net.device)
|
||||||
|
with torch.no_grad():
|
||||||
|
output = net(tensor, w=clamp(float(strength), 0.0, 1.0))[0]
|
||||||
|
restored = tensor2img(output.squeeze(0), rgb2bgr=True, min_max=(-1, 1))
|
||||||
|
helper.add_restored_face(restored.astype('uint8'))
|
||||||
|
bg_img = bg.enhance(img, outscale)
|
||||||
|
helper.get_inverse_affine(None)
|
||||||
|
return helper.paste_faces_to_input_image(upsample_img=bg_img), len(helper.cropped_faces)
|
||||||
|
|
||||||
|
return FaceRunner('codeformer', outscale, bg, restore)
|
||||||
|
|
||||||
|
|
||||||
|
FACE_BUILDERS = {'gfpgan': build_face_gfpgan, 'codeformer': build_face_codeformer}
|
||||||
|
|
||||||
|
|
||||||
|
def face_installed(name):
|
||||||
|
module = FACE_REGISTRY[name]['module']
|
||||||
|
try:
|
||||||
|
return importlib.util.find_spec(module) is not None
|
||||||
|
except (ImportError, ValueError, AttributeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def available_face_models():
|
||||||
|
return [name for name in FACE_REGISTRY if face_installed(name)]
|
||||||
|
|
||||||
|
|
||||||
|
def build_face(name, outscale, bg):
|
||||||
|
spec = FACE_REGISTRY[name]
|
||||||
|
return FACE_BUILDERS[name](spec, outscale, bg)
|
||||||
|
|
||||||
|
|
||||||
|
def process_image(img, outscale, model_name, face, face_name, strength):
|
||||||
|
denoise = face == 'all' and bool(MODEL_REGISTRY[model_name].get('denoise'))
|
||||||
|
if face == 'off':
|
||||||
|
suffix = ':wdn' if denoise else ''
|
||||||
|
with pool.acquire('esrgan:' + model_name + suffix, model_name,
|
||||||
|
lambda: build_esrgan(model_name, denoise)) as runner:
|
||||||
|
return runner.enhance(img, outscale), 0
|
||||||
|
key = 'face:%s@%d' % (face_name, outscale)
|
||||||
|
|
||||||
|
def factory():
|
||||||
|
return build_face(face_name, outscale, build_esrgan(model_name, denoise))
|
||||||
|
|
||||||
|
with pool.acquire(key, face_name, factory) as runner:
|
||||||
|
return runner.enhance(img, strength)
|
||||||
|
|
||||||
|
|
||||||
|
def degrade_to_cpu(warnings):
|
||||||
|
if state['degraded']:
|
||||||
|
return
|
||||||
|
log.warning('устройство %s не справилось, переключаюсь на CPU', state['device'])
|
||||||
|
warnings.append('не хватило памяти на %s, обработка переведена на CPU' % state['device'])
|
||||||
|
pool.clear()
|
||||||
|
state['device'] = 'cpu'
|
||||||
|
state['half'] = False
|
||||||
|
state['device_name'] = cpu_device_name()
|
||||||
|
state['degraded'] = True
|
||||||
|
state['tile'] = BASE_TILE
|
||||||
|
|
||||||
|
|
||||||
|
def run_guarded(img, outscale, model_name, face, face_name, strength, warnings):
|
||||||
|
with INFER_LOCK:
|
||||||
|
start_tile = state['tile']
|
||||||
|
try:
|
||||||
|
for tile in tile_ladder(start_tile):
|
||||||
|
state['tile'] = tile
|
||||||
|
pool.set_tile(tile)
|
||||||
|
try:
|
||||||
|
return process_image(img, outscale, model_name, face, face_name, strength)
|
||||||
|
except (RuntimeError, TileOOM) as err:
|
||||||
|
if not is_oom(err):
|
||||||
|
raise EnhanceError('ошибка модели: %s' % err, 500) from err
|
||||||
|
log.warning('нехватка памяти при tile=%s: %s', tile, err)
|
||||||
|
warnings.append('не хватило памяти при tile=%d' % tile)
|
||||||
|
if not state['device'].startswith('cpu'):
|
||||||
|
degrade_to_cpu(warnings)
|
||||||
|
try:
|
||||||
|
return process_image(img, outscale, model_name, face, face_name, strength)
|
||||||
|
except (RuntimeError, TileOOM) as err:
|
||||||
|
if is_oom(err):
|
||||||
|
raise EnhanceError('не хватило памяти даже на CPU: %s' % err, 500) from err
|
||||||
|
raise EnhanceError('ошибка модели на CPU: %s' % err, 500) from err
|
||||||
|
raise EnhanceError('не хватило памяти даже при tile=%d: пересмотрите PHOTO_AI_TILE или PHOTO_AI_MAX_PIXELS'
|
||||||
|
% state['tile'], 500)
|
||||||
|
finally:
|
||||||
|
state['tile'] = start_tile
|
||||||
|
pool.set_tile(start_tile)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_image(out, fmt, quality):
|
||||||
|
ext, media_type, params = fmt
|
||||||
|
if ext == 'jpg':
|
||||||
|
params = params + [quality]
|
||||||
|
ok, encoded = cv2.imencode('.' + ext, out, params)
|
||||||
|
if not ok:
|
||||||
|
raise EnhanceError('не удалось закодировать результат как %s' % ext, 500)
|
||||||
|
return encoded.tobytes(), media_type
|
||||||
|
|
||||||
|
|
||||||
|
def output_format(filename):
|
||||||
|
name = (filename or '').rsplit('/', 1)[-1]
|
||||||
|
ext = name.rsplit('.', 1)[-1].lower() if '.' in name else ''
|
||||||
|
if ext == 'jpeg':
|
||||||
|
ext = 'jpg'
|
||||||
|
return OUTPUT_FORMATS.get(ext, OUTPUT_FORMATS['jpg'])
|
||||||
|
|
||||||
|
|
||||||
|
def wants_json(request):
|
||||||
|
return 'application/json' in (request.headers.get('accept') or '').lower()
|
||||||
|
|
||||||
|
|
||||||
|
def driver_version():
|
||||||
|
try:
|
||||||
|
with open('/proc/driver/nvidia/version', 'r') as handle:
|
||||||
|
text = handle.read()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
match = re.search(r'\d+\.\d+\.\d+', text)
|
||||||
|
return match.group(0) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def vram_total_mb():
|
||||||
|
if not state['device'].startswith('cuda'):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return round(torch.cuda.get_device_properties(0).total_memory / (1024 * 1024))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def vram_free_mb():
|
||||||
|
if not state['device'].startswith('cuda'):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
free, _total = torch.cuda.mem_get_info()
|
||||||
|
return round(free / (1024 * 1024))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def validate(model_name, face, face_name, strength, quality):
|
||||||
|
if model_name not in MODEL_REGISTRY:
|
||||||
|
raise EnhanceError('неизвестная модель %r, доступны: %s'
|
||||||
|
% (model_name, ', '.join(MODEL_REGISTRY)), 400)
|
||||||
|
if face not in FACE_MODES:
|
||||||
|
raise EnhanceError('неизвестный режим лиц %r, доступны: %s' % (face, ', '.join(FACE_MODES)), 400)
|
||||||
|
if quality < 70 or quality > 100:
|
||||||
|
raise EnhanceError('jpeg_quality должен быть 70..100, получено %d' % quality, 400)
|
||||||
|
if face == 'off':
|
||||||
|
return
|
||||||
|
if face_name not in FACE_REGISTRY:
|
||||||
|
raise EnhanceError('неизвестная face-модель %r, доступны: %s'
|
||||||
|
% (face_name, ', '.join(available_face_models()) or 'нет'), 400)
|
||||||
|
if not face_installed(face_name):
|
||||||
|
others = [name for name in available_face_models()]
|
||||||
|
raise EnhanceError('модель лиц %s не установлена в образ, доступен %s'
|
||||||
|
% (face_name, ', '.join(others) or 'ни один'), 400)
|
||||||
|
if strength < 0.0 or strength > 1.0:
|
||||||
|
raise EnhanceError('strength должен быть 0..1, получено %s' % strength, 400)
|
||||||
|
if not FACE_REGISTRY[face_name]['strength'] and abs(strength - DEFAULT_STRENGTH) > 1e-6:
|
||||||
|
raise EnhanceError('strength применяется только к CodeFormer, для %s оставьте %s'
|
||||||
|
% (face_name, DEFAULT_STRENGTH), 400)
|
||||||
|
|
||||||
|
|
||||||
|
def preload():
|
||||||
|
names = list(MODEL_REGISTRY) if LOAD_ALL else [DEFAULT_MODEL]
|
||||||
|
for name in names:
|
||||||
|
try:
|
||||||
|
with pool.acquire('esrgan:' + name, name, lambda n=name: build_esrgan(n, False)):
|
||||||
|
log.info('модель %s готова', name)
|
||||||
|
except Exception as err:
|
||||||
|
log.error('предзагрузка модели %s не удалась: %s', name, err)
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event('startup')
|
||||||
|
async def startup():
|
||||||
|
log.info('устройство: %s (%s), half=%s, tile=%s, веса: %s',
|
||||||
|
state['device'], state['device_name'], state['half'], state['tile'], WEIGHTS_DIR)
|
||||||
|
threading.Thread(target=preload, name='photo-ai-preload', daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get('/health')
|
||||||
|
def health():
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'ready': pool.contains('esrgan:' + DEFAULT_MODEL),
|
||||||
|
'device': state['device'],
|
||||||
|
'device_name': state['device_name'],
|
||||||
|
'half': state['half'],
|
||||||
|
'tile': state['tile'],
|
||||||
|
'driver': driver_version(),
|
||||||
|
'cuda': torch.version.cuda,
|
||||||
|
'vram_total_mb': vram_total_mb(),
|
||||||
|
'vram_free_mb': vram_free_mb(),
|
||||||
|
'models': list(MODEL_REGISTRY),
|
||||||
|
'face_models': available_face_models(),
|
||||||
|
'loaded': pool.loaded_labels(),
|
||||||
|
'loading': pool.loading_labels(),
|
||||||
|
'max_pixels': MAX_PIXELS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get('/models')
|
||||||
|
def models():
|
||||||
|
loaded = pool.loaded_labels()
|
||||||
|
faces = available_face_models()
|
||||||
|
return {
|
||||||
|
'device': state['device'],
|
||||||
|
'device_name': state['device_name'],
|
||||||
|
'half': state['half'],
|
||||||
|
'tile': state['tile'],
|
||||||
|
'max_pixels': MAX_PIXELS,
|
||||||
|
'defaults': {
|
||||||
|
'model': DEFAULT_MODEL,
|
||||||
|
'scale': 2,
|
||||||
|
'face': 'off',
|
||||||
|
'face_model': DEFAULT_FACE_MODEL,
|
||||||
|
'strength': DEFAULT_STRENGTH,
|
||||||
|
'jpeg_quality': DEFAULT_JPEG_QUALITY,
|
||||||
|
},
|
||||||
|
'models': [
|
||||||
|
{
|
||||||
|
'name': name,
|
||||||
|
'title': spec['title'],
|
||||||
|
'scale': spec['scale'],
|
||||||
|
'weights': spec['file'],
|
||||||
|
'denoise': bool(spec.get('denoise')),
|
||||||
|
'loaded': name in loaded,
|
||||||
|
}
|
||||||
|
for name, spec in MODEL_REGISTRY.items()
|
||||||
|
],
|
||||||
|
'face_models': [
|
||||||
|
{
|
||||||
|
'name': name,
|
||||||
|
'title': spec['title'],
|
||||||
|
'weights': spec['file'],
|
||||||
|
'available': name in faces,
|
||||||
|
'strength': bool(spec['strength']),
|
||||||
|
'loaded': name in loaded,
|
||||||
|
}
|
||||||
|
for name, spec in FACE_REGISTRY.items()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post('/enhance')
|
||||||
|
async def enhance(request: Request,
|
||||||
|
image: UploadFile = File(...),
|
||||||
|
scale: int = Form(2),
|
||||||
|
model: str = Form(DEFAULT_MODEL),
|
||||||
|
face: str = Form('off'),
|
||||||
|
face_model: str = Form(''),
|
||||||
|
strength: float = Form(DEFAULT_STRENGTH),
|
||||||
|
jpeg_quality: int = Form(0)):
|
||||||
|
started = time.time()
|
||||||
|
model_name = (model or DEFAULT_MODEL).strip().lower()
|
||||||
|
face_mode = (face or 'off').strip().lower()
|
||||||
|
face_name = (face_model or '').strip().lower() or (
|
||||||
|
DEFAULT_FACE_MODEL if DEFAULT_FACE_MODEL in FACE_REGISTRY else 'gfpgan')
|
||||||
|
quality = jpeg_quality if jpeg_quality else DEFAULT_JPEG_QUALITY
|
||||||
|
try:
|
||||||
|
validate(model_name, face_mode, face_name, strength, quality)
|
||||||
|
data = await image.read()
|
||||||
|
img = cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR)
|
||||||
|
if img is None:
|
||||||
|
raise EnhanceError('bad image', 400)
|
||||||
|
if img.shape[0] * img.shape[1] > MAX_PIXELS:
|
||||||
|
ratio = (MAX_PIXELS / (img.shape[0] * img.shape[1])) ** 0.5
|
||||||
|
img = cv2.resize(img, (int(img.shape[1] * ratio), int(img.shape[0] * ratio)),
|
||||||
|
interpolation=cv2.INTER_AREA)
|
||||||
|
outscale = min(max(int(scale), 2), 4)
|
||||||
|
fmt = output_format(image.filename)
|
||||||
|
warnings = []
|
||||||
|
if face_mode != 'off':
|
||||||
|
if min(img.shape[0], img.shape[1]) < MIN_FACE_SIDE:
|
||||||
|
warnings.append('вход меньше %d×%d — лица могут не найтись'
|
||||||
|
% (MIN_FACE_SIDE, MIN_FACE_SIDE))
|
||||||
|
if face_name == 'codeformer' and not state['device'].startswith('cuda'):
|
||||||
|
warnings.append('CodeFormer на %s медленнее GFPGAN' % state['device'])
|
||||||
|
output, faces_found = await asyncio.to_thread(run_guarded, img, outscale, model_name,
|
||||||
|
face_mode, face_name, float(strength), warnings)
|
||||||
|
payload, media_type = encode_image(output, fmt, int(quality))
|
||||||
|
except EnhanceError as err:
|
||||||
|
if err.status_code == 503:
|
||||||
|
return error_response(503, err.message, headers={'Retry-After': str(RETRY_AFTER_SEC)})
|
||||||
|
return error_response(err.status_code, err.message)
|
||||||
|
if face_mode != 'off' and not faces_found:
|
||||||
|
warnings.append('лица не найдены, фон обработан апскейлом')
|
||||||
|
elapsed_ms = int((time.time() - started) * 1000)
|
||||||
|
if wants_json(request):
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'image_base64': base64.b64encode(payload).decode('ascii'),
|
||||||
|
'image_ext': fmt[0],
|
||||||
|
'model': model_name,
|
||||||
|
'face': face_mode,
|
||||||
|
'face_model': face_name if face_mode != 'off' else None,
|
||||||
|
'faces_found': faces_found,
|
||||||
|
'device': state['device'],
|
||||||
|
'elapsed_ms': elapsed_ms,
|
||||||
|
'warnings': warnings,
|
||||||
|
}
|
||||||
|
return Response(payload, media_type=media_type, headers={'X-Photo-AI-Model': model_name,
|
||||||
|
'X-Photo-AI-Face': face_mode,
|
||||||
|
'X-Photo-AI-Device': state['device'],
|
||||||
|
'X-Photo-AI-Elapsed-Ms': str(elapsed_ms)})
|
||||||
Vendored
+244
-20
@@ -3,23 +3,50 @@
|
|||||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100dvh}
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100dvh}
|
||||||
|
|
||||||
.layout{display:flex;min-height:100dvh}
|
.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{width:var(--sidebar);background:var(--card);border-right:1px solid var(--border);position:fixed;top:0;left:0;height:100dvh;display:flex;flex-direction:column;z-index:50;box-shadow:1px 0 0 rgba(0,0,0,.02);user-select:none}
|
||||||
.sidebar .logo{font-size:1.1rem;font-weight:700;padding:0 20px 20px;border-bottom:1px solid var(--border)}
|
.sidebar .logo{height:58px;padding:0 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;min-width:0;flex-shrink:0;font-size:1.02rem;font-weight:700;letter-spacing:-.01em;background:var(--card)}
|
||||||
.user-box{padding:14px 20px;border-bottom:1px solid var(--border);display:flex;flex-direction:column;gap:2px}
|
.sidebar .logo-ic{height:28px;width:auto;min-width:28px;border-radius:6px;background:var(--bg);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0}
|
||||||
.user-box .user-name{font-weight:600;font-size:.9rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
.sidebar .logo-ic img{max-width:100%;max-height:100%;object-fit:contain}
|
||||||
.user-box .user-role{color:var(--muted);font-size:.72rem}
|
.sidebar #sidebarName{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
|
||||||
.sidebar nav{flex:1;padding:12px 0}
|
.user-box{padding:10px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;flex-shrink:0;background:var(--card)}
|
||||||
.sidebar nav a{display:flex;align-items:center;gap:10px;padding:10px 20px;color:var(--muted);text-decoration:none;font-size:.9rem;transition:all .15s;border-left:3px solid transparent}
|
.user-box .user-avatar{width:32px;height:32px;border-radius:8px;background:rgba(37,99,235,.08);color:var(--accent);display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
||||||
|
.user-box .user-avatar svg{width:16px;height:16px}
|
||||||
|
.user-box .user-meta{display:flex;flex-direction:column;min-width:0;flex:1}
|
||||||
|
.user-box .user-name{font-weight:600;font-size:.85rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);line-height:1.25}
|
||||||
|
.user-box .user-role{color:var(--muted);font-size:.7rem;font-weight:500;line-height:1.2}
|
||||||
|
|
||||||
|
.nav-scroll-wrap{flex:1 1 auto;min-height:0;overflow-y:auto;overflow-x:hidden;overscroll-behavior:contain;padding:8px 0;scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
|
||||||
|
.nav-scroll-wrap::-webkit-scrollbar{width:5px}
|
||||||
|
.nav-scroll-wrap::-webkit-scrollbar-track{background:transparent}
|
||||||
|
.nav-scroll-wrap::-webkit-scrollbar-thumb{background:rgba(0,0,0,.12);border-radius:10px}
|
||||||
|
.nav-scroll-wrap::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.22)}
|
||||||
|
|
||||||
|
.sidebar nav{display:flex;flex-direction:column;gap:10px;padding:0 8px}
|
||||||
|
.nav-group{display:flex;flex-direction:column;gap:2px}
|
||||||
|
.nav-group-title{font-size:.66rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);padding:6px 10px 4px;user-select:none;opacity:.8}
|
||||||
|
.nav-items{display:flex;flex-direction:column;gap:1px}
|
||||||
|
|
||||||
|
.sidebar nav a{display:flex;align-items:center;gap:10px;padding:7px 10px;border-radius:8px;color:var(--muted);text-decoration:none;font-size:.84rem;font-weight:500;line-height:1.3;transition:all .14s ease;position:relative}
|
||||||
.sidebar nav a:hover{color:var(--text);background:var(--bg)}
|
.sidebar nav a:hover{color:var(--text);background:var(--bg)}
|
||||||
.sidebar nav a.active{color:var(--accent);background:rgba(37,99,235,.06);border-left-color:var(--accent);font-weight:500}
|
.sidebar nav a.active{color:var(--accent);background:rgba(37,99,235,.08);font-weight:600}
|
||||||
.sidebar nav a i,.sidebar .bottom a i{width:16px;height:16px;flex-shrink:0}
|
.sidebar nav a.active::before{content:'';position:absolute;left:0;top:6px;bottom:6px;width:3px;border-radius:0 3px 3px 0;background:var(--accent)}
|
||||||
|
.sidebar nav a i,.sidebar .bottom a i{width:16px;height:16px;flex-shrink:0;transition:transform .14s ease}
|
||||||
|
.sidebar nav a:hover i{transform:scale(1.06)}
|
||||||
|
.sidebar nav a .nav-text{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
|
||||||
|
.sidebar-badge{min-width:18px;height:18px;padding:0 6px;border-radius:999px;background:#ef4444;color:#fff;font-size:.65rem;font-weight:700;line-height:18px;text-align:center;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;margin-left:auto;box-shadow:0 1px 3px rgba(239,68,68,.35)}
|
||||||
|
.sidebar-badge[hidden]{display:none}
|
||||||
|
|
||||||
.sidebar nav a[data-nav=dashboard] svg{color:#6366f1}
|
.sidebar nav a[data-nav=dashboard] svg{color:#6366f1}
|
||||||
.sidebar nav a[data-nav=journal] svg{color:#0ea5e9}
|
.sidebar nav a[data-nav=journal] svg{color:#0ea5e9}
|
||||||
|
.sidebar nav a[data-nav=notifications] svg{color:#f59e0b}
|
||||||
.sidebar nav a[data-nav=students] svg{color:#14b8a6}
|
.sidebar nav a[data-nav=students] svg{color:#14b8a6}
|
||||||
.sidebar nav a[data-nav=groups] svg{color:#8b5cf6}
|
.sidebar nav a[data-nav=groups] svg{color:#8b5cf6}
|
||||||
.sidebar nav a[data-nav=files] svg{color:#f59e0b}
|
.sidebar nav a[data-nav=files] svg{color:#f59e0b}
|
||||||
|
.sidebar nav a[data-nav=photos] svg{color:#0891b2}
|
||||||
.sidebar nav a[data-nav=links] svg{color:#06b6d4}
|
.sidebar nav a[data-nav=links] svg{color:#06b6d4}
|
||||||
.sidebar nav a[data-nav=trash] svg{color:#ef4444}
|
.sidebar nav a[data-nav=trash] svg{color:#ef4444}
|
||||||
|
.sidebar nav a[data-nav=modules] svg{color:#3b82f6}
|
||||||
.sidebar nav a[data-nav=branches] svg{color:#10b981}
|
.sidebar nav a[data-nav=branches] svg{color:#10b981}
|
||||||
.sidebar nav a[data-nav=users] svg{color:#ec4899}
|
.sidebar nav a[data-nav=users] svg{color:#ec4899}
|
||||||
.sidebar nav a[data-nav=worker] svg{color:#a855f7}
|
.sidebar nav a[data-nav=worker] svg{color:#a855f7}
|
||||||
@@ -27,11 +54,18 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.sidebar nav a[data-nav=bans] svg{color:#dc2626}
|
.sidebar nav a[data-nav=bans] svg{color:#dc2626}
|
||||||
.sidebar nav a[data-nav=settings] svg{color:#475569}
|
.sidebar nav a[data-nav=settings] svg{color:#475569}
|
||||||
.sidebar nav a.active svg{color:var(--accent)}
|
.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 .bottom{padding:10px 10px 12px;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:8px;flex-shrink:0;background:var(--card)}
|
||||||
.sidebar .bottom a{display:flex;align-items:center;gap:10px;color:var(--muted);text-decoration:none;font-size:.8rem;padding:6px 0}
|
.sidebar .bottom-actions{display:flex;flex-direction:column;gap:1px}
|
||||||
.sidebar .bottom a:hover{color:var(--text)}
|
.sidebar .bottom-actions a{display:flex;align-items:center;gap:10px;padding:6px 10px;border-radius:8px;color:var(--muted);text-decoration:none;font-size:.82rem;font-weight:500;transition:all .14s ease;position:relative}
|
||||||
.sidebar .bottom .logout{color:#ef4444;cursor:pointer;margin-top:4px}
|
.sidebar .bottom-actions a:hover{color:var(--text);background:var(--bg)}
|
||||||
|
.sidebar .bottom-actions a[data-nav=home] svg{color:#2563eb}
|
||||||
|
.sidebar .bottom-actions .notify-bell svg{color:#f59e0b}
|
||||||
|
.sidebar .bottom-footer{display:flex;align-items:center;justify-content:space-between;gap:8px;padding-top:6px;border-top:1px dashed var(--border)}
|
||||||
|
.sidebar .bottom-footer .logout-btn{display:inline-flex;align-items:center;gap:6px;padding:4px 8px;border-radius:6px;color:#ef4444;text-decoration:none;font-size:.78rem;font-weight:600;cursor:pointer;transition:background .14s ease}
|
||||||
|
.sidebar .bottom-footer .logout-btn:hover{background:rgba(239,68,68,.08)}
|
||||||
|
.sidebar .bottom-footer .logout-btn svg{width:14px;height:14px;color:#ef4444}
|
||||||
|
.sidebar .bottom-footer .version{color:var(--muted);font-size:.7rem;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;opacity:.8}
|
||||||
|
|
||||||
.main{flex:1;margin-left:var(--sidebar);padding:32px}
|
.main{flex:1;margin-left:var(--sidebar);padding:32px}
|
||||||
.main h2{font-size:1.3rem;font-weight:600;margin-bottom:20px}
|
.main h2{font-size:1.3rem;font-weight:600;margin-bottom:20px}
|
||||||
@@ -49,8 +83,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}
|
.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{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)}
|
.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,#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{border-color:var(--accent)}
|
#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{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}
|
.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 +161,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.item .name-input:focus{border-color:var(--accent)}
|
.item .name-input:focus{border-color:var(--accent)}
|
||||||
.item .group-select{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:4px 8px;font-size:.8rem;outline:none;max-width:200px}
|
.item .group-select{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:4px 8px;font-size:.8rem;outline:none;max-width:200px}
|
||||||
.item .group-select:focus{border-color:var(--accent)}
|
.item .group-select:focus{border-color:var(--accent)}
|
||||||
|
.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{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 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}
|
.attach-list .cur{color:var(--muted);font-size:.75rem;margin-left:auto}
|
||||||
@@ -158,7 +196,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.group-card .save-sched:hover{opacity:.85}
|
.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{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)}
|
.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{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 label{font-weight:600;display:block;margin-bottom:4px}
|
||||||
.settings-row .hint{font-size:.8rem;color:var(--muted);max-width:420px;margin:0}
|
.settings-row .hint{font-size:.8rem;color:var(--muted);max-width:420px;margin:0}
|
||||||
@@ -192,6 +230,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.edit-modal .actions button:hover{opacity:.85}
|
.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{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}
|
.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)}
|
.group-cover{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:10px;display:block;background:var(--bg)}
|
||||||
@@ -242,17 +287,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{background:none;border:none;color:#ef4444;cursor:pointer;font-size:1.15rem;padding:4px;flex-shrink:0}
|
||||||
.del-btn:hover{opacity:.8}
|
.del-btn:hover{opacity:.8}
|
||||||
.trash-time{color:var(--muted);font-size:.75rem;margin-top:8px}
|
.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) --- */
|
/* --- Настройки (UX/UI) --- */
|
||||||
.page-head{display:flex;align-items:baseline;gap:14px;margin-bottom:24px}
|
.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-head h2{font-size:1.3rem;font-weight:600;margin:0}
|
||||||
.page-sub{color:var(--muted);font-size:.85rem}
|
.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{display:grid;grid-template-columns:1fr 1fr;gap:12px;align-items:start}
|
||||||
.link-fields > .full{grid-column:1/-1}
|
.link-fields > .full{grid-column:1/-1}
|
||||||
@media(max-width:600px){.link-fields{grid-template-columns:1fr}}
|
@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{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px;width:100%}
|
||||||
.settings-stack-grid > .settings-card{max-width:none;height: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-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{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}
|
.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 +338,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}
|
.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}}
|
@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-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}
|
.dash-col{display:flex;flex-direction:column;gap:20px;min-width:0}
|
||||||
@@ -365,6 +424,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.sys-bar-wrap{height:6px;background:var(--border);border-radius:3px;overflow:hidden;margin-top:4px}
|
.sys-bar-wrap{height:6px;background:var(--border);border-radius:3px;overflow:hidden;margin-top:4px}
|
||||||
.sys-bar{height:100%;transition:width .3s,background .3s}
|
.sys-bar{height:100%;transition:width .3s,background .3s}
|
||||||
.sys-meta{font-size:.7rem;color:var(--muted);margin-top:4px}
|
.sys-meta{font-size:.7rem;color:var(--muted);margin-top:4px}
|
||||||
|
.sys-inline{display:inline-flex;align-items:center;gap:7px;justify-content:flex-end}
|
||||||
|
|
||||||
.actions-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
.actions-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||||||
.audit-table{width:100%;border-collapse:collapse;font-size:.85rem}
|
.audit-table{width:100%;border-collapse:collapse;font-size:.85rem}
|
||||||
@@ -377,6 +437,27 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.audit-details:hover{text-decoration:underline}
|
.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{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 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 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}
|
.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 +499,147 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.worker-counts .chip.skipped b{color:var(--muted)}
|
.worker-counts .chip.skipped b{color:var(--muted)}
|
||||||
.worker-counts .chip.reverted b{color:var(--accent)}
|
.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}}
|
||||||
|
|
||||||
|
/* --- Notifications --- */
|
||||||
|
.notify-bell{position:relative}
|
||||||
|
.notify-badge{position:absolute;left:20px;top:-3px;min-width:16px;height:16px;padding:0 4px;border-radius:999px;background:#ef4444;color:#fff;font-size:.62rem;font-weight:700;line-height:16px;text-align:center}
|
||||||
|
.notify-badge[hidden]{display:none}
|
||||||
|
.notify-panel{position:fixed;left:12px;bottom:96px;width:min(380px,calc(100vw - 24px));max-height:min(70vh,520px);display:flex;flex-direction:column;background:var(--card);border:1px solid var(--border);border-radius:14px;box-shadow:0 18px 40px rgba(0,0,0,.18);z-index:90;overflow:hidden}
|
||||||
|
.notify-panel[hidden]{display:none}
|
||||||
|
.notify-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:12px 14px;border-bottom:1px solid var(--border);font-size:.9rem}
|
||||||
|
.notify-list{overflow-y:auto;flex:1}
|
||||||
|
.notify-empty{padding:22px 14px;color:var(--muted);font-size:.85rem;text-align:center}
|
||||||
|
.notify-item{display:flex;gap:10px;padding:11px 14px;border-bottom:1px solid var(--border);text-decoration:none;color:var(--text);transition:background .15s}
|
||||||
|
.notify-item:hover{background:var(--bg)}
|
||||||
|
.notify-item:last-child{border-bottom:none}
|
||||||
|
.notify-item.unread{background:rgba(37,99,235,.05)}
|
||||||
|
.notify-item.unread .notify-title{font-weight:600}
|
||||||
|
.notify-item.level-warning .notify-ic{background:rgba(245,158,11,.14);color:#f59e0b}
|
||||||
|
.notify-item.level-critical .notify-ic{background:rgba(239,68,68,.14);color:#ef4444}
|
||||||
|
.notify-ic{display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:8px;background:var(--bg);color:var(--muted);flex-shrink:0}
|
||||||
|
.notify-ic svg{width:15px;height:15px}
|
||||||
|
.notify-body{display:flex;flex-direction:column;gap:2px;min-width:0}
|
||||||
|
.notify-title{font-size:.84rem;line-height:1.3}
|
||||||
|
.notify-text{font-size:.76rem;color:var(--muted);line-height:1.35;overflow-wrap:anywhere}
|
||||||
|
.notify-time{font-size:.7rem;color:var(--muted)}
|
||||||
|
.notify-foot{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 14px;border-top:1px solid var(--border);font-size:.78rem}
|
||||||
|
.notify-foot a{color:var(--accent);text-decoration:none}
|
||||||
|
.notify-foot a:hover{text-decoration:underline}
|
||||||
|
.notify-link{background:none;border:none;color:var(--accent);font-size:.78rem;cursor:pointer;padding:0}
|
||||||
|
.notify-link:hover{text-decoration:underline}
|
||||||
|
.notify-types{display:flex;flex-direction:column;gap:6px;margin-top:6px}
|
||||||
|
.notify-type-badge{font-size:.65rem;font-weight:700;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:999px;padding:1px 7px;margin-left:6px;white-space:nowrap}
|
||||||
|
.notify-page-head{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:14px}
|
||||||
|
.notify-page-head .grow{flex:1}
|
||||||
|
.notify-page-list{display:flex;flex-direction:column;gap:8px}
|
||||||
|
.notify-card{display:flex;gap:12px;padding:14px 16px;background:var(--card);border:1px solid var(--border);border-radius:12px}
|
||||||
|
.notify-card.unread{border-color:rgba(37,99,235,.45);background:rgba(37,99,235,.04)}
|
||||||
|
.notify-card.level-warning{border-left:3px solid #f59e0b}
|
||||||
|
.notify-card.level-critical{border-left:3px solid #ef4444}
|
||||||
|
.notify-card .notify-body{flex:1}
|
||||||
|
.notify-card .notify-title{font-size:.92rem}
|
||||||
|
.notify-card .notify-text{font-size:.82rem}
|
||||||
|
.notify-card-actions{display:flex;flex-direction:column;gap:6px;align-items:flex-end}
|
||||||
|
@media(max-width:1024px){
|
||||||
|
.notify-panel{left:12px;right:12px;width:auto;bottom:12px}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+312
-6
@@ -49,13 +49,16 @@ function buildSidebar(active) {
|
|||||||
const base = [
|
const base = [
|
||||||
{ page: 'dashboard', label: 'Дашборд', icon: 'layout-dashboard' },
|
{ page: 'dashboard', label: 'Дашборд', icon: 'layout-dashboard' },
|
||||||
{ page: 'journal', label: 'Журнал', icon: 'book-open' },
|
{ page: 'journal', label: 'Журнал', icon: 'book-open' },
|
||||||
|
{ page: 'notifications', label: 'Уведомления', icon: 'bell', badge: true },
|
||||||
{ page: 'students', label: 'Ученики', icon: 'graduation-cap' },
|
{ page: 'students', label: 'Ученики', icon: 'graduation-cap' },
|
||||||
{ page: 'groups', label: 'Группы', icon: 'users' },
|
{ page: 'groups', label: 'Группы', icon: 'users' },
|
||||||
|
{ page: 'photos', label: 'Фото', icon: 'Image' },
|
||||||
{ page: 'files', label: 'Файлы', icon: 'folder' },
|
{ page: 'files', label: 'Файлы', icon: 'folder' },
|
||||||
{ page: 'links', label: 'Ссылки', icon: 'link' },
|
{ page: 'links', label: 'Ссылки', icon: 'link' },
|
||||||
{ page: 'trash', label: 'Корзина', icon: 'trash-2' },
|
{ page: 'trash', label: 'Корзина', icon: 'trash-2' },
|
||||||
];
|
];
|
||||||
const adminNav = [
|
const adminNav = [
|
||||||
|
{ page: 'modules', label: 'Темы модулей', icon: 'layers' },
|
||||||
{ page: 'branches', label: 'Филиалы', icon: 'building-2' },
|
{ page: 'branches', label: 'Филиалы', icon: 'building-2' },
|
||||||
{ page: 'users', label: 'Пользователи', icon: 'user-cog' },
|
{ page: 'users', label: 'Пользователи', icon: 'user-cog' },
|
||||||
{ page: 'worker', label: 'Воркер ИИ', icon: 'bot' },
|
{ page: 'worker', label: 'Воркер ИИ', icon: 'bot' },
|
||||||
@@ -63,23 +66,326 @@ function buildSidebar(active) {
|
|||||||
{ page: 'bans', label: 'Блокировки', icon: 'shield-off' },
|
{ page: 'bans', label: 'Блокировки', icon: 'shield-off' },
|
||||||
{ page: 'settings', label: 'Настройки', icon: 'settings' },
|
{ page: 'settings', label: 'Настройки', icon: 'settings' },
|
||||||
];
|
];
|
||||||
const nav = [...base, ...(isAdmin() ? adminNav : [])];
|
|
||||||
const userName = currentUser?.name || currentUser?.username || '';
|
const userName = currentUser?.name || currentUser?.username || '';
|
||||||
const roleLabel = isAdmin() ? 'Администратор' : 'Тутор';
|
const roleLabel = isAdmin() ? 'Администратор' : 'Тутор';
|
||||||
|
const renderItem = n => {
|
||||||
|
const isAct = n.page === active;
|
||||||
|
const badgeHtml = n.badge ? `<span class="sidebar-badge" id="sidebarNotifyBadge" ${notifyUnread > 0 ? '' : 'hidden'}>${notifyUnread > 99 ? '99+' : notifyUnread}</span>` : '';
|
||||||
|
return `<a href="${n.page}.html" data-nav="${n.page}" class="${isAct ? 'active' : ''}" title="${n.label}">
|
||||||
|
<i data-lucide="${n.icon}"></i>
|
||||||
|
<span class="nav-text">${n.label}</span>
|
||||||
|
${badgeHtml}
|
||||||
|
</a>`;
|
||||||
|
};
|
||||||
el.innerHTML = `
|
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-box">
|
||||||
<div class="user-name">${esc(userName)}</div>
|
<div class="user-avatar"><i data-lucide="${isAdmin() ? 'shield-check' : 'user'}"></i></div>
|
||||||
|
<div class="user-meta">
|
||||||
|
<div class="user-name" title="${esc(userName)}">${esc(userName)}</div>
|
||||||
<div class="user-role">${roleLabel}</div>
|
<div class="user-role">${roleLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nav-scroll-wrap">
|
||||||
<nav>
|
<nav>
|
||||||
${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('')}
|
<div class="nav-group">
|
||||||
|
<div class="nav-group-title">Основное</div>
|
||||||
|
<div class="nav-items">
|
||||||
|
${base.map(renderItem).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${isAdmin() ? `
|
||||||
|
<div class="nav-group">
|
||||||
|
<div class="nav-group-title">Администрирование</div>
|
||||||
|
<div class="nav-items">
|
||||||
|
${adminNav.map(renderItem).join('')}
|
||||||
|
</div>
|
||||||
|
</div>` : ''}
|
||||||
</nav>
|
</nav>
|
||||||
|
</div>
|
||||||
<div class="bottom">
|
<div class="bottom">
|
||||||
<a href="/" data-nav="home"><i data-lucide="home"></i>На главную</a>
|
<div class="bottom-actions">
|
||||||
<a class="logout" href="#" data-action="logout"><i data-lucide="log-out"></i>Выйти</a>
|
<a href="#" class="notify-bell" id="notifyBell" title="Быстрые уведомления">
|
||||||
|
<i data-lucide="bell"></i>
|
||||||
|
<span>Уведомления</span>
|
||||||
|
<span class="notify-badge" id="notifyBadge" ${notifyUnread > 0 ? '' : 'hidden'}>${notifyUnread > 99 ? '99+' : notifyUnread}</span>
|
||||||
|
</a>
|
||||||
|
<a href="/" data-nav="home" title="Перейти на главную страницу">
|
||||||
|
<i data-lucide="home"></i>
|
||||||
|
<span>На главную</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="bottom-footer">
|
||||||
|
<a class="logout-btn" href="#" data-action="logout" title="Выйти из аккаунта">
|
||||||
|
<i data-lucide="log-out"></i>
|
||||||
|
<span>Выйти</span>
|
||||||
|
</a>
|
||||||
|
<span class="version" id="sidebarVersion"></span>
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
renderIcons();
|
renderIcons();
|
||||||
|
loadVersion();
|
||||||
|
loadBrand();
|
||||||
|
initNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Notifications ---
|
||||||
|
const NOTIFY_ICONS = {
|
||||||
|
'entry.new': 'book-open',
|
||||||
|
'entry.ai.corrected': 'sparkles',
|
||||||
|
'entry.ai.error': 'bot',
|
||||||
|
'photo.job.done': 'image',
|
||||||
|
'photo.job.error': 'image-off',
|
||||||
|
'ip.ban': 'shield-off',
|
||||||
|
'backup.restore': 'database',
|
||||||
|
'backup.create': 'download',
|
||||||
|
'system.test': 'send',
|
||||||
|
};
|
||||||
|
const NOTIFY_LEVELS = { warning: 'Предупреждение', critical: 'Важно', info: '' };
|
||||||
|
|
||||||
|
let notifyList = [];
|
||||||
|
let notifyUnread = 0;
|
||||||
|
let notifyStream = null;
|
||||||
|
let notifyReconnectTimer = null;
|
||||||
|
let desktopNotify = localStorage.getItem('notifyDesktop') === '1';
|
||||||
|
|
||||||
|
function notifyIcon(type) { return NOTIFY_ICONS[type] || 'bell'; }
|
||||||
|
|
||||||
|
function attrEsc(s) { return esc(String(s === null || s === undefined ? '' : s).replace(/"/g, '"')); }
|
||||||
|
|
||||||
|
function notifyTime(ts) {
|
||||||
|
const d = new Date(ts);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
const diff = Date.now() - d.getTime();
|
||||||
|
if (diff < 60000) return 'только что';
|
||||||
|
if (diff < 3600000) return `${Math.floor(diff / 60000)} мин назад`;
|
||||||
|
if (diff < 86400000) return `${Math.floor(diff / 3600000)} ч назад`;
|
||||||
|
return d.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNotifyUnread(n) {
|
||||||
|
notifyUnread = Number(n) || 0;
|
||||||
|
const badge = document.getElementById('notifyBadge');
|
||||||
|
if (badge) {
|
||||||
|
badge.textContent = notifyUnread > 99 ? '99+' : String(notifyUnread);
|
||||||
|
badge.hidden = notifyUnread <= 0;
|
||||||
|
}
|
||||||
|
const navBadge = document.getElementById('sidebarNotifyBadge');
|
||||||
|
if (navBadge) {
|
||||||
|
navBadge.textContent = notifyUnread > 99 ? '99+' : String(notifyUnread);
|
||||||
|
navBadge.hidden = notifyUnread <= 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyItemHtml(n, expanded) {
|
||||||
|
const level = NOTIFY_LEVELS[n.level] ? ` level-${n.level}` : '';
|
||||||
|
return `<a href="#" class="notify-item${n.read ? '' : ' unread'}${level}" data-notify-id="${n.id}" data-notify-link="${attrEsc(n.link || '')}">
|
||||||
|
<span class="notify-ic"><i data-lucide="${notifyIcon(n.type)}"></i></span>
|
||||||
|
<span class="notify-body">
|
||||||
|
<span class="notify-title">${esc(n.title)}</span>
|
||||||
|
${n.body ? `<span class="notify-text">${esc(n.body)}</span>` : ''}
|
||||||
|
<span class="notify-time">${esc(notifyTime(n.created_at))}</span>
|
||||||
|
</span>
|
||||||
|
</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyPanelEl() {
|
||||||
|
let panel = document.getElementById('notifyPanel');
|
||||||
|
if (panel) return panel;
|
||||||
|
panel = document.createElement('div');
|
||||||
|
panel.className = 'notify-panel';
|
||||||
|
panel.id = 'notifyPanel';
|
||||||
|
panel.hidden = true;
|
||||||
|
document.body.appendChild(panel);
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNotifyPanel() {
|
||||||
|
const panel = notifyPanelEl();
|
||||||
|
if (!panel) return;
|
||||||
|
const items = notifyList.slice(0, 12);
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="notify-head">
|
||||||
|
<b>Уведомления</b>
|
||||||
|
<button type="button" class="notify-link" id="notifyReadAll">Прочитать все</button>
|
||||||
|
</div>
|
||||||
|
<div class="notify-list">
|
||||||
|
${items.length ? items.map(n => notifyItemHtml(n, false)).join('') : '<div class="notify-empty">Пока уведомлений нет</div>'}
|
||||||
|
</div>
|
||||||
|
<div class="notify-foot">
|
||||||
|
<a href="notifications.html">Все уведомления${notifyUnread ? ` (${notifyUnread})` : ''}</a>
|
||||||
|
<button type="button" class="notify-link" id="notifyDesktopToggle">${desktopNotify ? 'В браузере: вкл' : 'В браузере: выкл'}</button>
|
||||||
|
</div>`;
|
||||||
|
renderIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNotifications() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/notifications?limit=20`, { headers: hdr() });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
notifyList = data.items || [];
|
||||||
|
setNotifyUnread(data.unread);
|
||||||
|
renderNotifyPanel();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleNotifyPanel(force) {
|
||||||
|
const panel = notifyPanelEl();
|
||||||
|
if (!panel) return;
|
||||||
|
const open = force === undefined ? panel.hidden : !!force;
|
||||||
|
panel.hidden = !open;
|
||||||
|
if (open) { renderNotifyPanel(); loadNotifications(); }
|
||||||
|
}
|
||||||
|
async function markNotifyRead(id) {
|
||||||
|
const item = notifyList.find(n => n.id === id);
|
||||||
|
if (item && !item.read) {
|
||||||
|
item.read = true;
|
||||||
|
setNotifyUnread(Math.max(0, notifyUnread - 1));
|
||||||
|
renderNotifyPanel();
|
||||||
|
}
|
||||||
|
try { await fetch(`${API}/api/notifications/${id}/read`, { method: 'POST', headers: hdr() }); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAllNotifyRead() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/notifications/read-all`, { method: 'POST', headers: hdr() });
|
||||||
|
if (!res.ok) return;
|
||||||
|
notifyList.forEach(n => { n.read = true; });
|
||||||
|
setNotifyUnread(0);
|
||||||
|
renderNotifyPanel();
|
||||||
|
if (typeof onNotifyListChanged === 'function') onNotifyListChanged();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDesktopNotification(n) {
|
||||||
|
if (!desktopNotify) return;
|
||||||
|
if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return;
|
||||||
|
try {
|
||||||
|
const note = new Notification(n.title, { body: n.body || '', tag: 'whatido-' + n.id });
|
||||||
|
note.onclick = () => {
|
||||||
|
window.focus();
|
||||||
|
if (n.link) location.href = n.link;
|
||||||
|
note.close();
|
||||||
|
};
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleDesktopNotifications() {
|
||||||
|
if (desktopNotify) {
|
||||||
|
desktopNotify = false;
|
||||||
|
localStorage.setItem('notifyDesktop', '0');
|
||||||
|
renderNotifyPanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof Notification === 'undefined') { showToast('Браузер не поддерживает уведомления'); return; }
|
||||||
|
let perm = Notification.permission;
|
||||||
|
if (perm === 'default') {
|
||||||
|
try { perm = await Notification.requestPermission(); } catch {}
|
||||||
|
}
|
||||||
|
if (perm !== 'granted') { showToast('Разрешение на уведомления не выдано'); return; }
|
||||||
|
desktopNotify = true;
|
||||||
|
localStorage.setItem('notifyDesktop', '1');
|
||||||
|
renderNotifyPanel();
|
||||||
|
showToast('Уведомления в браузере включены');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleIncomingNotification(n) {
|
||||||
|
notifyList.unshift(n);
|
||||||
|
if (notifyList.length > 40) notifyList.length = 40;
|
||||||
|
setNotifyUnread(notifyUnread + 1);
|
||||||
|
renderNotifyPanel();
|
||||||
|
if (n.level === 'warning' || n.level === 'critical') showToast(n.title);
|
||||||
|
showDesktopNotification(n);
|
||||||
|
if (typeof onNotificationArrived === 'function') onNotificationArrived(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectNotifyStream() {
|
||||||
|
if (notifyStream) { notifyStream.close(); notifyStream = null; }
|
||||||
|
const connect = () => {
|
||||||
|
const t = sessionStorage.getItem('authToken');
|
||||||
|
if (!t) return;
|
||||||
|
notifyStream = new EventSource(`${API}/api/notifications/stream?token=${encodeURIComponent(t)}`);
|
||||||
|
notifyStream.addEventListener('ready', ev => {
|
||||||
|
try { setNotifyUnread(JSON.parse(ev.data).unread); } catch {}
|
||||||
|
});
|
||||||
|
notifyStream.addEventListener('notification', ev => {
|
||||||
|
let n = null;
|
||||||
|
try { n = JSON.parse(ev.data); } catch { return; }
|
||||||
|
if (!n || !n.id) return;
|
||||||
|
handleIncomingNotification(n);
|
||||||
|
});
|
||||||
|
notifyStream.onerror = () => {
|
||||||
|
if (notifyStream) { notifyStream.close(); notifyStream = null; }
|
||||||
|
clearTimeout(notifyReconnectTimer);
|
||||||
|
notifyReconnectTimer = setTimeout(connect, 5000);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
connect();
|
||||||
|
window.addEventListener('beforeunload', () => { if (notifyStream) notifyStream.close(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function initNotifications() {
|
||||||
|
const bell = document.getElementById('notifyBell');
|
||||||
|
const panel = notifyPanelEl();
|
||||||
|
if (!bell || !panel) return;
|
||||||
|
bell.addEventListener('click', e => { e.preventDefault(); toggleNotifyPanel(); });
|
||||||
|
panel.addEventListener('click', e => {
|
||||||
|
if (e.target.closest('#notifyReadAll')) { markAllNotifyRead(); return; }
|
||||||
|
if (e.target.closest('#notifyDesktopToggle')) { toggleDesktopNotifications(); return; }
|
||||||
|
const item = e.target.closest('[data-notify-id]');
|
||||||
|
if (!item) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const id = parseInt(item.dataset.notifyId, 10);
|
||||||
|
const link = item.dataset.notifyLink;
|
||||||
|
toggleNotifyPanel(false);
|
||||||
|
markNotifyRead(id);
|
||||||
|
if (link) location.href = link;
|
||||||
|
});
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
if (panel.hidden) return;
|
||||||
|
if (e.target.closest('#notifyPanel') || e.target.closest('#notifyBell')) return;
|
||||||
|
toggleNotifyPanel(false);
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', e => { if (e.key === 'Escape' && !panel.hidden) toggleNotifyPanel(false); });
|
||||||
|
loadNotifications();
|
||||||
|
connectNotifyStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Common UI ---
|
// --- Common UI ---
|
||||||
|
|||||||
+8
-3
@@ -16,6 +16,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions-row">
|
<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">
|
<input type="search" id="auditFilter" class="settings-input" placeholder="Фильтр по действию..." style="max-width:300px">
|
||||||
<button class="btn-link" id="auditRefreshBtn" type="button" title="Обновить">Обновить</button>
|
<button class="btn-link" id="auditRefreshBtn" type="button" title="Обновить">Обновить</button>
|
||||||
<span class="hint" id="auditCount"></span>
|
<span class="hint" id="auditCount"></span>
|
||||||
@@ -26,6 +29,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Время</th>
|
<th>Время</th>
|
||||||
|
<th>Кто</th>
|
||||||
<th>Действие</th>
|
<th>Действие</th>
|
||||||
<th>Детали</th>
|
<th>Детали</th>
|
||||||
<th>IP</th>
|
<th>IP</th>
|
||||||
@@ -39,11 +43,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-overlay" id="auditDetailsModal">
|
<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>
|
<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">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
<div class="actions-grid">
|
<div class="actions-grid">
|
||||||
<a class="action-btn" href="journal.html"><span class="aic"><i data-lucide="book-open"></i></span>Журнал</a>
|
<a class="action-btn" href="journal.html"><span class="aic"><i data-lucide="book-open"></i></span>Журнал</a>
|
||||||
<a class="action-btn" href="files.html"><span class="aic"><i data-lucide="folder"></i></span>Файлы</a>
|
<a class="action-btn" href="files.html"><span class="aic"><i data-lucide="folder"></i></span>Файлы</a>
|
||||||
|
<a class="action-btn" href="photos.html"><span class="aic"><i data-lucide="Image"></i></span>Фото</a>
|
||||||
<a class="action-btn" href="groups.html"><span class="aic"><i data-lucide="users"></i></span>Группы</a>
|
<a class="action-btn" href="groups.html"><span class="aic"><i data-lucide="users"></i></span>Группы</a>
|
||||||
<a class="action-btn" href="links.html"><span class="aic"><i data-lucide="link"></i></span>Ссылки</a>
|
<a class="action-btn" href="links.html"><span class="aic"><i data-lucide="link"></i></span>Ссылки</a>
|
||||||
<a class="action-btn" href="trash.html"><span class="aic"><i data-lucide="trash-2"></i></span>Корзина</a>
|
<a class="action-btn" href="trash.html"><span class="aic"><i data-lucide="trash-2"></i></span>Корзина</a>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -14,6 +14,7 @@
|
|||||||
<div class="add-row" style="align-items:center;gap:8px">
|
<div class="add-row" style="align-items:center;gap:8px">
|
||||||
<input type="text" id="newGroup" placeholder="Название группы" style="flex:1;max-width:300px">
|
<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="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>
|
<button type="button" id="addGroupBtn">Добавить</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="list" id="groupList"></div>
|
<div class="list" id="groupList"></div>
|
||||||
|
|||||||
+38
-4
@@ -9,7 +9,10 @@
|
|||||||
:root{--bg:#f5f5f5;--card:#fff;--border:#e5e5e5;--accent:#2563eb;--text:#171717;--muted:#737373;--radius:12px}
|
: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}
|
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}
|
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}
|
.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}}
|
@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 .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{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}
|
.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-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.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{background:none;border:none;color:#ef4444;font-size:.85rem;cursor:pointer;padding:4px;display:none}
|
||||||
.remove-photo.show{display:block}
|
.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.show{opacity:1}
|
||||||
.toast.error{background:#dc2626}
|
.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{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{color:var(--muted);text-decoration:none;font-size:.8rem;padding:6px 12px;border-radius:8px;transition:color .15s}
|
||||||
nav a:hover{color:var(--text)}
|
nav a:hover{color:var(--text)}
|
||||||
@@ -83,17 +97,23 @@ nav a:hover{color:var(--text)}
|
|||||||
<nav>
|
<nav>
|
||||||
<a href="admin.html">Админка</a>
|
<a href="admin.html">Админка</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
<div class="brand-head">
|
||||||
|
<img class="brand-logo" id="brandLogo" alt="" hidden>
|
||||||
|
<div class="brand-text">
|
||||||
<h1 id="title">Что мы узнали на занятии</h1>
|
<h1 id="title">Что мы узнали на занятии</h1>
|
||||||
<p class="sub">Отметь, что ты делал(а) на занятии</p>
|
<p class="sub">Отметь, что ты делал(а) на занятии</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form class="form" id="form">
|
<form class="form" id="form">
|
||||||
<div class="photo-col">
|
<div class="photo-col">
|
||||||
<div class="photo-card">
|
<div class="photo-card">
|
||||||
<div class="preview-wrap">
|
<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>
|
<button type="button" class="remove-photo" id="removePhotoBtn">Убрать фото</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="photo-btns">
|
<div class="photo-btns" id="camBtns">
|
||||||
<button type="button" class="cam" id="openCamBtn">Камера</button>
|
<button type="button" class="cam" id="openCamBtn">Камера</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,6 +124,7 @@ nav a:hover{color:var(--text)}
|
|||||||
</div>
|
</div>
|
||||||
<button type="button" class="files-btn" id="filesBtn">Выбрать файлы</button>
|
<button type="button" class="files-btn" id="filesBtn">Выбрать файлы</button>
|
||||||
<input type="file" id="filesInput" multiple hidden>
|
<input type="file" id="filesInput" multiple hidden>
|
||||||
|
<p class="paste-hint">Ctrl+V — вставить файлы из буфера</p>
|
||||||
<div class="files-list" id="filesList"></div>
|
<div class="files-list" id="filesList"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,6 +142,12 @@ nav a:hover{color:var(--text)}
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div class="field">
|
||||||
<label>Что сделал</label>
|
<label>Что сделал</label>
|
||||||
<textarea id="descInput" placeholder="Опиши, что проходил, что делал на занятии..." required></textarea>
|
<textarea id="descInput" placeholder="Опиши, что проходил, что делал на занятии..." required></textarea>
|
||||||
@@ -132,6 +159,13 @@ nav a:hover{color:var(--text)}
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</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">
|
<footer class="footer">
|
||||||
<div class="f-left" id="footerLeft"></div>
|
<div class="f-left" id="footerLeft"></div>
|
||||||
<div class="f-right" id="footerRight"></div>
|
<div class="f-right" id="footerRight"></div>
|
||||||
|
|||||||
@@ -5,6 +5,34 @@
|
|||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Журнал — Админ-панель</title>
|
<title>Журнал — Админ-панель</title>
|
||||||
<link rel="stylesheet" href="admin.css">
|
<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>
|
</head>
|
||||||
<body data-page="journal">
|
<body data-page="journal">
|
||||||
<div class="layout">
|
<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="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><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><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="dateFrom"></div>
|
||||||
<div><label>По</label><input type="date" id="dateTo"></div>
|
<div><label>По</label><input type="date" id="dateTo"></div>
|
||||||
<div><label> </label><button type="button" id="exportCsvBtn" style="background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 16px;font-size:.85rem;cursor:pointer;white-space:nowrap">Экспорт CSV</button></div>
|
<div><label> </label><button type="button" id="exportCsvBtn" style="background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 16px;font-size:.85rem;cursor:pointer;white-space:nowrap">Экспорт CSV</button></div>
|
||||||
@@ -48,6 +77,11 @@
|
|||||||
<label>Группа <span style="color:#ef4444">*</span></label>
|
<label>Группа <span style="color:#ef4444">*</span></label>
|
||||||
<select id="editGroup" class="settings-input" required></select>
|
<select id="editGroup" class="settings-input" required></select>
|
||||||
</div>
|
</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">
|
<div class="settings-field full">
|
||||||
<label>Что делала(а) <span style="color:#ef4444">*</span>
|
<label>Что делала(а) <span style="color:#ef4444">*</span>
|
||||||
<button type="button" id="aiCorrectBtn" class="ai-btn" title="Исправить ошибки ИИ">✨</button>
|
<button type="button" id="aiCorrectBtn" class="ai-btn" title="Исправить ошибки ИИ">✨</button>
|
||||||
@@ -161,6 +195,53 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
|
|||||||
+158
-8
@@ -9,6 +9,11 @@ const ACTION_LABELS = {
|
|||||||
'group.create': 'Создана группа',
|
'group.create': 'Создана группа',
|
||||||
'group.update': 'Изменена группа',
|
'group.update': 'Изменена группа',
|
||||||
'group.delete': 'Удалена группа',
|
'group.delete': 'Удалена группа',
|
||||||
|
'group.soft-delete': 'Группа в корзину',
|
||||||
|
'group.restore': 'Группа восстановлена',
|
||||||
|
'group.permanent-delete': 'Группа удалена безвозвратно',
|
||||||
|
'group.schedule-delete': 'Группа помечена на удаление',
|
||||||
|
'group.unschedule': 'Удаление группы отменено',
|
||||||
'group.photo.create': 'Загружена фото группы',
|
'group.photo.create': 'Загружена фото группы',
|
||||||
'group.photo.update': 'Изменено фото группы',
|
'group.photo.update': 'Изменено фото группы',
|
||||||
'group.photo.reorder': 'Изменён порядок фото группы',
|
'group.photo.reorder': 'Изменён порядок фото группы',
|
||||||
@@ -21,13 +26,33 @@ const ACTION_LABELS = {
|
|||||||
'student.update': 'Изменён ученик',
|
'student.update': 'Изменён ученик',
|
||||||
'student.delete': 'Удалён ученик',
|
'student.delete': 'Удалён ученик',
|
||||||
'student.batch-group': 'Ученики перенесены в группу',
|
'student.batch-group': 'Ученики перенесены в группу',
|
||||||
|
'module.create': 'Создан модуль',
|
||||||
|
'module.update': 'Изменён модуль',
|
||||||
|
'module.delete': 'Модуль отключён',
|
||||||
|
'module.restore': 'Модуль включён',
|
||||||
'entry.update': 'Изменена запись',
|
'entry.update': 'Изменена запись',
|
||||||
'entry.photo.update': 'Изменено фото записи',
|
'entry.photo.update': 'Изменено фото записи',
|
||||||
'entry.photo.set_main': 'Главное фото записи',
|
'entry.photo.set_main': 'Главное фото записи',
|
||||||
'entry.photo.delete': 'Удалено фото записи',
|
'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.soft_retry': 'ИИ-сервис недоступен, ожидание',
|
||||||
|
'photo.job.error': 'Ошибка обработки фото',
|
||||||
|
'photo-jobs.wake': 'Фото-воркер разбужен',
|
||||||
|
'photo-jobs.enabled': 'Переключена обработка фото',
|
||||||
|
'photo-jobs.requeue-failed': 'Ошибочные фото-задания возвращены в очередь',
|
||||||
'entry.soft-delete': 'Запись в корзину',
|
'entry.soft-delete': 'Запись в корзину',
|
||||||
'entry.restore': 'Запись восстановлена',
|
'entry.restore': 'Запись восстановлена',
|
||||||
'entry.permanent-delete': 'Запись удалена безвозвратно',
|
'entry.permanent-delete': 'Запись удалена безвозвратно',
|
||||||
|
'entry.schedule-delete': 'Запись помечена на удаление',
|
||||||
|
'entry.unschedule': 'Удаление записи отменено',
|
||||||
|
'trash.clear': 'Корзина помечена на удаление',
|
||||||
'link.create': 'Создана ссылка',
|
'link.create': 'Создана ссылка',
|
||||||
'link.update': 'Изменена ссылка',
|
'link.update': 'Изменена ссылка',
|
||||||
'link.delete': 'Удалена ссылка',
|
'link.delete': 'Удалена ссылка',
|
||||||
@@ -41,6 +66,11 @@ const ACTION_LABELS = {
|
|||||||
'ai.wake': 'Воркер ИИ разбужен вручную',
|
'ai.wake': 'Воркер ИИ разбужен вручную',
|
||||||
'ai.enabled': 'Переключена автопроверка ИИ',
|
'ai.enabled': 'Переключена автопроверка ИИ',
|
||||||
'ai.requeue-failed': 'Ошибочные записи возвращены в очередь ИИ',
|
'ai.requeue-failed': 'Ошибочные записи возвращены в очередь ИИ',
|
||||||
|
'ai.profile.create': 'Создан профиль ИИ',
|
||||||
|
'ai.profile.update': 'Изменён профиль ИИ',
|
||||||
|
'ai.profile.delete': 'Удалён профиль ИИ',
|
||||||
|
'ai.profile.activate': 'Выбран активный профиль ИИ',
|
||||||
|
'export.student': 'Экспорт данных ученика',
|
||||||
};
|
};
|
||||||
|
|
||||||
const AUDIT_PAGE_SIZE = 50;
|
const AUDIT_PAGE_SIZE = 50;
|
||||||
@@ -48,10 +78,30 @@ let auditPage = 1;
|
|||||||
let auditTotal = 0;
|
let auditTotal = 0;
|
||||||
let auditRows = [];
|
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) {
|
function fmtTime(t) {
|
||||||
try { return new Date(t).toLocaleString('ru'); } catch { return 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) {
|
function dt(t) {
|
||||||
if (!t) return '';
|
if (!t) return '';
|
||||||
if (typeof t === 'object') {
|
if (typeof t === 'object') {
|
||||||
@@ -71,6 +121,76 @@ function fullTarget(t) {
|
|||||||
} catch { return String(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) {
|
function renderPager(container, page, totalPages, onPageChange) {
|
||||||
if (totalPages <= 1) { container.innerHTML = ''; return; }
|
if (totalPages <= 1) { container.innerHTML = ''; return; }
|
||||||
const pages = [];
|
const pages = [];
|
||||||
@@ -91,19 +211,20 @@ function renderPager(container, page, totalPages, onPageChange) {
|
|||||||
|
|
||||||
async function loadAudit() {
|
async function loadAudit() {
|
||||||
const el = document.getElementById('auditTable').querySelector('tbody');
|
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();
|
const p = new URLSearchParams();
|
||||||
p.set('limit', AUDIT_PAGE_SIZE);
|
p.set('limit', AUDIT_PAGE_SIZE);
|
||||||
p.set('offset', (auditPage - 1) * 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() });
|
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();
|
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;
|
const totalPages = rows.length < AUDIT_PAGE_SIZE ? auditPage : auditPage + 1;
|
||||||
auditTotal = totalPages;
|
auditTotal = totalPages;
|
||||||
document.getElementById('auditCount').textContent = rows.length ? `Записей на странице: ${rows.length}` : '';
|
document.getElementById('auditCount').textContent = rows.length ? `Записей на странице: ${rows.length}` : '';
|
||||||
const q = (document.getElementById('auditFilter').value || '').toLowerCase().trim();
|
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) {
|
if (!filtered.length) {
|
||||||
document.getElementById('auditEmpty').style.display = 'block';
|
document.getElementById('auditEmpty').style.display = 'block';
|
||||||
el.innerHTML = '';
|
el.innerHTML = '';
|
||||||
@@ -113,8 +234,9 @@ async function loadAudit() {
|
|||||||
el.innerHTML = filtered.map((r, i) => `
|
el.innerHTML = filtered.map((r, i) => `
|
||||||
<tr>
|
<tr>
|
||||||
<td style="white-space:nowrap">${fmtTime(r.created_at)}</td>
|
<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><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>
|
<td style="color:var(--muted);white-space:nowrap">${esc(r.ip || '')}</td>
|
||||||
</tr>`).join('');
|
</tr>`).join('');
|
||||||
}
|
}
|
||||||
@@ -126,17 +248,44 @@ function goAuditPage(p) {
|
|||||||
loadAudit();
|
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('auditFilter').addEventListener('input', () => { auditPage = 1; loadAudit(); });
|
||||||
document.getElementById('auditRefreshBtn').addEventListener('click', 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');
|
const btn = e.target.closest('.audit-details');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const r = auditRows[parseInt(btn.dataset.auditIdx, 10)];
|
const r = auditRows[parseInt(btn.dataset.auditIdx, 10)];
|
||||||
if (!r) return;
|
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('auditDetailsTitle').textContent = ACTION_LABELS[r.action] || r.action;
|
||||||
document.getElementById('auditDetailsBody').textContent = fullTarget(r.target);
|
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>` : ''}`;
|
||||||
document.getElementById('auditDetailsModal').classList.add('open');
|
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('auditDetailsCloseBtn').addEventListener('click', () => {
|
||||||
document.getElementById('auditDetailsModal').classList.remove('open');
|
document.getElementById('auditDetailsModal').classList.remove('open');
|
||||||
@@ -148,6 +297,7 @@ document.getElementById('auditDetailsModal').addEventListener('click', e => {
|
|||||||
(async () => {
|
(async () => {
|
||||||
if (await requireAdminPage()) {
|
if (await requireAdminPage()) {
|
||||||
buildSidebar(document.body.dataset.page);
|
buildSidebar(document.body.dataset.page);
|
||||||
|
fillActionFilter();
|
||||||
loadAudit();
|
loadAudit();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -14,3 +14,7 @@ if (details) {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
document.querySelector('.error-actions button').addEventListener('click', () => history.back());
|
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
@@ -1,5 +1,6 @@
|
|||||||
const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
|
const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
|
||||||
let branchesCache = [];
|
let branchesCache = [];
|
||||||
|
let tutorsCache = [];
|
||||||
let allGroups = [];
|
let allGroups = [];
|
||||||
let currentCover = null;
|
let currentCover = null;
|
||||||
|
|
||||||
@@ -8,6 +9,16 @@ async function loadBranchesCache() {
|
|||||||
if (res.ok) branchesCache = await res.json();
|
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) {
|
function branchSelectHTML(selectedId) {
|
||||||
const opts = branchesCache.map(b => `<option value="${b.id}" ${b.id === selectedId ? 'selected' : ''}>${esc(b.name)}</option>`).join('');
|
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>`;
|
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() {
|
async function loadGroups() {
|
||||||
await loadBranchesCache();
|
await loadBranchesCache();
|
||||||
|
await loadTutorsCache();
|
||||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||||
const groups = await res.json();
|
const groups = await res.json();
|
||||||
allGroups = groups;
|
allGroups = groups;
|
||||||
@@ -33,6 +45,7 @@ async function loadGroups() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="group-schedule">
|
<div class="group-schedule">
|
||||||
${branchSelectHTML(g.branch_id)}
|
${branchSelectHTML(g.branch_id)}
|
||||||
|
${tutorSelectHTML(g.tutor_id)}
|
||||||
<div><label>День</label>
|
<div><label>День</label>
|
||||||
<select id="dow-${g.id}">
|
<select id="dow-${g.id}">
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
@@ -66,13 +79,15 @@ async function saveGroup(id) {
|
|||||||
const groupCard = nameInput.closest('.group-card');
|
const groupCard = nameInput.closest('.group-card');
|
||||||
const branchSelect = groupCard ? groupCard.querySelector('.branch-select') : null;
|
const branchSelect = groupCard ? groupCard.querySelector('.branch-select') : null;
|
||||||
const branch_id = branchSelect ? branchSelect.value : '';
|
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 dow = document.getElementById(`dow-${id}`).value;
|
||||||
const ts = document.getElementById(`ts-${id}`).value;
|
const ts = document.getElementById(`ts-${id}`).value;
|
||||||
const te = document.getElementById(`te-${id}`).value;
|
const te = document.getElementById(`te-${id}`).value;
|
||||||
const res = await fetch(`${API}/api/groups/${id}`, {
|
const res = await fetch(`${API}/api/groups/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: hdrJson(),
|
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) {
|
if (res.ok) {
|
||||||
loadGroups();
|
loadGroups();
|
||||||
@@ -87,16 +102,20 @@ async function addGroup() {
|
|||||||
const name = input.value.trim();
|
const name = input.value.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
await loadBranchesCache();
|
await loadBranchesCache();
|
||||||
|
await loadTutorsCache();
|
||||||
const branchSelect = document.getElementById('newGroupBranch');
|
const branchSelect = document.getElementById('newGroupBranch');
|
||||||
const branch_id = branchSelect ? branchSelect.value : '';
|
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(); }
|
if (res.ok) { input.value = ''; loadGroups(); }
|
||||||
else { const e = await res.json(); alert(e.error); }
|
else { const e = await res.json(); alert(e.error); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function delGroup(id) {
|
async function delGroup(id) {
|
||||||
if (!confirm('Удалить группу? Фото группы также будут удалены.')) return;
|
if (!confirm('Удалить группу? Группа попадёт в корзину, восстановить её можно будет позже.')) return;
|
||||||
await fetch(`${API}/api/groups/${id}`, { method: 'DELETE', headers: hdr() });
|
const res = await fetch(`${API}/api/groups/${id}`, { method: 'DELETE', headers: hdr() });
|
||||||
|
if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); }
|
||||||
loadGroups();
|
loadGroups();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,6 +433,12 @@ async function populateNewGroupBranch() {
|
|||||||
const sel = document.getElementById('newGroupBranch');
|
const sel = document.getElementById('newGroupBranch');
|
||||||
sel.innerHTML = '<option value="">— без филиала —</option>' + branches.map(b => `<option value="${b.id}">${esc(b.name)}</option>`).join('');
|
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 () => {
|
(async () => {
|
||||||
|
|||||||
+273
-11
@@ -4,17 +4,81 @@ const preview = document.getElementById('preview');
|
|||||||
const placeholderSrc = preview.src;
|
const placeholderSrc = preview.src;
|
||||||
const removePhotoBtn = document.getElementById('removePhotoBtn');
|
const removePhotoBtn = document.getElementById('removePhotoBtn');
|
||||||
const groupInput = document.getElementById('groupInput');
|
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 toast = document.getElementById('toast');
|
||||||
const camVideo = document.getElementById('camVideo');
|
const camVideo = document.getElementById('camVideo');
|
||||||
const camCanvas = document.getElementById('camCanvas');
|
const camCanvas = document.getElementById('camCanvas');
|
||||||
const camModal = document.getElementById('camModal');
|
const camModal = document.getElementById('camModal');
|
||||||
let capturedBlob = null;
|
let capturedBlob = null;
|
||||||
let stream = null;
|
let stream = null;
|
||||||
|
let camOpening = false;
|
||||||
const descInput = document.getElementById('descInput');
|
const descInput = document.getElementById('descInput');
|
||||||
const filesInput = document.getElementById('filesInput');
|
const filesInput = document.getElementById('filesInput');
|
||||||
const filesList = document.getElementById('filesList');
|
const filesList = document.getElementById('filesList');
|
||||||
const filesClearBtn = document.getElementById('filesClearBtn');
|
const filesClearBtn = document.getElementById('filesClearBtn');
|
||||||
let selectedFiles = [];
|
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) {
|
function humanSize(b) {
|
||||||
if (b < 1024) return b + ' Б';
|
if (b < 1024) return b + ' Б';
|
||||||
@@ -55,13 +119,108 @@ function clearFiles() {
|
|||||||
renderFiles();
|
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', () => {
|
filesInput.addEventListener('change', () => {
|
||||||
const keep = 10 - selectedFiles.length;
|
const res = addFiles([...filesInput.files]);
|
||||||
if (keep <= 0) { alert('Можно прикрепить не более 10 файлов'); filesInput.value = ''; return; }
|
|
||||||
selectedFiles = selectedFiles.concat([...filesInput.files].slice(0, keep));
|
|
||||||
if ([...filesInput.files].length > keep) alert('Можно прикрепить не более 10 файлов');
|
|
||||||
filesInput.value = '';
|
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);
|
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>`;
|
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 () => {
|
(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 res = await fetch(`${API}/api/students`);
|
||||||
const students = await res.json();
|
const students = await res.json();
|
||||||
const me = studentName ? students.find(s => s.name === studentName) : null;
|
const me = studentName ? students.find(s => s.name === studentName) : null;
|
||||||
@@ -97,9 +298,33 @@ if (params.get('nameInput')) {
|
|||||||
try {
|
try {
|
||||||
const fRes = await fetch(`${API}/api/public-settings`);
|
const fRes = await fetch(`${API}/api/public-settings`);
|
||||||
const f = await fRes.json();
|
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_left) document.getElementById('footerLeft').textContent = f.footer_left;
|
||||||
if (f.footer_right) document.getElementById('footerRight').textContent = f.footer_right;
|
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 */ }
|
} catch (e) { /* ignore */ }
|
||||||
|
checkPreviousSend();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
function showPreview(src) {
|
function showPreview(src) {
|
||||||
@@ -116,12 +341,22 @@ function removePhoto() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openCam() {
|
async function openCam() {
|
||||||
|
if (camOpening) return;
|
||||||
|
if (stream && camModal.classList.contains('open')) return;
|
||||||
|
camOpening = true;
|
||||||
try {
|
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;
|
camVideo.srcObject = stream;
|
||||||
camModal.classList.add('open');
|
camModal.classList.add('open');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Не удалось открыть камеру: ' + e.message);
|
alert('Не удалось открыть камеру: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
camOpening = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +366,10 @@ function closeCam() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function capture() {
|
function capture() {
|
||||||
|
if (!camVideo.videoWidth || !camVideo.videoHeight) {
|
||||||
|
notify('Камера ещё не готова, попробуйте через секунду', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
camCanvas.width = camVideo.videoWidth;
|
camCanvas.width = camVideo.videoWidth;
|
||||||
camCanvas.height = camVideo.videoHeight;
|
camCanvas.height = camVideo.videoHeight;
|
||||||
const ctx = camCanvas.getContext('2d');
|
const ctx = camCanvas.getContext('2d');
|
||||||
@@ -141,7 +380,7 @@ function capture() {
|
|||||||
capturedBlob = blob;
|
capturedBlob = blob;
|
||||||
showPreview(URL.createObjectURL(blob));
|
showPreview(URL.createObjectURL(blob));
|
||||||
closeCam();
|
closeCam();
|
||||||
}, 'image/jpeg', 0.85);
|
}, 'image/jpeg', captureQuality);
|
||||||
}
|
}
|
||||||
|
|
||||||
form.addEventListener('submit', async (e) => {
|
form.addEventListener('submit', async (e) => {
|
||||||
@@ -152,6 +391,16 @@ form.addEventListener('submit', async (e) => {
|
|||||||
setTimeout(() => toast.classList.remove('show', 'error'), 3200);
|
setTimeout(() => toast.classList.remove('show', 'error'), 3200);
|
||||||
return;
|
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');
|
const btn = document.getElementById('submitBtn');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Отправка...';
|
btn.textContent = 'Отправка...';
|
||||||
@@ -161,6 +410,7 @@ form.addEventListener('submit', async (e) => {
|
|||||||
selectedFiles.forEach(f => fd.append('files', f, f.name));
|
selectedFiles.forEach(f => fd.append('files', f, f.name));
|
||||||
fd.append('student_name', studentName || document.getElementById('nameInput').value);
|
fd.append('student_name', studentName || document.getElementById('nameInput').value);
|
||||||
fd.append('group_id', groupInput.value);
|
fd.append('group_id', groupInput.value);
|
||||||
|
fd.append('module_id', moduleId || '');
|
||||||
fd.append('description', document.getElementById('descInput').value);
|
fd.append('description', document.getElementById('descInput').value);
|
||||||
fd.append('website', document.getElementById('hpWebsite').value);
|
fd.append('website', document.getElementById('hpWebsite').value);
|
||||||
|
|
||||||
@@ -177,10 +427,8 @@ form.addEventListener('submit', async (e) => {
|
|||||||
form.reset();
|
form.reset();
|
||||||
removePhoto();
|
removePhoto();
|
||||||
clearFiles();
|
clearFiles();
|
||||||
toast.textContent = 'Готово!';
|
localStorage.setItem(SENT_KEY, String(Date.now()));
|
||||||
toast.classList.remove('error');
|
showSent('Запись отправлена!', 'Спасибо! Твоя запись уже передана преподавателю.', Date.now() + cooldownMs());
|
||||||
toast.classList.add('show');
|
|
||||||
setTimeout(() => toast.classList.remove('show'), 2500);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Ошибка: ' + err.message);
|
alert('Ошибка: ' + err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -191,7 +439,21 @@ form.addEventListener('submit', async (e) => {
|
|||||||
|
|
||||||
removePhotoBtn.addEventListener('click', removePhoto);
|
removePhotoBtn.addEventListener('click', removePhoto);
|
||||||
document.getElementById('openCamBtn').addEventListener('click', openCam);
|
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);
|
filesClearBtn.addEventListener('click', clearFiles);
|
||||||
document.getElementById('filesBtn').addEventListener('click', () => document.getElementById('filesInput').click());
|
document.getElementById('filesBtn').addEventListener('click', () => document.getElementById('filesInput').click());
|
||||||
document.getElementById('cancelCamBtn').addEventListener('click', closeCam);
|
document.getElementById('cancelCamBtn').addEventListener('click', closeCam);
|
||||||
document.getElementById('captureBtn').addEventListener('click', capture);
|
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
File diff suppressed because it is too large
Load Diff
+24
-6
@@ -3,6 +3,8 @@ let groups = [];
|
|||||||
let editingId = null;
|
let editingId = null;
|
||||||
let studentsLoaded = false;
|
let studentsLoaded = false;
|
||||||
let linkSettings = null;
|
let linkSettings = null;
|
||||||
|
let linkPage = 1;
|
||||||
|
const LINK_PAGE_SIZE = 20;
|
||||||
|
|
||||||
async function loadLinkSettings() {
|
async function loadLinkSettings() {
|
||||||
if (!linkSettings) {
|
if (!linkSettings) {
|
||||||
@@ -16,21 +18,30 @@ function linkUrl(token) { return `${location.origin}/s/${token}`; }
|
|||||||
|
|
||||||
async function loadLinks() {
|
async function loadLinks() {
|
||||||
await loadLinkSettings();
|
await loadLinkSettings();
|
||||||
const res = await fetch(`${API}/api/links`, { headers: hdr() });
|
const p = new URLSearchParams();
|
||||||
links = await res.json();
|
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');
|
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 => {
|
el.innerHTML = links.map(l => {
|
||||||
const namesHidden = l.show_student_names != null ? !l.show_student_names : linkSettings.share_show_student_names === 'false';
|
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 `
|
return `
|
||||||
<div class="item">
|
<div class="item${expired ? ' expired' : ''}">
|
||||||
<div style="min-width:0">
|
<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">
|
<div style="font-size:.8rem;color:var(--muted);margin-top:2px">
|
||||||
${l.group_name ? `Группа: <b>${esc(l.group_name)}</b>` : ''}
|
${l.group_name ? `Группа: <b>${esc(l.group_name)}</b>` : ''}
|
||||||
${l.student_name ? `${l.group_name ? ' · ' : ''}Ученик: <b>${esc(l.student_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.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>` : ''}
|
${namesHidden ? ` · <span style="color:#f59e0b">имена скрыты</span>` : ''}
|
||||||
${l.access_password_hash ? ` · <span style="color:#2563eb">🔒 пароль</span>` : ''}
|
${l.access_password_hash ? ` · <span style="color:#2563eb">🔒 пароль</span>` : ''}
|
||||||
· создана ${new Date(l.created_at).toLocaleString('ru')}
|
· создана ${new Date(l.created_at).toLocaleString('ru')}
|
||||||
@@ -38,12 +49,19 @@ async function loadLinks() {
|
|||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:8px;flex-shrink:0;align-items:center">
|
<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="/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-copy="${esc(linkUrl(l.token))}" title="Копировать ссылку">Копировать</button>
|
||||||
<button class="btn-link" data-edit-link="${l.id}" 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="Удалить">×</button>
|
<button class="btn-link" style="color:var(--muted)" data-del-link="${l.id}" title="Удалить">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;}).join('');
|
`;}).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}` : ''; }
|
function fmtDate(d) { const [y, m, day] = (d || '').split('-'); return d ? `${day}.${m}.${y}` : ''; }
|
||||||
|
|||||||
@@ -29,3 +29,22 @@ async function doLogin() {
|
|||||||
document.getElementById('pwdInput').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
|
document.getElementById('pwdInput').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
|
||||||
document.getElementById('userInput').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);
|
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 */ }
|
||||||
|
})();
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
let nfItems = [];
|
||||||
|
let nfTotal = 0;
|
||||||
|
let nfUnread = 0;
|
||||||
|
let nfUnreadOnly = false;
|
||||||
|
let nfLoading = false;
|
||||||
|
|
||||||
|
|
||||||
|
async function nfMarkRead(id) {
|
||||||
|
const res = await fetch(`${API}/api/notifications/${id}/read`, { method: 'POST', headers: hdr() });
|
||||||
|
if (!res.ok) { showToast('Не удалось отметить уведомление'); return; }
|
||||||
|
const item = nfItems.find(n => n.id === id);
|
||||||
|
if (item) item.read = true;
|
||||||
|
nfUnread = Math.max(0, nfUnread - 1);
|
||||||
|
if (typeof notifyList !== 'undefined') {
|
||||||
|
const shared = notifyList.find(n => n.id === id);
|
||||||
|
if (shared) shared.read = true;
|
||||||
|
}
|
||||||
|
if (nfUnreadOnly) {
|
||||||
|
nfItems = nfItems.filter(n => n.id !== id);
|
||||||
|
nfTotal = Math.max(0, nfTotal - 1);
|
||||||
|
loadNotifyPage(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderNotifyPage();
|
||||||
|
if (typeof setNotifyUnread === 'function') setNotifyUnread(nfUnread);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nfDelete(id) {
|
||||||
|
if (!confirm('Удалить уведомление у всех пользователей?')) return;
|
||||||
|
const res = await fetch(`${API}/api/notifications/${id}`, { method: 'DELETE', headers: hdr() });
|
||||||
|
if (!res.ok) { showToast('Не удалось удалить уведомление'); return; }
|
||||||
|
if (typeof notifyList !== 'undefined') notifyList = notifyList.filter(n => n.id !== id);
|
||||||
|
loadNotifyPage(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nfMarkAllRead() {
|
||||||
|
const res = await fetch(`${API}/api/notifications/read-all`, { method: 'POST', headers: hdr() });
|
||||||
|
if (!res.ok) { showToast('Не удалось отметить уведомления'); return; }
|
||||||
|
showToast('Все уведомления прочитаны');
|
||||||
|
if (typeof notifyList !== 'undefined') notifyList.forEach(n => { n.read = true; });
|
||||||
|
if (typeof setNotifyUnread === 'function') setNotifyUnread(0);
|
||||||
|
loadNotifyPage(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nfClearAll() {
|
||||||
|
if (!confirm('Удалить все уведомления у всех пользователей? Действие необратимо.')) return;
|
||||||
|
const res = await fetch(`${API}/api/notifications`, { method: 'DELETE', headers: hdr() });
|
||||||
|
if (!res.ok) { showToast('Не удалось очистить уведомления'); return; }
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
showToast(`Удалено уведомлений: ${data.deleted || 0}`);
|
||||||
|
if (typeof notifyList !== 'undefined') notifyList = [];
|
||||||
|
if (typeof setNotifyUnread === 'function') setNotifyUnread(0);
|
||||||
|
loadNotifyPage(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNotificationArrived() {
|
||||||
|
loadNotifyPage(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNotifyListChanged() {
|
||||||
|
loadNotifyPage(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('nfList').addEventListener('click', e => {
|
||||||
|
const readBtn = e.target.closest('[data-nf-read]');
|
||||||
|
if (readBtn) { e.preventDefault(); nfMarkRead(parseInt(readBtn.dataset.nfRead, 10)); return; }
|
||||||
|
const delBtn = e.target.closest('[data-nf-del]');
|
||||||
|
if (delBtn) { e.preventDefault(); nfDelete(parseInt(delBtn.dataset.nfDel, 10)); return; }
|
||||||
|
if (e.target.closest('a[href]')) {
|
||||||
|
const card = e.target.closest('[data-nf-card]');
|
||||||
|
if (card) nfMarkRead(parseInt(card.dataset.nfCard, 10));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('notifyFilter').addEventListener('click', e => {
|
||||||
|
const btn = e.target.closest('button[data-filter]');
|
||||||
|
if (!btn) return;
|
||||||
|
nfUnreadOnly = btn.dataset.filter === 'unread';
|
||||||
|
document.querySelectorAll('#notifyFilter button').forEach(b => b.classList.toggle('active', b === btn));
|
||||||
|
loadNotifyPage(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('nfReadAllBtn').addEventListener('click', nfMarkAllRead);
|
||||||
|
document.getElementById('nfClearBtn').addEventListener('click', nfClearAll);
|
||||||
|
document.getElementById('nfMoreBtn').addEventListener('click', () => loadNotifyPage(false));
|
||||||
|
document.getElementById('nfReloadBtn').addEventListener('click', () => loadNotifyPage(true));
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (!(await requireAdminPage())) return;
|
||||||
|
buildSidebar(document.body.dataset.page);
|
||||||
|
const clearBtn = document.getElementById('nfClearBtn');
|
||||||
|
if (clearBtn) clearBtn.hidden = !isAdmin();
|
||||||
|
const authStatus = document.getElementById('authStatus');
|
||||||
|
if (authStatus) authStatus.classList.add('loaded');
|
||||||
|
await loadNotifyPage(true);
|
||||||
|
})();
|
||||||
|
|
||||||
|
function nfIcon(type) {
|
||||||
|
return typeof NOTIFY_ICONS !== 'undefined' && NOTIFY_ICONS[type] ? NOTIFY_ICONS[type] : 'bell';
|
||||||
|
}
|
||||||
|
|
||||||
|
function nfLevelClass(level) {
|
||||||
|
return level === 'warning' || level === 'critical' ? ` level-${level}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function nfCardHtml(n) {
|
||||||
|
const when = typeof notifyTime === 'function' ? notifyTime(n.created_at) : '';
|
||||||
|
const linkAttr = typeof attrEsc === 'function' ? attrEsc(n.link) : esc(n.link);
|
||||||
|
return `<div class="notify-card${n.read ? '' : ' unread'}${nfLevelClass(n.level)}" data-nf-card="${n.id}">
|
||||||
|
<span class="notify-ic"><i data-lucide="${nfIcon(n.type)}"></i></span>
|
||||||
|
<span class="notify-body">
|
||||||
|
<span class="notify-title">${esc(n.title)}</span>
|
||||||
|
${n.body ? `<span class="notify-text">${esc(n.body)}</span>` : ''}
|
||||||
|
<span class="notify-time">${esc(when)}${n.read ? '' : ' · не прочитано'}</span>
|
||||||
|
</span>
|
||||||
|
<span class="notify-card-actions">
|
||||||
|
${n.link ? `<a class="btn-primary ghost" style="padding:6px 12px;font-size:.78rem" href="${linkAttr}">Открыть</a>` : ''}
|
||||||
|
${n.read ? '' : `<button type="button" class="notify-link" data-nf-read="${n.id}">Прочитано</button>`}
|
||||||
|
${isAdmin() ? `<button type="button" class="notify-link" style="color:#ef4444" data-nf-del="${n.id}">Удалить</button>` : ''}
|
||||||
|
</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNotifyPage() {
|
||||||
|
const list = document.getElementById('nfList');
|
||||||
|
const state = document.getElementById('nfState');
|
||||||
|
const more = document.getElementById('nfMoreBtn');
|
||||||
|
if (!list) return;
|
||||||
|
list.innerHTML = nfItems.length
|
||||||
|
? nfItems.map(nfCardHtml).join('')
|
||||||
|
: `<div class="notify-empty">${nfUnreadOnly ? 'Непрочитанных уведомлений нет' : 'Уведомлений пока нет'}</div>`;
|
||||||
|
if (state) {
|
||||||
|
state.textContent = nfItems.length
|
||||||
|
? `Показано ${nfItems.length} из ${nfTotal} · непрочитанных: ${nfUnread}`
|
||||||
|
: `Непрочитанных: ${nfUnread}`;
|
||||||
|
}
|
||||||
|
if (more) more.hidden = nfItems.length >= nfTotal;
|
||||||
|
renderIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNotifyPage(reset) {
|
||||||
|
if (nfLoading) return;
|
||||||
|
nfLoading = true;
|
||||||
|
const offset = reset ? 0 : nfItems.length;
|
||||||
|
try {
|
||||||
|
const q = new URLSearchParams({ limit: String(PAGE_SIZE), offset: String(offset) });
|
||||||
|
if (nfUnreadOnly) q.set('unread', '1');
|
||||||
|
const res = await fetch(`${API}/api/notifications?${q.toString()}`, { headers: hdr() });
|
||||||
|
if (!res.ok) { showToast('Ошибка загрузки уведомлений'); return; }
|
||||||
|
const data = await res.json();
|
||||||
|
const items = data.items || [];
|
||||||
|
nfItems = reset ? items : nfItems.concat(items);
|
||||||
|
nfTotal = Number(data.total) || 0;
|
||||||
|
nfUnread = Number(data.unread) || 0;
|
||||||
|
renderNotifyPage();
|
||||||
|
if (typeof setNotifyUnread === 'function') setNotifyUnread(nfUnread);
|
||||||
|
} catch {
|
||||||
|
showToast('Ошибка сети');
|
||||||
|
} finally {
|
||||||
|
nfLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
const PAGE_SIZE = 24;
|
||||||
|
let page = 1;
|
||||||
|
let totalPhotos = 0;
|
||||||
|
let currentPhotos = [];
|
||||||
|
let groupsLoaded = false;
|
||||||
|
let studentsLoaded = false;
|
||||||
|
let sourcesLoaded = false;
|
||||||
|
let searchTimer = null;
|
||||||
|
|
||||||
|
function humanSize(b) {
|
||||||
|
if (!b) return '';
|
||||||
|
if (b < 1024) return b + ' Б';
|
||||||
|
if (b < 1048576) return (b / 1024).toFixed(1) + ' КБ';
|
||||||
|
return (b / 1048576).toFixed(1) + ' МБ';
|
||||||
|
}
|
||||||
|
|
||||||
|
function photoLink(p) {
|
||||||
|
if (p.source_type === 'entry') return `journal.html#entry-${p.source_id}`;
|
||||||
|
if (p.source_type === 'entry_photo') return `journal.html#entry-${p.source_id}`;
|
||||||
|
if (p.source_type === 'group_photo') return 'groups.html';
|
||||||
|
if (p.source_type === 'student_photo') return 'students.html';
|
||||||
|
if (p.source_type === 'module_photo') return 'modules.html';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function photoCardHTML(p) {
|
||||||
|
const url = p.path;
|
||||||
|
const meta = [];
|
||||||
|
if (p.student_name) meta.push(esc(p.student_name));
|
||||||
|
if (p.group_name) meta.push(esc(p.group_name));
|
||||||
|
const link = photoLink(p);
|
||||||
|
return `
|
||||||
|
<div class="ph-card">
|
||||||
|
<img src="${esc(thumbSrc(url))}" data-img="${esc(url)}" loading="lazy" decoding="async" alt="">
|
||||||
|
<div class="ph-body">
|
||||||
|
<span class="ph-badge">${esc(p.source_label)}</span>
|
||||||
|
<div class="ph-title">${esc(p.title || 'Без описания')}</div>
|
||||||
|
${meta.length ? `<div class="ph-meta">${meta.join(' · ')}</div>` : ''}
|
||||||
|
<div class="ph-time">${new Date(p.created_at).toLocaleString('ru')}</div>
|
||||||
|
${link ? `<a class="ph-link" href="${link}">Открыть источник →</a>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrid() {
|
||||||
|
const grid = document.getElementById('grid');
|
||||||
|
if (!currentPhotos.length) {
|
||||||
|
grid.innerHTML = '<div class="empty" style="grid-column:1/-1">Фотографий не найдено</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
grid.innerHTML = currentPhotos.map(photoCardHTML).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGroupOptions() {
|
||||||
|
if (groupsLoaded) return;
|
||||||
|
const sel = document.getElementById('groupFilter');
|
||||||
|
while (sel.options.length > 1) sel.remove(1);
|
||||||
|
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||||
|
const groups = await res.json();
|
||||||
|
groups.forEach(g => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = g.id; o.textContent = g.name;
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
groupsLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStudentOptions() {
|
||||||
|
if (studentsLoaded) return;
|
||||||
|
const dl = document.getElementById('studentFilterList');
|
||||||
|
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
|
||||||
|
const names = await res.json();
|
||||||
|
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
|
||||||
|
studentsLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillSourceOptions() {
|
||||||
|
if (sourcesLoaded) return;
|
||||||
|
const sel = document.getElementById('sourceFilter');
|
||||||
|
const labels = [];
|
||||||
|
currentPhotos.forEach(p => { if (!labels.includes(p.source_label)) labels.push(p.source_label); });
|
||||||
|
const known = ['Главное фото записи', 'Фото записи', 'Фотохроника группы', 'Фото ученика', 'Тема модуля'];
|
||||||
|
known.concat(labels.filter(l => !known.includes(l))).forEach(label => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = label; o.textContent = label;
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
sourcesLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPhotos() {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
const q = document.getElementById('searchInput').value;
|
||||||
|
const sn = document.getElementById('studentFilter').value;
|
||||||
|
const gid = document.getElementById('groupFilter').value;
|
||||||
|
const df = document.getElementById('dateFrom').value;
|
||||||
|
const dt = document.getElementById('dateTo').value;
|
||||||
|
if (q) p.set('search', q);
|
||||||
|
if (sn) p.set('student_name', sn);
|
||||||
|
if (gid) p.set('group_id', gid);
|
||||||
|
if (df) p.set('date_from', df);
|
||||||
|
if (dt) p.set('date_to', dt);
|
||||||
|
p.set('limit', PAGE_SIZE);
|
||||||
|
p.set('offset', (page - 1) * PAGE_SIZE);
|
||||||
|
const res = await fetch(`${API}/api/photos?${p}`, { headers: hdr() });
|
||||||
|
if (!res.ok) {
|
||||||
|
document.getElementById('grid').innerHTML = '<div class="empty" style="grid-column:1/-1">Не удалось загрузить фотографии</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
const srcFilter = document.getElementById('sourceFilter').value;
|
||||||
|
currentPhotos = srcFilter ? data.photos.filter(x => x.source_label === srcFilter) : data.photos;
|
||||||
|
totalPhotos = srcFilter ? currentPhotos.length : data.total;
|
||||||
|
fillSourceOptions();
|
||||||
|
renderGrid();
|
||||||
|
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalPhotos / PAGE_SIZE)), goPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goPage(p) {
|
||||||
|
page = p;
|
||||||
|
loadPhotos();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('studentFilter').addEventListener('change', () => { page = 1; loadPhotos(); });
|
||||||
|
document.getElementById('groupFilter').addEventListener('change', () => { page = 1; loadPhotos(); });
|
||||||
|
document.getElementById('sourceFilter').addEventListener('change', () => { page = 1; loadPhotos(); });
|
||||||
|
document.getElementById('dateFrom').addEventListener('change', () => { page = 1; loadPhotos(); });
|
||||||
|
document.getElementById('dateTo').addEventListener('change', () => { page = 1; loadPhotos(); });
|
||||||
|
document.getElementById('searchInput').addEventListener('input', () => {
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(() => { page = 1; loadPhotos(); }, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (await checkAuth()) {
|
||||||
|
buildSidebar('photos');
|
||||||
|
await loadGroupOptions();
|
||||||
|
await loadStudentOptions();
|
||||||
|
loadPhotos();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''')}
|
||||||
|
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')}
|
||||||
+782
-70
File diff suppressed because it is too large
Load Diff
+45
-2
@@ -239,7 +239,15 @@ function renderShare(data, password) {
|
|||||||
const isAnonymized = !data.show_student_names;
|
const isAnonymized = !data.show_student_names;
|
||||||
const showStudentMessage = !!data.show_student_message;
|
const showStudentMessage = !!data.show_student_message;
|
||||||
const showEntryDate = !!data.show_entry_date;
|
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 = [
|
const body = [
|
||||||
!isAnonymized ? `<div class="name">${esc(e.student_name)}</div>` : '',
|
!isAnonymized ? `<div class="name">${esc(e.student_name)}</div>` : '',
|
||||||
!isAnonymized && e.group_name ? `<div><span class="group">${esc(e.group_name)}</span></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>` : ''}
|
${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>
|
</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'));
|
document.getElementById('lightbox').addEventListener('click', () => document.getElementById('lightbox').classList.remove('open'));
|
||||||
@@ -290,3 +309,27 @@ document.addEventListener('click', e => {
|
|||||||
notice.classList.add('hidden');
|
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) {}
|
||||||
|
})();
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ function renderStudents() {
|
|||||||
<select class="group-select" id="sgroup-${s.id}"></select>
|
<select class="group-select" id="sgroup-${s.id}"></select>
|
||||||
<div class="student-actions">
|
<div class="student-actions">
|
||||||
<button class="btn-save" data-save-student="${s.id}">Сохранить</button>
|
<button class="btn-save" data-save-student="${s.id}">Сохранить</button>
|
||||||
|
<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-export-student="${esc(s.name)}" title="Сформировать ZIP-отчёт: фотографии и файлы">Отчёт</button>
|
||||||
<button class="btn-link" data-copy-student="${esc(s.name)}" title="Скопировать ссылку с подстановкой имени">Ссылка</button>
|
<button class="btn-link" data-copy-student="${esc(s.name)}" title="Скопировать ссылку с подстановкой имени">Ссылка</button>
|
||||||
<button class="btn-del" data-del-student="${s.id}" title="Удалить">×</button>
|
<button class="btn-del" data-del-student="${s.id}" title="Удалить">×</button>
|
||||||
@@ -156,6 +158,215 @@ async function submitExport() {
|
|||||||
await exportStudent(name, opts);
|
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) {
|
async function delStudent(id) {
|
||||||
if (!confirm('Удалить ученика из списка? Старые записи останутся.')) return;
|
if (!confirm('Удалить ученика из списка? Старые записи останутся.')) return;
|
||||||
await fetch(`${API}/api/students/${id}`, { method: 'DELETE', headers: hdr() });
|
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; }
|
if (cp) { copyStudentLink(cp.dataset.copyStudent); return; }
|
||||||
const ex = e.target.closest('[data-export-student]');
|
const ex = e.target.closest('[data-export-student]');
|
||||||
if (ex) { openExportModal(ex.dataset.exportStudent.trim()); return; }
|
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]');
|
const dl = e.target.closest('[data-del-student]');
|
||||||
if (dl) { delStudent(parseInt(dl.dataset.delStudent, 10)); }
|
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 () => {
|
(async () => {
|
||||||
if (await checkAuth()) {
|
if (await checkAuth()) {
|
||||||
buildSidebar(document.body.dataset.page);
|
buildSidebar(document.body.dataset.page);
|
||||||
|
|||||||
+177
-13
@@ -1,7 +1,14 @@
|
|||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
let currentEntries = [];
|
let currentEntries = [];
|
||||||
|
let currentGroups = [];
|
||||||
|
let pendingEntries = [];
|
||||||
|
let pendingGroups = [];
|
||||||
let page = 1;
|
let page = 1;
|
||||||
let totalEntries = 0;
|
let totalEntries = 0;
|
||||||
|
let totalGroups = 0;
|
||||||
|
let pendingTotalEntries = 0;
|
||||||
|
let pendingTotalGroups = 0;
|
||||||
|
let purgeDays = 30;
|
||||||
|
|
||||||
function isImageFile(name) {
|
function isImageFile(name) {
|
||||||
return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
|
return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
|
||||||
@@ -28,32 +35,129 @@ function entryHTML(e) {
|
|||||||
<div class="trash-time">Удалена: ${new Date(e.deleted_at).toLocaleString('ru')}</div>
|
<div class="trash-time">Удалена: ${new Date(e.deleted_at).toLocaleString('ru')}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="restore-btn" data-restore="${e.id}" title="Восстановить">Восстановить</button>
|
<button class="restore-btn" data-restore="${e.id}" title="Восстановить">Восстановить</button>
|
||||||
<button class="del-btn" data-hard="${e.id}" title="Удалить навсегда (вместе с файлами)">×</button>
|
<button class="del-btn" data-hard="${e.id}" title="Пометить на удаление (через ${purgeDays} дней будет удалена безвозвратно)">×</button>
|
||||||
</div>
|
</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} дней будет удалена безвозвратно вместе с записями и фото)">×</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() {
|
function renderGrid() {
|
||||||
const grid = document.getElementById('grid');
|
const grid = document.getElementById('grid');
|
||||||
grid.className = 'grid';
|
grid.className = 'grid';
|
||||||
grid.innerHTML = currentEntries.length
|
grid.innerHTML = currentEntries.length
|
||||||
? currentEntries.map(entryHTML).join('')
|
? currentEntries.map(entryHTML).join('')
|
||||||
: '<div class="empty">Корзина пуста</div>';
|
: '<div class="empty">Записей в корзине нет</div>';
|
||||||
|
document.getElementById('entriesInfo').textContent =
|
||||||
|
totalEntries > 0 ? `— записей в корзине: ${totalEntries}` : '';
|
||||||
document.getElementById('trashInfo').textContent =
|
document.getElementById('trashInfo').textContent =
|
||||||
totalEntries > 0 ? `Записей в корзине: ${totalEntries}` : '';
|
totalEntries + totalGroups > 0
|
||||||
document.getElementById('emptyBtn').disabled = totalEntries === 0;
|
? `В корзине: записей ${totalEntries}, групп ${totalGroups}`
|
||||||
|
: 'Корзина пуста';
|
||||||
|
document.getElementById('emptyBtn').disabled = totalEntries + totalGroups === 0;
|
||||||
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalEntries / PAGE_SIZE)), goPage);
|
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() {
|
async function loadTrash() {
|
||||||
const p = new URLSearchParams();
|
const p = new URLSearchParams();
|
||||||
p.set('limit', PAGE_SIZE);
|
p.set('limit', PAGE_SIZE);
|
||||||
p.set('offset', (page - 1) * PAGE_SIZE);
|
p.set('offset', (page - 1) * PAGE_SIZE);
|
||||||
const res = await fetch(`${API}/api/trash?${p}`, { headers: hdr() });
|
const [trashRes, pendRes] = await Promise.all([
|
||||||
const data = await res.json();
|
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;
|
currentEntries = data.entries;
|
||||||
totalEntries = data.total;
|
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();
|
renderGrid();
|
||||||
|
renderPending();
|
||||||
}
|
}
|
||||||
|
|
||||||
function goPage(p) { page = p; loadTrash(); }
|
function goPage(p) { page = p; loadTrash(); }
|
||||||
@@ -65,22 +169,55 @@ async function restoreEntry(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function hardDelete(id) {
|
async function hardDelete(id) {
|
||||||
if (!confirm('Удалить запись навсегда? Файлы и фото будут удалены безвозвратно.')) return;
|
if (!confirm(`Пометить запись на удаление? Она будет безвозвратно удалена через ${purgeDays} дней вместе с файлами и фото.`)) return;
|
||||||
await fetch(`${API}/api/entries/${id}/permanent`, { method: 'DELETE', headers: hdr() });
|
const res = await fetch(`${API}/api/entries/${id}/permanent`, { method: 'DELETE', headers: hdr() });
|
||||||
showToast('Запись удалена навсегда');
|
if (res.ok) { showToast(`Запись помечена на удаление через ${purgeDays} дн.`); loadTrash(); }
|
||||||
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() {
|
async function emptyTrash() {
|
||||||
if (totalEntries === 0) return;
|
const parts = [];
|
||||||
if (!confirm(`Удалить все записи из корзины (${totalEntries})? Файлы будут удалены безвозвратно.`)) return;
|
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() });
|
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 || 'Ошибка'); }
|
else { const err = await res.json(); alert(err.error || 'Ошибка'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('emptyBtn').addEventListener('click', emptyTrash);
|
document.getElementById('emptyBtn').addEventListener('click', emptyTrash);
|
||||||
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
|
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 => {
|
document.getElementById('grid').addEventListener('click', e => {
|
||||||
const card = e.target.closest('.card');
|
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)); }
|
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 () => {
|
(async () => {
|
||||||
if (await checkAuth()) {
|
if (await checkAuth()) {
|
||||||
buildSidebar(document.body.dataset.page);
|
buildSidebar(document.body.dataset.page);
|
||||||
|
|||||||
+209
-2
@@ -1,5 +1,6 @@
|
|||||||
const AUTO_REFRESH_MS = 5000;
|
const AUTO_REFRESH_MS = 5000;
|
||||||
const RECENT_PAGE_SIZE = 15;
|
const RECENT_PAGE_SIZE = 15;
|
||||||
|
const PHOTO_PAGE_SIZE = 15;
|
||||||
const STATUS_LABELS = {
|
const STATUS_LABELS = {
|
||||||
pending: 'В очереди',
|
pending: 'В очереди',
|
||||||
processing: 'Проверяется',
|
processing: 'Проверяется',
|
||||||
@@ -16,10 +17,40 @@ const STATUS_BADGE = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let state = null;
|
let state = null;
|
||||||
|
let photoState = null;
|
||||||
|
let photoJobRow = null;
|
||||||
let refreshTimer = null;
|
let refreshTimer = null;
|
||||||
let busy = false;
|
let busy = false;
|
||||||
|
let photoBusy = false;
|
||||||
let recentRows = [];
|
let recentRows = [];
|
||||||
let recentPage = 1;
|
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) {
|
function fmtDur(ms) {
|
||||||
if (ms === null || ms === undefined || ms < 0) return '—';
|
if (ms === null || ms === undefined || ms < 0) return '—';
|
||||||
@@ -190,6 +221,116 @@ function render() {
|
|||||||
renderIcons();
|
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() {
|
async function load() {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
busy = true;
|
busy = true;
|
||||||
@@ -231,15 +372,28 @@ async function requeueFailed() {
|
|||||||
|
|
||||||
function setAutoRefresh(on) {
|
function setAutoRefresh(on) {
|
||||||
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
|
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('wakeBtn').addEventListener('click', wake);
|
||||||
document.getElementById('toggleBtn').addEventListener('click', toggleEnabled);
|
document.getElementById('toggleBtn').addEventListener('click', toggleEnabled);
|
||||||
document.getElementById('requeueBtn').addEventListener('click', requeueFailed);
|
document.getElementById('requeueBtn').addEventListener('click', requeueFailed);
|
||||||
document.getElementById('recentFilter').addEventListener('change', () => { recentPage = 1; renderRecent(); });
|
document.getElementById('recentFilter').addEventListener('change', () => { recentPage = 1; renderRecent(); });
|
||||||
document.getElementById('autoRefresh').addEventListener('change', e => setAutoRefresh(e.target.checked));
|
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 => {
|
document.getElementById('recentTable').addEventListener('click', e => {
|
||||||
const btn = e.target.closest('.ai-details');
|
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');
|
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 () => {
|
(async () => {
|
||||||
if (await requireAdminPage()) {
|
if (await requireAdminPage()) {
|
||||||
buildSidebar(document.body.dataset.page);
|
buildSidebar(document.body.dataset.page);
|
||||||
load();
|
load();
|
||||||
|
loadPhoto();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<h2>Ссылки</h2>
|
<h2>Ссылки</h2>
|
||||||
<p style="color:var(--muted);font-size:.85rem;margin-bottom:20px;max-width:680px">Каждая ссылка открывает публичную страницу с карточками записей выбранной группы за указанный период — без входа и выбора параметров.</p>
|
<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="list" id="linksList"><div class="empty">Загрузка...</div></div>
|
||||||
|
<div class="pager" id="pager"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -9,7 +9,10 @@
|
|||||||
:root{--bg:#f5f5f5;--card:#fff;--border:#e5e5e5;--accent:#2563eb;--text:#171717;--muted:#737373;--radius:12px}
|
: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}
|
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{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{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 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}
|
.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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="login">
|
<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="text" id="userInput" placeholder="Логин" autocomplete="username" autofocus>
|
||||||
<input type="password" id="pwdInput" placeholder="Пароль" autocomplete="current-password">
|
<input type="password" id="pwdInput" placeholder="Пароль" autocomplete="current-password">
|
||||||
<input type="text" id="hpWebsite" tabindex="-1" autocomplete="off" style="display:none" value="">
|
<input type="text" id="hpWebsite" tabindex="-1" autocomplete="off" style="display:none" value="">
|
||||||
|
|||||||
@@ -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 Введение в базы данных | 8 Разработка интерфейсов 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>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<!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="notifications">
|
||||||
|
<div class="layout">
|
||||||
|
<div class="sidebar" id="sidebar"></div>
|
||||||
|
<div class="main">
|
||||||
|
<div class="page-head">
|
||||||
|
<h2>Уведомления</h2>
|
||||||
|
<p class="page-sub">События системы: новые записи в журнале, обработка фото нейросетью, блокировки IP и другое</p>
|
||||||
|
<span class="page-status" id="authStatus"><span class="spinner dark"></span> Загрузка…</span>
|
||||||
|
<button class="head-btn" id="nfReloadBtn" type="button" title="Обновить список"><i data-lucide="rotate-ccw"></i> Обновить</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notify-page-head">
|
||||||
|
<div class="view-toggle" id="notifyFilter">
|
||||||
|
<button type="button" data-filter="all" class="active">Все</button>
|
||||||
|
<button type="button" data-filter="unread">Непрочитанные</button>
|
||||||
|
</div>
|
||||||
|
<span class="grow"></span>
|
||||||
|
<button class="btn-primary ghost" id="nfReadAllBtn" type="button"><i data-lucide="check"></i> Прочитать все</button>
|
||||||
|
<button class="btn-primary danger-ghost" id="nfClearBtn" type="button" hidden><i data-lucide="trash-2"></i> Очистить всё</button>
|
||||||
|
</div>
|
||||||
|
<div class="hint" id="nfState"></div>
|
||||||
|
|
||||||
|
<div class="notify-page-list" id="nfList"></div>
|
||||||
|
<div style="display:flex;justify-content:center;margin-top:16px">
|
||||||
|
<button class="btn-primary ghost" id="nfMoreBtn" type="button" hidden>Показать ещё</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="admin.js"></script>
|
||||||
|
<script src="js/notifications.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<!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">
|
||||||
|
<style>
|
||||||
|
.ph-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px}
|
||||||
|
.ph-card{background:var(--card);border:1px solid var(--border);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;min-width:0}
|
||||||
|
.ph-card img{width:100%;aspect-ratio:4/3;object-fit:cover;display:block;cursor:zoom-in;background:var(--bg);transition:opacity .15s}
|
||||||
|
.ph-card:hover img{opacity:.9}
|
||||||
|
.ph-card .ph-body{padding:10px 12px;display:flex;flex-direction:column;gap:4px;min-width:0}
|
||||||
|
.ph-badge{align-self:flex-start;background:var(--bg);border:1px solid var(--border);color:var(--muted);border-radius:999px;padding:2px 8px;font-size:.68rem;font-weight:700}
|
||||||
|
.ph-title{font-size:.82rem;font-weight:600;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;word-break:break-word}
|
||||||
|
.ph-meta{font-size:.72rem;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.ph-time{font-size:.7rem;color:var(--muted)}
|
||||||
|
.ph-link{font-size:.72rem;color:var(--accent);text-decoration:none}
|
||||||
|
.ph-link:hover{text-decoration:underline}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body data-page="photos">
|
||||||
|
<div class="layout">
|
||||||
|
<div class="sidebar" id="sidebar"></div>
|
||||||
|
<div class="main">
|
||||||
|
<h2>Фото</h2>
|
||||||
|
<div class="filters">
|
||||||
|
<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><select id="sourceFilter"><option value="">Все источники</option></select></div>
|
||||||
|
<div><label>С</label><input type="date" id="dateFrom"></div>
|
||||||
|
<div><label>По</label><input type="date" id="dateTo"></div>
|
||||||
|
</div>
|
||||||
|
<div class="ph-grid" id="grid"><div class="empty" style="grid-column:1/-1">Загрузка...</div></div>
|
||||||
|
<div class="pager" id="pager"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="imgModal">
|
||||||
|
<img id="imgModalSrc">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="admin.js"></script>
|
||||||
|
<script src="js/photos.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because one or more lines are too long
@@ -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)}
|
||||||
@@ -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>
|
||||||
+252
-61
@@ -12,84 +12,195 @@
|
|||||||
<div class="main">
|
<div class="main">
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h2>Настройки</h2>
|
<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>
|
||||||
|
|
||||||
|
<div class="settings-layout">
|
||||||
|
<aside class="settings-nav" id="settingsNav">
|
||||||
|
<a href="#sec-system" data-nav="sec-system" class="active"><i data-lucide="bar-chart-3"></i> Система</a>
|
||||||
|
<a href="#sec-stack" data-nav="sec-stack"><i data-lucide="server"></i> Стек</a>
|
||||||
|
<a href="#sec-brand" data-nav="sec-brand"><i data-lucide="palette"></i> Брендинг</a>
|
||||||
|
<a href="#sec-antispam" data-nav="sec-antispam"><i data-lucide="shield"></i> Антиспам</a>
|
||||||
|
<a href="#sec-share" data-nav="sec-share"><i data-lucide="link"></i> Публичные ссылки</a>
|
||||||
|
<a href="#sec-form" data-nav="sec-form"><i data-lucide="file-text"></i> Форма ответа</a>
|
||||||
|
<a href="#sec-photo" data-nav="sec-photo"><i data-lucide="camera"></i> Фото</a>
|
||||||
|
<a href="#sec-notify" data-nav="sec-notify"><i data-lucide="bell"></i> Уведомления</a>
|
||||||
|
<a href="#sec-ai" data-nav="sec-ai"><i data-lucide="bot"></i> ИИ</a>
|
||||||
|
<a href="#sec-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-stack settings-stack-grid">
|
||||||
<div class="settings-card wide">
|
<div class="settings-card wide" id="sec-system">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div class="ic"><i data-lucide="bar-chart-3"></i></div>
|
<div class="ic"><i data-lucide="bar-chart-3"></i></div>
|
||||||
<h3>Системная информация</h3>
|
<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>
|
||||||
<div id="systemInfo" class="sys-grid">
|
<div id="systemInfo" class="sys-grid">
|
||||||
<div class="sys-card loading">Загрузка…</div>
|
<div class="sys-card loading">Загрузка…</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-card">
|
<div class="settings-card wide" id="sec-stack">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="ic"><i data-lucide="server"></i></div>
|
||||||
|
<h3>Статус стека</h3>
|
||||||
|
<button class="icon-btn" id="stackRefreshBtn" type="button" title="Обновить" aria-label="Обновить стек"><i data-lucide="refresh-cw"></i></button>
|
||||||
|
<span class="head-live" id="stackLive" title="Версии и состояние сервисов"></span>
|
||||||
|
</div>
|
||||||
|
<p class="hint">На чём работает приложение прямо сейчас: версии компонентов, состояние сервисов и нагрузка. Обновление перезагружает данные и этого блока, и системной информации выше.</p>
|
||||||
|
<div id="stackInfo" class="sys-grid">
|
||||||
|
<div class="sys-card loading">Загрузка…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card" id="sec-brand">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="ic"><i data-lucide="palette"></i></div>
|
||||||
|
<h3>Брендинг</h3>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Название и логотип отображаются в админ-панели, на форме ответа ученика, странице входа и в отчёте.</p>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="systemName">Название системы</label>
|
||||||
|
<input type="text" id="systemName" class="settings-input" maxlength="60" placeholder="WhatIDo">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Логотип</label>
|
||||||
|
<div class="logo-picker">
|
||||||
|
<div class="logo-preview" id="logoPreview">
|
||||||
|
<span id="logoPreviewFallback">—</span>
|
||||||
|
<img id="logoPreviewImg" alt="Логотип">
|
||||||
|
</div>
|
||||||
|
<div class="logo-picker-actions">
|
||||||
|
<div class="logo-picker-btns">
|
||||||
|
<button class="btn-primary ghost" id="logoUploadBtn" type="button"><i data-lucide="upload"></i> Загрузить логотип</button>
|
||||||
|
<button class="btn-primary danger-ghost" id="logoRemoveBtn" type="button" hidden><i data-lucide="trash-2"></i> Удалить</button>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="logoInput" accept="image/*" style="display:none">
|
||||||
|
<div class="logo-upload-status" id="logoUploadStatus"></div>
|
||||||
|
<span class="hint">Рекомендуется широкое изображение с прозрачным фоном (PNG/WebP), не более 1 МБ</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card" id="sec-antispam">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div class="ic"><i data-lucide="shield"></i></div>
|
<div class="ic"><i data-lucide="shield"></i></div>
|
||||||
<h3>Защита от повторов</h3>
|
<h3>Защита от повторов</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">Если ученик уже отправил ответ, следующая отправка от него будет отклонена в течение заданного времени. Минимальное значение — 1 минута.</p>
|
<p class="hint">Если ученик уже отправил ответ, следующая отправка от него будет отклонена в течение заданного времени. Минимальное значение — 1 минута.</p>
|
||||||
<div class="settings-row">
|
|
||||||
<div class="settings-field">
|
<div class="settings-field">
|
||||||
<label for="spamInterval">Интервал ожидания</label>
|
<label for="spamInterval">Интервал ожидания</label>
|
||||||
<div class="unit-row">
|
<div class="unit-row">
|
||||||
<input type="number" id="spamInterval" class="settings-input" min="1" max="1440" step="1">
|
<input type="number" id="spamInterval" class="settings-input" min="1" max="1440" step="1">
|
||||||
<span class="unit">минут</span>
|
<span class="unit">минут</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
<span class="hint" id="spamIntervalHint"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-card">
|
<div class="settings-card" id="sec-share">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div class="ic"><i data-lucide="link"></i></div>
|
<div class="ic"><i data-lucide="link"></i></div>
|
||||||
<h3>Страница публичной ссылки /s/<hash></h3>
|
<h3>Страница публичной ссылки /s/<hash></h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">Управление видимостью элементов на публичных страницах записей.</p>
|
<p class="hint">Управление видимостью элементов на публичных страницах записей.</p>
|
||||||
<div class="settings-row">
|
<label class="toggle">
|
||||||
<div class="settings-field" style="flex:1">
|
|
||||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
|
||||||
<input type="checkbox" id="shareShowMessage">
|
<input type="checkbox" id="shareShowMessage">
|
||||||
Показать сообщение ученика
|
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||||
|
<span class="toggle-text"><b>Сообщение ученика</b><small>Текст «Что делала(а)» на странице по ссылке</small></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
<label class="toggle">
|
||||||
</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="shareShowDate">
|
<input type="checkbox" id="shareShowDate">
|
||||||
Показать дату в карточке записи
|
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||||
|
<span class="toggle-text"><b>Дата записи</b><small>Дата в карточке записи</small></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
<label class="toggle">
|
||||||
</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="shareShowNames">
|
<input type="checkbox" id="shareShowNames">
|
||||||
Показывать фамилии и имена учеников
|
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||||
|
<span class="toggle-text"><b>Фамилии и имена</b><small>Имена учеников в галерее работ</small></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
<label class="toggle">
|
||||||
</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="shareShowPhotos">
|
<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>
|
</label>
|
||||||
</div>
|
<div class="settings-field">
|
||||||
</div>
|
|
||||||
<div class="settings-row">
|
|
||||||
<div class="settings-field" style="flex:1">
|
|
||||||
<label for="cookieNoticeText">Уведомление о cookie</label>
|
<label for="cookieNoticeText">Уведомление о cookie</label>
|
||||||
<textarea id="cookieNoticeText" class="settings-input" rows="3" style="min-height:80px"></textarea>
|
<textarea id="cookieNoticeText" class="settings-input" rows="3" style="min-height:80px"></textarea>
|
||||||
</div>
|
<span class="hint">Пусто — баннер не показывается</span>
|
||||||
</div>
|
</div>
|
||||||
</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="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="card-head">
|
||||||
<div class="ic"><i data-lucide="file-text"></i></div>
|
<div class="ic"><i data-lucide="file-text"></i></div>
|
||||||
<h3>Футер на форме</h3>
|
<h3>Футер на форме</h3>
|
||||||
@@ -107,45 +218,87 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-card">
|
<div class="settings-card" id="sec-photo">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div class="ic"><i data-lucide="bot"></i></div>
|
<div class="ic"><i data-lucide="camera"></i></div>
|
||||||
<h3>Промпт ИИ для исправления текста</h3>
|
<h3>Фотография с веб-камеры</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">Инструкция для нейросети при исправлении ошибок в записи «Что делала(а)».</p>
|
<p class="hint">Разрешение и качество снимка на форме ответа ученика. Камера может выдать разрешение, ближайшее к выбранному.</p>
|
||||||
<div class="settings-row">
|
<label class="toggle">
|
||||||
<div class="settings-field" style="flex:1">
|
<input type="checkbox" id="camEnabled">
|
||||||
<label for="aiPrompt">Промпт</label>
|
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||||
<textarea id="aiPrompt" class="settings-input" rows="4" style="min-height:100px;font-family:monospace;font-size:.85rem"></textarea>
|
<span class="toggle-text"><b>Кнопка «Камера»</b><small>Показывать кнопку «Камера» на форме ответа. Если выключено, фото делается кликом по превью «Нажми чтобы сделать фото»</small></span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="settings-row">
|
|
||||||
<div class="settings-field" style="flex:1">
|
|
||||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
|
||||||
<input type="checkbox" id="aiAutoCheck">
|
|
||||||
Автоматически проверять новые записи (без участия человека)
|
|
||||||
</label>
|
</label>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="photoResolution">Разрешение</label>
|
||||||
|
<select id="photoResolution" class="settings-input">
|
||||||
|
<option value="640x480">640 × 480 (по умолчанию)</option>
|
||||||
|
<option value="1280x720">1280 × 720 (HD)</option>
|
||||||
|
<option value="1920x1080">1920 × 1080 (Full HD)</option>
|
||||||
|
<option value="2560x1440">2560 × 1440 (QHD)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="photoQuality">Качество JPEG</label>
|
||||||
|
<div class="unit-row">
|
||||||
|
<input type="number" id="photoQuality" class="settings-input" min="0.5" max="1" step="0.01" value="0.92">
|
||||||
|
<span class="unit">0.5–1.0</span>
|
||||||
|
</div>
|
||||||
|
<span class="hint">Больше значение — лучше качество и крупнее файл</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="photoEngine">Обработка улучшения фото</label>
|
||||||
|
<select id="photoEngine" class="settings-input">
|
||||||
|
<option value="auto">Авто (сервер, иначе клиент)</option>
|
||||||
|
<option value="server">Сервер (sharp)</option>
|
||||||
|
<option value="client">Клиент (браузер)</option>
|
||||||
|
</select>
|
||||||
|
<span class="hint">Серверный режим обрабатывает оригинал без пережатия и быстрее на больших фото</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ai-queue" id="aiQueueInfo"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-card">
|
<div class="settings-card" id="sec-trash">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div class="ic"><i data-lucide="database"></i></div>
|
<div class="ic"><i data-lucide="trash-2"></i></div>
|
||||||
<h3>Резервное копирование</h3>
|
<h3>Корзина</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Восстановление полностью заменит текущие данные.</p>
|
<p class="hint">При очистке корзины или удалении пункта данные не удаляются сразу, а помечаются на удаление и исчезают из корзины. Через указанное количество дней они удаляются автоматически и безвозвратно вместе с файлами. До этого помеченные данные можно вернуть в корзину и восстановить (страница «Корзина»).</p>
|
||||||
<div class="data-stats" id="dataStats"><span class="chip">Загрузка статистики…</span></div>
|
<div class="settings-field">
|
||||||
<div class="card-foot">
|
<label for="trashPurgeDays">Время до безвозвратного удаления</label>
|
||||||
<button class="btn-primary ghost" id="backupBtn" type="button"><i data-lucide="download"></i> Скачать бэкап</button>
|
<div class="unit-row">
|
||||||
<button class="btn-primary ghost" id="restoreBtn" type="button"><i data-lucide="upload"></i> Восстановить из файла</button>
|
<input type="number" id="trashPurgeDays" class="settings-input" min="1" max="365" step="1">
|
||||||
<input type="file" id="restoreFile" accept=".tar.gz,.gz,application/gzip" style="display:none">
|
<span class="unit">дней</span>
|
||||||
|
</div>
|
||||||
|
<span class="hint">От 1 до 365 дней</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="restore-status" id="restoreStatus"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-card" id="bansCard">
|
<div class="settings-card wide" id="sec-notify">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="ic"><i data-lucide="bell"></i></div>
|
||||||
|
<h3>Уведомления</h3>
|
||||||
|
<button class="icon-btn" id="notifyTestBtn" type="button" title="Отправить тестовое уведомление" aria-label="Тестовое уведомление"><i data-lucide="send"></i></button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Уведомления собираются в «колокольчике» админ-панели и на странице «Уведомления». Здесь выбирается, о каких событиях сообщать: новые записи журнала, обработка фото нейросетью, блокировки IP, бэкапы и другое.</p>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="notifyEnabled">
|
||||||
|
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||||
|
<span class="toggle-text"><b>Уведомления включены</b><small>Общий выключатель: при снятии галочки не создаётся ни одно уведомление</small></span>
|
||||||
|
</label>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="notifyRetentionDays">Хранение уведомлений</label>
|
||||||
|
<div class="unit-row">
|
||||||
|
<input type="number" id="notifyRetentionDays" class="settings-input" min="1" max="365" step="1">
|
||||||
|
<span class="unit">дней</span>
|
||||||
|
</div>
|
||||||
|
<span class="hint">От 1 до 365 дней, старые уведомления удаляются автоматически</span>
|
||||||
|
</div>
|
||||||
|
<div class="notify-types" id="notifyTypes"><span class="hint">Загрузка…</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card" id="sec-bans">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div class="ic"><i data-lucide="shield-off"></i></div>
|
<div class="ic"><i data-lucide="shield-off"></i></div>
|
||||||
<h3>Блокировки IP</h3>
|
<h3>Блокировки IP</h3>
|
||||||
@@ -156,11 +309,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="save-bar" id="saveBar">
|
<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>
|
<span class="hint" id="saveHint"><i data-lucide="triangle-alert"></i> Есть несохранённые изменения</span>
|
||||||
<button class="btn-primary" id="saveBtn" type="button">Сохранить изменения</button>
|
<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>
|
||||||
|
|
||||||
|
<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>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
:root{--yellow:#F7C90E;--dark:#211E22;--gray:#9C9C9C;--page:#f6f6f6;--card-bg:#fff;--radius:30px}
|
: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}
|
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{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 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 .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)}
|
.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-presentation .ficon{background:#F6E4E8}
|
||||||
.card .files a.ftype-other .ficon{background:#EFEFEF}
|
.card .files a.ftype-other .ficon{background:#EFEFEF}
|
||||||
.card .foot{margin-top:auto;padding:8px 6px 2px;font-size:.78rem;color:var(--gray)}
|
.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{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}
|
.empty .big{font-size:2rem;margin-bottom:10px}
|
||||||
.loading{grid-column:1/-1;text-align:center;color:var(--gray);padding:40px}
|
.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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<section class="hero">
|
<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>
|
<h1 id="title">Загрузка...</h1>
|
||||||
<div class="chips" id="chips"></div>
|
<div class="chips" id="chips"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -130,6 +130,85 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 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>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
|
|||||||
@@ -15,10 +15,30 @@
|
|||||||
<span class="trash-muted" id="trashInfo"></span>
|
<span class="trash-muted" id="trashInfo"></span>
|
||||||
<button class="danger-btn" id="emptyBtn" type="button">Очистить корзину</button>
|
<button class="danger-btn" id="emptyBtn" type="button">Очистить корзину</button>
|
||||||
</div>
|
</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="grid" id="grid"><div class="empty">Загрузка...</div></div>
|
||||||
<div class="pager" id="pager"></div>
|
<div class="pager" id="pager"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="imgModal">
|
<div class="modal-overlay" id="imgModal">
|
||||||
<img id="imgModalSrc">
|
<img id="imgModalSrc">
|
||||||
|
|||||||
@@ -5,6 +5,21 @@
|
|||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Воркер ИИ — Админ-панель</title>
|
<title>Воркер ИИ — Админ-панель</title>
|
||||||
<link rel="stylesheet" href="admin.css">
|
<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>
|
</head>
|
||||||
<body data-page="worker">
|
<body data-page="worker">
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
@@ -24,6 +39,8 @@
|
|||||||
<button class="btn-link" id="refreshBtn" type="button">Обновить</button>
|
<button class="btn-link" id="refreshBtn" type="button">Обновить</button>
|
||||||
<button class="btn-link" id="wakeBtn" type="button" title="Запустить обработку очереди немедленно">Разбудить</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="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>
|
<button class="btn-link" id="toggleBtn" type="button">…</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -78,6 +95,39 @@
|
|||||||
<div class="pager" id="recentPager"></div>
|
<div class="pager" id="recentPager"></div>
|
||||||
</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="settings-card wide" id="errorsCard" style="display:none">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div class="ic"><i data-lucide="triangle-alert"></i></div>
|
<div class="ic"><i data-lucide="triangle-alert"></i></div>
|
||||||
@@ -112,6 +162,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
|
|||||||
@@ -0,0 +1,464 @@
|
|||||||
|
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 parseInfoSections(chunks) {
|
||||||
|
const out = new Map();
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
for (const line of String(chunk || '').split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||||
|
const sep = trimmed.indexOf(':');
|
||||||
|
if (sep < 1) continue;
|
||||||
|
out.set(trimmed.slice(0, sep).trim(), trimmed.slice(sep + 1).trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
const offline = {
|
||||||
|
...base,
|
||||||
|
driver: 'memory',
|
||||||
|
version: null,
|
||||||
|
server_uptime_s: null,
|
||||||
|
used_memory_bytes: null,
|
||||||
|
used_memory_human: null,
|
||||||
|
keys: null,
|
||||||
|
};
|
||||||
|
if (!ready()) return offline;
|
||||||
|
try {
|
||||||
|
const [raw, dbsize, server] = await Promise.all([
|
||||||
|
client.info('memory'),
|
||||||
|
client.dbSize(),
|
||||||
|
client.info('server'),
|
||||||
|
]);
|
||||||
|
const fields = parseInfoSections([raw, server]);
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
driver: 'redis',
|
||||||
|
version: fields.get('redis_version') || null,
|
||||||
|
server_uptime_s: fields.has('uptime_in_seconds') ? Number(fields.get('uptime_in_seconds')) || null : null,
|
||||||
|
used_memory_bytes: fields.has('used_memory') ? Number(fields.get('used_memory')) || null : null,
|
||||||
|
used_memory_human: fields.get('used_memory_human') || null,
|
||||||
|
keys: Number(dbsize) || 0,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
noteError('info', e);
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
driver: 'redis',
|
||||||
|
version: null,
|
||||||
|
server_uptime_s: null,
|
||||||
|
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 };
|
||||||
@@ -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
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 MiB |
+5
-2
@@ -10,10 +10,13 @@ mkdir -p backups
|
|||||||
echo ">> Экспорт БД"
|
echo ">> Экспорт БД"
|
||||||
docker compose exec -T db pg_dump -U app -d whereldo | gzip > backups/db.sql.gz
|
docker compose exec -T db pg_dump -U app -d whereldo | gzip > backups/db.sql.gz
|
||||||
|
|
||||||
echo ">> Копирование фотографий"
|
echo ">> Экспорт файлов из хранилища"
|
||||||
rm -rf backups/_uploads
|
rm -rf backups/_uploads
|
||||||
mkdir -p 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
|
rm -rf backups/_uploads/.thumbs
|
||||||
|
|
||||||
echo ">> Сборка архива"
|
echo ">> Сборка архива"
|
||||||
|
|||||||
Executable
+53
@@ -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
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
ROOT="$(git rev-parse --show-toplevel)"
|
||||||
|
node "$ROOT/scripts/gen-version.js"
|
||||||
+11
-2
@@ -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
|
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
|
gzip -dc "$TMP/db.sql.gz" | docker compose exec -T db psql -U app -d whereldo
|
||||||
|
|
||||||
echo ">> Восстановление фотографий"
|
echo ">> Загрузка файлов в хранилище"
|
||||||
docker cp "$TMP/_uploads/." "whatido-app-1:/app/uploads/"
|
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 ">> Запуск приложения"
|
echo ">> Запуск приложения"
|
||||||
docker compose start app
|
docker compose start app
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
+543
@@ -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 };
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
+1209
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
```
|
||||||
|
|
||||||
@@ -3,8 +3,355 @@ const IDLE_MAX_MS = 60000;
|
|||||||
const REQUEST_TIMEOUT_MS = parseInt(process.env.AI_REQUEST_TIMEOUT_MS || '120000', 10);
|
const REQUEST_TIMEOUT_MS = parseInt(process.env.AI_REQUEST_TIMEOUT_MS || '120000', 10);
|
||||||
const MAX_INPUT_CHARS = 2000;
|
const MAX_INPUT_CHARS = 2000;
|
||||||
const MIN_TEXT_CHARS = 4;
|
const MIN_TEXT_CHARS = 4;
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { textDiff, FIELD_LABELS } = require('./diff');
|
||||||
|
const PHOTO_MAX_ATTEMPTS = 3;
|
||||||
|
const PHOTO_AI_TIMEOUT_MS = 300000;
|
||||||
|
const PHOTO_SOFT_MAX_RETRIES = Math.max(1, parseInt(process.env.PHOTO_AI_SOFT_MAX_RETRIES || '60', 10) || 60);
|
||||||
|
const PHOTO_SOFT_BACKOFF_MS = Math.max(1000, parseInt(process.env.PHOTO_AI_SOFT_BACKOFF_MS || '10000', 10) || 10000);
|
||||||
|
const PHOTO_SOFT_BACKOFF_MAX_MS = Math.max(PHOTO_SOFT_BACKOFF_MS, parseInt(process.env.PHOTO_AI_SOFT_BACKOFF_MAX_MS || '300000', 10) || 300000);
|
||||||
|
|
||||||
function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultPrompt, model }) {
|
function isTimeoutFailure(e) {
|
||||||
|
const name = e && e.name;
|
||||||
|
return name === 'TimeoutError' || name === 'AbortError';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSoftStatus(status) {
|
||||||
|
return status === 408 || status === 425 || status === 429 || status >= 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
function failureReason(e) {
|
||||||
|
const parts = [];
|
||||||
|
let cur = e;
|
||||||
|
for (let i = 0; cur && i < 5; i++) {
|
||||||
|
const code = cur.code || (cur.errors && cur.errors.code);
|
||||||
|
if (code && !parts.includes(code)) parts.push(code);
|
||||||
|
cur = cur.cause;
|
||||||
|
}
|
||||||
|
return parts.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function softFailure(message, e) {
|
||||||
|
const reason = e ? failureReason(e) || (e.message || '') : '';
|
||||||
|
const err = new Error(reason ? `${message} (${reason})` : message);
|
||||||
|
err.soft = true;
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntries, sharp, photoAiUrl, storage, bus, notifyEvent }) {
|
||||||
|
const AI_URL = photoAiUrl || process.env.PHOTO_AI_URL || '';
|
||||||
|
const IDLE_MIN = 2000;
|
||||||
|
const IDLE_MAX = 30000;
|
||||||
|
|
||||||
|
async function sendNotification(entryId, payload) {
|
||||||
|
if (!notifyEvent) return;
|
||||||
|
try {
|
||||||
|
await notifyEvent(entryId, payload);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Photo worker notification failed:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, soft_retries: 0, last_at: null, last_error: null };
|
||||||
|
const softTries = new Map();
|
||||||
|
const CONFIG = {
|
||||||
|
idle_min_ms: IDLE_MIN,
|
||||||
|
idle_max_ms: IDLE_MAX,
|
||||||
|
ai_timeout_ms: PHOTO_AI_TIMEOUT_MS,
|
||||||
|
max_attempts: PHOTO_MAX_ATTEMPTS,
|
||||||
|
soft_max_retries: PHOTO_SOFT_MAX_RETRIES,
|
||||||
|
soft_backoff_ms: PHOTO_SOFT_BACKOFF_MS,
|
||||||
|
soft_backoff_max_ms: PHOTO_SOFT_BACKOFF_MAX_MS,
|
||||||
|
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 out = await pipeline.jpeg({ quality: 92, mozjpeg: true }).toBuffer();
|
||||||
|
await storage.put(newName, out);
|
||||||
|
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');
|
||||||
|
let resp;
|
||||||
|
try {
|
||||||
|
resp = await fetch(AI_URL.replace(/\/+$/, '') + '/enhance', {
|
||||||
|
method: 'POST',
|
||||||
|
body: fd,
|
||||||
|
signal: AbortSignal.timeout(PHOTO_AI_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (isTimeoutFailure(e)) {
|
||||||
|
throw new Error(`ИИ-сервис не ответил за ${Math.round(PHOTO_AI_TIMEOUT_MS / 1000)} с`);
|
||||||
|
}
|
||||||
|
throw softFailure('ИИ-сервис недоступен', e);
|
||||||
|
}
|
||||||
|
if (!resp.ok) {
|
||||||
|
const retryAfter = resp.headers.get('retry-after');
|
||||||
|
const detail = `ИИ-сервис ответил ${resp.status}${retryAfter ? ` (Retry-After: ${retryAfter})` : ''}`;
|
||||||
|
if (isSoftStatus(resp.status)) throw softFailure(detail);
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
const out = Buffer.from(await resp.arrayBuffer());
|
||||||
|
const newName = crypto.randomBytes(12).toString('hex') + '.jpg';
|
||||||
|
await storage.put(newName, out);
|
||||||
|
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 });
|
||||||
|
await sendNotification(job.entry_id, {
|
||||||
|
type: 'photo.job.done',
|
||||||
|
title: job.action === 'ai' ? 'Фото обработано нейросетью: {student}' : 'Фото улучшено на сервере: {student}',
|
||||||
|
body: `Группа {group} · запись #${job.entry_id} · результат ждёт применения`,
|
||||||
|
target: { job_id: job.id, action: job.action, after_path: newPath },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function softRetry(job, message) {
|
||||||
|
const n = (softTries.get(job.id) || 0) + 1;
|
||||||
|
if (n <= PHOTO_SOFT_MAX_RETRIES) {
|
||||||
|
softTries.set(job.id, n);
|
||||||
|
stats.soft_retries++;
|
||||||
|
await pool.query(`UPDATE photo_jobs SET status = 'pending', error = $1 WHERE id = $2`, [message, job.id]);
|
||||||
|
if (logAudit && (n === 1 || n % 10 === 0)) {
|
||||||
|
await logAudit(null, 'photo.job.soft_retry', {
|
||||||
|
entry_id: job.entry_id,
|
||||||
|
job_id: job.id,
|
||||||
|
soft_attempt: n,
|
||||||
|
soft_limit: PHOTO_SOFT_MAX_RETRIES,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const delay = Math.min(PHOTO_SOFT_BACKOFF_MS * Math.pow(2, n - 1), PHOTO_SOFT_BACKOFF_MAX_MS);
|
||||||
|
return { ok: false, delay };
|
||||||
|
}
|
||||||
|
softTries.delete(job.id);
|
||||||
|
const text = `${message} — ИИ-сервис недоступен, мягкие повторы исчерпаны (${PHOTO_SOFT_MAX_RETRIES}), задание остановлено`;
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE photo_jobs SET status = 'error', error = $1, finished_at = now() WHERE id = $2`,
|
||||||
|
[text, job.id]
|
||||||
|
);
|
||||||
|
stats.errors++;
|
||||||
|
stats.last_error = text;
|
||||||
|
if (logAudit) {
|
||||||
|
await logAudit(null, 'photo.job.error', { entry_id: job.entry_id, job_id: job.id, error: text, soft_attempts: PHOTO_SOFT_MAX_RETRIES });
|
||||||
|
}
|
||||||
|
await sendNotification(job.entry_id, {
|
||||||
|
type: 'photo.job.error',
|
||||||
|
title: 'Ошибка обработки фото: {student}',
|
||||||
|
body: `Группа {group} · запись #${job.entry_id} · ${text}`,
|
||||||
|
target: { job_id: job.id, action: job.action, error: text },
|
||||||
|
});
|
||||||
|
return { ok: true, delay: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = 'У записи нет фото';
|
||||||
|
await sendNotification(job.entry_id, {
|
||||||
|
type: 'photo.job.error',
|
||||||
|
title: 'Ошибка обработки фото: {student}',
|
||||||
|
body: `Группа {group} · запись #${job.entry_id} · у записи нет фото для обработки`,
|
||||||
|
target: { job_id: job.id, action: job.action },
|
||||||
|
});
|
||||||
|
return { ok: true, delay: 0 };
|
||||||
|
}
|
||||||
|
const photoPath = rows[0].photo_path;
|
||||||
|
try {
|
||||||
|
const newPath = job.action === 'ai' ? await runAiEnhance(photoPath) : await enhanceWithSharp(photoPath, job.params);
|
||||||
|
await applyResult(job, newPath);
|
||||||
|
softTries.delete(job.id);
|
||||||
|
stats.done++;
|
||||||
|
stats.last_at = new Date().toISOString();
|
||||||
|
stats.last_error = null;
|
||||||
|
return { ok: true, delay: 0 };
|
||||||
|
} catch (e) {
|
||||||
|
const message = (e && e.message ? e.message : 'error').slice(0, 500);
|
||||||
|
stats.last_at = new Date().toISOString();
|
||||||
|
stats.last_error = message;
|
||||||
|
if (e && e.soft) return await softRetry(job, 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 { ok: false, delay: 0 };
|
||||||
|
}
|
||||||
|
softTries.delete(job.id);
|
||||||
|
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 });
|
||||||
|
await sendNotification(job.entry_id, {
|
||||||
|
type: 'photo.job.error',
|
||||||
|
title: 'Ошибка обработки фото: {student}',
|
||||||
|
body: `Группа {group} · запись #${job.entry_id} · ${message}`,
|
||||||
|
target: { job_id: job.id, action: job.action, error: message },
|
||||||
|
});
|
||||||
|
return { ok: true, delay: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 res = { ok: true, delay: 0 };
|
||||||
|
try {
|
||||||
|
res = await processOne(job);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Photo worker process error:', e);
|
||||||
|
} finally {
|
||||||
|
processing = false;
|
||||||
|
currentId = null;
|
||||||
|
}
|
||||||
|
if (!res || !res.ok) await sleep((res && res.delay) || 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 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 MODEL = model || process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf';
|
||||||
const DEFAULT_PROMPT = defaultPrompt || process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.';
|
const DEFAULT_PROMPT = defaultPrompt || process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.';
|
||||||
@@ -37,10 +384,17 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function notify() {
|
function wakeLocal() {
|
||||||
if (wake) wake();
|
if (wake) wake();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function notify() {
|
||||||
|
wakeLocal();
|
||||||
|
if (bus) bus.publish();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bus) bus.subscribe(wakeLocal);
|
||||||
|
|
||||||
async function isEnabled() {
|
async function isEnabled() {
|
||||||
const v = await getSetting('ai_autocheck_enabled', 'true');
|
const v = await getSetting('ai_autocheck_enabled', 'true');
|
||||||
return String(v) !== 'false';
|
return String(v) !== 'false';
|
||||||
@@ -70,27 +424,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) {
|
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 controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${AI_URL}/v1/chat/completions`, {
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: MODEL,
|
model,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
{ role: 'user', content: text },
|
{ role: 'user', content: text },
|
||||||
],
|
],
|
||||||
temperature: 0.1,
|
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}`);
|
if (!res.ok) throw new Error(`AI service error: ${res.status}`);
|
||||||
const data = await res.json();
|
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 {
|
} finally {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
}
|
}
|
||||||
@@ -125,7 +522,18 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm
|
|||||||
if (changed) stats.corrected++; else stats.unchanged++;
|
if (changed) stats.corrected++; else stats.unchanged++;
|
||||||
stats.last_at = new Date().toISOString();
|
stats.last_at = new Date().toISOString();
|
||||||
stats.last_error = null;
|
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;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = (e && e.message ? e.message : 'error').slice(0, 500);
|
const message = (e && e.message ? e.message : 'error').slice(0, 500);
|
||||||
@@ -215,4 +623,4 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm
|
|||||||
return { start, notify, getStats, getInfo };
|
return { start, notify, getStats, getInfo };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createEntryAutoChecker };
|
module.exports = { createEntryAutoChecker, createPhotoEnhanceWorker };
|
||||||
|
|||||||
Reference in New Issue
Block a user