Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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)
|
||||
BACKUP_UPLOAD_LIMIT_MB=500
|
||||
|
||||
# === Redis (кэш, rate limit, баны IP, pub/sub) ===
|
||||
# Пароль Redis. Обязателен, если Redis включён в docker-compose.
|
||||
REDIS_PASSWORD=замените-на-длинный-секрет
|
||||
# Префикс ключей — позволяет держать несколько инстансов в одном Redis.
|
||||
REDIS_PREFIX=whatido
|
||||
# Лимит памяти Redis. При превышении вытесняются ключи с наименьшим TTL (allkeys-lru).
|
||||
REDIS_MAXMEMORY=256mb
|
||||
|
||||
# Hugging Face token (нужен для закрытых/приватных репозиториев моделей)
|
||||
HUGGINGFACE_TOKEN=
|
||||
# Имя GGUF-файла модели для text-corrector (скачивается с Hugging Face, если отсутствует)
|
||||
@@ -27,3 +35,33 @@ CLOUDFLARE_TUNNEL_URL=http://app:3003
|
||||
# Максимальное ожидание WireGuard handshake перед fallback на прямой запуск
|
||||
# туннеля без VPN (сек). Если VPN-провайдер не отвечает — сайт всё равно поднимется.
|
||||
WG_HANDSHAKE_TIMEOUT=60
|
||||
|
||||
# ИИ-улучшение фото (Real-ESRGAN), пусто = контейнер photo-ai
|
||||
PHOTO_AI_URL=
|
||||
PHOTO_AI_MAX_PIXELS=4000000
|
||||
|
||||
# === Хранилище файлов (S3: SeaweedFS по умолчанию / MinIO) ===
|
||||
# local — файлы в ./uploads (по умолчанию), s3 — объекты в бакете S3/MinIO.
|
||||
STORAGE_DRIVER=local
|
||||
# Читать локальную копию, если объект ещё не перенесён в S3 (1 — включено).
|
||||
STORAGE_LOCAL_FALLBACK=1
|
||||
# Оставлять локальную копию после выгрузки в S3 (1 — оставлять, 0 — удалять).
|
||||
STORAGE_KEEP_LOCAL=0
|
||||
# Сколько часов хранить локальный кэш оригиналов (для sharp/миниатюр), 0 — не чистить.
|
||||
STORAGE_CACHE_MAX_AGE_HOURS=168
|
||||
# Образ S3-сервиса. По умолчанию SeaweedFS (свободный S3-сервер).
|
||||
# Для MinIO: S3_IMAGE=minio/minio:<тег> и запуск через docker-compose.minio.yml.
|
||||
S3_IMAGE=chrislusf/seaweedfs:latest
|
||||
S3_ENDPOINT=http://s3:9000
|
||||
S3_REGION=us-east-1
|
||||
S3_BUCKET=whatido
|
||||
# Логин/пароль S3 (для SeaweedFS — AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY,
|
||||
# для MinIO — MINIO_ROOT_USER/MINIO_ROOT_PASSWORD).
|
||||
S3_ACCESS_KEY=whatido
|
||||
S3_SECRET_KEY=замените-на-длинный-секрет
|
||||
# Path-style адресация (1 — включено, нужно для MinIO и SeaweedFS).
|
||||
S3_FORCE_PATH_STYLE=1
|
||||
# Необязательный префикс ключей внутри бакета (например, prod).
|
||||
S3_PREFIX=
|
||||
# Показывать веб-консоль MinIO (on/off), только для docker-compose.minio.yml
|
||||
MINIO_BROWSER=off
|
||||
|
||||
@@ -14,6 +14,9 @@ backups/
|
||||
*.sql.gz
|
||||
*.tar.gz
|
||||
|
||||
# --- Generated build artifacts ---
|
||||
public/version.json
|
||||
|
||||
# --- Node ---
|
||||
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).
|
||||
|
||||
- **Stack**: Node.js 20 + Express, PostgreSQL 16, Docker Compose, Tailscale (Serve/Funnel)
|
||||
- **Architecture**: Single Express server (`server.js`) + static frontend in `public/`
|
||||
- **Deployment**: Docker Compose (app + db + tailscale), bind-mounted uploads, named volume for Postgres data
|
||||
- **Auth**: Admin-only via `X-Admin-Token` header (value = `ADMIN_PASSWORD` env var). No user sessions.
|
||||
- **Stack**: Node.js 20 + Express, PostgreSQL 16, Redis 7, Docker Compose, S3-совместимое хранилище файлов, Tailscale (Serve/Funnel)
|
||||
- **Architecture**: Single Express server (`server.js`) + storage abstraction (`storage.js`) + cache/pub-sub abstraction (`redis.js`) + static frontend in `public/`
|
||||
- **Deployment**: Docker Compose (app + db + s3 + tailscale), bind-mounted uploads, named volumes for Postgres and S3 data
|
||||
- **Auth**: сессии в БД. `POST /api/auth/login` (bcrypt) → токен в заголовке `X-Auth-Token`. Роли: `admin` и не-admin, ограниченные филиалами (`user_branches`). `ADMIN_PASSWORD` используется **только** для автосоздания первого админа в пустой БД — это не механизм авторизации API
|
||||
|
||||
---
|
||||
|
||||
@@ -34,13 +34,50 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
|
||||
### 3. File Uploads
|
||||
- **Multer configs**: `upload` (images only), `adminUpload` (wider allowed ext), `uploadBackup` (restore)
|
||||
- **Limits**: 10 MB/file, 30 MB total per entry
|
||||
- **Storage**: `uploads/` bind-mounted to host, filenames = `timestamp-random.ext`
|
||||
- **Staging**: Multer always writes to `uploads/` (`timestamp-random.ext`); a global `res.on('finish')` hook persists each uploaded file through `storage.persist` on successful responses (only when `STORAGE_DRIVER=s3`)
|
||||
- **HEIC**: Auto-converted to JPEG via `heic-convert`
|
||||
- **Cleanup**: `safeUnlink` / `sweepOrphanedUploads` — never delete outside `uploads/`
|
||||
- **Cleanup**: `safeUnlink` / `sweepOrphanedUploads` — never delete outside `uploads/` or the configured bucket
|
||||
|
||||
### 3a. Storage (`storage.js`)
|
||||
- **Drivers**: `local` (default, files in `uploads/`) and `s3` (S3-compatible: SeaweedFS by default, MinIO via `docker-compose.minio.yml`)
|
||||
- **Keys are stable**: DB stores `/uploads/<name>`; S3 object keys are the same `<name>` (plus `.originals/<name>`). Never change key format — it would break existing DB rows and URLs
|
||||
- **API**: `put`, `putFile`, `head`, `exists`, `sizeOf`, `getStream`, `getBuffer`, `del`, `copyObject`, `listAll`, `localize`, `persist`, `streamTo`, `downloadAll`, `uploadTree`, `ensureBucket`, `usage`, `pruneCache`
|
||||
- **Rules**: never call `fs.*` on `uploads/` directly in request/worker code — use `storage.*`. `safeUnlink` is the only deletion helper (local + remote, idempotent)
|
||||
- **Read path**: `STORAGE_LOCAL_FALLBACK=1` prefers a local file when it still exists (covers in-flight uploads and partial migration); otherwise the app streams the object from S3
|
||||
- **Cache**: `.thumbs` (WebP miniatures) and `.cache` (originals localized for sharp/zip) live inside `uploads/` and are pruned hourly (`STORAGE_CACHE_MAX_AGE_HOURS`)
|
||||
- **Never publish the S3 API port**: only `127.0.0.1` on the host, file access stays behind app auth/rate limits
|
||||
|
||||
### 3b. Redis (`redis.js`)
|
||||
- **Единственная точка доступа**: `createRedis({ url, prefix })` — все операции кэша/счётчиков/pub-sub идут через неё
|
||||
- **API**: `get`, `set`, `del`, `dropPrefix`, `dropMatch`, `clear`, `wrap`, `incr`, `publish`, `on`, `rateLimitStore`, `info`, `connect`, `close`
|
||||
- **Graceful fallback — обязательное требование**: при недоступном Redis все операции уходят в in-memory backend с той же семантикой. Приложение обязано стартовать и работать без Redis
|
||||
- **Первое подключение ограничено по времени** (`REDIS_CONNECT_TIMEOUT_MS`, 5 с): node-redis не отклоняет `connect()` при недоступном сервере, а повторяет попытки бесконечно — без таймаута старт приложения зависнет навсегда
|
||||
- **Переподключение**: node-redis переподключается сам; по событию `ready` подписки и subscriber-клиент восстанавливаются (`ensureSubscriber`). Не пересоздавать subscriber через `destroy()` — это гонка с внутренним teardown node-redis
|
||||
- **Ключи**: `get`/`set` сами добавляют namespace (`REDIS_PREFIX`, по умолчанию `whatido`), `dropPrefix`/`dropMatch` тоже. В `rateLimitStore` префикс добавляется один раз в `base` — не применяйте `fullKey` повторно
|
||||
- **`scanDelete`**: курсор `SCAN` в node-redis v5 обязан быть строкой, числовой `0` вызовет `TypeError`. Возвращаемое значение курсора — тоже строка, сравнивайте с `'0'`
|
||||
- **`resetTime` в `rateLimitStore.increment` обязан быть `Date`** — express-rate-limit v8 вызывает `resetTime.getTime()`
|
||||
- **Пабликация всегда отдаёт подписчикам строку** (JSON), независимо от бэкенда — иначе fallback и Redis расходятся по формату
|
||||
- **Инвалидация — по префиксу** (`SCAN` + `DEL`), точечного удаления по ключу избегайте
|
||||
- **Ключевые пространства**: `setting:`, `groups:`, `students:`, `entries:`, `stats:`, `dashboard:`, `share:payload:`, `public-settings`, `system-info`, `session:`, `ban:`, `fail:`, `rl:`
|
||||
- **Сессии**: `loadUserByToken` кэширует пользователя на 30 с. Любая мутация `users` / `sessions` / `user_branches` обязана вызывать `invalidateSessions()` или удалять `session:<token>`, иначе деактивированный пользователь сохранит доступ
|
||||
- **Секреты**: пароль только в `REDIS_URL` / `REDIS_PASSWORD`, порт 6379 публикуется лишь на `127.0.0.1`
|
||||
|
||||
### 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
|
||||
- **Admin routes**: `requireAdmin` middleware (checks `X-Admin-Token`)
|
||||
- **Public routes**: `apiLimiter` (300/15min), `entryLimiter` (10/15min), `fileLimiter` (300/15min)
|
||||
- **Middleware**: `requireAuth` — читает `X-Auth-Token`, 401 без валидной активной сессии. `requireAdmin` — самодостаточный (внутри вызывает `requireAuth`, если `req.user` ещё нет), 403 при `role !== 'admin'`. `optionalAuth` — для публичных страниц с персонализацией
|
||||
- **Филиалы**: `branchScope(user)` / `branchWhere(user, alias)` — для не-admin `user.branch_ids` (из `user_branches`) ограничивают выборку; у `admin` `ids = null` и фильтр не добавляется
|
||||
- **Public routes**: `apiLimiter` (300/15min), `entryLimiter` (10/15min), `fileLimiter` (300/15min) — все на `cache.rateLimitStore(...)`, не на `MemoryStore`
|
||||
- **Responses**: JSON, `{ error: 'message' }` on failure, data directly on success
|
||||
- **Pagination**: `limit` / `offset` query params, return `{ items, total }` or `{ entries, total }`
|
||||
- **Filters**: `group_id`, `date_from`, `date_to`, `student_name`, `search`, `deleted`
|
||||
@@ -48,16 +85,18 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
|
||||
### 5. Frontend (public/)
|
||||
- Vanilla HTML/CSS/JS, no build step
|
||||
- Each page = single HTML file + shared `admin.js` / `admin.css`
|
||||
- API calls via `fetch` with `X-Admin-Token` from `localStorage`
|
||||
- API calls via `fetch` with `X-Auth-Token` (токен из `localStorage`); `X-Admin-Token` больше не используется и не работает
|
||||
- Share pages (`share.html`, `links.html`) work without auth
|
||||
|
||||
### 6. Docker / Compose
|
||||
- **Dockerfile**: Node 20 Alpine, installs deps, generates self-signed TLS cert
|
||||
- **docker-compose.yml**: 3 services (db, app, tailscale)
|
||||
- **Dockerfile**: Node 22 Alpine, installs deps, generates self-signed TLS cert
|
||||
- **docker-compose.yml**: сервисы `db`, `app`, `redis`, `s3` (+ опционально `tailscale`, `cloudflared`, `text-corrector`, `photo-ai`)
|
||||
- `db`: postgres:16-alpine, healthcheck, init.sql mounted
|
||||
- `redis`: redis:7-alpine, `--requirepass`, AOF, `maxmemory` + `allkeys-lru`, healthcheck, том `redis-data`, порт только на `127.0.0.1`
|
||||
- `app`: builds from Dockerfile, exposes 3003/3443, mounts uploads
|
||||
- `tailscale`: host network, NET_ADMIN, runs `start-tailscale.sh` (funnel to 127.0.0.1:3443)
|
||||
- **Env vars** (required): `ADMIN_PASSWORD`, `DB_PASSWORD`
|
||||
- **Env vars** (required): `ADMIN_PASSWORD`, `DB_PASSWORD`, `REDIS_PASSWORD`
|
||||
- **Env vars** (optional): `REDIS_PREFIX` (default `whatido`), `REDIS_MAXMEMORY` (default `256mb`), `REDIS_CONNECT_TIMEOUT_MS` (default `5000`)
|
||||
- **Port 443 on host** must be free (tailscale listens directly)
|
||||
|
||||
### 7. Tailscale Publication
|
||||
@@ -102,6 +141,15 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
|
||||
- Update Multer `fileFilter` functions
|
||||
- Keep `MAX_TOTAL_UPLOAD_BYTES` and per-file limit in sync
|
||||
|
||||
### Migrate files to S3 / switch storage driver
|
||||
1. `docker compose up -d s3`
|
||||
2. `docker compose exec -T app node scripts/migrate-to-s3.js --dry-run` then without the flag (idempotent, size-checked, keeps local files)
|
||||
3. `docker compose exec -T app node scripts/migrate-to-s3.js --verify-only`
|
||||
4. Set `STORAGE_DRIVER=s3` in `.env`, `docker compose up -d app`
|
||||
5. After verification: `docker compose exec -T app node scripts/migrate-to-s3.js --delete-local`
|
||||
- Rollback: `STORAGE_DRIVER=local` + `docker compose up -d app`
|
||||
- Do not run `--delete-local` before the app serves reads from S3 and the verification passes
|
||||
|
||||
---
|
||||
|
||||
## Testing & Verification
|
||||
@@ -115,21 +163,68 @@ docker compose up -d --build
|
||||
# Check logs
|
||||
docker compose logs -f app
|
||||
|
||||
# Test API (replace TOKEN)
|
||||
curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
|
||||
# Test API (replace LOGIN/PASS; X-Admin-Token больше не работает)
|
||||
TOKEN=$(curl -s -X POST http://localhost:3003/api/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"username\":\"$LOGIN\",\"password\":\"$PASS\"}" | sed -E 's/.*"token":"([a-f0-9]+)".*/\1/')
|
||||
curl -H "X-Auth-Token: $TOKEN" http://localhost:3003/api/auth/me
|
||||
# /api/groups — публичный (optionalAuth), 200 даже без токена:
|
||||
# для проверки авторизации берите /api/auth/me или /api/users
|
||||
|
||||
# Run backup/restore scripts
|
||||
./scripts/backup.sh
|
||||
./scripts/restore.sh backups/whatido-backup-<date>.tar.gz
|
||||
|
||||
# Storage checks
|
||||
docker compose up -d s3
|
||||
docker compose exec -T app node scripts/migrate-to-s3.js --dry-run
|
||||
docker compose exec -T app node scripts/migrate-to-s3.js --verify-only
|
||||
|
||||
# Redis checks
|
||||
node redis.selftest.js # unit + degradation, needs redis on 127.0.0.1:6379
|
||||
node api.smoketest.js # e2e, needs running stack
|
||||
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning INFO
|
||||
docker compose stop redis && node api.smoketest.js # app must keep working in-memory
|
||||
docker compose start redis # app reconnects on its own
|
||||
|
||||
# Audit diff checks
|
||||
node diff.selftest.js # unit, no stack needed
|
||||
```
|
||||
|
||||
`diff.js` builds the audit payload for text changes: word-level segments
|
||||
(`eq`/`del`/`add`), per-step stats (`added_words`, `removed_words`, `chars_before/after`) and a
|
||||
light `summarizeChanges`/`stripDiffs` pair for the audit list. Two rules to keep:
|
||||
the diff payload stored in `audit_log.target` must stay capped (it is rendered raw in the audit
|
||||
UI), and `GET /api/audit` must keep stripping `diff` while `GET /api/audit/:id` returns it —
|
||||
otherwise the list endpoint ships kilobytes of text per row.
|
||||
|
||||
Verify Redis state through `GET /api/system-info` → `cache` (`driver`, `ready`, `hits`, `misses`,
|
||||
`fallbackOps`, `used_memory_human`, `keys`).
|
||||
|
||||
`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)
|
||||
- [ ] No SQL interpolation — only `$1`, `$2`...
|
||||
- [ ] Upload path validation via `isSafeUploadPath` / `safeUnlink`
|
||||
- [ ] File I/O через `storage.*`, ключи объектов не выходят за пределы бакета/`uploads/`
|
||||
- [ ] Rate limiter on new public routes
|
||||
- [ ] Admin routes behind `requireAdmin`
|
||||
- [ ] New auth paths checked against the contract in `api.smoketest.js`, docs updated in the same change
|
||||
- [ ] No secrets in code — only via env vars
|
||||
- [ ] Helmet headers present (already global)
|
||||
- [ ] CORS disabled (no `cors` middleware)
|
||||
@@ -141,15 +236,24 @@ curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `server.js` | Entire backend (Express, routes, DB, uploads, backup) |
|
||||
| `worker.js` | Background AI auto-check worker for entry messages |
|
||||
| `storage.js` | Storage abstraction: `local` and `s3` drivers, key normalization, cache/thumb helpers |
|
||||
| `redis.js` | Redis abstraction: cache, counters, rate-limit store, pub/sub, in-memory fallback |
|
||||
| `redis.selftest.js` | Self-tests for `redis.js`, including behaviour with Redis unavailable |
|
||||
| `diff.js` / `diff.selftest.js` | Word-level text diff and audit change payload; self-tests |
|
||||
| `api.smoketest.js` | End-to-end API smoke test against a running stack |
|
||||
| `worker.js` | Background AI auto-check worker for entry messages + photo enhance worker |
|
||||
| `db/init.sql` | Initial schema (runs on fresh DB) |
|
||||
| `db/migration.sql` | Idempotent migrations for existing DBs |
|
||||
| `docker-compose.yml` | Service definitions (app, db, tailscale) |
|
||||
| `docker-compose.yml` | Service definitions (app, db, s3, tailscale) |
|
||||
| `docker-compose.minio.yml` | Override: S3 service backed by MinIO instead of SeaweedFS |
|
||||
| `Dockerfile` | App image build |
|
||||
| `public/*.html` | Frontend pages |
|
||||
| `public/admin.js` | Shared frontend logic |
|
||||
| `scripts/backup.sh` | Host-level backup script |
|
||||
| `scripts/restore.sh` | Host-level restore script |
|
||||
| `scripts/backup.sh` | Host-level backup script (DB dump + storage export) |
|
||||
| `scripts/restore.sh` | Host-level restore script (DB dump + storage import) |
|
||||
| `scripts/storage-sync.js` | Export/import all storage objects (used by backup/restore) |
|
||||
| `scripts/migrate-to-s3.js` | One-off/idempotent migration `uploads/` -> S3 bucket |
|
||||
| `scripts/deploy.sh` | Deploy script (pull master, build image with commit version, restart app) |
|
||||
| `start-tailscale.sh` | Tailscale container entrypoint |
|
||||
| `.env.example` | Env var template |
|
||||
|
||||
@@ -159,6 +263,14 @@ curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
|
||||
|
||||
- ❌ Add dependencies without updating `package.json` and rebuilding
|
||||
- ❌ Write files outside `uploads/` or `certs/`
|
||||
- ❌ Touch `uploads/` with `fs.*` in request/worker code — use `storage.*` (files may live only in S3)
|
||||
- ❌ Run `migrate-to-s3.js --delete-local` before verification and cutover
|
||||
- ❌ Expose the S3 API port publicly (only `127.0.0.1` in compose)
|
||||
- ❌ Expose the Redis port publicly (only `127.0.0.1` in compose)
|
||||
- ❌ Call `fs.*`/`pg` directly for cache, counters or pub/sub — use `redis.js`
|
||||
- ❌ Make Redis a hard dependency: any new Redis-backed path must keep the in-memory fallback
|
||||
- ❌ `await client.connect()` without a timeout — it never rejects while Redis is unreachable
|
||||
- ❌ Cache authorization-relevant data without an invalidation path on the mutation
|
||||
- ❌ Commit `.env`, `certs/`, `uploads/`, `backups/`, `node_modules/`
|
||||
- ❌ Expose DB port (5432) outside docker network
|
||||
- ❌ Use `eval`, `Function` constructor, or dynamic code execution
|
||||
@@ -172,12 +284,24 @@ curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
|
||||
# Full rebuild
|
||||
docker compose down && docker compose up -d --build
|
||||
|
||||
# Обновление на сервере (pull master + сборка образа с версией коммита + перезапуск app)
|
||||
./scripts/deploy.sh
|
||||
|
||||
# App logs
|
||||
docker compose logs -f app
|
||||
|
||||
# DB shell
|
||||
docker compose exec db psql -U app -d whereldo
|
||||
|
||||
# Redis status and cache keys
|
||||
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning DBSIZE
|
||||
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning KEYS 'whatido:*'
|
||||
|
||||
# S3 storage status and migration verification
|
||||
docker compose up -d s3
|
||||
docker compose exec -T app node scripts/migrate-to-s3.js --verify-only
|
||||
docker compose exec -T app node scripts/migrate-to-s3.js --delete-local
|
||||
|
||||
# Tailscale status
|
||||
docker exec -it whatido-tailscale-1 tailscale status
|
||||
|
||||
|
||||
+5
-2
@@ -1,16 +1,19 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:22-alpine
|
||||
ENV TZ=Europe/Moscow
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --omit=dev
|
||||
COPY . .
|
||||
RUN mkdir -p uploads certs
|
||||
ARG GIT_COMMIT=""
|
||||
ARG GIT_COMMIT_DATE=""
|
||||
RUN node -e "require('fs').writeFileSync('public/version.json', JSON.stringify({ full: process.env.GIT_COMMIT || '', short: (process.env.GIT_COMMIT || '').slice(0, 7), date: process.env.GIT_COMMIT_DATE || '' }, null, 2))"
|
||||
RUN set -e; \
|
||||
V="v$(cut -d. -f1-2 /etc/alpine-release)"; \
|
||||
try_repo() { \
|
||||
printf "%s/%s/main\n%s/%s/community\n" "$1" "$V" "$1" "$V" > /etc/apk/repositories; \
|
||||
echo "Trying apk mirror: $1"; \
|
||||
apk add --no-cache openssl; \
|
||||
timeout 45 apk add --no-cache --timeout 20 openssl; \
|
||||
}; \
|
||||
if ! try_repo "https://dl-cdn.alpinelinux.org/alpine" && \
|
||||
! try_repo "https://mirrors.edge.kernel.org/alpine" && \
|
||||
|
||||
@@ -182,7 +182,9 @@ branches
|
||||
| GET | `/api/share/:token` | Public | Share page data |
|
||||
| GET | `/api/links` | Admin | List share links |
|
||||
| POST/PUT/DELETE | `/api/links` | Admin | CRUD share links |
|
||||
| GET | `/api/backup` | Admin | Download backup |
|
||||
| GET | `/api/backup` | Admin | Build & download backup (compat) |
|
||||
| POST | `/api/backup` | Admin | Build backup, returns download URL |
|
||||
| GET | `/api/backup/:token` | Token | Download built backup (resumable) |
|
||||
| POST | `/api/restore` | Admin | Upload & restore backup |
|
||||
| GET | `/api/dashboard` | Admin | Dashboard data |
|
||||
| GET | `/api/stats` | Admin | Stats cards |
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
- **Share-ссылки** — публичные страницы-витрины с выбором группы / воспитанника / диапазона дат
|
||||
- **Дашборд** — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников
|
||||
- **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `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
|
||||
|
||||
## Технологии
|
||||
@@ -20,6 +22,8 @@
|
||||
- Node.js + Express
|
||||
- PostgreSQL (pg)
|
||||
- Multer (загрузка файлов), Tar (бэкапы)
|
||||
- S3-совместимое хранилище (AWS SDK v3): сервис `s3` (SeaweedFS / MinIO)
|
||||
- Redis: кэш, rate limit, баны IP, кэш сессий, pub/sub (SSE и воркеры)
|
||||
- Lucide (иконки UI)
|
||||
- Docker / Docker Compose
|
||||
- Tailscale (Serve / Funnel) — публикация по HTTPS
|
||||
@@ -48,6 +52,7 @@ docker compose up -d --build
|
||||
- **HTTP** `http://localhost:3003` — редирект на HTTPS
|
||||
- **HTTPS** `https://localhost:3443` — приложение (самоподписанный сертификат, примите предупреждение браузера)
|
||||
- **PostgreSQL** — доступен только внутри docker-сети (наружу не публикуется)
|
||||
- **Redis** — `127.0.0.1:6379` на хосте (только loopback), внутри сети — `redis:6379`
|
||||
|
||||
Управление:
|
||||
|
||||
@@ -59,6 +64,35 @@ docker compose down # остановка (данные сохраняю
|
||||
|
||||
> Приложение **не запустится** без `ADMIN_PASSWORD` (защита от пароля по умолчанию).
|
||||
> `DB_PASSWORD` задаёт пароль пользователя `app` в PostgreSQL.
|
||||
> `REDIS_PASSWORD` задаёт пароль Redis. Если сервис `redis` убрать из `docker-compose.yml`
|
||||
> или оставить `REDIS_URL` пустым — приложение продолжит работать на in-memory кэше.
|
||||
|
||||
## Обновление на сервере (деплой)
|
||||
|
||||
Код приложения находится внутри образа: bind-монтируется только `uploads/`. Поэтому после `git pull` нужна **пересборка образа** — `docker compose up -d` без `--build` и `docker compose restart` новый `server.js` и статику не подхватят.
|
||||
|
||||
```bash
|
||||
./scripts/deploy.sh # ветка master (либо $DEPLOY_BRANCH, либо первый аргумент)
|
||||
```
|
||||
|
||||
Скрипт проверяет рабочую копию, обновляет ветку (`fetch` + `checkout` + `pull --ff-only`), собирает образ с версией коммита, перезапускает `app`, затем сверяет `server.js` в контейнере с рабочей копией и печатает `/version.json`.
|
||||
|
||||
Вручную то же самое:
|
||||
|
||||
```bash
|
||||
git checkout master && git pull --ff-only origin master
|
||||
docker compose build --build-arg GIT_COMMIT=$(git rev-parse HEAD) --build-arg GIT_COMMIT_DATE=$(git log -1 --format=%cI) app
|
||||
docker compose up -d app
|
||||
```
|
||||
|
||||
Проверка:
|
||||
|
||||
```bash
|
||||
curl -sk https://127.0.0.1:3443/version.json # версия собранного коммита
|
||||
docker compose exec app md5sum /app/server.js # совпадает с md5sum server.js
|
||||
```
|
||||
|
||||
Версия сборки записывается в `public/version.json` внутри образа из аргументов `GIT_COMMIT` / `GIT_COMMIT_DATE` (`build.args` в `docker-compose.yml`, подставляет `scripts/deploy.sh`) и показывается в сайдбаре админки — она всегда соответствует собранному коду, даже если файл в рабочей копии устарел. Локально файл обновляет хук: `cp scripts/post-commit.sh .git/hooks/post-commit`. Если образ собран без аргументов (`docker compose build` вместо `deploy.sh`), версия в сайдбаре будет пустой.
|
||||
|
||||
## Конфигурация
|
||||
|
||||
@@ -66,14 +100,19 @@ docker compose down # остановка (данные сохраняю
|
||||
|
||||
| Переменная | По умолчанию | Назначение |
|
||||
|------------------|--------------------|-------------------------------------|
|
||||
| `ADMIN_PASSWORD` | — (обязательно) | Пароль администратора (X-Admin-Token). Без него сервер не стартует |
|
||||
| `ADMIN_PASSWORD` | — (обязательно) | Пароль первого администратора, создаётся в пустой БД. Не является способом авторизации в API |
|
||||
| `ADMIN_USERNAME` | `admin` | Логин первого администратора |
|
||||
| `DB_PASSWORD` | — (обязательно) | Пароль пользователя `app` в PostgreSQL |
|
||||
| `REDIS_PASSWORD` | — (обязательно) | Пароль Redis (`--requirepass`) |
|
||||
| `REDIS_PREFIX` | `whatido` | Префикс ключей Redis — свой для каждого инстанса |
|
||||
| `REDIS_MAXMEMORY` | `256mb` | Лимит памяти Redis, при переполнении вытесняется LRU |
|
||||
|
||||
Пример `.env` (в репозитории — `.env.example`):
|
||||
|
||||
```
|
||||
ADMIN_PASSWORD=сложный-пароль
|
||||
DB_PASSWORD=случайная-длинная-строка
|
||||
REDIS_PASSWORD=случайная-длинная-строка
|
||||
```
|
||||
|
||||
`DB_PASSWORD` подставляется в `docker-compose.yml` в `POSTGRES_PASSWORD` и `DATABASE_URL`. Если БД уже была инициализирована ранее, значение `DB_PASSWORD` должно совпадать с фактическим паролем пользователя `app` в БД (иначе приложение не подключится).
|
||||
@@ -212,20 +251,186 @@ docker compose exec cloudflared wg show # есть handshake — VPN подн
|
||||
- `group_photos` — фотохроника групп
|
||||
- `share_links` — публичные ссылки-витрины
|
||||
- `settings` — пары ключ/значение (анти-спам интервал, футер)
|
||||
- `audit_log` — журнал действий (`action`, `target` JSONB, `ip`, `user_id`)
|
||||
|
||||
Схема инициализируется при первом запуске из `db/init.sql`; миграции существующей БД — в `db/migration.sql`.
|
||||
|
||||
## Аудит изменений текста
|
||||
|
||||
Каждое сохранение записи журнала (`PUT /api/entries/:id`) сравнивает состояние «до» и «после» и пишет в `audit_log` не только факт, но и сами изменения:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 363,
|
||||
"source": "ai",
|
||||
"changed": true,
|
||||
"fields": ["description", "group_id"],
|
||||
"changes": [
|
||||
{ "field": "description", "label": "Текст работы",
|
||||
"stats": { "added_words": 5, "removed_words": 2, "chars_before": 75, "chars_after": 97 },
|
||||
"diff": [{ "type": "del", "text": "учитель" }, { "type": "add", "text": "очень " }] },
|
||||
{ "field": "group_id", "label": "Группа", "before": "4 · Суббота 9:00", "after": "5 · Суббота 11:30" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `source` — источник правки: `manual` (вручную), `ai` (текст принят из подсказки ИИ), `ai_manual` (ИИ + ручная правка), `ai_revert` (откат к оригиналу)
|
||||
- `diff` — пословный дифф (`eq` / `del` / `add`) с подсветкой в интерфейсе: удалённое зачёркнуто, добавленное выделено
|
||||
- `stats` — сколько слов и символов добавлено и удалено на этом шаге
|
||||
- те же данные пишутся для автопроверки ИИ (`entry.ai.auto-check`) и отката (`entry.ai.revert`)
|
||||
|
||||
Список `GET /api/audit` отдаёт облегчённый `target` (без `diff`), полный — `GET /api/audit/:id`: страница «Аудит» подгружает его при открытии деталей.
|
||||
|
||||
Пословный дифф и сборка изменений вынесены в `diff.js` (без зависимостей, с обрезкой слишком больших текстов), тесты — `node diff.selftest.js`.
|
||||
|
||||
## Хранилище файлов
|
||||
|
||||
Загруженные фото и файлы хранятся в каталоге `uploads/` на хосте и монтируются в контейнер (`./uploads:/app/uploads`). Это даёт прямой доступ к данным из-под хост-системы. Данные БД хранятся в именованном томе `pgdata`.
|
||||
По умолчанию загруженные фото и файлы хранятся в каталоге `uploads/` на хосте и монтируются в контейнер (`./uploads:/app/uploads`) — это драйвер `local`. Данные БД хранятся в именованном томе `pgdata`.
|
||||
|
||||
Дополнительно поддерживается **S3-совместимое хранилище** (сервис `s3` в compose, драйвер `s3`). Все обращения к файлам идут через приложение: URL (`/uploads/...`, `/uploads/thumb/...`, `/api/files/:token`, share-ссылки) и записи в БД (`/uploads/<файл>`) не меняются, поэтому переключение драйвера не требует миграции данных в БД.
|
||||
|
||||
### Сервис `s3`
|
||||
|
||||
```bash
|
||||
docker compose up -d s3 # поднимает S3-хранилище (том s3-data)
|
||||
```
|
||||
|
||||
- **По умолчанию — SeaweedFS** (`chrislusf/seaweedfs`): свободный S3-сервер; API слушает `127.0.0.1:9000` на хосте и `s3:9000` внутри compose-сети.
|
||||
- **MinIO**: официальные свободные образы `minio/minio` удалены из Docker Hub, поэтому MinIO подключается через оверрайд и образ из доступного вам зеркала:
|
||||
|
||||
```bash
|
||||
S3_IMAGE=<ваш-образ-minio> docker compose -f docker-compose.yml -f docker-compose.minio.yml up -d s3
|
||||
```
|
||||
|
||||
Бакет создаётся автоматически при старте приложения (`ensureBucket`) или скриптом миграции. Анонимный доступ к API хранилища закрыт: порт `9000` не публикуется наружу (только loopback), доступ к файлам остаётся через приложение с его аутентификацией и rate limit.
|
||||
|
||||
### Переменные окружения
|
||||
|
||||
| Переменная | По умолчанию | Назначение |
|
||||
|---|---|---|
|
||||
| `STORAGE_DRIVER` | `local` | `local` — файлы в `uploads/`, `s3` — объекты в бакете |
|
||||
| `S3_ENDPOINT` | `http://s3:9000` | Адрес S3 API внутри compose-сети |
|
||||
| `S3_BUCKET` | `whatido` | Бакет для объектов |
|
||||
| `S3_ACCESS_KEY` / `S3_SECRET_KEY` | `whatido` / — | Доступ к хранилищу (для MinIO это root-пользователь) |
|
||||
| `S3_FORCE_PATH_STYLE` | `1` | Path-style адресация (нужна MinIO/SeaweedFS) |
|
||||
| `S3_PREFIX` | — | Необязательный префикс ключей внутри бакета |
|
||||
| `STORAGE_LOCAL_FALLBACK` | `1` | Читать локальный файл, если объекта в S3 ещё нет |
|
||||
| `STORAGE_KEEP_LOCAL` | `0` | Оставлять локальную копию после выгрузки в S3 |
|
||||
| `STORAGE_CACHE_MAX_AGE_HOURS` | `168` | Срок жизни локального кэша оригиналов (для sharp/миниатюр) |
|
||||
|
||||
### Переход на S3 (миграция)
|
||||
|
||||
Порядок не прерывает работу: файлы сначала копируются в бакет, локальные остаются на месте и продолжают использоваться.
|
||||
|
||||
```bash
|
||||
# 1) поднять хранилище
|
||||
docker compose up -d s3
|
||||
|
||||
# 2) предпросмотр и загрузка файлов в бакет (идемпотентно, по размеру объекта)
|
||||
docker compose exec -T app node scripts/migrate-to-s3.js --dry-run
|
||||
docker compose exec -T app node scripts/migrate-to-s3.js
|
||||
|
||||
# 3) проверить, что все объекты на месте (ничего не меняет)
|
||||
docker compose exec -T app node scripts/migrate-to-s3.js --verify-only
|
||||
```
|
||||
|
||||
Дальше включить драйвер `s3` и перезапустить приложение:
|
||||
|
||||
```bash
|
||||
# в .env: STORAGE_DRIVER=s3
|
||||
docker compose up -d app
|
||||
```
|
||||
|
||||
Новые загрузки уходят в бакет (локальная копия удаляется, если `STORAGE_KEEP_LOCAL=0`), старые файлы ещё читаются из `uploads/` благодаря `STORAGE_LOCAL_FALLBACK=1`. Когда всё проверено — удалите локальные копии:
|
||||
|
||||
```bash
|
||||
docker compose exec -T app node scripts/migrate-to-s3.js --delete-local
|
||||
```
|
||||
|
||||
Откат в любой момент: `STORAGE_DRIVER=local` + `docker compose up -d app` (пока локальные копии не удалены).
|
||||
|
||||
Объём и состав хранилища видны в админке: Настройки → Системная информация (блок «Хранилище»).
|
||||
|
||||
## Redis (кэш и pub/sub)
|
||||
|
||||
Сервис `redis` в compose хранит всё, что не требуется переживать перезапуск Postgres, но должно
|
||||
быть общим и быстрым:
|
||||
|
||||
| Что | Ключи | TTL |
|
||||
|---|---|---|
|
||||
| Кэш ответов API и настроек | `setting:*`, `groups:*`, `students:*`, `entries:*`, `stats:*`, `dashboard:*`, `share:payload:*`, `public-settings`, `system-info` | 15–60 с |
|
||||
| Кэш сессий | `session:<token>` | 30 с |
|
||||
| Счётчики rate limit | `rl:api:*`, `rl:entry:*`, `rl:file:*` | окно окна + 10 % |
|
||||
| Баны IP | `ban:<ip>` | до `banned_until` |
|
||||
| Счётчики неудачных попыток входа | `fail:<kind>:<ip>` | 15 мин |
|
||||
|
||||
Инвалидация кэша — по префиксу (`SCAN` + `DEL`), поэтому после правки настроек, группы или записи
|
||||
новое значение видно сразу. Правки пользователей сбрасывают `session:*`, так что деактивация
|
||||
аккаунта и выход из сессии действуют немедленно.
|
||||
|
||||
Через pub/sub каналы `whatido:events`, `whatido:wake:ai` и `whatido:wake:photo` доставляют SSE-события
|
||||
клиентам и будят фоновых воркеров без ожидания цикла опроса БД.
|
||||
|
||||
### Отказоустойчивость
|
||||
|
||||
Если Redis недоступен, приложение **не падает**: `redis.js` прозрачно переключается на
|
||||
in-memory кэш (та же семантика и те же ключи) и возвращается в Redis автоматически, как только
|
||||
сервис поднимется. Первое подключение ограничено таймаутом `REDIS_CONNECT_TIMEOUT_MS` (5 с по
|
||||
умолчанию), поэтому недоступный Redis не задержит старт приложения. Текущее состояние видно в
|
||||
`GET /api/system-info` → `cache.driver` (`redis` или `memory`) и в блоке «Кэш» на странице
|
||||
Настроек → Стек (там же — «нет связи — в памяти», если 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/`
|
||||
- Восстановить из файла бэкапа
|
||||
|
||||
Архив формируется на сервере (`POST /api/backup`) и скачивается браузером по одноразовой ссылке (`GET /api/backup/<token>`, действует 30 минут) — загрузку можно возобновить при обрыве связи. Совместимый эндпоинт `GET /api/backup` отдаёт тот же архив сразу.
|
||||
|
||||
Также доступны скрипты на хосте:
|
||||
|
||||
```bash
|
||||
@@ -233,9 +438,13 @@ docker compose exec cloudflared wg show # есть handshake — VPN подн
|
||||
./scripts/restore.sh # восстановление из архива
|
||||
```
|
||||
|
||||
Форматы не взаимозаменяемы: скриптовый архив содержит `db.sql.gz` + `_uploads/` (перенос на другой хост через `scripts/restore.sh`), а веб-архив из админки — `data.json` + `uploads/` (кнопка «Восстановить»). Если в админку загрузить скриптовый архив, сервер вернёт подсказку, какой инструмент использовать.
|
||||
|
||||
Файлы попадают в бэкап из активного хранилища: при `STORAGE_DRIVER=s3` админ-бэкап и `scripts/backup.sh` выгружают объекты из бакета (`scripts/storage-sync.js export`), а восстановление загружает их обратно (`scripts/storage-sync.js import`). Миниатюры (`.thumbs`) в архив не включаются — они пересоздаются по запросу.
|
||||
|
||||
## Безопасность
|
||||
|
||||
- **Пароль администратора** обязателен (`ADMIN_PASSWORD`); фолбэка на `admin` нет.
|
||||
- **Пароль администратора** обязателен (`ADMIN_PASSWORD`) — он создаёт первого админа в пустой БД; фолбэка на `admin` нет. Самостоятельной роли в API не даёт: доступ только по сессиям.
|
||||
- **CORS отключён** — кросс-доменные запросы к API запрещены.
|
||||
- **Rate limiting** по IP на публичные роуты: `POST /api/entries` — 10 запросов / 15 мин, загрузка файлов и share-ссылки — 300 / 15 мин.
|
||||
- **Загрузки** ограничены: 30 МБ суммарно на запись, 10 МБ на файл; заблокированы опасные расширения (`.html`, `.js`, `.svg`, `.xml`, `.exe` и др.); SVG не отдаётся inline.
|
||||
@@ -261,24 +470,57 @@ docker compose exec cloudflared wg show # есть handshake — VPN подн
|
||||
| `GET/POST/PUT/DELETE` | `/api/share/...`, `/api/links` | Публичные ссылки |
|
||||
| `GET` | `/api/backup` | Скачать бэкап |
|
||||
| `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` | Статистика |
|
||||
|
||||
Защищённые админ-маршруты требуют заголовок `X-Admin-Token` с `ADMIN_PASSWORD`.
|
||||
Авторизация — по сессиям, не по статическому токену:
|
||||
|
||||
1. `POST /api/auth/login` с `username` и `password` возвращает `{ token, expires_at }`.
|
||||
2. Токен передаётся в заголовке `X-Auth-Token` во все защищённые запросы; `POST /api/auth/logout` удаляет сессию.
|
||||
3. `GET /api/auth/me` — текущий пользователь (`id`, `username`, `role`, `is_active`, `branch_ids`).
|
||||
|
||||
Заголовок `X-Admin-Token` больше не поддерживается. Маршруты помечены `requireAuth` (любой активный пользователь) или `requireAdmin` (только `role = admin`); филиалы не-admin ограничены его `user_branches`.
|
||||
|
||||
Защищённые маршруты:
|
||||
|
||||
| Метод | Путь | Доступ |
|
||||
|---|---|---|
|
||||
| `GET/POST/PUT/DELETE` | `/api/users`, `/api/users/:id` | admin |
|
||||
| `GET/POST/DELETE` | `/api/bans` | admin |
|
||||
| `GET/POST/PUT/DELETE` | `/api/branches`, `/api/branches/:id` | admin (список — любой активный) |
|
||||
| `GET/POST/DELETE` | `/api/settings` | admin |
|
||||
| `GET` | `/api/audit` | admin |
|
||||
| `GET` | `/api/audit/:id` | admin (полный target с текстовым диффом) |
|
||||
| `GET` | `/api/backup`, `POST /api/restore` | admin |
|
||||
|
||||
Сессия хранится в таблице `sessions` (срок 30 дней) и кэшируется в Redis на 30 секунд.
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
├── docker-compose.yml # сервисы: app + db + tailscale
|
||||
├── docker-compose.yml # сервисы: app + db + redis + s3 (+ опционально tailscale)
|
||||
├── docker-compose.minio.yml # оверрайд: S3-сервис на MinIO вместо SeaweedFS
|
||||
├── .env.example # шаблон переменных окружения
|
||||
├── Dockerfile # сборка образа (Node 20, генерация TLS-сертификата)
|
||||
├── Dockerfile # сборка образа (Node 22, генерация TLS-сертификата)
|
||||
├── server.js # Express-приложение
|
||||
├── worker.js # фоновый worker AI-проверки записей
|
||||
├── storage.js # абстракция хранилища: драйверы local и s3
|
||||
├── redis.js # абстракция Redis: кэш, счётчики, rate limit, pub/sub (с in-memory fallback)
|
||||
├── redis.selftest.js # тесты слоя Redis, включая деградацию при недоступном сервере
|
||||
├── diff.js # пословный diff текста и сборка изменений записи для аудита
|
||||
├── diff.selftest.js # тесты diff.js (вставки, удаления, большие тексты, обрезка)
|
||||
├── api.smoketest.js # сквозная проверка API по поднятому стеку
|
||||
├── worker.js # фоновый worker AI-проверки и ИИ-улучшения фото
|
||||
├── certs/ # cert.pem приложения (монтируется в tailscale, в git не хранится)
|
||||
├── db/
|
||||
│ ├── init.sql # схема при первом запуске
|
||||
│ └── migration.sql # миграции существующей БД
|
||||
├── public/ # статика (HTML/CSS/JS админки и витрин)
|
||||
├── scripts/ # вспомогательные скрипты
|
||||
├── uploads/ # загруженные файлы (bind-монт, вне git)
|
||||
├── scripts/ # вспомогательные скрипты (backup/restore/deploy, migrate-to-s3, storage-sync)
|
||||
├── uploads/ # локальные файлы и кэш миниатюр (bind-монт, вне git)
|
||||
└── backups/ # локальные бэкапы
|
||||
```
|
||||
@@ -0,0 +1,189 @@
|
||||
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);
|
||||
|
||||
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,
|
||||
time_start TIME,
|
||||
time_end TIME,
|
||||
cover_path VARCHAR(255)
|
||||
cover_path VARCHAR(255),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
purge_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS modules (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL UNIQUE,
|
||||
lessons_count INT NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
photo_path VARCHAR(255),
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE modules ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE modules ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS students (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(150) NOT NULL UNIQUE,
|
||||
group_id INT REFERENCES groups(id),
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
photo_path VARCHAR(255),
|
||||
profile JSONB
|
||||
);
|
||||
|
||||
ALTER TABLE students ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);
|
||||
ALTER TABLE students ADD COLUMN IF NOT EXISTS profile JSONB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS student_photos (
|
||||
id SERIAL PRIMARY KEY,
|
||||
student_id INT NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
||||
photo_path VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_student_photos_student_id ON student_photos(student_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
student_name VARCHAR(150) NOT NULL,
|
||||
group_id INT NOT NULL REFERENCES groups(id),
|
||||
module_id INT REFERENCES modules(id) ON DELETE SET NULL,
|
||||
description TEXT NOT NULL,
|
||||
description_original TEXT,
|
||||
description_ai TEXT,
|
||||
@@ -42,7 +74,9 @@ CREATE TABLE IF NOT EXISTS entries (
|
||||
ai_checked_at TIMESTAMPTZ,
|
||||
ai_error TEXT,
|
||||
photo_path VARCHAR(255),
|
||||
photo_original_path VARCHAR(255),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
purge_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
@@ -51,8 +85,26 @@ ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_ai TEXT;
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'pending';
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ;
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_error TEXT;
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL;
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_ai_pending ON entries(id) WHERE ai_status = 'pending' AND deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id);
|
||||
|
||||
CREATE OR REPLACE FUNCTION notify_entries_changed() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF (TG_OP = 'INSERT') THEN
|
||||
PERFORM pg_notify('entries_changed', json_build_object('type', 'entry_created', 'id', NEW.id)::text);
|
||||
ELSIF (TG_OP = 'UPDATE' AND OLD.ai_status IS DISTINCT FROM NEW.ai_status) THEN
|
||||
PERFORM pg_notify('entries_changed', json_build_object('type', 'ai_status', 'id', NEW.id, 'status', NEW.ai_status, 'error', NEW.ai_error, 'description', NEW.description, 'description_ai', NEW.description_ai, 'description_original', NEW.description_original)::text);
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_entries_notify ON entries;
|
||||
CREATE TRIGGER trg_entries_notify AFTER INSERT OR UPDATE OF ai_status ON entries
|
||||
FOR EACH ROW EXECUTE FUNCTION notify_entries_changed();
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
@@ -75,6 +127,10 @@ INSERT INTO settings (key, value) VALUES ('share_show_group_photos', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('camera_enabled', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('trash_purge_days', '30')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS share_links (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -146,6 +202,8 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS tutor_id INT REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_branches (
|
||||
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
branch_id INT NOT NULL REFERENCES branches(id) ON DELETE CASCADE,
|
||||
@@ -163,6 +221,27 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS photo_jobs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
action VARCHAR(20) NOT NULL DEFAULT 'ai',
|
||||
params JSONB,
|
||||
before_path VARCHAR(255),
|
||||
after_path VARCHAR(255),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
applied BOOLEAN NOT NULL DEFAULT false,
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
finished_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_photo_jobs_pending ON photo_jobs(id) WHERE status = 'pending';
|
||||
CREATE INDEX IF NOT EXISTS idx_photo_jobs_entry_id ON photo_jobs(entry_id);
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||
@@ -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 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 photo_path VARCHAR(255);
|
||||
ALTER TABLE students ADD COLUMN IF NOT EXISTS profile JSONB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
@@ -90,6 +92,7 @@ CREATE TABLE IF NOT EXISTS entry_photos (
|
||||
CREATE INDEX IF NOT EXISTS idx_entry_photos_entry_id ON entry_photos(entry_id);
|
||||
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ;
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_original TEXT;
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_ai TEXT;
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'pending';
|
||||
@@ -113,6 +116,12 @@ ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('camera_enabled', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('trash_purge_days', '30')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM settings WHERE key = 'ai_autocheck_migrated') THEN
|
||||
@@ -135,6 +144,8 @@ ALTER TABLE groups ADD COLUMN IF NOT EXISTS day_of_week INT;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS time_start TIME;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS time_end TIME;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS cover_path VARCHAR(255);
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -152,6 +163,8 @@ CREATE TABLE IF NOT EXISTS user_branches (
|
||||
PRIMARY KEY (user_id, branch_id)
|
||||
);
|
||||
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS tutor_id INT REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -184,3 +197,101 @@ FROM (
|
||||
FROM entry_photos ORDER BY entry_id, sort_order, id
|
||||
) p
|
||||
WHERE e.photo_path IS NULL AND p.entry_id = e.id;
|
||||
|
||||
CREATE OR REPLACE FUNCTION notify_entries_changed() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF (TG_OP = 'INSERT') THEN
|
||||
PERFORM pg_notify('entries_changed', json_build_object('type', 'entry_created', 'id', NEW.id)::text);
|
||||
ELSIF (TG_OP = 'UPDATE' AND OLD.ai_status IS DISTINCT FROM NEW.ai_status) THEN
|
||||
PERFORM pg_notify('entries_changed', json_build_object('type', 'ai_status', 'id', NEW.id, 'status', NEW.ai_status, 'error', NEW.ai_error, 'description', NEW.description, 'description_ai', NEW.description_ai, 'description_original', NEW.description_original)::text);
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_entries_notify ON entries;
|
||||
CREATE TRIGGER trg_entries_notify AFTER INSERT OR UPDATE OF ai_status ON entries
|
||||
FOR EACH ROW EXECUTE FUNCTION notify_entries_changed();
|
||||
|
||||
CREATE TABLE IF NOT EXISTS photo_jobs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
action VARCHAR(20) NOT NULL DEFAULT 'ai',
|
||||
params JSONB,
|
||||
before_path VARCHAR(255),
|
||||
after_path VARCHAR(255),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
applied BOOLEAN NOT NULL DEFAULT false,
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
finished_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_photo_jobs_pending ON photo_jobs(id) WHERE status = 'pending';
|
||||
CREATE INDEX IF NOT EXISTS idx_photo_jobs_entry_id ON photo_jobs(entry_id);
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255);
|
||||
ALTER TABLE photo_jobs ADD COLUMN IF NOT EXISTS applied BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS modules (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL UNIQUE,
|
||||
lessons_count INT NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE modules ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE modules ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS student_photos (
|
||||
id SERIAL PRIMARY KEY,
|
||||
student_id INT NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
||||
photo_path VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_student_photos_student_id ON student_photos(student_id);
|
||||
|
||||
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,26 @@
|
||||
# Переопределение S3-сервиса на MinIO.
|
||||
# Использование:
|
||||
# S3_IMAGE=minio/minio:RELEASE.2025-04-22T22-12-26Z \
|
||||
# docker compose -f docker-compose.yml -f docker-compose.minio.yml up -d s3
|
||||
#
|
||||
# Учтите: MinIO прекратил публикацию свободных образов (docker.io/minio/minio
|
||||
# удалён), поэтому образ нужно взять из доступного вам зеркала/архива и указать
|
||||
# его в S3_IMAGE.
|
||||
services:
|
||||
s3:
|
||||
image: ${S3_IMAGE:-minio/minio:latest}
|
||||
container_name: whatido-s3
|
||||
restart: unless-stopped
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${S3_ACCESS_KEY:-whatido}
|
||||
MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:-whatido-secret}
|
||||
MINIO_BROWSER: ${MINIO_BROWSER:-off}
|
||||
TZ: Europe/Moscow
|
||||
expose:
|
||||
- "9000"
|
||||
ports:
|
||||
- "127.0.0.1:9000:9000"
|
||||
- "127.0.0.1:9001:9001"
|
||||
volumes:
|
||||
- s3-data:/data
|
||||
+109
-16
@@ -3,6 +3,7 @@ name: whatido
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: whereldo
|
||||
POSTGRES_USER: app
|
||||
@@ -17,10 +18,48 @@ services:
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
# Redis: кэш запросов, rate limit, баны IP, кэш сессий, pub/sub для SSE и воркеров.
|
||||
# Приложение не падает, если Redis недоступен — автоматически работает
|
||||
# на in-memory кэше (см. redis.js).
|
||||
# Отладка: docker compose exec redis redis-cli -a "$REDIS_PASSWORD" INFO
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: whatido-redis
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
redis-server
|
||||
--requirepass ${REDIS_PASSWORD}
|
||||
--appendonly yes
|
||||
--appendfsync everysec
|
||||
--maxmemory ${REDIS_MAXMEMORY:-256mb}
|
||||
--maxmemory-policy allkeys-lru
|
||||
--save ""
|
||||
environment:
|
||||
TZ: Europe/Moscow
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||
expose:
|
||||
- "6379"
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" ping | grep -q PONG"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 5s
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
network: host
|
||||
args:
|
||||
GIT_COMMIT: ${GIT_COMMIT:-}
|
||||
GIT_COMMIT_DATE: ${GIT_COMMIT_DATE:-}
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
expose:
|
||||
- "3003"
|
||||
- "3443"
|
||||
@@ -29,20 +68,59 @@ services:
|
||||
- "3443:3443"
|
||||
environment:
|
||||
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/whereldo
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
|
||||
REDIS_PREFIX: ${REDIS_PREFIX:-whatido}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
BACKUP_UPLOAD_LIMIT_MB: ${BACKUP_UPLOAD_LIMIT_MB:-500}
|
||||
AI_MODEL: ${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf}
|
||||
AI_PROMPT: ${AI_PROMPT:-}
|
||||
AI_REQUEST_TIMEOUT_MS: ${AI_REQUEST_TIMEOUT_MS:-120000}
|
||||
PHOTO_AI_URL: ${PHOTO_AI_URL:-http://photo-ai:8080}
|
||||
NODE_ENV: production
|
||||
TZ: Europe/Moscow
|
||||
STORAGE_DRIVER: ${STORAGE_DRIVER:-local}
|
||||
STORAGE_LOCAL_FALLBACK: ${STORAGE_LOCAL_FALLBACK:-1}
|
||||
STORAGE_KEEP_LOCAL: ${STORAGE_KEEP_LOCAL:-0}
|
||||
STORAGE_CACHE_MAX_AGE_HOURS: ${STORAGE_CACHE_MAX_AGE_HOURS:-168}
|
||||
S3_ENDPOINT: ${S3_ENDPOINT:-http://minio:9000}
|
||||
S3_REGION: ${S3_REGION:-us-east-1}
|
||||
S3_BUCKET: ${S3_BUCKET:-whatido}
|
||||
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-whatido}
|
||||
S3_SECRET_KEY: ${S3_SECRET_KEY:-whatido-secret}
|
||||
S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-1}
|
||||
S3_PREFIX: ${S3_PREFIX:-}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
|
||||
# S3-совместимое хранилище файлов. API 9000 доступен только внутри сети compose
|
||||
# (плюс loopback хоста для отладки/миграции).
|
||||
# По умолчанию — SeaweedFS: свободный S3-сервер, доступный в Docker Hub.
|
||||
# Для MinIO (если образ доступен в вашем зеркале) используйте:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.minio.yml up -d s3
|
||||
# Перенос файлов из ./uploads в бакет:
|
||||
# docker compose exec -T app node scripts/migrate-to-s3.js --dry-run
|
||||
s3:
|
||||
image: ${S3_IMAGE:-chrislusf/seaweedfs:latest}
|
||||
container_name: whatido-s3
|
||||
restart: unless-stopped
|
||||
command: server -dir=/data -s3 -s3.port=9000
|
||||
environment:
|
||||
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY:-whatido}
|
||||
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY:-whatido-secret}
|
||||
TZ: Europe/Moscow
|
||||
expose:
|
||||
- "9000"
|
||||
ports:
|
||||
- "127.0.0.1:9000:9000"
|
||||
volumes:
|
||||
- s3-data:/data
|
||||
|
||||
# Публикация через Tailscale (Serve / Funnel) без проброса портов.
|
||||
# Приложение доступно по https://whatido.<tailnet>.ts.net
|
||||
# tailscale:
|
||||
@@ -72,22 +150,22 @@ services:
|
||||
# если в wg/wg0.conf лежит конфиг — контейнер сначала поднимает VPN и
|
||||
# только потом запускает туннель (исход Cloudflare через VPN). Без конфига
|
||||
# туннель стартует сразу, как в базовой схеме.
|
||||
cloudflared:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.cloudflared
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
privileged: true
|
||||
volumes:
|
||||
- ./wg:/etc/wireguard:ro
|
||||
environment:
|
||||
TZ: Europe/Moscow
|
||||
CLOUDFLARE_TUNNEL_URL: ${CLOUDFLARE_TUNNEL_URL:-http://app:3003}
|
||||
WG_HANDSHAKE_TIMEOUT: ${WG_HANDSHAKE_TIMEOUT:-60}
|
||||
depends_on:
|
||||
- app
|
||||
# cloudflared:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile.cloudflared
|
||||
# restart: unless-stopped
|
||||
# cap_add:
|
||||
# - NET_ADMIN
|
||||
# privileged: true
|
||||
# volumes:
|
||||
# - ./wg:/etc/wireguard:ro
|
||||
# environment:
|
||||
# TZ: Europe/Moscow
|
||||
# CLOUDFLARE_TUNNEL_URL: ${CLOUDFLARE_TUNNEL_URL:-http://app:3003}
|
||||
# WG_HANDSHAKE_TIMEOUT: ${WG_HANDSHAKE_TIMEOUT:-60}
|
||||
# depends_on:
|
||||
# - app
|
||||
|
||||
|
||||
|
||||
@@ -111,7 +189,22 @@ services:
|
||||
- "8080:8080"
|
||||
|
||||
|
||||
# ИИ-улучшение фотографий (Real-ESRGAN x2, CPU).
|
||||
# Поднимается вместе со стеком; если не нужен — PHOTO_AI_URL пустой в .env.
|
||||
photo-ai:
|
||||
build: ./photo-ai
|
||||
container_name: photo-ai
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MODEL_PATH: /models/RealESRGAN_x2plus.pth
|
||||
MAX_INPUT_PIXELS: ${PHOTO_AI_MAX_PIXELS:-4000000}
|
||||
TZ: Europe/Moscow
|
||||
volumes:
|
||||
- photo-ai-models:/models
|
||||
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
photo-ai-models:
|
||||
redis-data:
|
||||
s3-data:
|
||||
|
||||
Generated
+495
-2
@@ -8,6 +8,7 @@
|
||||
"name": "whereldo",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1141.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"express": "^4.21.0",
|
||||
"express-rate-limit": "^8.7.0",
|
||||
@@ -16,10 +17,319 @@
|
||||
"lucide": "^1.44.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"pg": "^8.13.0",
|
||||
"redis": "^5.12.1",
|
||||
"sharp": "^0.34.5",
|
||||
"tar": "^7.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/checksums": {
|
||||
"version": "3.1001.1",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1001.1.tgz",
|
||||
"integrity": "sha512-x12Q17KYlJAd3nKf8LV5LV0vt8sh8/6YfQLGPtrGnQf/tW4jqxPGq5GPpuVitpQYM3eUR4XB7CbxZf751NMbLw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/client-s3": {
|
||||
"version": "3.1141.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1141.0.tgz",
|
||||
"integrity": "sha512-uOVH37xGLenAdJkCPCin/JJG2PgWrFcSsDnQ9+C9Zq8N9Oalo5ol4xmn5fG28iWAlA/b/9boQZgHbMh+UsIhcg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/checksums": "^3.1001.1",
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.84",
|
||||
"@aws-sdk/middleware-sdk-s3": "^3.972.77",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/fetch-http-handler": "^5.8.0",
|
||||
"@smithy/node-http-handler": "^4.12.1",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/core": {
|
||||
"version": "3.978.1",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.1.tgz",
|
||||
"integrity": "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@aws-sdk/xml-builder": "^3.972.41",
|
||||
"@aws/lambda-invoke-store": "^0.3.0",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/signature-v4": "^5.7.3",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"bowser": "^2.11.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-env": {
|
||||
"version": "3.972.72",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.72.tgz",
|
||||
"integrity": "sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-http": {
|
||||
"version": "3.972.74",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.74.tgz",
|
||||
"integrity": "sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/fetch-http-handler": "^5.8.0",
|
||||
"@smithy/node-http-handler": "^4.12.1",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-ini": {
|
||||
"version": "3.973.17",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.17.tgz",
|
||||
"integrity": "sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/credential-provider-env": "^3.972.72",
|
||||
"@aws-sdk/credential-provider-http": "^3.972.74",
|
||||
"@aws-sdk/credential-provider-login": "^3.972.79",
|
||||
"@aws-sdk/credential-provider-process": "^3.972.72",
|
||||
"@aws-sdk/credential-provider-sso": "^3.973.16",
|
||||
"@aws-sdk/credential-provider-web-identity": "^3.972.78",
|
||||
"@aws-sdk/nested-clients": "^3.997.46",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/credential-provider-imds": "^4.5.2",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-login": {
|
||||
"version": "3.972.79",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.79.tgz",
|
||||
"integrity": "sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/nested-clients": "^3.997.46",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-node": {
|
||||
"version": "3.972.84",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.84.tgz",
|
||||
"integrity": "sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-provider-env": "^3.972.72",
|
||||
"@aws-sdk/credential-provider-http": "^3.972.74",
|
||||
"@aws-sdk/credential-provider-ini": "^3.973.17",
|
||||
"@aws-sdk/credential-provider-process": "^3.972.72",
|
||||
"@aws-sdk/credential-provider-sso": "^3.973.16",
|
||||
"@aws-sdk/credential-provider-web-identity": "^3.972.78",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/credential-provider-imds": "^4.5.2",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-process": {
|
||||
"version": "3.972.72",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.72.tgz",
|
||||
"integrity": "sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-sso": {
|
||||
"version": "3.973.16",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.16.tgz",
|
||||
"integrity": "sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/nested-clients": "^3.997.46",
|
||||
"@aws-sdk/token-providers": "3.1138.0",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-web-identity": {
|
||||
"version": "3.972.78",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.78.tgz",
|
||||
"integrity": "sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/nested-clients": "^3.997.46",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/middleware-sdk-s3": {
|
||||
"version": "3.972.77",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.77.tgz",
|
||||
"integrity": "sha512-E7W2UOeUoc+lg3uIfR/dM7ZwusHwhBQrKMnlkRv4EXRR+C0YtV1pg25xC7GdZIhXH+NAMgZPCbE7o5to2cjFiw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/nested-clients": {
|
||||
"version": "3.997.46",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.46.tgz",
|
||||
"integrity": "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/fetch-http-handler": "^5.8.0",
|
||||
"@smithy/node-http-handler": "^4.12.1",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/signature-v4-multi-region": {
|
||||
"version": "3.996.47",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.47.tgz",
|
||||
"integrity": "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/signature-v4": "^5.7.3",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/token-providers": {
|
||||
"version": "3.1138.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1138.0.tgz",
|
||||
"integrity": "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/nested-clients": "^3.997.46",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/types": {
|
||||
"version": "3.974.6",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.6.tgz",
|
||||
"integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/xml-builder": {
|
||||
"version": "3.972.41",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz",
|
||||
"integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws/lambda-invoke-store": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
|
||||
"integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
@@ -654,6 +964,159 @@
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@redis/bloom": {
|
||||
"version": "5.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.12.1.tgz",
|
||||
"integrity": "sha512-PUUfv+ms7jgPSBVoo/DN4AkPHj4D5TZSd6SbJX7egzBplkYUcKmHRE8RKia7UtZ8bSQbLguLvxVO+asKtQfZWA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 18.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^5.12.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/client": {
|
||||
"version": "5.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz",
|
||||
"integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cluster-key-slot": "1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@node-rs/xxhash": "^1.1.0",
|
||||
"@opentelemetry/api": ">=1 <2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@node-rs/xxhash": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/json": {
|
||||
"version": "5.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@redis/json/-/json-5.12.1.tgz",
|
||||
"integrity": "sha512-eOze75esLve4vfqDel7aMX08CNaiLLQS2fV8mpRN9NxPe1rVR4vQyYiW/OgtGUysF6QOr9ANhfxABKNOJfXdKg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 18.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^5.12.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/search": {
|
||||
"version": "5.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@redis/search/-/search-5.12.1.tgz",
|
||||
"integrity": "sha512-ItlxbxC9cKI6IU1TLWoczwJCRb6TdmkEpWv05UrPawqaAnWGRu3rcIqsc5vN483T2fSociuyV1UkWIL5I4//2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 18.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^5.12.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/time-series": {
|
||||
"version": "5.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.12.1.tgz",
|
||||
"integrity": "sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 18.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^5.12.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/core": {
|
||||
"version": "3.35.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.0.tgz",
|
||||
"integrity": "sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/credential-provider-imds": {
|
||||
"version": "4.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz",
|
||||
"integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.33.2",
|
||||
"@smithy/types": "^4.17.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/fetch-http-handler": {
|
||||
"version": "5.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz",
|
||||
"integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.33.3",
|
||||
"@smithy/types": "^4.18.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/node-http-handler": {
|
||||
"version": "4.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz",
|
||||
"integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.33.3",
|
||||
"@smithy/types": "^4.18.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/signature-v4": {
|
||||
"version": "5.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz",
|
||||
"integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/types": {
|
||||
"version": "4.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz",
|
||||
"integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/abbrev": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
|
||||
@@ -807,6 +1270,12 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/bowser": {
|
||||
"version": "2.14.1",
|
||||
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
|
||||
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
@@ -881,6 +1350,15 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cluster-key-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-support": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
||||
@@ -2067,6 +2545,22 @@
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redis": {
|
||||
"version": "5.12.1",
|
||||
"resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz",
|
||||
"integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@redis/bloom": "5.12.1",
|
||||
"@redis/client": "5.12.1",
|
||||
"@redis/json": "5.12.1",
|
||||
"@redis/search": "5.12.1",
|
||||
"@redis/time-series": "5.12.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
@@ -2402,8 +2896,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1141.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"express": "^4.21.0",
|
||||
"express-rate-limit": "^8.7.0",
|
||||
@@ -14,6 +15,7 @@
|
||||
"lucide": "^1.44.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"pg": "^8.13.0",
|
||||
"redis": "^5.12.1",
|
||||
"sharp": "^0.34.5",
|
||||
"tar": "^7.4.3"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
FROM python:3.10-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install --no-cache-dir "typing-extensions==4.12.2" "numpy<2"
|
||||
|
||||
RUN pip install --no-cache-dir torch torchvision --index-url https://download.pytorch.org/whl/cpu
|
||||
|
||||
RUN pip install --no-cache-dir realesrgan==0.3.0 fastapi "uvicorn[standard]" python-multipart opencv-python-headless
|
||||
|
||||
RUN BASICSR_DEG=$(python -c "import basicsr; import os; print(os.path.join(os.path.dirname(basicsr.__file__), 'data', 'degradations.py'))" 2>/dev/null) || \
|
||||
BASICSR_DEG=$(find /usr/local/lib/python3.10 -path "*/basicsr/data/degradations.py" 2>/dev/null | head -1) && \
|
||||
if [ -n "$BASICSR_DEG" ]; then \
|
||||
sed -i 's/from torchvision.transforms.functional_tensor/from torchvision.transforms.functional/g' "$BASICSR_DEG" && \
|
||||
echo "basicsr patch applied to $BASICSR_DEG"; \
|
||||
else \
|
||||
echo "basicsr degradations.py not found, skipping patch"; \
|
||||
fi
|
||||
|
||||
COPY app.py .
|
||||
|
||||
ENV MODEL_PATH=/models/RealESRGAN_x2plus.pth
|
||||
|
||||
VOLUME /models
|
||||
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
@@ -0,0 +1,73 @@
|
||||
import io
|
||||
import os
|
||||
import asyncio
|
||||
import threading
|
||||
import urllib.request
|
||||
|
||||
from fastapi import FastAPI, File, Form, UploadFile
|
||||
from fastapi.responses import Response
|
||||
import numpy as np
|
||||
import cv2
|
||||
import torch
|
||||
from basicsr.archs.rrdbnet_arch import RRDBNet
|
||||
from realesrgan import RealESRGANer
|
||||
|
||||
MODEL_PATH = os.environ.get('MODEL_PATH', '/models/RealESRGAN_x2plus.pth')
|
||||
MODEL_URL = 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth'
|
||||
MAX_PIXELS = int(os.environ.get('MAX_INPUT_PIXELS', str(4_000_000)))
|
||||
|
||||
lock = threading.Lock()
|
||||
upsampler = None
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
def ensure_model():
|
||||
if not os.path.exists(MODEL_PATH):
|
||||
os.makedirs(os.path.dirname(MODEL_PATH), exist_ok=True)
|
||||
tmp = MODEL_PATH + '.tmp'
|
||||
urllib.request.urlretrieve(MODEL_URL, tmp)
|
||||
os.replace(tmp, MODEL_PATH)
|
||||
|
||||
|
||||
def load_model():
|
||||
global upsampler
|
||||
ensure_model()
|
||||
model = RRDBNet(num_in_ch=3, num_out_ch=3, scale=2, num_feat=64, num_block=23, num_grow_ch=32)
|
||||
upsampler = RealESRGANer(
|
||||
scale=2,
|
||||
model_path=MODEL_PATH,
|
||||
model=model,
|
||||
tile=256,
|
||||
tile_pad=10,
|
||||
pre_pad=0,
|
||||
half=False,
|
||||
device='cpu',
|
||||
)
|
||||
|
||||
|
||||
@app.on_event('startup')
|
||||
async def startup():
|
||||
await asyncio.to_thread(load_model)
|
||||
|
||||
|
||||
@app.get('/health')
|
||||
def health():
|
||||
return {'ok': upsampler is not None}
|
||||
|
||||
|
||||
@app.post('/enhance')
|
||||
async def enhance(image: UploadFile = File(...), scale: int = Form(2)):
|
||||
data = await image.read()
|
||||
img = cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR)
|
||||
if img is None:
|
||||
return Response('bad image', status_code=400)
|
||||
if img.shape[0] * img.shape[1] > MAX_PIXELS:
|
||||
r = (MAX_PIXELS / (img.shape[0] * img.shape[1])) ** 0.5
|
||||
img = cv2.resize(img, (int(img.shape[1] * r), int(img.shape[0] * r)), interpolation=cv2.INTER_AREA)
|
||||
outscale = min(max(int(scale), 2), 4)
|
||||
with lock:
|
||||
out, _ = upsampler.enhance(img, outscale=outscale)
|
||||
ok, enc = cv2.imencode('.jpg', out, [int(cv2.IMWRITE_JPEG_QUALITY), 92])
|
||||
if not ok:
|
||||
return Response('encode failed', status_code=500)
|
||||
return Response(enc.tobytes(), media_type='image/jpeg')
|
||||
+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}
|
||||
|
||||
.layout{display:flex;min-height:100dvh}
|
||||
.sidebar{width:var(--sidebar);background:var(--card);border-right:1px solid var(--border);padding:24px 0;position:fixed;top:0;left:0;height:100dvh;display:flex;flex-direction:column}
|
||||
.sidebar .logo{font-size:1.1rem;font-weight:700;padding:0 20px 20px;border-bottom:1px solid var(--border)}
|
||||
.user-box{padding:14px 20px;border-bottom:1px solid var(--border);display:flex;flex-direction:column;gap:2px}
|
||||
.user-box .user-name{font-weight:600;font-size:.9rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.user-box .user-role{color:var(--muted);font-size:.72rem}
|
||||
.sidebar nav{flex:1;padding:12px 0}
|
||||
.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}
|
||||
.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{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)}
|
||||
.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}
|
||||
.sidebar .logo-ic img{max-width:100%;max-height:100%;object-fit:contain}
|
||||
.sidebar #sidebarName{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
|
||||
.user-box{padding:10px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;flex-shrink:0;background:var(--card)}
|
||||
.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.active{color:var(--accent);background:rgba(37,99,235,.06);border-left-color:var(--accent);font-weight:500}
|
||||
.sidebar nav a i,.sidebar .bottom a i{width:16px;height:16px;flex-shrink:0}
|
||||
.sidebar nav a.active{color:var(--accent);background:rgba(37,99,235,.08);font-weight:600}
|
||||
.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=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=groups] svg{color:#8b5cf6}
|
||||
.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=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=users] svg{color:#ec4899}
|
||||
.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=settings] svg{color:#475569}
|
||||
.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 a{display:flex;align-items:center;gap:10px;color:var(--muted);text-decoration:none;font-size:.8rem;padding:6px 0}
|
||||
.sidebar .bottom a:hover{color:var(--text)}
|
||||
.sidebar .bottom .logout{color:#ef4444;cursor:pointer;margin-top:4px}
|
||||
|
||||
.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-actions{display:flex;flex-direction:column;gap:1px}
|
||||
.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-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 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}
|
||||
.search-input{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;outline:none}
|
||||
.search-input:focus{border-color:var(--accent)}
|
||||
#studentFilter,#linkStudent{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;outline:none;width:100%;max-width:220px}
|
||||
#studentFilter:focus,#linkStudent:focus{border-color:var(--accent)}
|
||||
#studentFilter,#linkStudent,#moduleFilter{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;outline:none;width:100%;max-width:220px}
|
||||
#studentFilter:focus,#linkStudent:focus,#moduleFilter:focus{border-color:var(--accent)}
|
||||
|
||||
.pager{display:flex;gap:6px;align-items:center;justify-content:center;margin-top:20px;flex-wrap:wrap}
|
||||
.pager button{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:6px 12px;font-size:.85rem;cursor:pointer}
|
||||
@@ -127,6 +161,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
||||
.item .name-input:focus{border-color:var(--accent)}
|
||||
.item .group-select{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:4px 8px;font-size:.8rem;outline:none;max-width:200px}
|
||||
.item .group-select:focus{border-color:var(--accent)}
|
||||
.item.expired{opacity:.6;border-color:rgba(239,68,68,.35);background:rgba(239,68,68,.03)}
|
||||
.item.expired:hover{opacity:.85}
|
||||
.badge-expired{display:inline-block;font-size:.68rem;font-weight:700;line-height:1;padding:3px 8px;border-radius:999px;background:rgba(239,68,68,.12);color:#ef4444;vertical-align:middle;margin-left:6px}
|
||||
.badge-soon{display:inline-block;font-size:.68rem;font-weight:700;line-height:1;padding:3px 8px;border-radius:999px;background:rgba(245,158,11,.14);color:#f59e0b;vertical-align:middle;margin-left:6px}
|
||||
.attach-list{max-height:280px;overflow-y:auto;display:flex;flex-direction:column;gap:4px;border:1px solid var(--border);border-radius:8px;padding:8px}
|
||||
.attach-list label{display:flex;gap:8px;align-items:center;font-size:.85rem;color:var(--text);margin:0;cursor:pointer}
|
||||
.attach-list .cur{color:var(--muted);font-size:.75rem;margin-left:auto}
|
||||
@@ -158,7 +196,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
||||
.group-card .save-sched:hover{opacity:.85}
|
||||
.group-card .btn-link{background:var(--bg);border:1px solid var(--border);color:var(--accent);border-radius:8px;padding:8px 14px;font-size:.85rem;font-weight:600;cursor:pointer}
|
||||
.group-card .btn-link:hover{border-color:var(--accent)}
|
||||
.settings-card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:24px;max-width:680px;display:flex;flex-direction:column;gap:16px}
|
||||
.settings-card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:24px;display:flex;flex-direction:column;gap:16px}
|
||||
.settings-row{display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap}
|
||||
.settings-row label{font-weight:600;display:block;margin-bottom:4px}
|
||||
.settings-row .hint{font-size:.8rem;color:var(--muted);max-width:420px;margin:0}
|
||||
@@ -192,6 +230,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
||||
.edit-modal .actions button:hover{opacity:.85}
|
||||
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#16a34a;color:#fff;padding:12px 24px;border-radius:var(--radius);font-size:.9rem;opacity:0;transition:opacity .3s;pointer-events:none;z-index:200}
|
||||
.toast.show{opacity:1}
|
||||
.pf-photos{display:flex;flex-wrap:wrap;gap:10px}
|
||||
.pf-photo{position:relative;width:96px;height:96px;border-radius:10px;overflow:hidden;border:2px solid var(--border);cursor:pointer;flex:0 0 auto}
|
||||
.pf-photo.main{border-color:var(--accent)}
|
||||
.pf-photo img{width:100%;height:100%;object-fit:cover;display:block}
|
||||
.pf-badge{position:absolute;left:4px;bottom:4px;background:var(--accent);color:#fff;font-size:.65rem;font-weight:600;padding:2px 6px;border-radius:6px}
|
||||
.pf-del{position:absolute;top:4px;right:4px;width:20px;height:20px;border:none;border-radius:50%;background:rgba(0,0,0,.6);color:#fff;font-size:.7rem;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center}
|
||||
.pf-del:hover{background:#ef4444}
|
||||
|
||||
/* --- Фото группы (хронология) --- */
|
||||
.group-cover{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:10px;display:block;background:var(--bg)}
|
||||
@@ -242,17 +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:hover{opacity:.8}
|
||||
.trash-time{color:var(--muted);font-size:.75rem;margin-top:8px}
|
||||
.pending-toggle{margin-bottom:16px;max-width:680px}
|
||||
.trash-section{margin-top:24px}
|
||||
.trash-section h4{margin:16px 0 8px;font-size:.95rem;font-weight:600}
|
||||
.card.pending{border-color:#d97706;background:linear-gradient(180deg,rgba(217,119,6,.06),transparent 55%)}
|
||||
.trash-time.warn{color:#d97706;font-weight:600}
|
||||
|
||||
/* --- Настройки (UX/UI) --- */
|
||||
.page-head{display:flex;align-items:baseline;gap:14px;margin-bottom:24px}
|
||||
.page-head h2{font-size:1.3rem;font-weight:600;margin:0}
|
||||
.page-sub{color:var(--muted);font-size:.85rem}
|
||||
.settings-stack{display:flex;flex-direction:column;gap:20px;max-width:680px}
|
||||
.settings-stack{display:flex;flex-direction:column;gap:20px}
|
||||
.link-fields{display:grid;grid-template-columns:1fr 1fr;gap:12px;align-items:start}
|
||||
.link-fields > .full{grid-column:1/-1}
|
||||
@media(max-width:600px){.link-fields{grid-template-columns:1fr}}
|
||||
.settings-stack-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:20px;max-width:1200px}
|
||||
.settings-stack-grid > .settings-card{max-width:none;height:100%}
|
||||
.settings-stack-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px;width:100%}
|
||||
@media(max-width:900px){.settings-stack-grid{grid-template-columns:1fr}}
|
||||
.settings-stack-grid > .settings-card{max-width:none;min-width:0;height:100%}
|
||||
.settings-stack-grid > .settings-card.wide{grid-column:1/-1}
|
||||
.settings-card .card-head{display:flex;align-items:center;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--border);margin-top:-2px}
|
||||
.settings-card .card-head .ic{width:38px;height:38px;border-radius:11px;background:rgba(37,99,235,.08);display:flex;align-items:center;justify-content:center;font-size:1.15rem;flex-shrink:0}
|
||||
@@ -287,6 +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}
|
||||
@media(max-width:640px){.save-bar{left:16px;right:16px}}
|
||||
|
||||
.logo-picker{display:flex;align-items:flex-start;gap:20px;flex-wrap:wrap}
|
||||
.logo-preview{width:160px;max-width:100%;min-width:96px;min-height:72px;max-height:96px;height:auto;border:1px dashed var(--border);background:var(--bg);display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0;font-size:1.4rem;font-weight:700;color:var(--muted)}
|
||||
.logo-preview img{max-width:100%;max-height:96px;width:auto;height:auto;object-fit:contain;display:none}
|
||||
.logo-picker-actions{display:flex;flex-direction:column;gap:10px;align-items:flex-start;min-width:0}
|
||||
.logo-picker-btns{display:flex;gap:10px;flex-wrap:wrap}
|
||||
.logo-upload-status{font-size:.8rem;color:var(--muted);min-height:18px;display:flex;align-items:center;gap:8px}
|
||||
.logo-upload-status .fail{color:#ef4444}
|
||||
|
||||
/* --- Дашборд --- */
|
||||
.dash-grid{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,1fr);gap:20px;align-items:start}
|
||||
.dash-col{display:flex;flex-direction:column;gap:20px;min-width:0}
|
||||
@@ -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{height:100%;transition:width .3s,background .3s}
|
||||
.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}
|
||||
.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-body{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px;max-height:60vh;overflow:auto}
|
||||
.audit-details-body pre{margin:0;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.78rem;line-height:1.45;color:var(--text)}
|
||||
.audit-details-body.audit-loading{opacity:.6}
|
||||
.audit-details-head{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px}
|
||||
.audit-pill{background:var(--card);border:1px solid var(--border);border-radius:999px;padding:3px 10px;font-size:.72rem;color:var(--muted)}
|
||||
.audit-changes{display:flex;flex-direction:column;gap:12px}
|
||||
.audit-change{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:10px 12px}
|
||||
.audit-change-title{font-weight:600;font-size:.82rem;margin-bottom:6px}
|
||||
.audit-change-stats{display:flex;flex-wrap:wrap;gap:10px;font-size:.75rem;margin-bottom:8px}
|
||||
.audit-change-line{font-size:.8rem;line-height:1.6;word-break:break-word}
|
||||
.audit-tag-del,.audit-tag-add{display:inline-block;min-width:52px;font-size:.7rem;text-transform:uppercase;letter-spacing:.03em;padding:1px 6px;border-radius:4px;margin-right:6px}
|
||||
.audit-tag-del{background:rgba(239,68,68,.14);color:#dc2626}
|
||||
.audit-tag-add{background:rgba(22,163,74,.14);color:#16a34a}
|
||||
.audit-stat-add{color:#16a34a;font-weight:600}
|
||||
.audit-stat-del{color:#dc2626;font-weight:600}
|
||||
.audit-stat-muted{color:var(--muted)}
|
||||
.audit-diff{white-space:pre-wrap;word-break:break-word;font-size:.85rem;line-height:1.6;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:10px;max-height:40vh;overflow:auto}
|
||||
.audit-diff-add{background:rgba(22,163,74,.18);color:#14532d;text-decoration:none;border-radius:3px;padding:0 2px}
|
||||
.audit-diff-del{background:rgba(239,68,68,.16);color:#7f1d1d;border-radius:3px;padding:0 2px}
|
||||
.audit-truncated{margin-top:6px;font-size:.72rem;color:#b45309}
|
||||
.audit-raw{margin-top:12px;font-size:.78rem;color:var(--muted)}
|
||||
.audit-raw summary{cursor:pointer}
|
||||
.audit-raw pre{margin-top:8px;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:10px}
|
||||
|
||||
/* --- AI Correction --- */
|
||||
.ai-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;padding:4px 8px;border-radius:6px;transition:color .15s,background .15s;vertical-align:middle;margin-left:6px}
|
||||
@@ -418,4 +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.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}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+315
-9
@@ -49,13 +49,16 @@ function buildSidebar(active) {
|
||||
const base = [
|
||||
{ page: 'dashboard', label: 'Дашборд', icon: 'layout-dashboard' },
|
||||
{ page: 'journal', label: 'Журнал', icon: 'book-open' },
|
||||
{ page: 'notifications', label: 'Уведомления', icon: 'bell', badge: true },
|
||||
{ page: 'students', label: 'Ученики', icon: 'graduation-cap' },
|
||||
{ page: 'groups', label: 'Группы', icon: 'users' },
|
||||
{ page: 'photos', label: 'Фото', icon: 'Image' },
|
||||
{ page: 'files', label: 'Файлы', icon: 'folder' },
|
||||
{ page: 'links', label: 'Ссылки', icon: 'link' },
|
||||
{ page: 'trash', label: 'Корзина', icon: 'trash-2' },
|
||||
];
|
||||
const adminNav = [
|
||||
{ page: 'modules', label: 'Темы модулей', icon: 'layers' },
|
||||
{ page: 'branches', label: 'Филиалы', icon: 'building-2' },
|
||||
{ page: 'users', label: 'Пользователи', icon: 'user-cog' },
|
||||
{ page: 'worker', label: 'Воркер ИИ', icon: 'bot' },
|
||||
@@ -63,23 +66,326 @@ function buildSidebar(active) {
|
||||
{ page: 'bans', label: 'Блокировки', icon: 'shield-off' },
|
||||
{ page: 'settings', label: 'Настройки', icon: 'settings' },
|
||||
];
|
||||
const nav = [...base, ...(isAdmin() ? adminNav : [])];
|
||||
const userName = currentUser?.name || currentUser?.username || '';
|
||||
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 = `
|
||||
<div class="logo">Админ-панель</div>
|
||||
<div class="logo">
|
||||
<span class="logo-ic" id="sidebarLogo"></span>
|
||||
<span id="sidebarName">Админ-панель</span>
|
||||
</div>
|
||||
<div class="user-box">
|
||||
<div class="user-name">${esc(userName)}</div>
|
||||
<div class="user-role">${roleLabel}</div>
|
||||
<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>
|
||||
</div>
|
||||
<div class="nav-scroll-wrap">
|
||||
<nav>
|
||||
<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>
|
||||
</div>
|
||||
<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('')}
|
||||
</nav>
|
||||
<div class="bottom">
|
||||
<a href="/" data-nav="home"><i data-lucide="home"></i>На главную</a>
|
||||
<a class="logout" href="#" data-action="logout"><i data-lucide="log-out"></i>Выйти</a>
|
||||
<div class="bottom-actions">
|
||||
<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>`;
|
||||
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 ---
|
||||
|
||||
+8
-3
@@ -16,6 +16,9 @@
|
||||
</div>
|
||||
|
||||
<div class="actions-row">
|
||||
<select id="auditActionFilter" class="settings-input" style="max-width:280px">
|
||||
<option value="">Все действия</option>
|
||||
</select>
|
||||
<input type="search" id="auditFilter" class="settings-input" placeholder="Фильтр по действию..." style="max-width:300px">
|
||||
<button class="btn-link" id="auditRefreshBtn" type="button" title="Обновить">Обновить</button>
|
||||
<span class="hint" id="auditCount"></span>
|
||||
@@ -26,6 +29,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Время</th>
|
||||
<th>Кто</th>
|
||||
<th>Действие</th>
|
||||
<th>Детали</th>
|
||||
<th>IP</th>
|
||||
@@ -39,11 +43,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-overlay" id="auditDetailsModal">
|
||||
<div class="edit-modal" style="max-width:640px">
|
||||
<div class="edit-modal" style="max-width:820px">
|
||||
<h3 id="auditDetailsTitle">Детали действия</h3>
|
||||
<div class="audit-details-body"><pre id="auditDetailsBody"></pre></div>
|
||||
<div class="audit-details-head" id="auditDetailsHead"></div>
|
||||
<div class="audit-details-body" id="auditDetailsBody"></div>
|
||||
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||
<button type="button" class="cancel" id="auditDetailsCloseBtn">Закрыть</button>
|
||||
<button type="button" class="btn-primary ghost" id="auditDetailsCloseBtn">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
<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="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="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>
|
||||
|
||||
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">
|
||||
<input type="text" id="newGroup" placeholder="Название группы" style="flex:1;max-width:300px">
|
||||
<select id="newGroupBranch" style="background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;min-width:180px"><option value="">— без филиала —</option></select>
|
||||
<select id="newGroupTutor" style="background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;min-width:160px"><option value="">— не назначен —</option></select>
|
||||
<button type="button" id="addGroupBtn">Добавить</button>
|
||||
</div>
|
||||
<div class="list" id="groupList"></div>
|
||||
|
||||
+40
-6
@@ -9,7 +9,10 @@
|
||||
:root{--bg:#f5f5f5;--card:#fff;--border:#e5e5e5;--accent:#2563eb;--text:#171717;--muted:#737373;--radius:12px}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100dvh;padding:24px 16px}
|
||||
h1{font-size:1.5rem;font-weight:600;margin-bottom:8px;letter-spacing:-.02em;text-align:center}
|
||||
.sub{color:var(--muted);font-size:.85rem;margin-bottom:32px;text-align:center}
|
||||
.sub{color:var(--muted);font-size:.85rem;text-align:center}
|
||||
.brand-head{display:flex;align-items:center;justify-content:center;gap:16px;margin-bottom:32px}
|
||||
.brand-logo{height:76px;width:auto;max-width:min(46vw,340px);object-fit:contain;flex-shrink:0}
|
||||
.brand-text{display:flex;flex-direction:column;align-items:flex-start}
|
||||
|
||||
.form{width:100%;max-width:900px;margin:0 auto;display:grid;grid-template-columns:320px 1fr;gap:32px;align-items:start}
|
||||
@media(max-width:760px){.form{grid-template-columns:1fr;max-width:480px}}
|
||||
@@ -36,9 +39,14 @@ h1{font-size:1.5rem;font-weight:600;margin-bottom:8px;letter-spacing:-.02em;text
|
||||
.files-list .file-row .f-size{color:var(--muted);flex-shrink:0}
|
||||
.files-list .file-row button{background:none;border:none;color:var(--muted);cursor:pointer;font-size:.85rem;padding:0 2px;flex-shrink:0}
|
||||
.files-list .file-row button:hover{color:#ef4444}
|
||||
.paste-hint{font-size:.75rem;color:var(--muted);line-height:1.35;text-align:center}
|
||||
.files-card .paste-hint{text-align:left}
|
||||
@media(hover:none){.paste-hint{display:none}}
|
||||
.preview-wrap{width:100%;display:flex;flex-direction:column;align-items:center;gap:8px}
|
||||
.preview{max-width:100%;width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:var(--radius);background:var(--card);display:block;border:1px dashed var(--border)}
|
||||
.preview{max-width:100%;width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:var(--radius);background:var(--card);display:block;border:1px dashed var(--border);cursor:pointer;transition:border-color .15s}
|
||||
.preview.has-photo{border-style:solid}
|
||||
.preview:hover,.preview:focus-visible{border-color:var(--accent)}
|
||||
.preview-caption{font-size:.85rem;color:var(--muted);text-align:center;line-height:1.35}
|
||||
.remove-photo{background:none;border:none;color:#ef4444;font-size:.85rem;cursor:pointer;padding:4px;display:none}
|
||||
.remove-photo.show{display:block}
|
||||
|
||||
@@ -63,6 +71,12 @@ textarea{resize:vertical;min-height:160px;font-family:inherit;line-height:1.5}
|
||||
.toast.show{opacity:1}
|
||||
.toast.error{background:#dc2626}
|
||||
|
||||
.sent-panel{width:100%;max-width:900px;margin:32px auto 0;background:var(--card);border:2px solid var(--accent);border-radius:var(--radius);padding:40px 32px;display:none;flex-direction:column;gap:20px;align-items:center;text-align:center;box-shadow:0 8px 24px rgba(37,99,235,.18)}
|
||||
.sent-check{width:88px;height:88px;border-radius:50%;background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:center;font-size:2.6rem}
|
||||
.sent-panel h2{font-size:1.6rem;font-weight:700;letter-spacing:-.02em;color:var(--text)}
|
||||
.sent-panel p{font-size:1.05rem;color:var(--muted);max-width:520px}
|
||||
.sent-timer{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);padding:14px 20px;font-size:1rem;font-weight:600;color:var(--text)}
|
||||
|
||||
nav{position:fixed;top:16px;right:16px;display:flex;gap:8px}
|
||||
nav a{color:var(--muted);text-decoration:none;font-size:.8rem;padding:6px 12px;border-radius:8px;transition:color .15s}
|
||||
nav a:hover{color:var(--text)}
|
||||
@@ -83,17 +97,23 @@ nav a:hover{color:var(--text)}
|
||||
<nav>
|
||||
<a href="admin.html">Админка</a>
|
||||
</nav>
|
||||
<h1 id="title">Что мы узнали на занятии</h1>
|
||||
<p class="sub">Отметь, что ты делал(а) на занятии</p>
|
||||
<div class="brand-head">
|
||||
<img class="brand-logo" id="brandLogo" alt="" hidden>
|
||||
<div class="brand-text">
|
||||
<h1 id="title">Что мы узнали на занятии</h1>
|
||||
<p class="sub">Отметь, что ты делал(а) на занятии</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="form" id="form">
|
||||
<div class="photo-col">
|
||||
<div class="photo-card">
|
||||
<div class="preview-wrap">
|
||||
<img class="preview" id="preview" alt="Фото" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='640' height='480'%3E%3Crect width='100%25' height='100%25' fill='none' stroke='%23475569' stroke-width='2' stroke-dasharray='8 8'/%3E%3Ctext x='50%25' y='50%25' fill='%2364748b' font-family='sans-serif' font-size='28' text-anchor='middle' dominant-baseline='middle'%3EФото%3C/text%3E%3C/svg%3E">
|
||||
<img class="preview" id="preview" alt="Фото" title="Открыть камеру" tabindex="0" role="button" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='640' height='480'%3E%3Crect width='100%25' height='100%25' fill='none' stroke='%23475569' stroke-width='2' stroke-dasharray='8 8'/%3E%3Ctext x='50%25' y='50%25' fill='%2364748b' font-family='sans-serif' font-size='28' text-anchor='middle' dominant-baseline='middle'%3EФото%3C/text%3E%3C/svg%3E">
|
||||
<p class="preview-caption" id="previewCaption">Нажми чтобы сделать фото</p>
|
||||
<button type="button" class="remove-photo" id="removePhotoBtn">Убрать фото</button>
|
||||
</div>
|
||||
<div class="photo-btns">
|
||||
<div class="photo-btns" id="camBtns">
|
||||
<button type="button" class="cam" id="openCamBtn">Камера</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,6 +124,7 @@ nav a:hover{color:var(--text)}
|
||||
</div>
|
||||
<button type="button" class="files-btn" id="filesBtn">Выбрать файлы</button>
|
||||
<input type="file" id="filesInput" multiple hidden>
|
||||
<p class="paste-hint">Ctrl+V — вставить файлы из буфера</p>
|
||||
<div class="files-list" id="filesList"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,6 +142,12 @@ nav a:hover{color:var(--text)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field" id="moduleField" style="display:none">
|
||||
<label>Тема модуля</label>
|
||||
<input type="text" id="moduleInput" list="moduleInputList" placeholder="Начни вводить название темы..." autocomplete="off">
|
||||
<datalist id="moduleInputList"></datalist>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Что сделал</label>
|
||||
<textarea id="descInput" placeholder="Опиши, что проходил, что делал на занятии..." required></textarea>
|
||||
@@ -132,6 +159,13 @@ nav a:hover{color:var(--text)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="sent-panel" id="sentPanel">
|
||||
<div class="sent-check">✓</div>
|
||||
<h2 id="sentTitle">Запись отправлена!</h2>
|
||||
<p id="sentText">Спасибо! Твоя запись уже передана преподавателю.</p>
|
||||
<div class="sent-timer" id="sentTimer"></div>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="f-left" id="footerLeft"></div>
|
||||
<div class="f-right" id="footerRight"></div>
|
||||
|
||||
@@ -5,6 +5,34 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Журнал — Админ-панель</title>
|
||||
<link rel="stylesheet" href="admin.css">
|
||||
<link rel="stylesheet" href="admin.css">
|
||||
<style>
|
||||
.enhance-modal { max-width: 1100px; width: 94vw; }
|
||||
.enhance-body { display: flex; gap: 16px; align-items: stretch; margin-top: 4px; }
|
||||
.enhance-compare { position: relative; flex: 1 1 auto; min-width: 0; border-radius: 10px; overflow: hidden; background: var(--bg); border: 1px solid var(--border); user-select: none; touch-action: none; cursor: ew-resize; }
|
||||
.enhance-compare.zoomed { cursor: grab; }
|
||||
.enhance-compare.panning { cursor: grabbing; }
|
||||
.enhance-stage { transform-origin: 0 0; will-change: transform; }
|
||||
.enhance-stage > img, .enhance-stage > canvas { display: block; width: 100%; height: 100%; }
|
||||
.enhance-stage > img { height: auto; }
|
||||
.enhance-top { position: absolute; inset: 0; overflow: hidden; }
|
||||
.enhance-top .enhance-stage { position: absolute; inset: 0; }
|
||||
.enhance-divider { position: absolute; top: 0; bottom: 0; left: 50%; width: 32px; transform: translateX(-50%); pointer-events: auto; cursor: ew-resize; }
|
||||
.enhance-divider::before { content: ''; position: absolute; top: 0; bottom: 0; left: 50%; width: 2px; transform: translateX(-50%); background: #fff; box-shadow: 0 0 6px rgba(0,0,0,.6); pointer-events: none; }
|
||||
.enhance-handle { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 34px; height: 34px; border-radius: 50%; background: #fff; color: #333; display: flex; align-items: center; justify-content: center; font-size: 1rem; box-shadow: 0 2px 8px rgba(0,0,0,.4); pointer-events: none; }
|
||||
.enhance-badge { position: absolute; top: 10px; padding: 3px 10px; border-radius: 999px; background: rgba(0,0,0,.55); color: #fff; font-size: .72rem; pointer-events: none; }
|
||||
.enhance-badge.left { left: 10px; }
|
||||
.enhance-badge.right { right: 10px; }
|
||||
.enhance-loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: .85rem; background: var(--bg); }
|
||||
.enhance-sliders { flex: 0 0 240px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.enhance-sliders .settings-field input[type=range] { width: 100%; }
|
||||
.enhance-foot { justify-content: flex-end; gap: 8px; margin-top: 14px; }
|
||||
.photo-thumb { width: 56px; height: 56px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); display: block; }
|
||||
@media (max-width: 800px) {
|
||||
.enhance-body { flex-direction: column; }
|
||||
.enhance-sliders { flex: 1 1 auto; display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body data-page="journal">
|
||||
<div class="layout">
|
||||
@@ -15,6 +43,7 @@
|
||||
<div><label>Поиск</label><input type="text" id="searchInput" class="search-input" placeholder="Имя или описание"></div>
|
||||
<div><label>Студент</label><input type="text" id="studentFilter" list="studentFilterList" placeholder="Все / введите имя..."><datalist id="studentFilterList"></datalist></div>
|
||||
<div><label>Группа</label><select id="groupFilter"><option value="">Все группы</option></select></div>
|
||||
<div><label>Модуль</label><input type="text" id="moduleFilter" list="moduleFilterList" placeholder="Все модули" autocomplete="off"><datalist id="moduleFilterList"></datalist></div>
|
||||
<div><label>С</label><input type="date" id="dateFrom"></div>
|
||||
<div><label>По</label><input type="date" id="dateTo"></div>
|
||||
<div><label> </label><button type="button" id="exportCsvBtn" style="background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 16px;font-size:.85rem;cursor:pointer;white-space:nowrap">Экспорт CSV</button></div>
|
||||
@@ -48,6 +77,11 @@
|
||||
<label>Группа <span style="color:#ef4444">*</span></label>
|
||||
<select id="editGroup" class="settings-input" required></select>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Тема модуля</label>
|
||||
<input type="text" id="editModule" class="settings-input" list="editModuleList" placeholder="Начните вводить название..." autocomplete="off">
|
||||
<datalist id="editModuleList"></datalist>
|
||||
</div>
|
||||
<div class="settings-field full">
|
||||
<label>Что делала(а) <span style="color:#ef4444">*</span>
|
||||
<button type="button" id="aiCorrectBtn" class="ai-btn" title="Исправить ошибки ИИ">✨</button>
|
||||
@@ -161,6 +195,53 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="enhanceModal">
|
||||
<div class="edit-modal enhance-modal">
|
||||
<h3>Улучшение фотографии</h3>
|
||||
<div class="enhance-body">
|
||||
<div class="enhance-compare" id="enhanceCompare">
|
||||
<div class="enhance-stage" id="enhanceStage">
|
||||
<img id="enhanceOrig" alt="Оригинал">
|
||||
</div>
|
||||
<div class="enhance-top" id="enhanceTop" style="clip-path:inset(0 0 0 50%)">
|
||||
<div class="enhance-stage" id="enhanceInner">
|
||||
<canvas id="enhanceCanvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="enhance-divider" id="enhanceDivider"><div class="enhance-handle">⇔</div></div>
|
||||
<div class="enhance-badge left">Оригинал</div>
|
||||
<div class="enhance-badge right">Результат</div>
|
||||
<div class="enhance-loading" id="enhanceLoading">Загрузка...</div>
|
||||
</div>
|
||||
<div class="enhance-sliders">
|
||||
<div class="settings-field"><label>Яркость <span id="enhanceBrightnessVal">100%</span></label><input type="range" id="enhanceBrightness" min="50" max="150" value="100"></div>
|
||||
<div class="settings-field"><label>Контраст <span id="enhanceContrastVal">100%</span></label><input type="range" id="enhanceContrast" min="50" max="150" value="100"></div>
|
||||
<div class="settings-field"><label>Насыщенность <span id="enhanceSaturateVal">100%</span></label><input type="range" id="enhanceSaturate" min="0" max="200" value="100"></div>
|
||||
<div class="settings-field"><label>Шумодав <span id="enhanceDenoiseVal">0%</span></label><input type="range" id="enhanceDenoise" min="0" max="100" value="0"></div>
|
||||
<div class="settings-field"><label>Резкость <span id="enhanceSharpVal">0%</span></label><input type="range" id="enhanceSharp" min="0" max="100" value="0"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-foot enhance-foot">
|
||||
<button type="button" class="btn-primary ghost" id="enhanceHistoryBtn" title="История изменений фотографии">🕘 История</button>
|
||||
<button type="button" class="btn-primary ghost" id="enhanceRestoreBtn" style="display:none" title="Вернуть исходную фотографию">↩ Вернуть оригинал</button>
|
||||
<button type="button" class="btn-primary ghost" id="enhanceAiBtn" style="display:none" title="ИИ-восстановление (Real-ESRGAN)">🤖 ИИ</button>
|
||||
<button type="button" class="btn-primary ghost" id="enhanceAutoBtn" title="Подобрать настройки автоматически">✨ Авто</button>
|
||||
<button type="button" class="btn-primary ghost" id="enhanceCancelBtn">Оставить как есть</button>
|
||||
<button type="button" class="btn-primary" id="enhanceSaveBtn">Применить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="photoHistoryModal">
|
||||
<div class="edit-modal enhance-modal">
|
||||
<h3 id="photoHistoryTitle">История изменений фотографии</h3>
|
||||
<div id="photoHistoryList" style="display:flex;flex-direction:column;gap:12px;max-height:60vh;overflow:auto"></div>
|
||||
<div class="card-foot enhance-foot">
|
||||
<button type="button" class="btn-primary ghost" id="photoHistoryCloseBtn">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="vendor/lucide.min.js"></script>
|
||||
<script src="admin.js"></script>
|
||||
|
||||
+157
-8
@@ -9,6 +9,11 @@ const ACTION_LABELS = {
|
||||
'group.create': 'Создана группа',
|
||||
'group.update': 'Изменена группа',
|
||||
'group.delete': 'Удалена группа',
|
||||
'group.soft-delete': 'Группа в корзину',
|
||||
'group.restore': 'Группа восстановлена',
|
||||
'group.permanent-delete': 'Группа удалена безвозвратно',
|
||||
'group.schedule-delete': 'Группа помечена на удаление',
|
||||
'group.unschedule': 'Удаление группы отменено',
|
||||
'group.photo.create': 'Загружена фото группы',
|
||||
'group.photo.update': 'Изменено фото группы',
|
||||
'group.photo.reorder': 'Изменён порядок фото группы',
|
||||
@@ -21,13 +26,32 @@ const ACTION_LABELS = {
|
||||
'student.update': 'Изменён ученик',
|
||||
'student.delete': 'Удалён ученик',
|
||||
'student.batch-group': 'Ученики перенесены в группу',
|
||||
'module.create': 'Создан модуль',
|
||||
'module.update': 'Изменён модуль',
|
||||
'module.delete': 'Модуль отключён',
|
||||
'module.restore': 'Модуль включён',
|
||||
'entry.update': 'Изменена запись',
|
||||
'entry.photo.update': 'Изменено фото записи',
|
||||
'entry.photo.set_main': 'Главное фото записи',
|
||||
'entry.photo.delete': 'Удалено фото записи',
|
||||
'entry.photo.enhance': 'Фото улучшено',
|
||||
'entry.photo.enhance-ai.queue': 'ИИ-обработка фото поставлена в очередь',
|
||||
'entry.photo.apply': 'Применён результат обработки фото',
|
||||
'entry.photo.reject': 'Отклонён результат обработки фото',
|
||||
'entry.photo.rollback': 'Фото отката к версии',
|
||||
'entry.photo.restore_original': 'Возвращён оригинал фото',
|
||||
'photo.job.preview': 'Создан предпросмотр обработки фото',
|
||||
'photo.job.retry': 'Повтор обработки фото',
|
||||
'photo.job.error': 'Ошибка обработки фото',
|
||||
'photo-jobs.wake': 'Фото-воркер разбужен',
|
||||
'photo-jobs.enabled': 'Переключена обработка фото',
|
||||
'photo-jobs.requeue-failed': 'Ошибочные фото-задания возвращены в очередь',
|
||||
'entry.soft-delete': 'Запись в корзину',
|
||||
'entry.restore': 'Запись восстановлена',
|
||||
'entry.permanent-delete': 'Запись удалена безвозвратно',
|
||||
'entry.schedule-delete': 'Запись помечена на удаление',
|
||||
'entry.unschedule': 'Удаление записи отменено',
|
||||
'trash.clear': 'Корзина помечена на удаление',
|
||||
'link.create': 'Создана ссылка',
|
||||
'link.update': 'Изменена ссылка',
|
||||
'link.delete': 'Удалена ссылка',
|
||||
@@ -41,6 +65,11 @@ const ACTION_LABELS = {
|
||||
'ai.wake': 'Воркер ИИ разбужен вручную',
|
||||
'ai.enabled': 'Переключена автопроверка ИИ',
|
||||
'ai.requeue-failed': 'Ошибочные записи возвращены в очередь ИИ',
|
||||
'ai.profile.create': 'Создан профиль ИИ',
|
||||
'ai.profile.update': 'Изменён профиль ИИ',
|
||||
'ai.profile.delete': 'Удалён профиль ИИ',
|
||||
'ai.profile.activate': 'Выбран активный профиль ИИ',
|
||||
'export.student': 'Экспорт данных ученика',
|
||||
};
|
||||
|
||||
const AUDIT_PAGE_SIZE = 50;
|
||||
@@ -48,10 +77,30 @@ let auditPage = 1;
|
||||
let auditTotal = 0;
|
||||
let auditRows = [];
|
||||
|
||||
const EDIT_SOURCE_LABELS = {
|
||||
manual: 'Вручную',
|
||||
ai: 'ИИ',
|
||||
ai_manual: 'ИИ + правка вручную',
|
||||
ai_revert: 'Откат к оригиналу (ИИ)'
|
||||
};
|
||||
|
||||
const FIELD_LABELS = {
|
||||
student_name: 'ФИО ученика',
|
||||
group_id: 'Группа',
|
||||
module_id: 'Тема модуля',
|
||||
description: 'Текст работы'
|
||||
};
|
||||
|
||||
function fmtTime(t) {
|
||||
try { return new Date(t).toLocaleString('ru'); } catch { return t || ''; }
|
||||
}
|
||||
|
||||
function parseTarget(t) {
|
||||
if (t === null || t === undefined) return null;
|
||||
if (typeof t === 'object') return t;
|
||||
try { return JSON.parse(t); } catch { return null; }
|
||||
}
|
||||
|
||||
function dt(t) {
|
||||
if (!t) return '';
|
||||
if (typeof t === 'object') {
|
||||
@@ -71,6 +120,76 @@ function fullTarget(t) {
|
||||
} catch { return String(t); }
|
||||
}
|
||||
|
||||
function changeLabel(c) {
|
||||
return c.label || FIELD_LABELS[c.field] || c.field;
|
||||
}
|
||||
|
||||
function changeSummary(c) {
|
||||
if (c.field === 'description' && c.stats) return `текст: +${c.stats.added_words}/−${c.stats.removed_words} слов`;
|
||||
if (c.field === 'description') return 'текст работы';
|
||||
return changeLabel(c).toLowerCase();
|
||||
}
|
||||
|
||||
function targetSummary(t) {
|
||||
const o = parseTarget(t);
|
||||
if (!o) return dt(t);
|
||||
if (Array.isArray(o.changes)) {
|
||||
if (!o.changes.length) return esc(`без изменений${o.source ? ` · ${EDIT_SOURCE_LABELS[o.source] || o.source}` : ''}`);
|
||||
const parts = o.changes.map(changeSummary);
|
||||
if (o.photos_added) parts.push(`фото +${o.photos_added}`);
|
||||
const src = o.source ? ` · ${EDIT_SOURCE_LABELS[o.source] || o.source}` : '';
|
||||
return esc(parts.join(', ') + src);
|
||||
}
|
||||
return dt(t);
|
||||
}
|
||||
|
||||
function renderDiffSegments(segments) {
|
||||
return (segments || []).map(s => {
|
||||
const text = esc(s.text);
|
||||
if (s.type === 'add') return `<ins class="audit-diff-add">${text}</ins>`;
|
||||
if (s.type === 'del') return `<del class="audit-diff-del">${text}</del>`;
|
||||
return `<span>${text}</span>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderChange(c) {
|
||||
const label = changeLabel(c);
|
||||
if (c.field !== 'description') {
|
||||
return `<div class="audit-change">
|
||||
<div class="audit-change-title">${esc(label)}</div>
|
||||
<div class="audit-change-line"><span class="audit-tag-del">было</span> ${esc(c.before === null || c.before === undefined ? '—' : c.before)}</div>
|
||||
<div class="audit-change-line"><span class="audit-tag-add">стало</span> ${esc(c.after === null || c.after === undefined ? '—' : c.after)}</div>
|
||||
</div>`;
|
||||
}
|
||||
const s = c.stats || {};
|
||||
const parts = [];
|
||||
if (s.added_words || s.added_chars) parts.push(`<span class="audit-stat-add">+${s.added_words || 0} слов (+${s.added_chars || 0} симв.)</span>`);
|
||||
if (s.removed_words || s.removed_chars) parts.push(`<span class="audit-stat-del">−${s.removed_words || 0} слов (−${s.removed_chars || 0} симв.)</span>`);
|
||||
if (s.chars_before !== undefined) parts.push(`<span class="audit-stat-muted">было ${s.chars_before} симв. → стало ${s.chars_after} симв.</span>`);
|
||||
return `<div class="audit-change">
|
||||
<div class="audit-change-title">${esc(label)}</div>
|
||||
<div class="audit-change-stats">${parts.join('')}</div>
|
||||
<div class="audit-diff">${renderDiffSegments(c.diff) || '<span class="audit-stat-muted">нет текстового диффа</span>'}</div>
|
||||
${c.truncated ? '<div class="audit-truncated">Показана только часть изменений</div>' : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderTargetDetails(target) {
|
||||
const o = parseTarget(target);
|
||||
if (!o) return `<pre>${esc(fullTarget(target))}</pre>`;
|
||||
if (!Array.isArray(o.changes)) return `<pre>${esc(fullTarget(target))}</pre>`;
|
||||
const head = [];
|
||||
if (o.id) head.push(`<span class="audit-pill">Запись #${esc(o.id)}</span>`);
|
||||
if (o.source) head.push(`<span class="audit-pill">Источник: ${esc(EDIT_SOURCE_LABELS[o.source] || o.source)}</span>`);
|
||||
head.push(`<span class="audit-pill">Полей затронуто: ${o.changes.length}</span>`);
|
||||
if (o.photos_added) head.push(`<span class="audit-pill">Добавлено фото: ${o.photos_added}</span>`);
|
||||
const body = o.changes.length
|
||||
? o.changes.map(renderChange).join('')
|
||||
: '<div class="audit-stat-muted">Изменений не зафиксировано.</div>';
|
||||
return `${head.join('')}<div class="audit-changes">${body}</div>
|
||||
<details class="audit-raw"><summary>Технические данные (JSON)</summary><pre>${esc(fullTarget(target))}</pre></details>`;
|
||||
}
|
||||
|
||||
function renderPager(container, page, totalPages, onPageChange) {
|
||||
if (totalPages <= 1) { container.innerHTML = ''; return; }
|
||||
const pages = [];
|
||||
@@ -91,19 +210,20 @@ function renderPager(container, page, totalPages, onPageChange) {
|
||||
|
||||
async function loadAudit() {
|
||||
const el = document.getElementById('auditTable').querySelector('tbody');
|
||||
el.innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Загрузка…</td></tr>';
|
||||
el.innerHTML = '<tr><td colspan="5" style="color:var(--muted)">Загрузка…</td></tr>';
|
||||
const p = new URLSearchParams();
|
||||
p.set('limit', AUDIT_PAGE_SIZE);
|
||||
p.set('offset', (auditPage - 1) * AUDIT_PAGE_SIZE);
|
||||
const action = document.getElementById('auditActionFilter').value;
|
||||
if (action) p.set('action', action);
|
||||
const res = await fetch(`${API}/api/audit?${p}`, { headers: hdr() });
|
||||
if (!res.ok) { el.innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Ошибка загрузки</td></tr>'; return; }
|
||||
if (!res.ok) { el.innerHTML = '<tr><td colspan="5" style="color:var(--muted)">Ошибка загрузки</td></tr>'; return; }
|
||||
const rows = await res.json();
|
||||
// Total count for pagination (approximate: if we got less than page size, we're on last page)
|
||||
const totalPages = rows.length < AUDIT_PAGE_SIZE ? auditPage : auditPage + 1;
|
||||
auditTotal = totalPages;
|
||||
document.getElementById('auditCount').textContent = rows.length ? `Записей на странице: ${rows.length}` : '';
|
||||
const q = (document.getElementById('auditFilter').value || '').toLowerCase().trim();
|
||||
const filtered = q ? rows.filter(r => (ACTION_LABELS[r.action] || r.action).toLowerCase().includes(q)) : rows;
|
||||
const filtered = q ? rows.filter(r => `${ACTION_LABELS[r.action] || r.action} ${targetSummary(r.target)}`.toLowerCase().includes(q)) : rows;
|
||||
if (!filtered.length) {
|
||||
document.getElementById('auditEmpty').style.display = 'block';
|
||||
el.innerHTML = '';
|
||||
@@ -113,8 +233,9 @@ async function loadAudit() {
|
||||
el.innerHTML = filtered.map((r, i) => `
|
||||
<tr>
|
||||
<td style="white-space:nowrap">${fmtTime(r.created_at)}</td>
|
||||
<td style="white-space:nowrap">${esc(r.user_name || 'система')}</td>
|
||||
<td><b>${esc(ACTION_LABELS[r.action] || r.action)}</b></td>
|
||||
<td><button type="button" class="audit-details" data-audit-idx="${i}">${dt(r.target)}</button></td>
|
||||
<td><button type="button" class="audit-details" data-audit-idx="${i}">${targetSummary(r.target)}</button></td>
|
||||
<td style="color:var(--muted);white-space:nowrap">${esc(r.ip || '')}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
@@ -126,17 +247,44 @@ function goAuditPage(p) {
|
||||
loadAudit();
|
||||
}
|
||||
|
||||
function fillActionFilter() {
|
||||
const sel = document.getElementById('auditActionFilter');
|
||||
const current = sel.value;
|
||||
const opts = Object.entries(ACTION_LABELS)
|
||||
.sort((a, b) => a[1].localeCompare(b[1], 'ru'))
|
||||
.map(([k, v]) => `<option value="${esc(k)}">${esc(v)}</option>`)
|
||||
.join('');
|
||||
sel.innerHTML = '<option value="">Все действия</option>' + opts;
|
||||
sel.value = current;
|
||||
}
|
||||
|
||||
document.getElementById('auditActionFilter').addEventListener('change', () => { auditPage = 1; loadAudit(); });
|
||||
document.getElementById('auditFilter').addEventListener('input', () => { auditPage = 1; loadAudit(); });
|
||||
document.getElementById('auditRefreshBtn').addEventListener('click', loadAudit);
|
||||
|
||||
document.getElementById('auditTable').addEventListener('click', e => {
|
||||
document.getElementById('auditTable').addEventListener('click', async e => {
|
||||
const btn = e.target.closest('.audit-details');
|
||||
if (!btn) return;
|
||||
const r = auditRows[parseInt(btn.dataset.auditIdx, 10)];
|
||||
if (!r) return;
|
||||
const modal = document.getElementById('auditDetailsModal');
|
||||
const headEl = document.getElementById('auditDetailsHead');
|
||||
const bodyEl = document.getElementById('auditDetailsBody');
|
||||
document.getElementById('auditDetailsTitle').textContent = ACTION_LABELS[r.action] || r.action;
|
||||
document.getElementById('auditDetailsBody').textContent = fullTarget(r.target);
|
||||
document.getElementById('auditDetailsModal').classList.add('open');
|
||||
headEl.innerHTML = `<span class="audit-pill">${esc(fmtTime(r.created_at))}</span><span class="audit-pill">${esc(r.user_name || 'система')}</span>${r.ip ? `<span class="audit-pill">IP ${esc(r.ip)}</span>` : ''}`;
|
||||
bodyEl.innerHTML = renderTargetDetails(r.target);
|
||||
modal.classList.add('open');
|
||||
if (!r.id) return;
|
||||
bodyEl.classList.add('audit-loading');
|
||||
try {
|
||||
const res = await fetch(`${API}/api/audit/${r.id}`, { headers: hdr() });
|
||||
if (!res.ok) return;
|
||||
const full = await res.json();
|
||||
bodyEl.innerHTML = renderTargetDetails(full.target);
|
||||
} catch (err) {
|
||||
} finally {
|
||||
bodyEl.classList.remove('audit-loading');
|
||||
}
|
||||
});
|
||||
document.getElementById('auditDetailsCloseBtn').addEventListener('click', () => {
|
||||
document.getElementById('auditDetailsModal').classList.remove('open');
|
||||
@@ -148,6 +296,7 @@ document.getElementById('auditDetailsModal').addEventListener('click', e => {
|
||||
(async () => {
|
||||
if (await requireAdminPage()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
fillActionFilter();
|
||||
loadAudit();
|
||||
}
|
||||
})();
|
||||
@@ -14,3 +14,7 @@ if (details) {
|
||||
})();
|
||||
|
||||
document.querySelector('.error-actions button').addEventListener('click', () => history.back());
|
||||
|
||||
fetch('/api/public-settings').then(r => r.ok ? r.json() : null).then(s => {
|
||||
if (s && (s.system_name || '').trim()) document.title = `Ошибка — ${s.system_name}`;
|
||||
}).catch(() => {});
|
||||
+29
-4
@@ -1,5 +1,6 @@
|
||||
const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
|
||||
let branchesCache = [];
|
||||
let tutorsCache = [];
|
||||
let allGroups = [];
|
||||
let currentCover = null;
|
||||
|
||||
@@ -8,6 +9,16 @@ async function loadBranchesCache() {
|
||||
if (res.ok) branchesCache = await res.json();
|
||||
}
|
||||
|
||||
async function loadTutorsCache() {
|
||||
const res = await fetch(`${API}/api/users/tutors`, { headers: hdr() });
|
||||
if (res.ok) tutorsCache = await res.json();
|
||||
}
|
||||
|
||||
function tutorSelectHTML(selectedId) {
|
||||
const opts = tutorsCache.map(t => `<option value="${t.id}" ${t.id === selectedId ? 'selected' : ''}>${esc(t.name || t.username)}</option>`).join('');
|
||||
return `<div><label>Тутор</label><select class="tutor-select" style="background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-size:.8rem"><option value="">— не назначен —</option>${opts}</select></div>`;
|
||||
}
|
||||
|
||||
function branchSelectHTML(selectedId) {
|
||||
const opts = branchesCache.map(b => `<option value="${b.id}" ${b.id === selectedId ? 'selected' : ''}>${esc(b.name)}</option>`).join('');
|
||||
return `<div><label>Филиал</label><select class="branch-select" style="background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-size:.8rem"><option value="">— без филиала —</option>${opts}</select></div>`;
|
||||
@@ -15,6 +26,7 @@ function branchSelectHTML(selectedId) {
|
||||
|
||||
async function loadGroups() {
|
||||
await loadBranchesCache();
|
||||
await loadTutorsCache();
|
||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||
const groups = await res.json();
|
||||
allGroups = groups;
|
||||
@@ -33,6 +45,7 @@ async function loadGroups() {
|
||||
</div>
|
||||
<div class="group-schedule">
|
||||
${branchSelectHTML(g.branch_id)}
|
||||
${tutorSelectHTML(g.tutor_id)}
|
||||
<div><label>День</label>
|
||||
<select id="dow-${g.id}">
|
||||
<option value="">—</option>
|
||||
@@ -66,13 +79,15 @@ async function saveGroup(id) {
|
||||
const groupCard = nameInput.closest('.group-card');
|
||||
const branchSelect = groupCard ? groupCard.querySelector('.branch-select') : null;
|
||||
const branch_id = branchSelect ? branchSelect.value : '';
|
||||
const tutorSelect = groupCard ? groupCard.querySelector('.tutor-select') : null;
|
||||
const tutor_id = tutorSelect ? (tutorSelect.value || null) : undefined;
|
||||
const dow = document.getElementById(`dow-${id}`).value;
|
||||
const ts = document.getElementById(`ts-${id}`).value;
|
||||
const te = document.getElementById(`te-${id}`).value;
|
||||
const res = await fetch(`${API}/api/groups/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: hdrJson(),
|
||||
body: JSON.stringify({ name, branch_id: branch_id || null, day_of_week: dow !== '' ? parseInt(dow) : null, time_start: ts || null, time_end: te || null })
|
||||
body: JSON.stringify({ name, branch_id: branch_id || null, tutor_id, day_of_week: dow !== '' ? parseInt(dow) : null, time_start: ts || null, time_end: te || null })
|
||||
});
|
||||
if (res.ok) {
|
||||
loadGroups();
|
||||
@@ -87,16 +102,20 @@ async function addGroup() {
|
||||
const name = input.value.trim();
|
||||
if (!name) return;
|
||||
await loadBranchesCache();
|
||||
await loadTutorsCache();
|
||||
const branchSelect = document.getElementById('newGroupBranch');
|
||||
const branch_id = branchSelect ? branchSelect.value : '';
|
||||
const res = await fetch(`${API}/api/groups`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name, branch_id: branch_id || null }) });
|
||||
const tutorSelect = document.getElementById('newGroupTutor');
|
||||
const tutor_id = tutorSelect && tutorSelect.value ? tutorSelect.value : null;
|
||||
const res = await fetch(`${API}/api/groups`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name, branch_id: branch_id || null, tutor_id }) });
|
||||
if (res.ok) { input.value = ''; loadGroups(); }
|
||||
else { const e = await res.json(); alert(e.error); }
|
||||
}
|
||||
|
||||
async function delGroup(id) {
|
||||
if (!confirm('Удалить группу? Фото группы также будут удалены.')) return;
|
||||
await fetch(`${API}/api/groups/${id}`, { method: 'DELETE', headers: hdr() });
|
||||
if (!confirm('Удалить группу? Группа попадёт в корзину, восстановить её можно будет позже.')) return;
|
||||
const res = await fetch(`${API}/api/groups/${id}`, { method: 'DELETE', headers: hdr() });
|
||||
if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); }
|
||||
loadGroups();
|
||||
}
|
||||
|
||||
@@ -414,6 +433,12 @@ async function populateNewGroupBranch() {
|
||||
const sel = document.getElementById('newGroupBranch');
|
||||
sel.innerHTML = '<option value="">— без филиала —</option>' + branches.map(b => `<option value="${b.id}">${esc(b.name)}</option>`).join('');
|
||||
}
|
||||
const tRes = await fetch(`${API}/api/users/tutors`, { headers: hdr() });
|
||||
if (tRes.ok) {
|
||||
const tutors = await tRes.json();
|
||||
const tSel = document.getElementById('newGroupTutor');
|
||||
tSel.innerHTML = '<option value="">— не назначен —</option>' + tutors.map(t => `<option value="${t.id}">${esc(t.name || t.username)}</option>`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
|
||||
+273
-11
@@ -4,17 +4,81 @@ const preview = document.getElementById('preview');
|
||||
const placeholderSrc = preview.src;
|
||||
const removePhotoBtn = document.getElementById('removePhotoBtn');
|
||||
const groupInput = document.getElementById('groupInput');
|
||||
const moduleInput = document.getElementById('moduleInput');
|
||||
const moduleInputList = document.getElementById('moduleInputList');
|
||||
const moduleField = document.getElementById('moduleField');
|
||||
const moduleNames = new Map();
|
||||
let moduleSearchTimer = null;
|
||||
const toast = document.getElementById('toast');
|
||||
const camVideo = document.getElementById('camVideo');
|
||||
const camCanvas = document.getElementById('camCanvas');
|
||||
const camModal = document.getElementById('camModal');
|
||||
let capturedBlob = null;
|
||||
let stream = null;
|
||||
let camOpening = false;
|
||||
const descInput = document.getElementById('descInput');
|
||||
const filesInput = document.getElementById('filesInput');
|
||||
const filesList = document.getElementById('filesList');
|
||||
const filesClearBtn = document.getElementById('filesClearBtn');
|
||||
let selectedFiles = [];
|
||||
const sentPanel = document.getElementById('sentPanel');
|
||||
const sentTitle = document.getElementById('sentTitle');
|
||||
const sentText = document.getElementById('sentText');
|
||||
const sentTimer = document.getElementById('sentTimer');
|
||||
const SENT_KEY = 'whatido_entry_sent_at';
|
||||
let entryCooldownMin = 30;
|
||||
let captureWidth = 640;
|
||||
let captureHeight = 480;
|
||||
let captureQuality = 0.92;
|
||||
let sentTimerId = null;
|
||||
|
||||
function unlockForm() {
|
||||
clearInterval(sentTimerId);
|
||||
sentTimerId = null;
|
||||
localStorage.removeItem(SENT_KEY);
|
||||
sentPanel.style.display = 'none';
|
||||
form.style.display = 'grid';
|
||||
toast.textContent = 'Можно отправить новую запись';
|
||||
toast.classList.add('show');
|
||||
setTimeout(() => toast.classList.remove('show'), 2500);
|
||||
}
|
||||
|
||||
function tickSentTimer(unlockTime) {
|
||||
const remain = Math.max(0, unlockTime - Date.now());
|
||||
if (remain <= 0) {
|
||||
sentTimer.textContent = '';
|
||||
unlockForm();
|
||||
return;
|
||||
}
|
||||
const m = Math.floor(remain / 60000);
|
||||
const s = Math.floor((remain % 60000) / 1000);
|
||||
sentTimer.textContent = 'Новую запись можно отправить через ' + m + ' мин ' + s + ' сек';
|
||||
}
|
||||
|
||||
function showSent(title, text, unlockTime) {
|
||||
form.style.display = 'none';
|
||||
sentPanel.style.display = 'flex';
|
||||
sentTitle.textContent = title;
|
||||
sentText.textContent = text;
|
||||
tickSentTimer(unlockTime);
|
||||
clearInterval(sentTimerId);
|
||||
sentTimerId = setInterval(() => tickSentTimer(unlockTime), 1000);
|
||||
}
|
||||
|
||||
function cooldownMs() { return entryCooldownMin * 60 * 1000; }
|
||||
|
||||
function checkPreviousSend() {
|
||||
const raw = localStorage.getItem(SENT_KEY);
|
||||
if (!raw) return;
|
||||
const sentAt = parseInt(raw, 10);
|
||||
if (!Number.isFinite(sentAt)) { localStorage.removeItem(SENT_KEY); return; }
|
||||
const unlockTime = sentAt + cooldownMs();
|
||||
if (Date.now() < unlockTime) {
|
||||
showSent('Запись уже отправлена', 'Ты уже отправил(а) запись сегодня. Дождись окончания отсчёта, чтобы отправить новую.', unlockTime);
|
||||
} else {
|
||||
localStorage.removeItem(SENT_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
function humanSize(b) {
|
||||
if (b < 1024) return b + ' Б';
|
||||
@@ -55,13 +119,108 @@ function clearFiles() {
|
||||
renderFiles();
|
||||
}
|
||||
|
||||
const MAX_FILES = 10;
|
||||
const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
||||
const MAX_TOTAL_BYTES = 30 * 1024 * 1024;
|
||||
const BLOCKED_FILE_EXT = /\.(?:html?|js|mjs|cjs|svg|xml|json|map|wasm|php\d?|phtml|asp|aspx|jsp|sh|bat|cmd|cgi|exe|dll|com|msi|scr|hta|vbs|py|r|rb|htaccess)$/i;
|
||||
const MIME_EXT = {
|
||||
'image/png': '.png', 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp',
|
||||
'image/bmp': '.bmp', 'image/avif': '.avif', 'image/heic': '.heic', 'image/heif': '.heif', 'image/svg+xml': '.svg',
|
||||
'application/pdf': '.pdf', 'application/zip': '.zip', 'application/x-zip-compressed': '.zip',
|
||||
'application/msword': '.doc', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
|
||||
'application/vnd.ms-excel': '.xls', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
|
||||
'application/vnd.ms-powerpoint': '.ppt', 'application/vnd.openxmlformats-officedocument.presentationml.presentation': '.pptx',
|
||||
'text/plain': '.txt', 'text/csv': '.csv', 'application/rtf': '.rtf',
|
||||
'application/x-rar-compressed': '.rar', 'application/vnd.rar': '.rar', 'application/x-7z-compressed': '.7z'
|
||||
};
|
||||
let notifyTimer = null;
|
||||
|
||||
function notify(text, isError) {
|
||||
toast.textContent = text;
|
||||
toast.classList.add('show');
|
||||
toast.classList.toggle('error', !!isError);
|
||||
clearTimeout(notifyTimer);
|
||||
notifyTimer = setTimeout(() => toast.classList.remove('show', 'error'), 3200);
|
||||
}
|
||||
|
||||
function extOf(name) {
|
||||
const s = String(name || '');
|
||||
const i = s.lastIndexOf('.');
|
||||
return i > 0 ? s.slice(i).toLowerCase() : '';
|
||||
}
|
||||
|
||||
function extFromFile(file) {
|
||||
const named = extOf(file.name);
|
||||
if (/^\.[a-z0-9]{1,8}$/i.test(named)) return named;
|
||||
const byMime = MIME_EXT[String(file.type || '').toLowerCase()];
|
||||
if (byMime) return byMime;
|
||||
const sub = String(file.type || '').split('/')[1] || '';
|
||||
return /^[a-z0-9]{1,8}$/i.test(sub) ? '.' + sub : '';
|
||||
}
|
||||
|
||||
function pasteFileName(file) {
|
||||
const base = String(file.name || '').trim();
|
||||
const stem = base.replace(/\.[a-z0-9]{1,8}$/i, '');
|
||||
if (base && !/^(image|photo|picture|blob|file|screenshot|снимок|скриншот|изображение|файл)$/i.test(stem)) return base;
|
||||
const d = new Date();
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const stamp = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`;
|
||||
return `Вставка ${stamp}${extFromFile(file)}`;
|
||||
}
|
||||
|
||||
function totalUploadBytes() {
|
||||
return (capturedBlob ? capturedBlob.size : 0) + selectedFiles.reduce((sum, f) => sum + f.size, 0);
|
||||
}
|
||||
|
||||
function addFiles(list) {
|
||||
const added = [];
|
||||
const skipped = [];
|
||||
for (const file of list) {
|
||||
if (selectedFiles.length >= MAX_FILES) { skipped.push('Можно прикрепить не более 10 файлов'); break; }
|
||||
if (file.size > MAX_FILE_BYTES) { skipped.push(`«${file.name}» больше 10 МБ`); continue; }
|
||||
if (BLOCKED_FILE_EXT.test(extOf(file.name))) { skipped.push(`«${file.name}»: недопустимый тип файла`); continue; }
|
||||
if (totalUploadBytes() + file.size > MAX_TOTAL_BYTES) { skipped.push('Суммарный размер файлов — не более 30 МБ'); break; }
|
||||
selectedFiles.push(file);
|
||||
added.push(file.name);
|
||||
}
|
||||
if (added.length) renderFiles();
|
||||
return { added, skipped };
|
||||
}
|
||||
|
||||
filesInput.addEventListener('change', () => {
|
||||
const keep = 10 - selectedFiles.length;
|
||||
if (keep <= 0) { alert('Можно прикрепить не более 10 файлов'); filesInput.value = ''; return; }
|
||||
selectedFiles = selectedFiles.concat([...filesInput.files].slice(0, keep));
|
||||
if ([...filesInput.files].length > keep) alert('Можно прикрепить не более 10 файлов');
|
||||
const res = addFiles([...filesInput.files]);
|
||||
filesInput.value = '';
|
||||
renderFiles();
|
||||
if (res.skipped.length) alert(res.skipped[0]);
|
||||
});
|
||||
|
||||
document.addEventListener('paste', (e) => {
|
||||
if (form.style.display === 'none') return;
|
||||
const clip = e.clipboardData;
|
||||
if (!clip) return;
|
||||
const pasted = [];
|
||||
for (const item of Array.from(clip.items || [])) {
|
||||
if (item.kind !== 'file') continue;
|
||||
const file = item.getAsFile();
|
||||
if (file) pasted.push(file);
|
||||
}
|
||||
if (!pasted.length && clip.files && clip.files.length) pasted.push(...Array.from(clip.files));
|
||||
if (!pasted.length) return;
|
||||
e.preventDefault();
|
||||
let filesAdded = 0;
|
||||
const skipped = [];
|
||||
for (const raw of pasted) {
|
||||
const name = pasteFileName(raw);
|
||||
const file = raw.name === name ? raw : new File([raw], name, { type: raw.type || 'application/octet-stream', lastModified: raw.lastModified });
|
||||
const res = addFiles([file]);
|
||||
filesAdded += res.added.length;
|
||||
skipped.push(...res.skipped);
|
||||
}
|
||||
if (!filesAdded) {
|
||||
if (skipped.length) notify(skipped[0], true);
|
||||
return;
|
||||
}
|
||||
const summary = filesAdded === 1 ? 'Файл добавлен' : `Файлов добавлено: ${filesAdded}`;
|
||||
notify(summary + (skipped.length ? '. ' + skipped[0] : ''), skipped.length > 0);
|
||||
});
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
@@ -74,7 +233,49 @@ if (params.get('nameInput')) {
|
||||
field.innerHTML = `<label>Фамилия и имя</label><div class="fixed-name" id="fixedName">${escName(studentName)}</div>`;
|
||||
}
|
||||
|
||||
function renderModuleOptions(list) {
|
||||
list.forEach(m => moduleNames.set(m.name, m.id));
|
||||
moduleInputList.innerHTML = '';
|
||||
list.forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.name;
|
||||
moduleInputList.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchModules(q, limit) {
|
||||
const p = new URLSearchParams();
|
||||
if (q) p.set('search', q);
|
||||
p.set('limit', String(limit));
|
||||
p.set('active', '1');
|
||||
try {
|
||||
const res = await fetch(`${API}/api/modules?${p}`);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.modules || [];
|
||||
} catch (e) { return []; }
|
||||
}
|
||||
|
||||
async function resolveModuleId(name) {
|
||||
const n = (name || '').trim();
|
||||
if (!n) return null;
|
||||
if (moduleNames.has(n)) return moduleNames.get(n);
|
||||
const list = await fetchModules(n, 5);
|
||||
renderModuleOptions(list);
|
||||
const lower = n.toLowerCase();
|
||||
const exact = list.find(m => m.name.toLowerCase() === lower);
|
||||
return exact ? exact.id : null;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const list = await fetchModules('', 50);
|
||||
if (list.length) {
|
||||
renderModuleOptions(list);
|
||||
moduleInput.required = true;
|
||||
moduleField.style.display = '';
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
const res = await fetch(`${API}/api/students`);
|
||||
const students = await res.json();
|
||||
const me = studentName ? students.find(s => s.name === studentName) : null;
|
||||
@@ -97,9 +298,33 @@ if (params.get('nameInput')) {
|
||||
try {
|
||||
const fRes = await fetch(`${API}/api/public-settings`);
|
||||
const f = await fRes.json();
|
||||
const title = document.getElementById('title');
|
||||
if (title && (f.system_name || '').trim()) {
|
||||
title.textContent = f.system_name;
|
||||
document.title = f.system_name;
|
||||
}
|
||||
const brandLogo = document.getElementById('brandLogo');
|
||||
if (brandLogo && f.system_logo) {
|
||||
brandLogo.src = f.system_logo;
|
||||
brandLogo.hidden = false;
|
||||
brandLogo.alt = f.system_name || '';
|
||||
}
|
||||
if (f.footer_left) document.getElementById('footerLeft').textContent = f.footer_left;
|
||||
if (f.footer_right) document.getElementById('footerRight').textContent = f.footer_right;
|
||||
const c = parseInt(f.spam_interval_min, 10);
|
||||
if (Number.isFinite(c) && c >= 1) entryCooldownMin = c;
|
||||
const w = parseInt(f.photo_capture_width, 10);
|
||||
const h = parseInt(f.photo_capture_height, 10);
|
||||
if (Number.isFinite(w) && w >= 160 && w <= 4096) captureWidth = w;
|
||||
if (Number.isFinite(h) && h >= 120 && h <= 4096) captureHeight = h;
|
||||
const q = parseFloat(f.photo_capture_quality);
|
||||
if (Number.isFinite(q) && q >= 0.5 && q <= 1) captureQuality = q;
|
||||
if (f.camera_enabled === 'false') {
|
||||
const camBtns = document.getElementById('camBtns');
|
||||
if (camBtns) camBtns.style.display = 'none';
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
checkPreviousSend();
|
||||
})();
|
||||
|
||||
function showPreview(src) {
|
||||
@@ -116,12 +341,22 @@ function removePhoto() {
|
||||
}
|
||||
|
||||
async function openCam() {
|
||||
if (camOpening) return;
|
||||
if (stream && camModal.classList.contains('open')) return;
|
||||
camOpening = true;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } } });
|
||||
const s = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: captureWidth }, height: { ideal: captureHeight } } });
|
||||
if (camModal.classList.contains('open')) {
|
||||
s.getTracks().forEach(t => t.stop());
|
||||
return;
|
||||
}
|
||||
stream = s;
|
||||
camVideo.srcObject = stream;
|
||||
camModal.classList.add('open');
|
||||
} catch (e) {
|
||||
alert('Не удалось открыть камеру: ' + e.message);
|
||||
} finally {
|
||||
camOpening = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +366,10 @@ function closeCam() {
|
||||
}
|
||||
|
||||
function capture() {
|
||||
if (!camVideo.videoWidth || !camVideo.videoHeight) {
|
||||
notify('Камера ещё не готова, попробуйте через секунду', true);
|
||||
return;
|
||||
}
|
||||
camCanvas.width = camVideo.videoWidth;
|
||||
camCanvas.height = camVideo.videoHeight;
|
||||
const ctx = camCanvas.getContext('2d');
|
||||
@@ -141,7 +380,7 @@ function capture() {
|
||||
capturedBlob = blob;
|
||||
showPreview(URL.createObjectURL(blob));
|
||||
closeCam();
|
||||
}, 'image/jpeg', 0.85);
|
||||
}, 'image/jpeg', captureQuality);
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
@@ -152,6 +391,16 @@ form.addEventListener('submit', async (e) => {
|
||||
setTimeout(() => toast.classList.remove('show', 'error'), 3200);
|
||||
return;
|
||||
}
|
||||
let moduleId = '';
|
||||
if (moduleField.style.display !== 'none') {
|
||||
moduleId = await resolveModuleId(moduleInput.value);
|
||||
if (!moduleId) {
|
||||
toast.textContent = 'Выберите тему модуля из списка подсказок';
|
||||
toast.classList.add('show', 'error');
|
||||
setTimeout(() => toast.classList.remove('show', 'error'), 3200);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const btn = document.getElementById('submitBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Отправка...';
|
||||
@@ -161,6 +410,7 @@ form.addEventListener('submit', async (e) => {
|
||||
selectedFiles.forEach(f => fd.append('files', f, f.name));
|
||||
fd.append('student_name', studentName || document.getElementById('nameInput').value);
|
||||
fd.append('group_id', groupInput.value);
|
||||
fd.append('module_id', moduleId || '');
|
||||
fd.append('description', document.getElementById('descInput').value);
|
||||
fd.append('website', document.getElementById('hpWebsite').value);
|
||||
|
||||
@@ -177,10 +427,8 @@ form.addEventListener('submit', async (e) => {
|
||||
form.reset();
|
||||
removePhoto();
|
||||
clearFiles();
|
||||
toast.textContent = 'Готово!';
|
||||
toast.classList.remove('error');
|
||||
toast.classList.add('show');
|
||||
setTimeout(() => toast.classList.remove('show'), 2500);
|
||||
localStorage.setItem(SENT_KEY, String(Date.now()));
|
||||
showSent('Запись отправлена!', 'Спасибо! Твоя запись уже передана преподавателю.', Date.now() + cooldownMs());
|
||||
} catch (err) {
|
||||
alert('Ошибка: ' + err.message);
|
||||
} finally {
|
||||
@@ -191,7 +439,21 @@ form.addEventListener('submit', async (e) => {
|
||||
|
||||
removePhotoBtn.addEventListener('click', removePhoto);
|
||||
document.getElementById('openCamBtn').addEventListener('click', openCam);
|
||||
preview.addEventListener('click', openCam);
|
||||
preview.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openCam(); }
|
||||
});
|
||||
filesClearBtn.addEventListener('click', clearFiles);
|
||||
document.getElementById('filesBtn').addEventListener('click', () => document.getElementById('filesInput').click());
|
||||
document.getElementById('cancelCamBtn').addEventListener('click', closeCam);
|
||||
document.getElementById('captureBtn').addEventListener('click', capture);
|
||||
moduleInput.addEventListener('input', () => {
|
||||
clearTimeout(moduleSearchTimer);
|
||||
const q = moduleInput.value.trim();
|
||||
moduleSearchTimer = setTimeout(async () => {
|
||||
renderModuleOptions(await fetchModules(q, 20));
|
||||
}, 250);
|
||||
});
|
||||
moduleInput.addEventListener('focus', async () => {
|
||||
if (!moduleInput.value.trim()) renderModuleOptions(await fetchModules('', 50));
|
||||
});
|
||||
+889
-11
File diff suppressed because it is too large
Load Diff
+24
-6
@@ -3,6 +3,8 @@ let groups = [];
|
||||
let editingId = null;
|
||||
let studentsLoaded = false;
|
||||
let linkSettings = null;
|
||||
let linkPage = 1;
|
||||
const LINK_PAGE_SIZE = 20;
|
||||
|
||||
async function loadLinkSettings() {
|
||||
if (!linkSettings) {
|
||||
@@ -16,21 +18,30 @@ function linkUrl(token) { return `${location.origin}/s/${token}`; }
|
||||
|
||||
async function loadLinks() {
|
||||
await loadLinkSettings();
|
||||
const res = await fetch(`${API}/api/links`, { headers: hdr() });
|
||||
links = await res.json();
|
||||
const p = new URLSearchParams();
|
||||
p.set('limit', LINK_PAGE_SIZE);
|
||||
p.set('offset', (linkPage - 1) * LINK_PAGE_SIZE);
|
||||
const res = await fetch(`${API}/api/links?${p}`, { headers: hdr() });
|
||||
const data = await res.json();
|
||||
links = data.items;
|
||||
const total = data.total;
|
||||
const el = document.getElementById('linksList');
|
||||
if (!links.length) { el.innerHTML = '<div class="empty">Ссылок пока нет. Создайте её в Журнале кнопкой «Ссылка».</div>'; return; }
|
||||
if (!links.length) { el.innerHTML = `<div class="empty">${linkPage > 1 ? 'На этой странице ссылок нет.' : 'Ссылок пока нет. Создайте её в Журнале кнопкой «Ссылка».'}</div>`; renderPager(document.getElementById('pager'), 1, 1, goLinkPage); return; }
|
||||
const now = Date.now();
|
||||
el.innerHTML = links.map(l => {
|
||||
const namesHidden = l.show_student_names != null ? !l.show_student_names : linkSettings.share_show_student_names === 'false';
|
||||
const expired = l.expires_at && new Date(l.expires_at).getTime() < now;
|
||||
const daysLeft = l.expires_at ? Math.ceil((new Date(l.expires_at).getTime() - now) / 86400000) : null;
|
||||
const soon = !expired && daysLeft !== null && daysLeft <= 1;
|
||||
return `
|
||||
<div class="item">
|
||||
<div class="item${expired ? ' expired' : ''}">
|
||||
<div style="min-width:0">
|
||||
<div class="name">${esc(l.name)}</div>
|
||||
<div class="name">${esc(l.name)}${expired ? ' <span class="badge-expired">истекла</span>' : soon ? ' <span class="badge-soon">истекает</span>' : ''}</div>
|
||||
<div style="font-size:.8rem;color:var(--muted);margin-top:2px">
|
||||
${l.group_name ? `Группа: <b>${esc(l.group_name)}</b>` : ''}
|
||||
${l.student_name ? `${l.group_name ? ' · ' : ''}Ученик: <b>${esc(l.student_name)}</b>` : ''}
|
||||
${l.date_from || l.date_to ? ` · период: <b>${l.date_from ? l.date_from.split('-').reverse().join('.') : '…'}</b> — <b>${l.date_to ? l.date_to.split('-').reverse().join('.') : '…'}</b>` : ' · весь период'}
|
||||
${l.expires_at ? ` · истекает ${new Date(l.expires_at).toLocaleDateString('ru')}` : ''}
|
||||
${expired ? ` · <span style="color:#ef4444;font-weight:600">срок действия истёк ${new Date(l.expires_at).toLocaleDateString('ru')}</span>` : l.expires_at ? ` · истекает ${new Date(l.expires_at).toLocaleDateString('ru')}` : ''}
|
||||
${namesHidden ? ` · <span style="color:#f59e0b">имена скрыты</span>` : ''}
|
||||
${l.access_password_hash ? ` · <span style="color:#2563eb">🔒 пароль</span>` : ''}
|
||||
· создана ${new Date(l.created_at).toLocaleString('ru')}
|
||||
@@ -38,12 +49,19 @@ async function loadLinks() {
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-shrink:0;align-items:center">
|
||||
<a class="btn-link" style="text-decoration:none" href="/s/${l.token}" target="_blank" title="Открыть">Открыть</a>
|
||||
<a class="btn-link" style="text-decoration:none" href="/r/${l.token}" target="_blank" title="Отчёт ученика">Отчёт</a>
|
||||
<button class="btn-link" data-copy="${esc(linkUrl(l.token))}" title="Копировать ссылку">Копировать</button>
|
||||
<button class="btn-link" data-edit-link="${l.id}" title="Редактировать">✎</button>
|
||||
<button class="btn-link" style="color:var(--muted)" data-del-link="${l.id}" title="Удалить">×</button>
|
||||
</div>
|
||||
</div>
|
||||
`;}).join('');
|
||||
renderPager(document.getElementById('pager'), linkPage, Math.max(1, Math.ceil(total / LINK_PAGE_SIZE)), goLinkPage);
|
||||
}
|
||||
|
||||
function goLinkPage(p) {
|
||||
linkPage = p;
|
||||
loadLinks();
|
||||
}
|
||||
|
||||
function fmtDate(d) { const [y, m, day] = (d || '').split('-'); return d ? `${day}.${m}.${y}` : ''; }
|
||||
|
||||
@@ -29,3 +29,22 @@ async function doLogin() {
|
||||
document.getElementById('pwdInput').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
|
||||
document.getElementById('userInput').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
|
||||
document.querySelector('.login button').addEventListener('click', doLogin);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/public-settings`);
|
||||
if (!res.ok) return;
|
||||
const s = await res.json();
|
||||
const title = document.getElementById('loginTitle');
|
||||
if (title && (s.system_name || '').trim()) title.textContent = s.system_name;
|
||||
const logo = document.getElementById('loginLogo');
|
||||
if (logo && s.system_logo) {
|
||||
const img = document.createElement('img');
|
||||
img.src = s.system_logo;
|
||||
img.alt = '';
|
||||
logo.appendChild(img);
|
||||
logo.style.display = 'flex';
|
||||
}
|
||||
document.title = title ? title.textContent + ' — Вход' : document.title;
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
@@ -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')}
|
||||
+789
-77
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 showStudentMessage = !!data.show_student_message;
|
||||
const showEntryDate = !!data.show_entry_date;
|
||||
grid.innerHTML = data.entries.map(e => {
|
||||
const dayKey = e => {
|
||||
const d = new Date(e.created_at);
|
||||
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
||||
};
|
||||
const dayLabel = k => {
|
||||
const [y,m,d] = k.split('-').map(Number);
|
||||
return new Date(y, m-1, d).toLocaleDateString('ru-RU', { day:'numeric', month:'long', year:'numeric' });
|
||||
};
|
||||
const cardHTML = e => {
|
||||
const body = [
|
||||
!isAnonymized ? `<div class="name">${esc(e.student_name)}</div>` : '',
|
||||
!isAnonymized && e.group_name ? `<div><span class="group">${esc(e.group_name)}</span></div>` : '',
|
||||
@@ -257,7 +265,18 @@ function renderShare(data, password) {
|
||||
${showEntryDate ? `<div class="foot">${new Date(e.created_at).toLocaleString('ru',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
};
|
||||
const groups = [];
|
||||
let lastKey = null;
|
||||
data.entries.forEach(e => {
|
||||
const k = dayKey(e);
|
||||
if (k !== lastKey) { groups.push({ key: k, entries: [] }); lastKey = k; }
|
||||
groups[groups.length-1].entries.push(e);
|
||||
});
|
||||
grid.innerHTML = groups.map(g =>
|
||||
`<div class="date-sep"><span class="date-chip">${esc(dayLabel(g.key))}</span><span class="date-line"></span></div>` +
|
||||
g.entries.map(cardHTML).join('')
|
||||
).join('');
|
||||
}
|
||||
|
||||
document.getElementById('lightbox').addEventListener('click', () => document.getElementById('lightbox').classList.remove('open'));
|
||||
@@ -290,3 +309,27 @@ document.addEventListener('click', e => {
|
||||
notice.classList.add('hidden');
|
||||
});
|
||||
})();
|
||||
|
||||
(async function(){
|
||||
try {
|
||||
const res = await fetch('/api/public-settings');
|
||||
if (!res.ok) return;
|
||||
const s = await res.json();
|
||||
const head = document.getElementById('brandHead');
|
||||
if (!head) return;
|
||||
const logo = document.getElementById('brandLogo');
|
||||
const name = document.getElementById('brandName');
|
||||
let any = false;
|
||||
if (name && (s.system_name || '').trim()) {
|
||||
name.textContent = s.system_name;
|
||||
name.hidden = false;
|
||||
any = true;
|
||||
}
|
||||
if (logo && s.system_logo) {
|
||||
logo.src = s.system_logo;
|
||||
logo.hidden = false;
|
||||
any = true;
|
||||
}
|
||||
if (any) head.hidden = false;
|
||||
} catch (e) {}
|
||||
})();
|
||||
|
||||
@@ -47,6 +47,8 @@ function renderStudents() {
|
||||
<select class="group-select" id="sgroup-${s.id}"></select>
|
||||
<div class="student-actions">
|
||||
<button class="btn-save" data-save-student="${s.id}">Сохранить</button>
|
||||
<button class="btn-link" data-profile-student="${s.id}" title="Скачать профиль ученика (ZIP: фото и работы)">Профиль</button>
|
||||
<button class="btn-link" data-profile-data="${s.id}" title="Данные профиля: заполняют разделы страницы профиля в архиве">Данные</button>
|
||||
<button class="btn-link" data-export-student="${esc(s.name)}" title="Сформировать ZIP-отчёт: фотографии и файлы">Отчёт</button>
|
||||
<button class="btn-link" data-copy-student="${esc(s.name)}" title="Скопировать ссылку с подстановкой имени">Ссылка</button>
|
||||
<button class="btn-del" data-del-student="${s.id}" title="Удалить">×</button>
|
||||
@@ -156,6 +158,215 @@ async function submitExport() {
|
||||
await exportStudent(name, opts);
|
||||
}
|
||||
|
||||
async function exportStudentProfile(id) {
|
||||
const s = allStudents.find(x => x.id === id);
|
||||
if (!s) return;
|
||||
const btn = document.querySelector(`[data-profile-student="${id}"]`);
|
||||
const label = btn ? btn.textContent : '';
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Готовим...'; }
|
||||
showToast(`Формируем профиль: ${s.name}...`);
|
||||
try {
|
||||
const res = await fetch(`${API}/api/export/student?name=${encodeURIComponent(s.name)}`, { headers: hdr() });
|
||||
if (!res.ok) {
|
||||
const e = await res.json().catch(() => ({}));
|
||||
showToast(e.error || 'Не удалось сформировать профиль');
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
downloadBlob(blob, `profile_${safeWindowsName(s.name)}_${new Date().toISOString().slice(0, 10)}.zip`);
|
||||
showToast(`Готово: ${s.name}`);
|
||||
} catch (err) {
|
||||
showToast('Ошибка при формировании профиля');
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.textContent = label; }
|
||||
}
|
||||
}
|
||||
|
||||
let profileDataId = null;
|
||||
let profilePhotos = [];
|
||||
|
||||
function openProfileData(id) {
|
||||
profileDataId = id;
|
||||
loadGroupOptions();
|
||||
loadStudentPhotos(id);
|
||||
fetch(`${API}/api/students/${id}/profile`, { headers: hdr() })
|
||||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('load'))))
|
||||
.then(data => fillProfileForm(data.profile, data.name, data.photo_path))
|
||||
.catch(() => fillProfileForm(null, '', ''));
|
||||
document.getElementById('profileDataModal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeProfileData() {
|
||||
document.getElementById('profileDataModal').classList.remove('open');
|
||||
profileDataId = null;
|
||||
}
|
||||
|
||||
async function loadStudentPhotos(id) {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/students/${id}/photos`, { headers: hdr() });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
profilePhotos = data.photos || [];
|
||||
document.getElementById('pfPhoto').value = data.photo_path || '';
|
||||
renderStudentPhotos();
|
||||
} catch (err) {
|
||||
profilePhotos = [];
|
||||
}
|
||||
}
|
||||
|
||||
function renderStudentPhotos() {
|
||||
const wrap = document.getElementById('pfPhotos');
|
||||
if (!wrap) return;
|
||||
const main = document.getElementById('pfPhoto').value.trim();
|
||||
if (!profilePhotos.length) {
|
||||
wrap.innerHTML = '<span class="hint" style="margin:0">Пока нет загруженных фото</span>';
|
||||
return;
|
||||
}
|
||||
wrap.innerHTML = profilePhotos.map(p => `
|
||||
<div class="pf-photo${p.photo_path === main ? ' main' : ''}" data-pf-set="${p.id}" title="Сделать главным">
|
||||
<img src="${esc(p.photo_path)}" alt="" loading="lazy">
|
||||
${p.photo_path === main ? '<span class="pf-badge">Главная</span>' : ''}
|
||||
<button type="button" class="pf-del" data-pf-del="${p.id}" title="Удалить фото">✕</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function uploadStudentPhoto(file) {
|
||||
if (!profileDataId || !file) return;
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert('Можно загружать только изображения');
|
||||
return;
|
||||
}
|
||||
showToast('Загружаем фото...');
|
||||
const fd = new FormData();
|
||||
fd.append('photo', file);
|
||||
try {
|
||||
const res = await fetch(`${API}/api/students/${profileDataId}/photos`, { method: 'POST', headers: hdr(), body: fd });
|
||||
if (res.ok) {
|
||||
await loadStudentPhotos(profileDataId);
|
||||
showToast('Фото загружено');
|
||||
} else {
|
||||
const e = await res.json().catch(() => ({}));
|
||||
alert(e.error || 'Не удалось загрузить фото');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Ошибка при загрузке фото');
|
||||
}
|
||||
}
|
||||
|
||||
async function setMainStudentPhoto(pid) {
|
||||
if (!profileDataId) return;
|
||||
try {
|
||||
const res = await fetch(`${API}/api/students/${profileDataId}/photos/${pid}/main`, { method: 'PUT', headers: hdr() });
|
||||
if (res.ok) {
|
||||
const r = await res.json();
|
||||
document.getElementById('pfPhoto').value = r.photo_path || '';
|
||||
renderStudentPhotos();
|
||||
showToast('Главное фото обновлено');
|
||||
} else {
|
||||
const e = await res.json().catch(() => ({}));
|
||||
alert(e.error || 'Не удалось выбрать главное фото');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Ошибка при выборе главного фото');
|
||||
}
|
||||
}
|
||||
|
||||
async function delStudentPhoto(pid) {
|
||||
if (!profileDataId) return;
|
||||
if (!confirm('Удалить это фото?')) return;
|
||||
try {
|
||||
const res = await fetch(`${API}/api/students/${profileDataId}/photos/${pid}`, { method: 'DELETE', headers: hdr() });
|
||||
if (res.ok) {
|
||||
await loadStudentPhotos(profileDataId);
|
||||
showToast('Фото удалено');
|
||||
} else {
|
||||
const e = await res.json().catch(() => ({}));
|
||||
alert(e.error || 'Не удалось удалить фото');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Ошибка при удалении фото');
|
||||
}
|
||||
}
|
||||
|
||||
function fillProfileForm(profile, name, photoPath) {
|
||||
const p = profile || {};
|
||||
const set = (id, v) => { document.getElementById(id).value = v === null || v === undefined ? '' : String(v); };
|
||||
document.getElementById('profileDataTitle').textContent = name || 'Профиль ученика';
|
||||
set('pfRole', p.role); set('pfStatus', p.status); set('pfStatusNote', p.status_note);
|
||||
set('pfCity', p.city); set('pfMentor', p.mentor); set('pfJoined', p.joined);
|
||||
set('pfBio', p.bio); set('pfQuote', p.quote);
|
||||
set('pfTags', (p.tags || []).join(', '));
|
||||
set('pfContacts', (p.contacts || []).map(c => [c.icon || 'link', c.label, c.href || ''].join(' | ')).join('\n'));
|
||||
set('pfAchievements', (p.achievements || []).join('\n'));
|
||||
set('pfSkills', (p.skills || []).map(s => [s.group || 'Навыки', s.name, s.level || '', s.value === null || s.value === undefined ? '' : s.value].join(' | ')).join('\n'));
|
||||
set('pfExperience', (p.experience || []).map(e => [e.title, e.company || '', e.period || '', e.date || '', e.badge || '', e.description || '', (e.tags || []).join(',')].join(' | ')).join('\n'));
|
||||
set('pfEducation', (p.education || []).map(m => [m.module, m.progress === null || m.progress === undefined ? '' : m.progress, m.grade || '', m.teacher || ''].join(' | ')).join('\n'));
|
||||
set('pfPhoto', photoPath || '');
|
||||
}
|
||||
|
||||
function collectProfile() {
|
||||
const val = id => document.getElementById(id).value.trim();
|
||||
const lines = id => val(id).split('\n').map(s => s.trim()).filter(Boolean);
|
||||
const cols = line => line.split('|').map(s => s.trim());
|
||||
const intOrNull = s => (s === '' || s === null || s === undefined ? null : Number(s));
|
||||
const out = {
|
||||
role: val('pfRole') || null,
|
||||
status: val('pfStatus') || null,
|
||||
status_note: val('pfStatusNote') || null,
|
||||
city: val('pfCity') || null,
|
||||
mentor: val('pfMentor') || null,
|
||||
joined: val('pfJoined') || null,
|
||||
bio: val('pfBio') || null,
|
||||
quote: val('pfQuote') || null,
|
||||
tags: val('pfTags') ? val('pfTags').split(',').map(s => s.trim()).filter(Boolean) : [],
|
||||
contacts: lines('pfContacts').map(l => {
|
||||
const c = cols(l);
|
||||
return { icon: c[0] || 'link', label: c[1] || '', href: c[2] || '' };
|
||||
}).filter(c => c.label),
|
||||
achievements: lines('pfAchievements'),
|
||||
skills: lines('pfSkills').map(l => {
|
||||
const c = cols(l);
|
||||
return { group: c[0] || 'Навыки', name: c[1] || '', level: c[2] || null, value: intOrNull(c[3]) };
|
||||
}).filter(s => s.name),
|
||||
experience: lines('pfExperience').map(l => {
|
||||
const c = cols(l);
|
||||
return {
|
||||
title: c[0] || '', company: c[1] || null, period: c[2] || null, date: c[3] || null,
|
||||
badge: c[4] || null, description: c[5] || null,
|
||||
tags: c[6] ? c[6].split(',').map(s => s.trim()).filter(Boolean) : [],
|
||||
};
|
||||
}).filter(e => e.title),
|
||||
education: lines('pfEducation').map(l => {
|
||||
const c = cols(l);
|
||||
return { module: c[0] || '', progress: intOrNull(c[1]), grade: c[2] || null, teacher: c[3] || null };
|
||||
}).filter(m => m.module),
|
||||
};
|
||||
const hasData = Object.keys(out).some(k => (Array.isArray(out[k]) ? out[k].length : out[k]));
|
||||
return hasData ? out : null;
|
||||
}
|
||||
|
||||
async function saveProfileData(e) {
|
||||
e.preventDefault();
|
||||
if (!profileDataId) return;
|
||||
const body = { profile: collectProfile() };
|
||||
const photoPath = document.getElementById('pfPhoto').value.trim();
|
||||
body.photo_path = photoPath || null;
|
||||
const res = await fetch(`${API}/api/students/${profileDataId}/profile`, {
|
||||
method: 'PUT',
|
||||
headers: hdrJson(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (res.ok) {
|
||||
closeProfileData();
|
||||
loadStudents();
|
||||
showToast('Данные профиля сохранены');
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Не удалось сохранить данные профиля');
|
||||
}
|
||||
}
|
||||
|
||||
async function delStudent(id) {
|
||||
if (!confirm('Удалить ученика из списка? Старые записи останутся.')) return;
|
||||
await fetch(`${API}/api/students/${id}`, { method: 'DELETE', headers: hdr() });
|
||||
@@ -318,10 +529,46 @@ document.getElementById('studentList').addEventListener('click', e => {
|
||||
if (cp) { copyStudentLink(cp.dataset.copyStudent); return; }
|
||||
const ex = e.target.closest('[data-export-student]');
|
||||
if (ex) { openExportModal(ex.dataset.exportStudent.trim()); return; }
|
||||
const pf = e.target.closest('[data-profile-student]');
|
||||
if (pf) { exportStudentProfile(parseInt(pf.dataset.profileStudent, 10)); return; }
|
||||
const pd = e.target.closest('[data-profile-data]');
|
||||
if (pd) { openProfileData(parseInt(pd.dataset.profileData, 10)); return; }
|
||||
const dl = e.target.closest('[data-del-student]');
|
||||
if (dl) { delStudent(parseInt(dl.dataset.delStudent, 10)); }
|
||||
});
|
||||
|
||||
document.getElementById('profileDataForm').addEventListener('submit', saveProfileData);
|
||||
document.getElementById('profileDataCancelBtn').addEventListener('click', closeProfileData);
|
||||
document.getElementById('pfPhotoUploadBtn').addEventListener('click', () => document.getElementById('pfPhotoFile').click());
|
||||
document.getElementById('pfPhotoFile').addEventListener('change', e => {
|
||||
const f = e.target.files && e.target.files[0];
|
||||
e.target.value = '';
|
||||
if (f) uploadStudentPhoto(f);
|
||||
});
|
||||
document.getElementById('pfPhotos').addEventListener('click', e => {
|
||||
const del = e.target.closest('[data-pf-del]');
|
||||
if (del) { delStudentPhoto(parseInt(del.dataset.pfDel, 10)); return; }
|
||||
const set = e.target.closest('[data-pf-set]');
|
||||
if (set) setMainStudentPhoto(parseInt(set.dataset.pfSet, 10));
|
||||
});
|
||||
document.addEventListener('paste', e => {
|
||||
if (!profileDataId) return;
|
||||
if (!document.getElementById('profileDataModal').classList.contains('open')) return;
|
||||
const items = e.clipboardData && e.clipboardData.items;
|
||||
if (!items) return;
|
||||
for (const it of items) {
|
||||
if (it.type && it.type.startsWith('image/')) {
|
||||
const f = it.getAsFile();
|
||||
if (f) {
|
||||
e.preventDefault();
|
||||
uploadStudentPhoto(f);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
document.getElementById('profileDataModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeProfileData(); });
|
||||
|
||||
(async () => {
|
||||
if (await checkAuth()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
|
||||
+177
-13
@@ -1,7 +1,14 @@
|
||||
const PAGE_SIZE = 20;
|
||||
let currentEntries = [];
|
||||
let currentGroups = [];
|
||||
let pendingEntries = [];
|
||||
let pendingGroups = [];
|
||||
let page = 1;
|
||||
let totalEntries = 0;
|
||||
let totalGroups = 0;
|
||||
let pendingTotalEntries = 0;
|
||||
let pendingTotalGroups = 0;
|
||||
let purgeDays = 30;
|
||||
|
||||
function isImageFile(name) {
|
||||
return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
|
||||
@@ -28,32 +35,129 @@ function entryHTML(e) {
|
||||
<div class="trash-time">Удалена: ${new Date(e.deleted_at).toLocaleString('ru')}</div>
|
||||
</div>
|
||||
<button class="restore-btn" data-restore="${e.id}" title="Восстановить">Восстановить</button>
|
||||
<button class="del-btn" data-hard="${e.id}" title="Удалить навсегда (вместе с файлами)">×</button>
|
||||
<button class="del-btn" data-hard="${e.id}" title="Пометить на удаление (через ${purgeDays} дней будет удалена безвозвратно)">×</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function groupHTML(g) {
|
||||
return `
|
||||
<div class="card">
|
||||
${g.cover_path ? `<img src="${thumbSrc(g.cover_path)}" data-img="${API}${g.cover_path}" loading="lazy" decoding="async" alt="">` : ''}
|
||||
<div class="info">
|
||||
<div class="name">${esc(g.name)}</div>
|
||||
${g.branch_name ? `<div class="group">${esc(g.branch_name)}</div>` : ''}
|
||||
<div class="trash-time">Удалена: ${new Date(g.deleted_at).toLocaleString('ru')}</div>
|
||||
</div>
|
||||
<button class="restore-btn" data-restore-group="${g.id}" title="Восстановить">Восстановить</button>
|
||||
<button class="del-btn" data-hard-group="${g.id}" title="Пометить на удаление (через ${purgeDays} дней будет удалена безвозвратно вместе с записями и фото)">×</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function pendingEntryHTML(e) {
|
||||
return `
|
||||
<div class="card pending">
|
||||
${e.photo_path ? `<img src="${thumbSrc(e.photo_path)}" data-img="${API}${e.photo_path}" loading="lazy" decoding="async" alt="">` : ''}
|
||||
<div class="info">
|
||||
<div class="name">${esc(e.student_name)}</div>
|
||||
<div class="group">${esc(e.group_name)}</div>
|
||||
<div class="desc">${esc(e.description)}</div>
|
||||
${filesHTML(e.files)}
|
||||
<div class="trash-time warn">Удаление через ${purgeDays} дн.: ${new Date(e.purge_at).toLocaleString('ru')}</div>
|
||||
</div>
|
||||
<button class="restore-btn" data-unschedule="${e.id}" title="Вернуть в корзину">В корзину</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function pendingGroupHTML(g) {
|
||||
return `
|
||||
<div class="card pending">
|
||||
${g.cover_path ? `<img src="${thumbSrc(g.cover_path)}" data-img="${API}${g.cover_path}" loading="lazy" decoding="async" alt="">` : ''}
|
||||
<div class="info">
|
||||
<div class="name">${esc(g.name)}</div>
|
||||
${g.branch_name ? `<div class="group">${esc(g.branch_name)}</div>` : ''}
|
||||
<div class="trash-time warn">Удаление через ${purgeDays} дн.: ${new Date(g.purge_at).toLocaleString('ru')}</div>
|
||||
</div>
|
||||
<button class="restore-btn" data-unschedule-group="${g.id}" title="Вернуть в корзину">В корзину</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderGroups() {
|
||||
const grid = document.getElementById('groupsGrid');
|
||||
grid.className = 'grid';
|
||||
grid.innerHTML = currentGroups.length
|
||||
? currentGroups.map(groupHTML).join('')
|
||||
: '<div class="empty">Групп в корзине нет</div>';
|
||||
document.getElementById('groupsInfo').textContent =
|
||||
totalGroups > 0 ? `— групп в корзине: ${totalGroups}` : '';
|
||||
}
|
||||
|
||||
function renderGrid() {
|
||||
const grid = document.getElementById('grid');
|
||||
grid.className = 'grid';
|
||||
grid.innerHTML = currentEntries.length
|
||||
? currentEntries.map(entryHTML).join('')
|
||||
: '<div class="empty">Корзина пуста</div>';
|
||||
: '<div class="empty">Записей в корзине нет</div>';
|
||||
document.getElementById('entriesInfo').textContent =
|
||||
totalEntries > 0 ? `— записей в корзине: ${totalEntries}` : '';
|
||||
document.getElementById('trashInfo').textContent =
|
||||
totalEntries > 0 ? `Записей в корзине: ${totalEntries}` : '';
|
||||
document.getElementById('emptyBtn').disabled = totalEntries === 0;
|
||||
totalEntries + totalGroups > 0
|
||||
? `В корзине: записей ${totalEntries}, групп ${totalGroups}`
|
||||
: 'Корзина пуста';
|
||||
document.getElementById('emptyBtn').disabled = totalEntries + totalGroups === 0;
|
||||
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalEntries / PAGE_SIZE)), goPage);
|
||||
}
|
||||
|
||||
function renderPending() {
|
||||
document.getElementById('pendingGroupsGrid').className = 'grid';
|
||||
document.getElementById('pendingGroupsGrid').innerHTML = pendingGroups.length
|
||||
? pendingGroups.map(pendingGroupHTML).join('')
|
||||
: '<div class="empty">Нет групп, помеченных на удаление</div>';
|
||||
document.getElementById('pendingGrid').className = 'grid';
|
||||
document.getElementById('pendingGrid').innerHTML = pendingEntries.length
|
||||
? pendingEntries.map(pendingEntryHTML).join('')
|
||||
: '<div class="empty">Нет записей, помеченных на удаление</div>';
|
||||
document.getElementById('pendingGroupsInfo').textContent =
|
||||
pendingTotalGroups > 0 ? `— ${pendingTotalGroups}` : '';
|
||||
document.getElementById('pendingEntriesInfo').textContent =
|
||||
pendingTotalEntries > 0 ? `— ${pendingTotalEntries}` : '';
|
||||
const n = pendingTotalEntries + pendingTotalGroups;
|
||||
document.getElementById('pendingInfo').textContent =
|
||||
n > 0 ? `— будет удалено: ${n}` : '';
|
||||
const hint = document.getElementById('pendingToggleHint');
|
||||
if (hint) {
|
||||
hint.textContent = n > 0
|
||||
? `Помечено: записей ${pendingTotalEntries}, групп ${pendingTotalGroups} — безвозвратно удалятся через ${purgeDays} дней`
|
||||
: 'Данных, помеченных на удаление, нет';
|
||||
}
|
||||
document.getElementById('pendingSection').hidden = !document.getElementById('pendingToggle').checked;
|
||||
}
|
||||
|
||||
async function loadTrash() {
|
||||
const p = new URLSearchParams();
|
||||
p.set('limit', PAGE_SIZE);
|
||||
p.set('offset', (page - 1) * PAGE_SIZE);
|
||||
const res = await fetch(`${API}/api/trash?${p}`, { headers: hdr() });
|
||||
const data = await res.json();
|
||||
const [trashRes, pendRes] = await Promise.all([
|
||||
fetch(`${API}/api/trash?${p}`, { headers: hdr() }),
|
||||
fetch(`${API}/api/trash/pending`, { headers: hdr() }),
|
||||
]);
|
||||
const data = await trashRes.json();
|
||||
const pend = await pendRes.json();
|
||||
currentEntries = data.entries;
|
||||
totalEntries = data.total;
|
||||
currentGroups = data.groups;
|
||||
totalGroups = data.total_groups;
|
||||
pendingEntries = pend.entries;
|
||||
pendingTotalEntries = pend.total;
|
||||
pendingGroups = pend.groups;
|
||||
pendingTotalGroups = pend.total_groups;
|
||||
if (pend.purge_days > 0) purgeDays = pend.purge_days;
|
||||
renderGroups();
|
||||
renderGrid();
|
||||
renderPending();
|
||||
}
|
||||
|
||||
function goPage(p) { page = p; loadTrash(); }
|
||||
@@ -65,22 +169,55 @@ async function restoreEntry(id) {
|
||||
}
|
||||
|
||||
async function hardDelete(id) {
|
||||
if (!confirm('Удалить запись навсегда? Файлы и фото будут удалены безвозвратно.')) return;
|
||||
await fetch(`${API}/api/entries/${id}/permanent`, { method: 'DELETE', headers: hdr() });
|
||||
showToast('Запись удалена навсегда');
|
||||
loadTrash();
|
||||
if (!confirm(`Пометить запись на удаление? Она будет безвозвратно удалена через ${purgeDays} дней вместе с файлами и фото.`)) return;
|
||||
const res = await fetch(`${API}/api/entries/${id}/permanent`, { method: 'DELETE', headers: hdr() });
|
||||
if (res.ok) { showToast(`Запись помечена на удаление через ${purgeDays} дн.`); loadTrash(); }
|
||||
else { const err = await res.json(); showToast(err.error || 'Ошибка'); }
|
||||
}
|
||||
|
||||
async function restoreGroup(id) {
|
||||
const res = await fetch(`${API}/api/groups/${id}/restore`, { method: 'PUT', headers: hdr() });
|
||||
if (res.ok) { showToast('Группа восстановлена'); loadTrash(); }
|
||||
else { const err = await res.json(); showToast(err.error || 'Ошибка восстановления'); }
|
||||
}
|
||||
|
||||
async function hardDeleteGroup(id, name) {
|
||||
if (!confirm(`Пометить группу «${name}» на удаление? Записи журнала, фото и файлы группы будут безвозвратно удалены через ${purgeDays} дней, ученики открепятся.`)) return;
|
||||
const res = await fetch(`${API}/api/groups/${id}/permanent`, { method: 'DELETE', headers: hdr() });
|
||||
if (res.ok) { showToast(`Группа помечена на удаление через ${purgeDays} дн.`); loadTrash(); }
|
||||
else { const err = await res.json(); showToast(err.error || 'Ошибка'); }
|
||||
}
|
||||
|
||||
async function unscheduleEntry(id) {
|
||||
const res = await fetch(`${API}/api/entries/${id}/unschedule`, { method: 'PUT', headers: hdr() });
|
||||
if (res.ok) { showToast('Запись возвращена в корзину'); loadTrash(); }
|
||||
else { const err = await res.json(); showToast(err.error || 'Ошибка'); }
|
||||
}
|
||||
|
||||
async function unscheduleGroup(id) {
|
||||
const res = await fetch(`${API}/api/groups/${id}/unschedule`, { method: 'PUT', headers: hdr() });
|
||||
if (res.ok) { showToast('Группа возвращена в корзину'); loadTrash(); }
|
||||
else { const err = await res.json(); showToast(err.error || 'Ошибка'); }
|
||||
}
|
||||
|
||||
async function emptyTrash() {
|
||||
if (totalEntries === 0) return;
|
||||
if (!confirm(`Удалить все записи из корзины (${totalEntries})? Файлы будут удалены безвозвратно.`)) return;
|
||||
const parts = [];
|
||||
if (totalEntries) parts.push(`записей: ${totalEntries}`);
|
||||
if (totalGroups) parts.push(`групп: ${totalGroups}`);
|
||||
if (!parts.length) return;
|
||||
if (!confirm(`Пометить всё из корзины (${parts.join(', ')}) на удаление? Данные будут безвозвратно удалены через ${purgeDays} дней.`)) return;
|
||||
const res = await fetch(`${API}/api/trash`, { method: 'DELETE', headers: hdr() });
|
||||
if (res.ok) { showToast('Корзина очищена'); page = 1; loadTrash(); }
|
||||
if (res.ok) { showToast(`Всё помечено на удаление через ${purgeDays} дн.`); page = 1; loadTrash(); }
|
||||
else { const err = await res.json(); alert(err.error || 'Ошибка'); }
|
||||
}
|
||||
|
||||
document.getElementById('emptyBtn').addEventListener('click', emptyTrash);
|
||||
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
|
||||
document.getElementById('pendingToggle').addEventListener('change', () => {
|
||||
const open = document.getElementById('pendingToggle').checked;
|
||||
document.getElementById('pendingSection').hidden = !open;
|
||||
if (open) loadTrash();
|
||||
});
|
||||
|
||||
document.getElementById('grid').addEventListener('click', e => {
|
||||
const card = e.target.closest('.card');
|
||||
@@ -91,6 +228,33 @@ document.getElementById('grid').addEventListener('click', e => {
|
||||
if (hd && (e.target === hd || hd.contains(e.target))) { hardDelete(parseInt(hd.dataset.hard, 10)); }
|
||||
});
|
||||
|
||||
document.getElementById('groupsGrid').addEventListener('click', e => {
|
||||
const card = e.target.closest('.card');
|
||||
if (!card) return;
|
||||
const rs = card.querySelector('[data-restore-group]');
|
||||
if (rs && (e.target === rs || rs.contains(e.target))) { restoreGroup(parseInt(rs.dataset.restoreGroup, 10)); return; }
|
||||
const hd = card.querySelector('[data-hard-group]');
|
||||
if (hd && (e.target === hd || hd.contains(e.target))) {
|
||||
const id = parseInt(hd.dataset.hardGroup, 10);
|
||||
const g = currentGroups.find(x => String(x.id) === String(id));
|
||||
hardDeleteGroup(id, g ? g.name : id);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('pendingGrid').addEventListener('click', e => {
|
||||
const card = e.target.closest('.card');
|
||||
if (!card) return;
|
||||
const us = card.querySelector('[data-unschedule]');
|
||||
if (us && (e.target === us || us.contains(e.target))) { unscheduleEntry(parseInt(us.dataset.unschedule, 10)); }
|
||||
});
|
||||
|
||||
document.getElementById('pendingGroupsGrid').addEventListener('click', e => {
|
||||
const card = e.target.closest('.card');
|
||||
if (!card) return;
|
||||
const us = card.querySelector('[data-unschedule-group]');
|
||||
if (us && (e.target === us || us.contains(e.target))) { unscheduleGroup(parseInt(us.dataset.unscheduleGroup, 10)); }
|
||||
});
|
||||
|
||||
(async () => {
|
||||
if (await checkAuth()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
|
||||
+209
-2
@@ -1,5 +1,6 @@
|
||||
const AUTO_REFRESH_MS = 5000;
|
||||
const RECENT_PAGE_SIZE = 15;
|
||||
const PHOTO_PAGE_SIZE = 15;
|
||||
const STATUS_LABELS = {
|
||||
pending: 'В очереди',
|
||||
processing: 'Проверяется',
|
||||
@@ -16,10 +17,40 @@ const STATUS_BADGE = {
|
||||
};
|
||||
|
||||
let state = null;
|
||||
let photoState = null;
|
||||
let photoJobRow = null;
|
||||
let refreshTimer = null;
|
||||
let busy = false;
|
||||
let photoBusy = false;
|
||||
let recentRows = [];
|
||||
let recentPage = 1;
|
||||
let photoPage = 1;
|
||||
let photoTotal = 0;
|
||||
|
||||
const PHOTO_ACTION_LABELS = {
|
||||
ai: 'ИИ-обработка',
|
||||
enhance: 'Улучшение',
|
||||
restore: 'Возврат оригинала',
|
||||
rollback: 'Откат',
|
||||
};
|
||||
const PHOTO_STATUS_LABELS = {
|
||||
pending: 'В очереди',
|
||||
processing: 'Обрабатывается',
|
||||
done: 'Готово',
|
||||
error: 'Ошибка',
|
||||
rejected: 'Отклонено',
|
||||
};
|
||||
const PHOTO_STATUS_BADGE = {
|
||||
done: 'ai-badge-done',
|
||||
error: 'ai-badge-error',
|
||||
processing: 'ai-badge-skipped',
|
||||
};
|
||||
|
||||
function thumbUrl(p) {
|
||||
if (!p) return '';
|
||||
if (p.startsWith('/uploads/.originals/')) return `${API}${p}`;
|
||||
return `${API}/uploads/thumb/${p.split('/').pop()}`;
|
||||
}
|
||||
|
||||
function fmtDur(ms) {
|
||||
if (ms === null || ms === undefined || ms < 0) return '—';
|
||||
@@ -190,6 +221,116 @@ function render() {
|
||||
renderIcons();
|
||||
}
|
||||
|
||||
function renderPhoto() {
|
||||
if (!photoState) return;
|
||||
const w = photoState.worker || {};
|
||||
document.getElementById('photoStateHint').textContent = !photoState.enabled
|
||||
? 'Обработка фотографий выключена'
|
||||
: (!photoState.ai_configured ? 'PHOTO_AI_URL не настроен' : (w.processing ? `Обрабатывается задание #${w.current_id ?? ''}` : 'Ожидание очереди'));
|
||||
document.getElementById('photoToggleBtn').textContent = photoState.enabled ? '📷 Выключить фото-воркер' : '📷 Включить фото-воркер';
|
||||
const c = photoState.counts || {};
|
||||
document.getElementById('photoCounts').innerHTML = [
|
||||
['pending', 'В очереди', c.pending || 0],
|
||||
['processing', 'Обрабатывается', c.processing || 0],
|
||||
['done', 'Готово', c.done || 0],
|
||||
['error', 'Ошибка', c.error || 0],
|
||||
].map(([cls, label, n]) => `<span class="chip ${cls}">${label}: <b>${n}</b></span>`).join('');
|
||||
const rows = photoState.recent || [];
|
||||
renderPhotoPending();
|
||||
const tbody = document.querySelector('#photoTable tbody');
|
||||
const empty = document.getElementById('photoEmpty');
|
||||
const pager = document.getElementById('photoRecentPager');
|
||||
const maxPage = Math.max(1, Math.ceil(photoTotal / PHOTO_PAGE_SIZE));
|
||||
if (photoPage > maxPage) photoPage = maxPage;
|
||||
if (!photoTotal) { tbody.innerHTML = ''; empty.style.display = 'block'; renderPager(pager, photoPage, maxPage, p => { photoPage = p; loadPhoto(); }); return; }
|
||||
empty.style.display = 'none';
|
||||
tbody.innerHTML = rows.map(r => {
|
||||
const before = r.before_path ? `<img class="photo-thumb" loading="lazy" src="${esc(thumbUrl(r.before_path))}" alt="было">` : '<span style="color:var(--muted)">—</span>';
|
||||
const after = r.after_path ? `<img class="photo-thumb" loading="lazy" src="${esc(thumbUrl(r.after_path))}" alt="стало">` : '<span style="color:var(--muted)">—</span>';
|
||||
const canRollback = r.status === 'done' && r.before_path && r.action !== 'rollback';
|
||||
const badge = `<span class="ai-badge ${PHOTO_STATUS_BADGE[r.status] || ''}">${esc(PHOTO_STATUS_LABELS[r.status] || r.status)}</span>`;
|
||||
return `<tr>
|
||||
<td style="white-space:nowrap;color:var(--muted)">${fmtTime(r.finished_at)}</td>
|
||||
<td>#${r.entry_id} ${esc(r.student_name || '')}</td>
|
||||
<td>${esc(PHOTO_ACTION_LABELS[r.action] || r.action)}</td>
|
||||
<td>${badge}</td>
|
||||
<td>${before}</td>
|
||||
<td>${after}</td>
|
||||
<td style="color:#ef4444;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(r.error || '')}">${r.error ? esc(r.error) : ''}</td>
|
||||
<td style="white-space:nowrap">
|
||||
<button type="button" class="btn-link" data-photo-view="${r.id}">Смотреть</button>
|
||||
${canRollback ? `<button type="button" class="btn-link" data-photo-rollback="${r.id}" data-photo-entry="${r.entry_id}">↩ Откатить</button>` : ''}
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
renderPager(pager, photoPage, maxPage, p => { photoPage = p; loadPhoto(); });
|
||||
renderIcons();
|
||||
}
|
||||
|
||||
function renderPhotoPending() {
|
||||
const rows = photoState.pending || [];
|
||||
document.getElementById('photoPendingCount').textContent = rows.length;
|
||||
const tbody = document.querySelector('#photoPendingTable tbody');
|
||||
const empty = document.getElementById('photoPendingEmpty');
|
||||
if (!rows.length) { tbody.innerHTML = ''; empty.style.display = 'block'; return; }
|
||||
empty.style.display = 'none';
|
||||
tbody.innerHTML = rows.map(r => {
|
||||
const processing = r.status === 'processing';
|
||||
const action = esc(PHOTO_ACTION_LABELS[r.action] || r.action);
|
||||
const badge = `<span class="ai-badge ${processing ? 'ai-badge-skipped' : ''}">${processing ? '⏳ Обрабатывается' : 'В очереди'}</span>`;
|
||||
return `<tr${processing ? ' style="background:rgba(59,130,246,.08)"' : ''}>
|
||||
<td>#${r.id}</td>
|
||||
<td>#${r.entry_id} ${esc(r.student_name || '')} <span style="color:var(--muted)">(${esc(r.group_name || '')})</span></td>
|
||||
<td>${action} ${badge}</td>
|
||||
<td style="color:var(--muted);white-space:nowrap">${fmtTime(r.created_at)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function findPhotoJob(id) {
|
||||
return (photoState && photoState.recent || []).find(r => r.id === id) || null;
|
||||
}
|
||||
|
||||
function setPhotoJobClip(p) {
|
||||
const v = Math.min(100, Math.max(0, p));
|
||||
document.getElementById('photoJobTop').style.clipPath = `inset(0 0 0 ${v}%)`;
|
||||
document.getElementById('photoJobDivider').style.left = v + '%';
|
||||
}
|
||||
|
||||
function openPhotoJob(r) {
|
||||
photoJobRow = r;
|
||||
document.getElementById('photoJobTitle').textContent = `Обработка фото — запись #${r.entry_id} ${r.student_name || ''}`;
|
||||
document.getElementById('photoJobBefore').src = thumbUrl(r.before_path);
|
||||
document.getElementById('photoJobAfter').src = r.after_path ? `${API}${r.after_path}` : '';
|
||||
document.getElementById('photoJobRollbackBtn').style.display = (r.before_path && r.action !== 'rollback') ? '' : 'none';
|
||||
setPhotoJobClip(50);
|
||||
document.getElementById('photoJobModal').classList.add('open');
|
||||
}
|
||||
|
||||
async function loadPhoto() {
|
||||
if (photoBusy) return;
|
||||
photoBusy = true;
|
||||
try {
|
||||
const res = await fetch(`${API}/api/photo-jobs/status?recent_limit=${PHOTO_PAGE_SIZE}&recent_offset=${(photoPage - 1) * PHOTO_PAGE_SIZE}`, { headers: hdr() });
|
||||
if (!res.ok) return;
|
||||
photoState = await res.json();
|
||||
photoTotal = photoState.recent_total || 0;
|
||||
renderPhoto();
|
||||
} catch {
|
||||
} finally {
|
||||
photoBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function postPhoto(path_, body) {
|
||||
const opts = { method: 'POST', headers: body ? hdrJson() : hdr() };
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
const res = await fetch(`${API}${path_}`, opts);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) { showToast(data.error || 'Ошибка'); return null; }
|
||||
return data;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
@@ -231,15 +372,28 @@ async function requeueFailed() {
|
||||
|
||||
function setAutoRefresh(on) {
|
||||
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
|
||||
if (on) refreshTimer = setInterval(load, AUTO_REFRESH_MS);
|
||||
if (on) refreshTimer = setInterval(() => { load(); loadPhoto(); }, AUTO_REFRESH_MS);
|
||||
}
|
||||
|
||||
document.getElementById('refreshBtn').addEventListener('click', load);
|
||||
document.getElementById('refreshBtn').addEventListener('click', () => { load(); loadPhoto(); });
|
||||
document.getElementById('wakeBtn').addEventListener('click', wake);
|
||||
document.getElementById('toggleBtn').addEventListener('click', toggleEnabled);
|
||||
document.getElementById('requeueBtn').addEventListener('click', requeueFailed);
|
||||
document.getElementById('recentFilter').addEventListener('change', () => { recentPage = 1; renderRecent(); });
|
||||
document.getElementById('autoRefresh').addEventListener('change', e => setAutoRefresh(e.target.checked));
|
||||
document.getElementById('photoWakeBtn').addEventListener('click', async () => {
|
||||
if (await postPhoto('/api/photo-jobs/wake')) { showToast('Фото-воркер разбужен'); loadPhoto(); }
|
||||
});
|
||||
document.getElementById('photoToggleBtn').addEventListener('click', async () => {
|
||||
const next = !(photoState && photoState.enabled);
|
||||
const data = await postPhoto('/api/photo-jobs/enabled', { enabled: next });
|
||||
if (data) { showToast(next ? 'Обработка фото включена' : 'Обработка фото выключена'); loadPhoto(); }
|
||||
});
|
||||
document.getElementById('photoRequeueBtn').addEventListener('click', async () => {
|
||||
if (!confirm('Вернуть все задания с ошибкой в очередь на повторную обработку?')) return;
|
||||
const data = await postPhoto('/api/photo-jobs/requeue-failed');
|
||||
if (data) { showToast(`В очередь возвращено: ${data.count}`); loadPhoto(); }
|
||||
});
|
||||
|
||||
document.getElementById('recentTable').addEventListener('click', e => {
|
||||
const btn = e.target.closest('.ai-details');
|
||||
@@ -258,10 +412,63 @@ document.getElementById('aiDetailModal').addEventListener('click', e => {
|
||||
if (e.target === e.currentTarget) e.currentTarget.classList.remove('open');
|
||||
});
|
||||
|
||||
document.getElementById('photoTable').addEventListener('click', async e => {
|
||||
const viewBtn = e.target.closest('[data-photo-view]');
|
||||
if (viewBtn) {
|
||||
const r = findPhotoJob(parseInt(viewBtn.dataset.photoView, 10));
|
||||
if (r) openPhotoJob(r);
|
||||
return;
|
||||
}
|
||||
const rb = e.target.closest('[data-photo-rollback]');
|
||||
if (rb) {
|
||||
const entryId = rb.dataset.photoEntry;
|
||||
const jobId = rb.dataset.photoRollback;
|
||||
if (!confirm('Откатить фотографию записи к версии «было»? Текущая версия сохранится в истории.')) return;
|
||||
const data = await postPhoto(`/api/entries/${entryId}/photo/jobs/${jobId}/rollback`);
|
||||
if (data) { showToast('Фотография отката'); loadPhoto(); }
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('photoJobCloseBtn').addEventListener('click', () => {
|
||||
document.getElementById('photoJobModal').classList.remove('open');
|
||||
});
|
||||
document.getElementById('photoJobModal').addEventListener('click', e => {
|
||||
if (e.target === e.currentTarget) e.currentTarget.classList.remove('open');
|
||||
});
|
||||
document.getElementById('photoJobRollbackBtn').addEventListener('click', async () => {
|
||||
const r = photoJobRow;
|
||||
if (!r) return;
|
||||
if (!confirm('Откатить фотографию записи к версии «было»? Текущая версия сохранится в истории.')) return;
|
||||
const data = await postPhoto(`/api/entries/${r.entry_id}/photo/jobs/${r.id}/rollback`);
|
||||
if (data) {
|
||||
showToast('Фотография отката');
|
||||
document.getElementById('photoJobModal').classList.remove('open');
|
||||
loadPhoto();
|
||||
}
|
||||
});
|
||||
|
||||
(function initPhotoJobDrag() {
|
||||
const area = document.getElementById('photoJobCompare');
|
||||
let dragging = false;
|
||||
const move = ev => {
|
||||
const rect = area.getBoundingClientRect();
|
||||
setPhotoJobClip(((ev.clientX - rect.left) / rect.width) * 100);
|
||||
};
|
||||
area.addEventListener('pointerdown', ev => {
|
||||
dragging = true;
|
||||
area.setPointerCapture(ev.pointerId);
|
||||
move(ev);
|
||||
});
|
||||
area.addEventListener('pointermove', ev => { if (dragging) move(ev); });
|
||||
area.addEventListener('pointerup', () => { dragging = false; });
|
||||
area.addEventListener('pointercancel', () => { dragging = false; });
|
||||
})();
|
||||
|
||||
(async () => {
|
||||
if (await requireAdminPage()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
load();
|
||||
loadPhoto();
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<h2>Ссылки</h2>
|
||||
<p style="color:var(--muted);font-size:.85rem;margin-bottom:20px;max-width:680px">Каждая ссылка открывает публичную страницу с карточками записей выбранной группы за указанный период — без входа и выбора параметров.</p>
|
||||
<div class="list" id="linksList"><div class="empty">Загрузка...</div></div>
|
||||
<div class="pager" id="pager"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+8
-2
@@ -9,7 +9,10 @@
|
||||
:root{--bg:#f5f5f5;--card:#fff;--border:#e5e5e5;--accent:#2563eb;--text:#171717;--muted:#737373;--radius:12px}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100dvh;display:flex;align-items:center;justify-content:center}
|
||||
.login{max-width:320px;width:100%;text-align:center;background:var(--card);border:1px solid var(--border);border-radius:16px;padding:40px 32px}
|
||||
.login h1{font-size:1.3rem;font-weight:600;margin-bottom:24px}
|
||||
.login .brand{display:flex;flex-direction:column;align-items:center;gap:14px;margin-bottom:24px}
|
||||
.login .brand .logo{height:48px;width:auto;min-width:48px;background:var(--bg);border:1px solid var(--border);display:none;align-items:center;justify-content:center;overflow:hidden}
|
||||
.login .brand .logo img{max-width:100%;max-height:100%;object-fit:contain}
|
||||
.login h1{font-size:1.3rem;font-weight:600;margin:0}
|
||||
.login input{width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:var(--radius);padding:14px 16px;font-size:.95rem;outline:none;margin-bottom:12px;text-align:center}
|
||||
.login input:focus{border-color:var(--accent)}
|
||||
.login button{width:100%;background:var(--accent);color:#fff;border:none;border-radius:var(--radius);padding:14px;font-size:1rem;font-weight:600;cursor:pointer}
|
||||
@@ -22,7 +25,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
||||
</head>
|
||||
<body>
|
||||
<div class="login">
|
||||
<h1>Админ-панель</h1>
|
||||
<div class="brand">
|
||||
<div class="logo" id="loginLogo"></div>
|
||||
<h1 id="loginTitle">Админ-панель</h1>
|
||||
</div>
|
||||
<input type="text" id="userInput" placeholder="Логин" autocomplete="username" autofocus>
|
||||
<input type="password" id="pwdInput" placeholder="Пароль" autocomplete="current-password">
|
||||
<input type="text" id="hpWebsite" tabindex="-1" autocomplete="off" style="display:none" value="">
|
||||
|
||||
@@ -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>
|
||||
+271
-80
@@ -12,84 +12,195 @@
|
||||
<div class="main">
|
||||
<div class="page-head">
|
||||
<h2>Настройки</h2>
|
||||
<p class="page-sub">Параметры формы и данные системы</p>
|
||||
<p class="page-sub">Параметры формы, публичных страниц и данные системы</p>
|
||||
<span class="page-status" id="authStatus"><span class="spinner dark"></span> Загрузка…</span>
|
||||
<button class="head-btn" id="reloadBtn" type="button" title="Перезагрузить настройки"><i data-lucide="rotate-ccw"></i> Сбросить</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-stack settings-stack-grid">
|
||||
<div class="settings-card wide">
|
||||
<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-card wide" id="sec-system">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="bar-chart-3"></i></div>
|
||||
<h3>Системная информация</h3>
|
||||
<button class="icon-btn" id="systemRefreshBtn" type="button" title="Обновить" aria-label="Обновить"><i data-lucide="refresh-cw"></i></button>
|
||||
<span class="head-live" id="systemLive" title="Данные кэшируются на сервере"></span>
|
||||
</div>
|
||||
<div id="systemInfo" class="sys-grid">
|
||||
<div class="sys-card loading">Загрузка…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="settings-card wide" id="sec-stack">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="shield"></i></div>
|
||||
<h3>Защита от повторов</h3>
|
||||
<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">Если ученик уже отправил ответ, следующая отправка от него будет отклонена в течение заданного времени. Минимальное значение — 1 минута.</p>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field">
|
||||
<label for="spamInterval">Интервал ожидания</label>
|
||||
<div class="unit-row">
|
||||
<input type="number" id="spamInterval" class="settings-input" min="1" max="1440" step="1">
|
||||
<span class="unit">минут</span>
|
||||
<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">
|
||||
<div class="settings-card" id="sec-antispam">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="shield"></i></div>
|
||||
<h3>Защита от повторов</h3>
|
||||
</div>
|
||||
<p class="hint">Если ученик уже отправил ответ, следующая отправка от него будет отклонена в течение заданного времени. Минимальное значение — 1 минута.</p>
|
||||
<div class="settings-field">
|
||||
<label for="spamInterval">Интервал ожидания</label>
|
||||
<div class="unit-row">
|
||||
<input type="number" id="spamInterval" class="settings-input" min="1" max="1440" step="1">
|
||||
<span class="unit">минут</span>
|
||||
</div>
|
||||
<span class="field-error" id="spamIntervalError"></span>
|
||||
<div class="preset-btns" id="spamPresets">
|
||||
<button class="preset-btn" type="button" data-min="1">1 мин</button>
|
||||
<button class="preset-btn" type="button" data-min="15">15 мин</button>
|
||||
<button class="preset-btn" type="button" data-min="30">30 мин</button>
|
||||
<button class="preset-btn" type="button" data-min="60">1 час</button>
|
||||
<button class="preset-btn" type="button" data-min="1440">Сутки</button>
|
||||
</div>
|
||||
<span class="hint" id="spamIntervalHint"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card" id="sec-share">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="link"></i></div>
|
||||
<h3>Страница публичной ссылки /s/<hash></h3>
|
||||
</div>
|
||||
<p class="hint">Управление видимостью элементов на публичных страницах записей.</p>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="shareShowMessage">
|
||||
Показать сообщение ученика
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="shareShowDate">
|
||||
Показать дату в карточке записи
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="shareShowNames">
|
||||
Показывать фамилии и имена учеников
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="shareShowPhotos">
|
||||
Показывать фото группы
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label for="cookieNoticeText">Уведомление о cookie</label>
|
||||
<textarea id="cookieNoticeText" class="settings-input" rows="3" style="min-height:80px"></textarea>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="shareShowMessage">
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
<span class="toggle-text"><b>Сообщение ученика</b><small>Текст «Что делала(а)» на странице по ссылке</small></span>
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="shareShowDate">
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
<span class="toggle-text"><b>Дата записи</b><small>Дата в карточке записи</small></span>
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="shareShowNames">
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
<span class="toggle-text"><b>Фамилии и имена</b><small>Имена учеников в галерее работ</small></span>
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="shareShowPhotos">
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
<span class="toggle-text"><b>Фото группы</b><small>Групповое фото в шапке страницы</small></span>
|
||||
</label>
|
||||
<div class="settings-field">
|
||||
<label for="cookieNoticeText">Уведомление о cookie</label>
|
||||
<textarea id="cookieNoticeText" class="settings-input" rows="3" style="min-height:80px"></textarea>
|
||||
<span class="hint">Пусто — баннер не показывается</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card" id="sec-ai">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="bot"></i></div>
|
||||
<h3>Промпт ИИ для исправления текста</h3>
|
||||
</div>
|
||||
<p class="hint">Инструкция для нейросети при исправлении ошибок в записи «Что делала(а)».</p>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="aiAutoCheck">
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
<span class="toggle-text"><b>Автопроверка новых записей</b><small>Без участия человека — воркер сам обрабатывает очередь</small></span>
|
||||
</label>
|
||||
<div class="settings-field">
|
||||
<label for="aiPrompt">Промпт</label>
|
||||
<textarea id="aiPrompt" class="settings-input mono" rows="5" spellcheck="false"></textarea>
|
||||
</div>
|
||||
<div class="ai-queue" id="aiQueueInfo"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="boxes"></i></div>
|
||||
<h3>Профили моделей ИИ</h3>
|
||||
</div>
|
||||
<p class="hint">Модель, выбранная по умолчанию, обрабатывает записи учеников. «Нативная» — llama.cpp в Docker. Можно добавить любого провайдера с OpenAI-совместимым API (OpenRouter, OpenAI, Ollama и др.).</p>
|
||||
<div class="ai-queue" id="aiProfileActive"></div>
|
||||
<div class="card-foot" style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
|
||||
<label style="display:flex;align-items:center;gap:8px;font-size:.88rem;min-width:0;max-width:100%">
|
||||
Модель по умолчанию:
|
||||
<select id="aiActiveProfile" class="settings-input" style="width:auto;min-width:0;max-width:100%;flex:1 1 200px">
|
||||
<option value="native">Нативная (llama.cpp)</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="btn-primary ghost" id="aiProfileAddBtn" type="button"><i data-lucide="plus"></i> Добавить профиль</button>
|
||||
</div>
|
||||
<div id="aiProfilesList" class="ai-profiles-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card" id="sec-backup">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="database"></i></div>
|
||||
<h3>Резервное копирование</h3>
|
||||
<button class="icon-btn" id="statsRefreshBtn" type="button" title="Обновить статистику" aria-label="Обновить статистику"><i data-lucide="refresh-cw"></i></button>
|
||||
</div>
|
||||
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Архив формируется на сервере, скачивание выполняет браузер — при обрыве связи загрузку можно продолжить. Восстановление полностью заменит текущие данные.</p>
|
||||
<div class="data-stats" id="dataStats"><span class="chip">Загрузка статистики…</span></div>
|
||||
<div class="card-foot">
|
||||
<button class="btn-primary ghost" id="backupBtn" type="button"><i data-lucide="download"></i> Скачать бэкап</button>
|
||||
<button class="btn-primary ghost danger" id="restoreBtn" type="button"><i data-lucide="upload"></i> Восстановить из файла</button>
|
||||
<input type="file" id="restoreFile" accept=".tar.gz,.gz,application/gzip" style="display:none">
|
||||
</div>
|
||||
<div class="restore-status" id="restoreStatus"></div>
|
||||
<div class="restore-status" id="backupStatus"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card" id="sec-form">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="file-text"></i></div>
|
||||
<h3>Футер на форме</h3>
|
||||
@@ -107,45 +218,87 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="settings-card" id="sec-photo">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="bot"></i></div>
|
||||
<h3>Промпт ИИ для исправления текста</h3>
|
||||
<div class="ic"><i data-lucide="camera"></i></div>
|
||||
<h3>Фотография с веб-камеры</h3>
|
||||
</div>
|
||||
<p class="hint">Инструкция для нейросети при исправлении ошибок в записи «Что делала(а)».</p>
|
||||
<p class="hint">Разрешение и качество снимка на форме ответа ученика. Камера может выдать разрешение, ближайшее к выбранному.</p>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="camEnabled">
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
<span class="toggle-text"><b>Кнопка «Камера»</b><small>Показывать кнопку «Камера» на форме ответа. Если выключено, фото делается кликом по превью «Нажми чтобы сделать фото»</small></span>
|
||||
</label>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label for="aiPrompt">Промпт</label>
|
||||
<textarea id="aiPrompt" class="settings-input" rows="4" style="min-height:100px;font-family:monospace;font-size:.85rem"></textarea>
|
||||
<div class="settings-field">
|
||||
<label for="photoResolution">Разрешение</label>
|
||||
<select id="photoResolution" class="settings-input">
|
||||
<option value="640x480">640 × 480 (по умолчанию)</option>
|
||||
<option value="1280x720">1280 × 720 (HD)</option>
|
||||
<option value="1920x1080">1920 × 1080 (Full HD)</option>
|
||||
<option value="2560x1440">2560 × 1440 (QHD)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="photoQuality">Качество JPEG</label>
|
||||
<div class="unit-row">
|
||||
<input type="number" id="photoQuality" class="settings-input" min="0.5" max="1" step="0.01" value="0.92">
|
||||
<span class="unit">0.5–1.0</span>
|
||||
</div>
|
||||
<span class="hint">Больше значение — лучше качество и крупнее файл</span>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="photoEngine">Обработка улучшения фото</label>
|
||||
<select id="photoEngine" class="settings-input">
|
||||
<option value="auto">Авто (сервер, иначе клиент)</option>
|
||||
<option value="server">Сервер (sharp)</option>
|
||||
<option value="client">Клиент (браузер)</option>
|
||||
</select>
|
||||
<span class="hint">Серверный режим обрабатывает оригинал без пережатия и быстрее на больших фото</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ai-queue" id="aiQueueInfo"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="settings-card" id="sec-trash">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="database"></i></div>
|
||||
<h3>Резервное копирование</h3>
|
||||
<div class="ic"><i data-lucide="trash-2"></i></div>
|
||||
<h3>Корзина</h3>
|
||||
</div>
|
||||
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Восстановление полностью заменит текущие данные.</p>
|
||||
<div class="data-stats" id="dataStats"><span class="chip">Загрузка статистики…</span></div>
|
||||
<div class="card-foot">
|
||||
<button class="btn-primary ghost" id="backupBtn" type="button"><i data-lucide="download"></i> Скачать бэкап</button>
|
||||
<button class="btn-primary ghost" id="restoreBtn" type="button"><i data-lucide="upload"></i> Восстановить из файла</button>
|
||||
<input type="file" id="restoreFile" accept=".tar.gz,.gz,application/gzip" style="display:none">
|
||||
<p class="hint">При очистке корзины или удалении пункта данные не удаляются сразу, а помечаются на удаление и исчезают из корзины. Через указанное количество дней они удаляются автоматически и безвозвратно вместе с файлами. До этого помеченные данные можно вернуть в корзину и восстановить (страница «Корзина»).</p>
|
||||
<div class="settings-field">
|
||||
<label for="trashPurgeDays">Время до безвозвратного удаления</label>
|
||||
<div class="unit-row">
|
||||
<input type="number" id="trashPurgeDays" class="settings-input" min="1" max="365" step="1">
|
||||
<span class="unit">дней</span>
|
||||
</div>
|
||||
<span class="hint">От 1 до 365 дней</span>
|
||||
</div>
|
||||
<div class="restore-status" id="restoreStatus"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card" id="bansCard">
|
||||
<div class="settings-card wide" id="sec-notify">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="bell"></i></div>
|
||||
<h3>Уведомления</h3>
|
||||
<button class="icon-btn" id="notifyTestBtn" type="button" title="Отправить тестовое уведомление" aria-label="Тестовое уведомление"><i data-lucide="send"></i></button>
|
||||
</div>
|
||||
<p class="hint">Уведомления собираются в «колокольчике» админ-панели и на странице «Уведомления». Здесь выбирается, о каких событиях сообщать: новые записи журнала, обработка фото нейросетью, блокировки IP, бэкапы и другое.</p>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="notifyEnabled">
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
<span class="toggle-text"><b>Уведомления включены</b><small>Общий выключатель: при снятии галочки не создаётся ни одно уведомление</small></span>
|
||||
</label>
|
||||
<div class="settings-field">
|
||||
<label for="notifyRetentionDays">Хранение уведомлений</label>
|
||||
<div class="unit-row">
|
||||
<input type="number" id="notifyRetentionDays" class="settings-input" min="1" max="365" step="1">
|
||||
<span class="unit">дней</span>
|
||||
</div>
|
||||
<span class="hint">От 1 до 365 дней, старые уведомления удаляются автоматически</span>
|
||||
</div>
|
||||
<div class="notify-types" id="notifyTypes"><span class="hint">Загрузка…</span></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card" id="sec-bans">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="shield-off"></i></div>
|
||||
<h3>Блокировки IP</h3>
|
||||
@@ -153,14 +306,52 @@
|
||||
<p class="hint">IP автоматически блокируется на 24 часа: за заполнение скрытого антиспам-поля, за 10 неудачных попыток входа или 10 неверных паролей на защищённой ссылке.</p>
|
||||
<div class="data-stats" id="bansList"><span class="chip">Загрузка…</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="save-bar" id="saveBar">
|
||||
<span class="hint" id="saveHint" style="display:inline-flex;align-items:center;gap:6px"><i data-lucide="triangle-alert"></i> Есть несохранённые изменения</span>
|
||||
<button class="btn-primary" id="saveBtn" type="button">Сохранить изменения</button>
|
||||
<span class="hint" id="saveHint"><i data-lucide="triangle-alert"></i> Есть несохранённые изменения</span>
|
||||
<span class="save-bar-actions">
|
||||
<button class="btn-primary ghost" id="resetBtn" type="button"><i data-lucide="rotate-ccw"></i> Отменить</button>
|
||||
<button class="btn-primary" id="saveBtn" type="button"><i data-lucide="check"></i> Сохранить</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="aiProfileModal">
|
||||
<div class="edit-modal" style="max-width:560px">
|
||||
<h3 id="aiProfileModalTitle">Добавить профиль ИИ</h3>
|
||||
<div class="settings-field">
|
||||
<label for="aiProfileName">Название профиля <span style="color:#ef4444">*</span></label>
|
||||
<input type="text" id="aiProfileName" class="settings-input" placeholder="Например: OpenRouter GPT-4o-mini">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="aiProfileModel">Модель <span style="color:#ef4444">*</span></label>
|
||||
<input type="text" id="aiProfileModel" class="settings-input" placeholder="openai/gpt-4o-mini">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="aiProfileBaseUrl">Base URL (OpenAI-совместимый API) <span style="color:#ef4444">*</span></label>
|
||||
<input type="text" id="aiProfileBaseUrl" class="settings-input" placeholder="https://openrouter.ai/api/v1">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="aiProfileApiKey">API-ключ (если требуется)</label>
|
||||
<input type="password" id="aiProfileApiKey" class="settings-input" placeholder="sk-...">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="aiProfileMaxTokens">Max tokens (лимит ответа модели)</label>
|
||||
<input type="number" id="aiProfileMaxTokens" class="settings-input" min="16" max="32768" placeholder="Авто (1024–4096)">
|
||||
<span class="hint">Оставьте пустым для авто. Для reasoning-моделей (gpt-oss, deepseek-r1) нужно 1024+ — иначе рассуждения не уместятся и ответ придёт пустым.</span>
|
||||
</div>
|
||||
<div class="ai-test-status" id="aiProfileTestStatus"></div>
|
||||
<div class="actions" style="flex-wrap:wrap">
|
||||
<button class="btn-primary" id="aiProfileSaveBtn" type="button">Сохранить</button>
|
||||
<button class="btn-primary ghost" id="aiProfileTestBtn" type="button"><i data-lucide="plug-zap"></i> Проверить</button>
|
||||
<button class="btn-primary ghost" id="aiProfileCancelBtn" type="button">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="vendor/lucide.min.js"></script>
|
||||
<script src="admin.js"></script>
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
:root{--yellow:#F7C90E;--dark:#211E22;--gray:#9C9C9C;--page:#f6f6f6;--card-bg:#fff;--radius:30px}
|
||||
body{font-family:'Segoe UI',system-ui,-apple-system,Roboto,sans-serif;background:var(--page);color:var(--dark);min-height:100dvh}
|
||||
.hero{background:var(--yellow);padding:44px 20px 52px;text-align:center;position:relative;overflow:hidden}
|
||||
.hero .brand{display:flex;align-items:center;justify-content:center;gap:14px;margin-bottom:16px}
|
||||
.hero .brand[hidden]{display:none}
|
||||
.hero .brand .brand-logo{height:52px;width:auto;max-width:min(42vw,300px);object-fit:contain;display:block}
|
||||
.hero .brand .brand-name{font-size:1.05rem;font-weight:800;letter-spacing:-.02em;color:var(--dark);opacity:.8}
|
||||
.hero h1{font-size:clamp(1.6rem,4.5vw,2.9rem);font-weight:800;line-height:1.05;letter-spacing:-.02em;color:var(--dark);max-width:900px;margin:0 auto}
|
||||
.hero .chips{margin-top:20px;display:flex;gap:10px;justify-content:center;flex-wrap:wrap}
|
||||
.hero .chip{background:#fff;color:var(--dark);border-radius:999px;padding:8px 18px;font-size:.88rem;font-weight:600;box-shadow:5px 5px 10px rgba(216,173,0,.25)}
|
||||
@@ -38,6 +42,9 @@ body{font-family:'Segoe UI',system-ui,-apple-system,Roboto,sans-serif;background
|
||||
.card .files a.ftype-presentation .ficon{background:#F6E4E8}
|
||||
.card .files a.ftype-other .ficon{background:#EFEFEF}
|
||||
.card .foot{margin-top:auto;padding:8px 6px 2px;font-size:.78rem;color:var(--gray)}
|
||||
.date-sep{grid-column:1/-1;display:flex;align-items:center;gap:14px;margin:8px 0 -8px}
|
||||
.date-sep .date-chip{background:var(--dark);color:#fff;font-size:.82rem;font-weight:700;padding:7px 16px;border-radius:999px;letter-spacing:.01em}
|
||||
.date-sep .date-line{flex:1;height:1px;background:#E3E3E3}
|
||||
.empty{grid-column:1/-1;background:#fff;border-radius:var(--radius);padding:48px 24px;text-align:center;color:var(--gray);box-shadow:0 1px 3px rgba(20,30,40,.06)}
|
||||
.empty .big{font-size:2rem;margin-bottom:10px}
|
||||
.loading{grid-column:1/-1;text-align:center;color:var(--gray);padding:40px}
|
||||
@@ -85,6 +92,10 @@ body{font-family:'Segoe UI',system-ui,-apple-system,Roboto,sans-serif;background
|
||||
</head>
|
||||
<body>
|
||||
<section class="hero">
|
||||
<div class="brand" id="brandHead" hidden>
|
||||
<img class="brand-logo" id="brandLogo" alt="" hidden>
|
||||
<span class="brand-name" id="brandName" hidden></span>
|
||||
</div>
|
||||
<h1 id="title">Загрузка...</h1>
|
||||
<div class="chips" id="chips"></div>
|
||||
</section>
|
||||
|
||||
@@ -130,6 +130,85 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="profileDataModal">
|
||||
<div class="edit-modal" style="max-width:900px;width:96%;max-height:92dvh;overflow-y:auto">
|
||||
<h3>Данные профиля: <span id="profileDataTitle" style="color:var(--accent)"></span></h3>
|
||||
<form id="profileDataForm" style="display:grid;grid-template-columns:1fr 1fr;gap:12px 16px">
|
||||
<div class="settings-field">
|
||||
<label>Роль / направление</label>
|
||||
<input type="text" id="pfRole" class="settings-input" placeholder="Например: Ученик направления «Frontend-разработка»">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Статус</label>
|
||||
<input type="text" id="pfStatus" class="settings-input" placeholder="Активно учится">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Примечание к статусу</label>
|
||||
<input type="text" id="pfStatusNote" class="settings-input" placeholder="учится с сентября 2022">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Город</label>
|
||||
<input type="text" id="pfCity" class="settings-input">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Наставник</label>
|
||||
<input type="text" id="pfMentor" class="settings-input">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>В академии с</label>
|
||||
<input type="text" id="pfJoined" class="settings-input" placeholder="В академии с сентября 2022">
|
||||
</div>
|
||||
<div class="settings-field" style="grid-column:1/-1">
|
||||
<label>О себе</label>
|
||||
<textarea id="pfBio" class="settings-input" style="min-height:70px" placeholder="Короткий рассказ ученика о себе"></textarea>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Цитата</label>
|
||||
<input type="text" id="pfQuote" class="settings-input" placeholder="«Мой принцип: сначала понять, потом написать»">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Теги (через запятую)</label>
|
||||
<input type="text" id="pfTags" class="settings-input" placeholder="HTML, CSS, JavaScript">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Контакты — строка: Иконка | Подпись | Ссылка</label>
|
||||
<textarea id="pfContacts" class="settings-input" style="min-height:64px" placeholder="mail | maria@example.com | mailto:maria@example.com send | @nickname | https://t.me/nickname"></textarea>
|
||||
<span class="hint">Иконки: mail, send, phone, github, globe, link, at-sign</span>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Достижения — по одному в строке</label>
|
||||
<textarea id="pfAchievements" class="settings-input" style="min-height:64px"></textarea>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Навыки — строка: Группа | Навык | Уровень | %</label>
|
||||
<textarea id="pfSkills" class="settings-input" style="min-height:64px" placeholder="Вёрстка | HTML5 · семантика | Изучен | 95"></textarea>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Опыт работы — строка: Заголовок | Организация | Период | Дата | Бейдж | Описание | Теги (через запятую)</label>
|
||||
<textarea id="pfExperience" class="settings-input" style="min-height:64px" placeholder="Хакатон Digital City | Академия | Март 2024 | 03.2024 | Хакатон | Прототип карты заявок, 2 место | Vue 3, Leaflet"></textarea>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Прогресс по модулям — строка: Тема модуля | Прогресс % | Оценка | Преподаватель</label>
|
||||
<textarea id="pfEducation" class="settings-input" style="min-height:64px" placeholder="JavaScript: основы языка | 100 | Оценка 4,7 | Игорь Дёмин"></textarea>
|
||||
</div>
|
||||
<div class="settings-field" style="grid-column:1/-1">
|
||||
<label>Фотографии ученика</label>
|
||||
<input type="hidden" id="pfPhoto">
|
||||
<div class="pf-photos" id="pfPhotos"></div>
|
||||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:6px">
|
||||
<input type="file" id="pfPhotoFile" accept="image/*" hidden>
|
||||
<button type="button" class="btn-primary ghost" id="pfPhotoUploadBtn">Загрузить фото</button>
|
||||
<span class="hint">Клик по фото — сделать главным. Можно вставить из буфера обмена (Ctrl+V)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="grid-column:1/-1;display:flex;justify-content:flex-end;gap:8px">
|
||||
<button type="button" class="btn-primary ghost" id="profileDataCancelBtn">Отмена</button>
|
||||
<button type="submit" class="btn-primary">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="vendor/lucide.min.js"></script>
|
||||
<script src="admin.js"></script>
|
||||
|
||||
+22
-2
@@ -15,8 +15,28 @@
|
||||
<span class="trash-muted" id="trashInfo"></span>
|
||||
<button class="danger-btn" id="emptyBtn" type="button">Очистить корзину</button>
|
||||
</div>
|
||||
<div class="grid" id="grid"><div class="empty">Загрузка...</div></div>
|
||||
<div class="pager" id="pager"></div>
|
||||
<label class="toggle pending-toggle" id="pendingToggleRow">
|
||||
<input type="checkbox" id="pendingToggle">
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
<span class="toggle-text"><b>Показать помеченные на удаление</b><small id="pendingToggleHint">Данные не удаляются сразу: они помечаются и исчезают из корзины. Здесь их можно вернуть в корзину и восстановить</small></span>
|
||||
</label>
|
||||
<div class="trash-section" id="pendingSection" hidden>
|
||||
<h3>Помечены на удаление <span class="trash-muted" id="pendingInfo"></span></h3>
|
||||
<p class="trash-muted">Эти данные будут удалены автоматически и безвозвратно вместе с файлами. Чтобы отменить удаление, нажмите «В корзину» — данные вернутся в корзину, откуда их можно восстановить.</p>
|
||||
<h4>Группы <span class="trash-muted" id="pendingGroupsInfo"></span></h4>
|
||||
<div class="grid" id="pendingGroupsGrid"><div class="empty">Загрузка...</div></div>
|
||||
<h4>Записи журнала <span class="trash-muted" id="pendingEntriesInfo"></span></h4>
|
||||
<div class="grid" id="pendingGrid"><div class="empty">Загрузка...</div></div>
|
||||
</div>
|
||||
<div class="trash-section" id="groupsSection">
|
||||
<h3>Группы <span class="trash-muted" id="groupsInfo"></span></h3>
|
||||
<div class="grid" id="groupsGrid"><div class="empty">Загрузка...</div></div>
|
||||
</div>
|
||||
<div class="trash-section">
|
||||
<h3>Записи журнала <span class="trash-muted" id="entriesInfo"></span></h3>
|
||||
<div class="grid" id="grid"><div class="empty">Загрузка...</div></div>
|
||||
<div class="pager" id="pager"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,21 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Воркер ИИ — Админ-панель</title>
|
||||
<link rel="stylesheet" href="admin.css">
|
||||
<style>
|
||||
.enhance-compare { position: relative; border-radius: 10px; overflow: hidden; background: var(--bg); border: 1px solid var(--border); user-select: none; touch-action: none; cursor: ew-resize; }
|
||||
.enhance-stage { transform-origin: 0 0; }
|
||||
.enhance-stage > img { display: block; width: 100%; height: auto; }
|
||||
.enhance-top { position: absolute; inset: 0; overflow: hidden; }
|
||||
.enhance-top .enhance-stage { position: absolute; inset: 0; }
|
||||
.enhance-top .enhance-stage > img { height: 100%; object-fit: fill; }
|
||||
.enhance-divider { position: absolute; top: 0; bottom: 0; left: 50%; width: 32px; transform: translateX(-50%); cursor: ew-resize; }
|
||||
.enhance-divider::before { content: ''; position: absolute; top: 0; bottom: 0; left: 50%; width: 2px; transform: translateX(-50%); background: #fff; box-shadow: 0 0 6px rgba(0,0,0,.6); pointer-events: none; }
|
||||
.enhance-handle { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 34px; height: 34px; border-radius: 50%; background: #fff; color: #333; display: flex; align-items: center; justify-content: center; font-size: 1rem; box-shadow: 0 2px 8px rgba(0,0,0,.4); pointer-events: none; }
|
||||
.enhance-badge { position: absolute; top: 10px; padding: 3px 10px; border-radius: 999px; background: rgba(0,0,0,.55); color: #fff; font-size: .72rem; pointer-events: none; }
|
||||
.enhance-badge.left { left: 10px; }
|
||||
.enhance-badge.right { right: 10px; }
|
||||
.photo-thumb { width: 56px; height: 56px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body data-page="worker">
|
||||
<div class="layout">
|
||||
@@ -24,6 +39,8 @@
|
||||
<button class="btn-link" id="refreshBtn" type="button">Обновить</button>
|
||||
<button class="btn-link" id="wakeBtn" type="button" title="Запустить обработку очереди немедленно">Разбудить</button>
|
||||
<button class="btn-link" id="requeueBtn" type="button" title="Вернуть все записи с ошибкой в очередь">Перепроверить ошибки</button>
|
||||
<button class="btn-link" id="photoWakeBtn" type="button" title="Разбудить фото-воркер">📷 Разбудить фото</button>
|
||||
<button class="btn-link" id="photoToggleBtn" type="button">📷 …</button>
|
||||
<button class="btn-link" id="toggleBtn" type="button">…</button>
|
||||
</div>
|
||||
|
||||
@@ -78,6 +95,39 @@
|
||||
<div class="pager" id="recentPager"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card wide">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="image"></i></div>
|
||||
<h3>Обработка фотографий</h3>
|
||||
</div>
|
||||
<div class="actions-row" style="margin-bottom:10px">
|
||||
<span class="hint" id="photoStateHint"></span>
|
||||
<span style="flex:1"></span>
|
||||
<button class="btn-link" id="photoRequeueBtn" type="button" title="Вернуть задания с ошибкой в очередь">Повторить ошибки фото</button>
|
||||
</div>
|
||||
<div class="worker-counts" id="photoCounts" style="margin-bottom:10px"></div>
|
||||
<p class="hint">В очереди (<span id="photoPendingCount">0</span>)</p>
|
||||
<div class="audit-wrap" style="margin-bottom:14px">
|
||||
<table class="audit-table" id="photoPendingTable">
|
||||
<thead>
|
||||
<tr><th>Задание</th><th>Запись</th><th>Действие</th><th>Поставлено</th></tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<div class="empty" id="photoPendingEmpty" style="display:none">Очередь пуста.</div>
|
||||
</div>
|
||||
<div class="audit-wrap">
|
||||
<table class="audit-table" id="photoTable">
|
||||
<thead>
|
||||
<tr><th>Время</th><th>Запись</th><th>Действие</th><th>Статус</th><th>Было</th><th>Стало</th><th>Ошибка</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<div class="empty" id="photoEmpty" style="display:none">Обработок фотографий пока нет.</div>
|
||||
</div>
|
||||
<div class="pager" id="photoRecentPager"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card wide" id="errorsCard" style="display:none">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="triangle-alert"></i></div>
|
||||
@@ -112,6 +162,28 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-overlay" id="photoJobModal">
|
||||
<div class="edit-modal" style="max-width:900px">
|
||||
<h3 id="photoJobTitle">Обработка фотографии</h3>
|
||||
<div class="enhance-compare" id="photoJobCompare">
|
||||
<div class="enhance-stage">
|
||||
<img id="photoJobBefore" alt="Было">
|
||||
</div>
|
||||
<div class="enhance-top" id="photoJobTop" style="clip-path:inset(0 0 0 50%)">
|
||||
<div class="enhance-stage enhance-stage-fill">
|
||||
<img id="photoJobAfter" alt="Стало">
|
||||
</div>
|
||||
</div>
|
||||
<div class="enhance-divider" id="photoJobDivider"><div class="enhance-handle">⇔</div></div>
|
||||
<div class="enhance-badge left">Было</div>
|
||||
<div class="enhance-badge right">Стало</div>
|
||||
</div>
|
||||
<div class="card-foot" style="justify-content:flex-end;gap:8px;margin-top:14px">
|
||||
<button type="button" class="btn-primary ghost" id="photoJobCloseBtn">Закрыть</button>
|
||||
<button type="button" class="btn-primary" id="photoJobRollbackBtn">↩ Откатить к версии «Было»</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="vendor/lucide.min.js"></script>
|
||||
<script src="admin.js"></script>
|
||||
|
||||
@@ -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 ">> Экспорт БД"
|
||||
docker compose exec -T db pg_dump -U app -d whereldo | gzip > backups/db.sql.gz
|
||||
|
||||
echo ">> Копирование фотографий"
|
||||
echo ">> Экспорт файлов из хранилища"
|
||||
rm -rf backups/_uploads
|
||||
mkdir -p backups/_uploads
|
||||
docker cp "whatido-app-1:/app/uploads/." "backups/_uploads/"
|
||||
docker compose exec -T app rm -rf /tmp/whatido-export
|
||||
docker compose exec -T app node scripts/storage-sync.js export /tmp/whatido-export
|
||||
docker cp "whatido-app-1:/tmp/whatido-export/." "backups/_uploads/"
|
||||
docker compose exec -T app rm -rf /tmp/whatido-export
|
||||
rm -rf backups/_uploads/.thumbs
|
||||
|
||||
echo ">> Сборка архива"
|
||||
|
||||
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
|
||||
gzip -dc "$TMP/db.sql.gz" | docker compose exec -T db psql -U app -d whereldo
|
||||
|
||||
echo ">> Восстановление фотографий"
|
||||
docker cp "$TMP/_uploads/." "whatido-app-1:/app/uploads/"
|
||||
echo ">> Загрузка файлов в хранилище"
|
||||
docker compose start app
|
||||
for _ in $(seq 1 30); do
|
||||
docker compose exec -T app true >/dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
docker compose exec -T app rm -rf /tmp/whatido-import
|
||||
docker compose exec -T app mkdir -p /tmp/whatido-import
|
||||
docker cp "$TMP/_uploads/." "whatido-app-1:/tmp/whatido-import/"
|
||||
docker compose exec -T app node scripts/storage-sync.js import /tmp/whatido-import
|
||||
docker compose exec -T app rm -rf /tmp/whatido-import
|
||||
|
||||
echo ">> Запуск приложения"
|
||||
docker compose start app
|
||||
|
||||
@@ -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,272 @@ const IDLE_MAX_MS = 60000;
|
||||
const REQUEST_TIMEOUT_MS = parseInt(process.env.AI_REQUEST_TIMEOUT_MS || '120000', 10);
|
||||
const MAX_INPUT_CHARS = 2000;
|
||||
const MIN_TEXT_CHARS = 4;
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const { textDiff, FIELD_LABELS } = require('./diff');
|
||||
const PHOTO_MAX_ATTEMPTS = 3;
|
||||
const PHOTO_AI_TIMEOUT_MS = 300000;
|
||||
|
||||
function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultPrompt, model }) {
|
||||
function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntries, sharp, photoAiUrl, uploadsDir, storage, bus, 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, last_at: null, last_error: null };
|
||||
const CONFIG = {
|
||||
idle_min_ms: IDLE_MIN,
|
||||
idle_max_ms: IDLE_MAX,
|
||||
ai_timeout_ms: PHOTO_AI_TIMEOUT_MS,
|
||||
max_attempts: PHOTO_MAX_ATTEMPTS,
|
||||
ai_url: AI_URL,
|
||||
};
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => {
|
||||
const t = setTimeout(() => { wake = null; resolve(); }, ms);
|
||||
wake = () => { clearTimeout(t); wake = null; resolve(); };
|
||||
});
|
||||
}
|
||||
|
||||
function wakeLocal() {
|
||||
if (wake) wake();
|
||||
}
|
||||
|
||||
function notify() {
|
||||
wakeLocal();
|
||||
if (bus) bus.publish();
|
||||
}
|
||||
|
||||
if (bus) bus.subscribe(wakeLocal);
|
||||
|
||||
async function isEnabled() {
|
||||
const v = await getSetting('photo_worker_enabled', 'true');
|
||||
return String(v) !== 'false';
|
||||
}
|
||||
|
||||
async function claimNext() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { rows } = await client.query(
|
||||
`SELECT j.id, j.entry_id, j.action, j.params
|
||||
FROM photo_jobs j
|
||||
WHERE j.status = 'pending'
|
||||
ORDER BY j.id ASC LIMIT 1 FOR UPDATE SKIP LOCKED`
|
||||
);
|
||||
if (!rows.length) {
|
||||
await client.query('COMMIT');
|
||||
return null;
|
||||
}
|
||||
await client.query(`UPDATE photo_jobs SET status = 'processing' WHERE id = $1`, [rows[0].id]);
|
||||
await client.query('COMMIT');
|
||||
return rows[0];
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function enhanceWithSharp(srcKey, params) {
|
||||
if (!sharp) throw new Error('sharp недоступен на сервере');
|
||||
const p = params || {};
|
||||
const clamp = (v, min, max, def) => {
|
||||
const n = parseFloat(v);
|
||||
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : def;
|
||||
};
|
||||
const brightness = clamp(p.brightness, 10, 300, 100);
|
||||
const contrast = clamp(p.contrast, 10, 300, 100);
|
||||
const saturate = clamp(p.saturate, 0, 300, 100);
|
||||
const sharpAmt = clamp(p.sharp, 0, 100, 0);
|
||||
const denoise = clamp(p.denoise, 0, 100, 0);
|
||||
const srcPath = await storage.localize(srcKey);
|
||||
if (!srcPath) throw new Error('Файл фото не найден');
|
||||
let pipeline = sharp(srcPath).rotate();
|
||||
if (denoise > 0) pipeline = pipeline.median(denoise > 70 ? 5 : 3);
|
||||
pipeline = pipeline.modulate({ brightness: brightness / 100, saturation: saturate / 100 });
|
||||
if (contrast !== 100) {
|
||||
const a = contrast / 100;
|
||||
pipeline = pipeline.linear(a, 128 * (1 - a));
|
||||
}
|
||||
if (sharpAmt > 0) {
|
||||
pipeline = pipeline.sharpen({ sigma: 0.5 + (sharpAmt / 100) * 1.5, m1: 0, m2: 1 + sharpAmt / 50 });
|
||||
}
|
||||
const newName = crypto.randomBytes(12).toString('hex') + '.jpg';
|
||||
const outPath = path.join(uploadsDir, newName);
|
||||
await pipeline.jpeg({ quality: 92, mozjpeg: true }).toFile(outPath);
|
||||
await storage.persist(newName, outPath);
|
||||
return `/uploads/${newName}`;
|
||||
}
|
||||
|
||||
async function runAiEnhance(srcKey) {
|
||||
if (!AI_URL) throw new Error('PHOTO_AI_URL не настроен');
|
||||
const buf = await storage.getBuffer(srcKey);
|
||||
if (!buf) throw new Error('Файл фото не найден');
|
||||
const fd = new FormData();
|
||||
fd.append('image', new Blob([buf], { type: 'image/jpeg' }), 'photo.jpg');
|
||||
fd.append('scale', '2');
|
||||
const resp = await fetch(AI_URL.replace(/\/+$/, '') + '/enhance', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
signal: AbortSignal.timeout(PHOTO_AI_TIMEOUT_MS),
|
||||
});
|
||||
if (!resp.ok) throw new Error('AI service error: ' + resp.status);
|
||||
const out = Buffer.from(await resp.arrayBuffer());
|
||||
const newName = crypto.randomBytes(12).toString('hex') + '.jpg';
|
||||
const outPath = path.join(uploadsDir, newName);
|
||||
fs.writeFileSync(outPath, out);
|
||||
await storage.persist(newName, outPath);
|
||||
return `/uploads/${newName}`;
|
||||
}
|
||||
async function applyResult(job, newPath) {
|
||||
await pool.query(
|
||||
`UPDATE photo_jobs SET status = 'done', after_path = $1, error = NULL, finished_at = now() WHERE id = $2`,
|
||||
[newPath, job.id]
|
||||
);
|
||||
if (logAudit) await logAudit(null, 'photo.job.preview', { entry_id: job.entry_id, job_id: job.id, action: job.action, after_path: newPath });
|
||||
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 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 true;
|
||||
}
|
||||
const photoPath = rows[0].photo_path;
|
||||
try {
|
||||
const newPath = job.action === 'ai' ? await runAiEnhance(photoPath) : await enhanceWithSharp(photoPath, job.params);
|
||||
await applyResult(job, newPath);
|
||||
stats.done++;
|
||||
stats.last_at = new Date().toISOString();
|
||||
stats.last_error = null;
|
||||
return true;
|
||||
} catch (e) {
|
||||
const message = (e && e.message ? e.message : 'error').slice(0, 500);
|
||||
stats.last_at = new Date().toISOString();
|
||||
stats.last_error = message;
|
||||
const { rows: cur } = await pool.query('SELECT attempts FROM photo_jobs WHERE id = $1', [job.id]);
|
||||
const tries = ((cur[0] && cur[0].attempts) || 0) + 1;
|
||||
if (tries < PHOTO_MAX_ATTEMPTS) {
|
||||
await pool.query(`UPDATE photo_jobs SET status = 'pending', attempts = $1, error = $2 WHERE id = $3`, [tries, message, job.id]);
|
||||
if (logAudit) await logAudit(null, 'photo.job.retry', { entry_id: job.entry_id, job_id: job.id, attempt: tries, error: message });
|
||||
return false;
|
||||
}
|
||||
await pool.query(
|
||||
`UPDATE photo_jobs SET status = 'error', attempts = $1, error = $2, finished_at = now() WHERE id = $3`,
|
||||
[tries, message, job.id]
|
||||
);
|
||||
stats.errors++;
|
||||
if (logAudit) await logAudit(null, 'photo.job.error', { entry_id: job.entry_id, job_id: job.id, error: message });
|
||||
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 true;
|
||||
}
|
||||
}
|
||||
|
||||
async function loop() {
|
||||
while (!stopped) {
|
||||
let job = null;
|
||||
try {
|
||||
if (!(await isEnabled())) {
|
||||
await sleep(IDLE_MAX);
|
||||
continue;
|
||||
}
|
||||
job = await claimNext();
|
||||
} catch (e) {
|
||||
console.error('Photo worker claim error:', e);
|
||||
await sleep(IDLE_MAX);
|
||||
continue;
|
||||
}
|
||||
if (!job) {
|
||||
await sleep(idleMs);
|
||||
idleMs = Math.min(idleMs * 2, IDLE_MAX);
|
||||
continue;
|
||||
}
|
||||
idleMs = IDLE_MIN;
|
||||
processing = true;
|
||||
currentId = job.id;
|
||||
let ok = true;
|
||||
try {
|
||||
ok = await processOne(job);
|
||||
} catch (e) {
|
||||
console.error('Photo worker process error:', e);
|
||||
} finally {
|
||||
processing = false;
|
||||
currentId = null;
|
||||
}
|
||||
if (!ok) await sleep(IDLE_MAX);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetStale() {
|
||||
await pool.query(`UPDATE photo_jobs SET status = 'pending' WHERE status = 'processing'`);
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
startedAt = Date.now();
|
||||
resetStale()
|
||||
.catch((e) => console.error('Photo worker reset error:', e))
|
||||
.finally(() => { loop().catch((e) => console.error('Photo worker loop error:', e)); });
|
||||
}
|
||||
|
||||
function getInfo() {
|
||||
return {
|
||||
started_at: startedAt ? new Date(startedAt).toISOString() : null,
|
||||
uptime_ms: startedAt ? Date.now() - startedAt : 0,
|
||||
processing,
|
||||
current_id: currentId,
|
||||
config: CONFIG,
|
||||
...stats,
|
||||
};
|
||||
}
|
||||
|
||||
return { start, notify, getInfo };
|
||||
}
|
||||
|
||||
function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultPrompt, model, bus }) {
|
||||
const AI_URL = aiUrl || process.env.AI_URL || 'http://text-corrector:8080';
|
||||
const MODEL = model || process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf';
|
||||
const DEFAULT_PROMPT = defaultPrompt || process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.';
|
||||
@@ -37,10 +301,17 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm
|
||||
});
|
||||
}
|
||||
|
||||
function notify() {
|
||||
function wakeLocal() {
|
||||
if (wake) wake();
|
||||
}
|
||||
|
||||
function notify() {
|
||||
wakeLocal();
|
||||
if (bus) bus.publish();
|
||||
}
|
||||
|
||||
if (bus) bus.subscribe(wakeLocal);
|
||||
|
||||
async function isEnabled() {
|
||||
const v = await getSetting('ai_autocheck_enabled', 'true');
|
||||
return String(v) !== 'false';
|
||||
@@ -70,27 +341,70 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveActiveProfile() {
|
||||
try {
|
||||
const active = await getSetting('ai_active_profile', 'native');
|
||||
if (active && active !== 'native') {
|
||||
const raw = await getSetting('ai_profiles', '[]');
|
||||
const list = JSON.parse(raw || '[]');
|
||||
if (Array.isArray(list)) {
|
||||
return list.find(p => p && p.id === active && p.base_url && p.model) || null;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeOpenAiBase(base) {
|
||||
let b = String(base || '').trim().replace(/\/+$/, '');
|
||||
if (!/^https?:\/\//i.test(b)) return null;
|
||||
if (!/\/v1$/i.test(b)) b += '/v1';
|
||||
return b;
|
||||
}
|
||||
|
||||
async function callModel(text, systemPrompt) {
|
||||
const profile = await resolveActiveProfile();
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
let url;
|
||||
let model;
|
||||
let maxTokens;
|
||||
if (profile) {
|
||||
const base = normalizeOpenAiBase(profile.base_url);
|
||||
if (!base) throw new Error('Некорректный base_url профиля ИИ');
|
||||
url = `${base}/chat/completions`;
|
||||
model = profile.model;
|
||||
if (profile.api_key) headers.Authorization = `Bearer ${profile.api_key}`;
|
||||
maxTokens = parseInt(profile.max_tokens, 10) || Math.min(4096, Math.max(1024, text.length * 2 + 512));
|
||||
} else {
|
||||
url = `${AI_URL.replace(/\/+$/, '')}/v1/chat/completions`;
|
||||
model = MODEL;
|
||||
maxTokens = Math.min(256, Math.max(128, text.length + 64));
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(`${AI_URL}/v1/chat/completions`, {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: text },
|
||||
],
|
||||
temperature: 0.1,
|
||||
max_tokens: Math.min(512, Math.max(64, text.length + 32)),
|
||||
max_tokens: maxTokens,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`AI service error: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return (data.choices?.[0]?.message?.content || '').trim();
|
||||
const choice = data.choices && data.choices[0];
|
||||
const content = (choice && choice.message && choice.message.content || '').trim();
|
||||
if (!content) {
|
||||
throw new Error(`Модель вернула пустой ответ (finish_reason=${choice && choice.finish_reason || 'unknown'})`);
|
||||
}
|
||||
return content;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
@@ -125,7 +439,18 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm
|
||||
if (changed) stats.corrected++; else stats.unchanged++;
|
||||
stats.last_at = new Date().toISOString();
|
||||
stats.last_error = null;
|
||||
if (logAudit) await logAudit(null, 'entry.ai.auto-check', { id: row.id, changed });
|
||||
if (logAudit) {
|
||||
const d = textDiff(String(row.description ?? ''), changed ? checked : original);
|
||||
await logAudit(null, 'entry.ai.auto-check', {
|
||||
id: row.id,
|
||||
source: 'ai',
|
||||
changed,
|
||||
fields: changed ? ['description'] : [],
|
||||
changes: changed
|
||||
? [{ field: 'description', label: FIELD_LABELS.description, stats: d.stats, diff: d.segments, truncated: d.truncated }]
|
||||
: []
|
||||
});
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
const message = (e && e.message ? e.message : 'error').slice(0, 500);
|
||||
@@ -215,4 +540,4 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm
|
||||
return { start, notify, getStats, getInfo };
|
||||
}
|
||||
|
||||
module.exports = { createEntryAutoChecker };
|
||||
module.exports = { createEntryAutoChecker, createPhotoEnhanceWorker };
|
||||
|
||||
Reference in New Issue
Block a user