Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -27,3 +27,7 @@ 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
|
||||
|
||||
@@ -27,5 +27,8 @@ Thumbs.db
|
||||
# --- Playwright test artifacts ---
|
||||
.playwright-mcp/
|
||||
|
||||
# --- Generated version data ---
|
||||
public/version.json
|
||||
|
||||
# --- Internal audit (not for commit) ---
|
||||
SECURITY_AUDIT.md
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ RUN set -e; \
|
||||
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" && \
|
||||
|
||||
+68
@@ -24,17 +24,44 @@ CREATE TABLE IF NOT EXISTS groups (
|
||||
cover_path VARCHAR(255)
|
||||
);
|
||||
|
||||
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,6 +69,7 @@ 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,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
@@ -51,8 +79,25 @@ 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;
|
||||
|
||||
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,
|
||||
@@ -146,6 +191,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 +210,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,
|
||||
|
||||
@@ -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,
|
||||
@@ -152,6 +154,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 +188,64 @@ FROM (
|
||||
FROM entry_photos ORDER BY entry_id, sort_order, id
|
||||
) p
|
||||
WHERE e.photo_path IS NULL AND p.entry_id = e.id;
|
||||
|
||||
CREATE OR REPLACE FUNCTION notify_entries_changed() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF (TG_OP = 'INSERT') THEN
|
||||
PERFORM pg_notify('entries_changed', json_build_object('type', 'entry_created', 'id', NEW.id)::text);
|
||||
ELSIF (TG_OP = 'UPDATE' AND OLD.ai_status IS DISTINCT FROM NEW.ai_status) THEN
|
||||
PERFORM pg_notify('entries_changed', json_build_object('type', 'ai_status', 'id', NEW.id, 'status', NEW.ai_status, 'error', NEW.ai_error, 'description', NEW.description, 'description_ai', NEW.description_ai, 'description_original', NEW.description_original)::text);
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_entries_notify ON entries;
|
||||
CREATE TRIGGER trg_entries_notify AFTER INSERT OR UPDATE OF ai_status ON entries
|
||||
FOR EACH ROW EXECUTE FUNCTION notify_entries_changed();
|
||||
|
||||
CREATE TABLE IF NOT EXISTS photo_jobs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
action VARCHAR(20) NOT NULL DEFAULT 'ai',
|
||||
params JSONB,
|
||||
before_path VARCHAR(255),
|
||||
after_path VARCHAR(255),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
applied BOOLEAN NOT NULL DEFAULT false,
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
finished_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_photo_jobs_pending ON photo_jobs(id) WHERE status = 'pending';
|
||||
CREATE INDEX IF NOT EXISTS idx_photo_jobs_entry_id ON photo_jobs(entry_id);
|
||||
|
||||
INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255);
|
||||
ALTER TABLE photo_jobs ADD COLUMN IF NOT EXISTS applied BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS modules (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL UNIQUE,
|
||||
lessons_count INT NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE modules ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE modules ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS student_photos (
|
||||
id SERIAL PRIMARY KEY,
|
||||
student_id INT NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
||||
photo_path VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_student_photos_student_id ON student_photos(student_id);
|
||||
|
||||
+32
-16
@@ -21,6 +21,8 @@ services:
|
||||
build:
|
||||
context: .
|
||||
network: host
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
expose:
|
||||
- "3003"
|
||||
- "3443"
|
||||
@@ -35,6 +37,7 @@ services:
|
||||
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
|
||||
depends_on:
|
||||
@@ -72,22 +75,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 +114,20 @@ 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:
|
||||
|
||||
@@ -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')
|
||||
+130
-7
@@ -4,7 +4,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
||||
|
||||
.layout{display:flex;min-height:100dvh}
|
||||
.sidebar{width:var(--sidebar);background:var(--card);border-right:1px solid var(--border);padding:24px 0;position:fixed;top:0;left:0;height:100dvh;display:flex;flex-direction:column}
|
||||
.sidebar .logo{font-size:1.1rem;font-weight:700;padding:0 20px 20px;border-bottom:1px solid var(--border)}
|
||||
.sidebar .logo{font-size:1.1rem;font-weight:700;padding:0 20px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;min-width:0}
|
||||
.sidebar .logo-ic{height:28px;width:auto;min-width:28px;background:var(--bg);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0}
|
||||
.sidebar .logo-ic img{max-width:100%;max-height:100%;object-fit:contain}
|
||||
.sidebar #sidebarName{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
|
||||
.user-box{padding:14px 20px;border-bottom:1px solid var(--border);display:flex;flex-direction:column;gap:2px}
|
||||
.user-box .user-name{font-weight:600;font-size:.9rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.user-box .user-role{color:var(--muted);font-size:.72rem}
|
||||
@@ -29,6 +32,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
||||
.sidebar nav a.active svg{color:var(--accent)}
|
||||
.sidebar .bottom a[data-nav=home] svg{color:#2563eb}
|
||||
.sidebar .bottom{padding:12px 20px;border-top:1px solid var(--border)}
|
||||
.sidebar .version{color:var(--muted);font-size:.72rem;padding:0 20px 8px}
|
||||
.sidebar .bottom a{display:flex;align-items:center;gap:10px;color:var(--muted);text-decoration:none;font-size:.8rem;padding:6px 0}
|
||||
.sidebar .bottom a:hover{color:var(--text)}
|
||||
.sidebar .bottom .logout{color:#ef4444;cursor:pointer;margin-top:4px}
|
||||
@@ -49,8 +53,8 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
||||
.filters label{font-size:.8rem;color:var(--muted);display:block;margin-bottom:4px}
|
||||
.search-input{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;outline:none}
|
||||
.search-input:focus{border-color:var(--accent)}
|
||||
#studentFilter,#linkStudent{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;outline:none;width:100%;max-width:220px}
|
||||
#studentFilter:focus,#linkStudent:focus{border-color:var(--accent)}
|
||||
#studentFilter,#linkStudent,#moduleFilter{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;outline:none;width:100%;max-width:220px}
|
||||
#studentFilter:focus,#linkStudent:focus,#moduleFilter:focus{border-color:var(--accent)}
|
||||
|
||||
.pager{display:flex;gap:6px;align-items:center;justify-content:center;margin-top:20px;flex-wrap:wrap}
|
||||
.pager button{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:6px 12px;font-size:.85rem;cursor:pointer}
|
||||
@@ -127,6 +131,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
||||
.item .name-input:focus{border-color:var(--accent)}
|
||||
.item .group-select{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:4px 8px;font-size:.8rem;outline:none;max-width:200px}
|
||||
.item .group-select:focus{border-color:var(--accent)}
|
||||
.item.expired{opacity:.6;border-color:rgba(239,68,68,.35);background:rgba(239,68,68,.03)}
|
||||
.item.expired:hover{opacity:.85}
|
||||
.badge-expired{display:inline-block;font-size:.68rem;font-weight:700;line-height:1;padding:3px 8px;border-radius:999px;background:rgba(239,68,68,.12);color:#ef4444;vertical-align:middle;margin-left:6px}
|
||||
.badge-soon{display:inline-block;font-size:.68rem;font-weight:700;line-height:1;padding:3px 8px;border-radius:999px;background:rgba(245,158,11,.14);color:#f59e0b;vertical-align:middle;margin-left:6px}
|
||||
.attach-list{max-height:280px;overflow-y:auto;display:flex;flex-direction:column;gap:4px;border:1px solid var(--border);border-radius:8px;padding:8px}
|
||||
.attach-list label{display:flex;gap:8px;align-items:center;font-size:.85rem;color:var(--text);margin:0;cursor:pointer}
|
||||
.attach-list .cur{color:var(--muted);font-size:.75rem;margin-left:auto}
|
||||
@@ -158,7 +166,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
||||
.group-card .save-sched:hover{opacity:.85}
|
||||
.group-card .btn-link{background:var(--bg);border:1px solid var(--border);color:var(--accent);border-radius:8px;padding:8px 14px;font-size:.85rem;font-weight:600;cursor:pointer}
|
||||
.group-card .btn-link:hover{border-color:var(--accent)}
|
||||
.settings-card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:24px;max-width:680px;display:flex;flex-direction:column;gap:16px}
|
||||
.settings-card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:24px;display:flex;flex-direction:column;gap:16px}
|
||||
.settings-row{display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap}
|
||||
.settings-row label{font-weight:600;display:block;margin-bottom:4px}
|
||||
.settings-row .hint{font-size:.8rem;color:var(--muted);max-width:420px;margin:0}
|
||||
@@ -192,6 +200,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
||||
.edit-modal .actions button:hover{opacity:.85}
|
||||
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#16a34a;color:#fff;padding:12px 24px;border-radius:var(--radius);font-size:.9rem;opacity:0;transition:opacity .3s;pointer-events:none;z-index:200}
|
||||
.toast.show{opacity:1}
|
||||
.pf-photos{display:flex;flex-wrap:wrap;gap:10px}
|
||||
.pf-photo{position:relative;width:96px;height:96px;border-radius:10px;overflow:hidden;border:2px solid var(--border);cursor:pointer;flex:0 0 auto}
|
||||
.pf-photo.main{border-color:var(--accent)}
|
||||
.pf-photo img{width:100%;height:100%;object-fit:cover;display:block}
|
||||
.pf-badge{position:absolute;left:4px;bottom:4px;background:var(--accent);color:#fff;font-size:.65rem;font-weight:600;padding:2px 6px;border-radius:6px}
|
||||
.pf-del{position:absolute;top:4px;right:4px;width:20px;height:20px;border:none;border-radius:50%;background:rgba(0,0,0,.6);color:#fff;font-size:.7rem;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center}
|
||||
.pf-del:hover{background:#ef4444}
|
||||
|
||||
/* --- Фото группы (хронология) --- */
|
||||
.group-cover{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:10px;display:block;background:var(--bg)}
|
||||
@@ -247,12 +262,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
||||
.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 +303,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}
|
||||
@@ -418,4 +442,103 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
||||
.worker-counts .chip.skipped b{color:var(--muted)}
|
||||
.worker-counts .chip.reverted b{color:var(--accent)}
|
||||
|
||||
@keyframes entryEnter{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
|
||||
@keyframes entryPopIn{0%{opacity:0;transform:translateY(26px) scale(.95)}55%{opacity:1;transform:translateY(-5px) scale(1.015)}100%{opacity:1;transform:none}}
|
||||
@keyframes entryFadeOut{to{opacity:0}}
|
||||
.entry-enter{animation:entryEnter .4s cubic-bezier(.22,1,.36,1) both}
|
||||
.entry-appear{animation:entryPopIn .6s cubic-bezier(.22,1,.36,1) both;position:relative}
|
||||
.entry-appear::before{content:'';position:absolute;inset:-1px;border-radius:inherit;box-shadow:0 0 0 3px rgba(247,201,14,.9),0 10px 30px rgba(247,201,14,.4);animation:entryFadeOut 2.4s ease .35s both;pointer-events:none}
|
||||
.entry-appear::after{content:'';position:absolute;inset:0;border-radius:inherit;background:linear-gradient(180deg,rgba(247,201,14,.16),transparent 55%);animation:entryFadeOut 2.4s ease .35s both;pointer-events:none}
|
||||
@media (prefers-reduced-motion: reduce){.entry-enter,.entry-appear,.entry-appear::before,.entry-appear::after{animation:none}}
|
||||
|
||||
.ai-profiles-list{margin-top:12px;display:flex;flex-direction:column;gap:8px}
|
||||
.ai-profile-item{display:flex;align-items:center;gap:12px;padding:10px 14px;border:1px solid var(--border);border-radius:10px;background:var(--card)}
|
||||
.ai-profile-item.active{border-color:var(--accent);background:rgba(37,99,235,.05)}
|
||||
.ai-profile-item .pi-info{flex:1;min-width:0}
|
||||
.ai-profile-item .pi-name{font-weight:600;font-size:.9rem;display:flex;align-items:center;gap:8px}
|
||||
.ai-profile-item .pi-meta{color:var(--muted);font-size:.78rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.ai-profile-item .pi-actions{display:flex;gap:6px;flex-shrink:0}
|
||||
.pi-btn{background:none;border:1px solid var(--border);border-radius:8px;padding:5px 10px;font-size:.78rem;cursor:pointer;color:var(--text);transition:all .15s}
|
||||
.pi-btn:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.pi-btn.danger:hover{border-color:#ef4444;color:#ef4444}
|
||||
.pi-badge{font-size:.68rem;font-weight:700;padding:2px 8px;border-radius:999px;background:rgba(22,163,74,.12);color:#16a34a;white-space:nowrap}
|
||||
|
||||
/* --- Настройки (UX/UI v2) --- */
|
||||
body[data-page=settings] .page-head{align-items:flex-end}
|
||||
.page-status{margin-left:auto;display:inline-flex;align-items:center;gap:8px;font-size:.8rem;color:var(--muted);background:var(--card);border:1px solid var(--border);border-radius:999px;padding:6px 14px;white-space:nowrap;transition:opacity .3s}
|
||||
.page-status.loaded{opacity:0;pointer-events:none}
|
||||
.page-status.err{color:#ef4444;opacity:1}
|
||||
.head-btn{display:inline-flex;align-items:center;gap:7px;background:var(--card);border:1px solid var(--border);color:var(--muted);border-radius:10px;padding:8px 14px;font-size:.82rem;font-weight:600;cursor:pointer;transition:all .15s;white-space:nowrap;flex-shrink:0}
|
||||
.head-btn:hover{color:var(--accent);border-color:var(--accent)}
|
||||
.head-btn svg{width:14px;height:14px}
|
||||
.settings-layout{display:grid;grid-template-columns:190px minmax(0,1fr);gap:28px;align-items:start}
|
||||
.settings-nav{position:sticky;top:16px;display:flex;flex-direction:column;gap:2px}
|
||||
.settings-nav a{display:block;padding:9px 13px;border-radius:10px;color:var(--muted);text-decoration:none;font-size:.86rem;font-weight:500;transition:all .15s;white-space:nowrap}
|
||||
.settings-nav a:hover{color:var(--text);background:var(--card)}
|
||||
.settings-nav a.active{color:var(--accent);background:var(--card);font-weight:600;box-shadow:inset 2px 0 0 var(--accent)}
|
||||
.settings-nav a svg{width:15px;height:15px;flex-shrink:0;vertical-align:-3px;margin-right:2px}
|
||||
.settings-layout .settings-stack,.settings-layout .settings-stack-grid{max-width:none}
|
||||
.settings-card[id]{scroll-margin-top:16px}
|
||||
.settings-card{transition:border-color .2s,box-shadow .2s}
|
||||
.settings-card:target{border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,.1)}
|
||||
.head-live{margin-left:auto;font-size:.72rem;color:var(--muted);white-space:nowrap}
|
||||
.toggle{position:relative;display:flex;align-items:center;gap:12px;padding:10px 12px;background:var(--bg);border:1px solid transparent;border-radius:10px;cursor:pointer;transition:border-color .15s,background .15s}
|
||||
.toggle:hover{border-color:var(--border);background:var(--card)}
|
||||
.toggle input{position:absolute;opacity:0;width:1px;height:1px}
|
||||
.toggle-track{position:relative;width:40px;height:22px;border-radius:999px;background:#d4d4d4;flex-shrink:0;transition:background .2s}
|
||||
.toggle-thumb{position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.25);transition:transform .2s}
|
||||
.toggle input:checked ~ .toggle-track{background:var(--accent)}
|
||||
.toggle input:checked ~ .toggle-track .toggle-thumb{transform:translateX(18px)}
|
||||
.toggle input:focus-visible ~ .toggle-track{box-shadow:0 0 0 3px rgba(37,99,235,.25)}
|
||||
.toggle-text{display:flex;flex-direction:column;gap:1px;min-width:0}
|
||||
.toggle-text b{font-size:.87rem;font-weight:600}
|
||||
.toggle-text small{font-size:.74rem;color:var(--muted);line-height:1.4}
|
||||
.settings-field{position:relative}
|
||||
.field-error{color:#ef4444;font-size:.75rem;font-weight:600;min-height:0;display:block}
|
||||
.settings-input.invalid{border-color:#ef4444;box-shadow:0 0 0 3px rgba(239,68,68,.1)}
|
||||
.settings-input.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.84rem;line-height:1.5;min-height:110px;resize:vertical}
|
||||
.preset-btns{display:flex;gap:6px;flex-wrap:wrap;margin-top:4px}
|
||||
.preset-btn{background:var(--card);border:1px solid var(--border);border-radius:999px;padding:5px 12px;font-size:.75rem;font-weight:600;color:var(--muted);cursor:pointer;transition:all .15s}
|
||||
.preset-btn:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.preset-btn.active{background:rgba(37,99,235,.08);border-color:var(--accent);color:var(--accent)}
|
||||
.toast.error{background:#dc2626}
|
||||
.queue-link{color:var(--accent);text-decoration:none;font-weight:600;font-size:.8rem;white-space:nowrap}
|
||||
.queue-link:hover{text-decoration:underline}
|
||||
.ai-queue{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
|
||||
.ai-queue .chip{background:var(--bg);border:1px solid var(--border);border-radius:999px;padding:6px 12px;font-size:.78rem;color:var(--text);display:inline-flex;align-items:center;gap:6px}
|
||||
.ai-queue .chip b{color:var(--accent);font-weight:700}
|
||||
.ban-chip{flex-direction:column;align-items:flex-start;gap:2px;padding-right:8px}
|
||||
.chip-sub{font-size:.72rem;color:var(--muted);font-weight:400}
|
||||
.unban-btn{background:none;border:none;color:var(--accent);cursor:pointer;font-weight:700;font-size:.9rem;padding:0 2px;line-height:1}
|
||||
.unban-btn:hover{color:#ef4444}
|
||||
.ban-chip.confirming{background:rgba(239,68,68,.07);border-color:rgba(239,68,68,.4)}
|
||||
.chip-confirm{display:inline-flex;align-items:center;gap:8px;font-size:.74rem;font-weight:600;white-space:nowrap;padding-right:4px}
|
||||
.chip-yes,.chip-no{background:none;border:none;cursor:pointer;font-weight:700;padding:0}
|
||||
.chip-yes{color:#16a34a}
|
||||
.chip-no{color:var(--muted)}
|
||||
.btn-primary.danger-ghost{background:var(--card);border:1px solid rgba(239,68,68,.35);color:#ef4444}
|
||||
.btn-primary.danger-ghost:hover{border-color:#ef4444;opacity:1}
|
||||
.ai-test-status{font-size:.85rem;min-height:1.3em}
|
||||
.ai-test-status .ok{color:#16a34a;font-weight:600}
|
||||
.ai-test-status .fail{color:#ef4444;font-weight:600}
|
||||
.save-bar{animation:saveBarIn .25s cubic-bezier(.22,1,.36,1)}
|
||||
.save-bar.show{border-color:rgba(245,158,11,.5)}
|
||||
.save-bar-actions{display:flex;gap:10px;flex-wrap:wrap}
|
||||
@keyframes saveBarIn{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
|
||||
.icon-btn{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:8px;border:1px solid var(--border);background:var(--card);color:var(--muted);cursor:pointer;transition:all .15s}
|
||||
.icon-btn:hover{color:var(--accent);border-color:var(--accent)}
|
||||
.icon-btn svg{width:14px;height:14px}
|
||||
.icon-btn.spinning svg{animation:spin 1s linear infinite}
|
||||
.sys-card.warn{border-color:rgba(245,158,11,.5);background:rgba(245,158,11,.05)}
|
||||
.sys-card.danger{border-color:rgba(239,68,68,.5);background:rgba(239,68,68,.05)}
|
||||
@media(max-width:1024px){
|
||||
.settings-layout{grid-template-columns:1fr;gap:14px}
|
||||
.settings-nav{position:sticky;top:0;z-index:40;flex-direction:row;gap:6px;overflow-x:auto;background:var(--bg);padding:8px 0;scrollbar-width:none}
|
||||
.settings-nav::-webkit-scrollbar{display:none}
|
||||
.settings-nav a{border:1px solid var(--border);background:var(--card);border-radius:999px;padding:7px 14px;font-size:.8rem}
|
||||
.settings-nav a.active{box-shadow:none;border-color:var(--accent)}
|
||||
.settings-card[id]{scroll-margin-top:60px}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce){.save-bar{animation:none}.toggle-track,.toggle-thumb{transition:none}.icon-btn.spinning svg{animation:none}}
|
||||
|
||||
|
||||
|
||||
+41
-1
@@ -56,6 +56,7 @@ function buildSidebar(active) {
|
||||
{ page: 'trash', label: 'Корзина', icon: 'trash-2' },
|
||||
];
|
||||
const adminNav = [
|
||||
{ page: 'modules', label: 'Темы модулей', icon: 'layers' },
|
||||
{ page: 'branches', label: 'Филиалы', icon: 'building-2' },
|
||||
{ page: 'users', label: 'Пользователи', icon: 'user-cog' },
|
||||
{ page: 'worker', label: 'Воркер ИИ', icon: 'bot' },
|
||||
@@ -67,7 +68,10 @@ function buildSidebar(active) {
|
||||
const userName = currentUser?.name || currentUser?.username || '';
|
||||
const roleLabel = isAdmin() ? 'Администратор' : 'Тутор';
|
||||
el.innerHTML = `
|
||||
<div class="logo">Админ-панель</div>
|
||||
<div class="logo">
|
||||
<span class="logo-ic" id="sidebarLogo"></span>
|
||||
<span id="sidebarName">Админ-панель</span>
|
||||
</div>
|
||||
<div class="user-box">
|
||||
<div class="user-name">${esc(userName)}</div>
|
||||
<div class="user-role">${roleLabel}</div>
|
||||
@@ -76,10 +80,46 @@ function buildSidebar(active) {
|
||||
${nav.map(n => `<a href="${n.page}.html" data-nav="${n.page}" class="${n.page === active ? 'active' : ''}"><i data-lucide="${n.icon}"></i>${n.label}</a>`).join('')}
|
||||
</nav>
|
||||
<div class="bottom">
|
||||
<div class="version" id="sidebarVersion"></div>
|
||||
<a href="/" data-nav="home"><i data-lucide="home"></i>На главную</a>
|
||||
<a class="logout" href="#" data-action="logout"><i data-lucide="log-out"></i>Выйти</a>
|
||||
</div>`;
|
||||
renderIcons();
|
||||
loadVersion();
|
||||
loadBrand();
|
||||
}
|
||||
|
||||
async function loadBrand() {
|
||||
const logoEl = document.getElementById('sidebarLogo');
|
||||
const nameEl = document.getElementById('sidebarName');
|
||||
if (!logoEl || !nameEl) return;
|
||||
try {
|
||||
const res = await fetch(`${API}/api/public-settings`);
|
||||
if (!res.ok) return;
|
||||
const s = await res.json();
|
||||
if ((s.system_name || '').trim()) nameEl.textContent = s.system_name;
|
||||
if (s.system_logo) {
|
||||
const img = document.createElement('img');
|
||||
img.src = s.system_logo;
|
||||
img.alt = '';
|
||||
logoEl.classList.add('has-logo');
|
||||
logoEl.appendChild(img);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadVersion() {
|
||||
const el = document.getElementById('sidebarVersion');
|
||||
if (!el) return;
|
||||
try {
|
||||
const res = await fetch(`${API}/version.json?t=${Date.now()}`);
|
||||
if (!res.ok) { el.textContent = ''; return; }
|
||||
const v = await res.json();
|
||||
if (!v.short) { el.textContent = ''; return; }
|
||||
const d = (v.date || '').slice(0, 10);
|
||||
el.textContent = `v${v.short}${d ? ' · ' + d : ''}`;
|
||||
el.title = `${v.short} · ${v.date || ''}`;
|
||||
} catch { el.textContent = ''; }
|
||||
}
|
||||
|
||||
// --- Common UI ---
|
||||
|
||||
@@ -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>
|
||||
|
||||
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>
|
||||
|
||||
+30
-3
@@ -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}}
|
||||
@@ -63,6 +66,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,8 +92,13 @@ 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">
|
||||
@@ -121,6 +135,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 +152,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>
|
||||
|
||||
@@ -21,10 +21,26 @@ 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': 'Запись удалена безвозвратно',
|
||||
@@ -41,6 +57,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;
|
||||
@@ -95,6 +116,8 @@ async function loadAudit() {
|
||||
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; }
|
||||
const rows = await res.json();
|
||||
@@ -126,6 +149,18 @@ 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);
|
||||
|
||||
@@ -148,6 +183,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(() => {});
|
||||
+26
-2
@@ -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,9 +102,12 @@ 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); }
|
||||
}
|
||||
@@ -414,6 +432,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 () => {
|
||||
|
||||
+150
-6
@@ -4,6 +4,11 @@ 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');
|
||||
@@ -15,6 +20,64 @@ 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 + ' Б';
|
||||
@@ -74,7 +137,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 +202,29 @@ 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;
|
||||
} catch (e) { /* ignore */ }
|
||||
checkPreviousSend();
|
||||
})();
|
||||
|
||||
function showPreview(src) {
|
||||
@@ -117,7 +242,7 @@ function removePhoto() {
|
||||
|
||||
async function openCam() {
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } } });
|
||||
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: captureWidth }, height: { ideal: captureHeight } } });
|
||||
camVideo.srcObject = stream;
|
||||
camModal.classList.add('open');
|
||||
} catch (e) {
|
||||
@@ -141,7 +266,7 @@ function capture() {
|
||||
capturedBlob = blob;
|
||||
showPreview(URL.createObjectURL(blob));
|
||||
closeCam();
|
||||
}, 'image/jpeg', 0.85);
|
||||
}, 'image/jpeg', captureQuality);
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
@@ -152,6 +277,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 +296,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 +313,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 {
|
||||
@@ -195,3 +329,13 @@ 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));
|
||||
});
|
||||
+880
-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,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')}
|
||||
+524
-63
@@ -1,4 +1,27 @@
|
||||
const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText'];
|
||||
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine'];
|
||||
const SECTION_IDS = ['sec-system', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-form', 'sec-bans'];
|
||||
const SPAM_MAX = 10080;
|
||||
const BAN_REASONS = {
|
||||
'honeypot': 'Антиспам-поле',
|
||||
'login-bruteforce': 'Подбор пароля входа',
|
||||
'share-password-bruteforce': 'Подбор пароля ссылки',
|
||||
'manual': 'Вручную',
|
||||
};
|
||||
|
||||
let currentLogo = '';
|
||||
let logoOriginal = '';
|
||||
|
||||
function renderLogoPreview() {
|
||||
const img = document.getElementById('logoPreviewImg');
|
||||
const fb = document.getElementById('logoPreviewFallback');
|
||||
const rm = document.getElementById('logoRemoveBtn');
|
||||
if (!img || !fb || !rm) return;
|
||||
const hasLogo = !!currentLogo;
|
||||
img.style.display = hasLogo ? 'block' : 'none';
|
||||
fb.style.display = hasLogo ? 'none' : '';
|
||||
if (hasLogo) img.src = currentLogo;
|
||||
rm.hidden = !hasLogo;
|
||||
}
|
||||
|
||||
function fieldValue(el) {
|
||||
return el.type === 'checkbox' ? String(el.checked) : el.value;
|
||||
@@ -8,24 +31,74 @@ function setBtnLoading(btn, on, spinnerClass) {
|
||||
if (on) {
|
||||
btn.dataset.label = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = `<span class="spinner ${spinnerClass || ''}"></span> ${btn.dataset.loadingText || 'Обработка...'}`;
|
||||
const cls = spinnerClass || (btn.classList.contains('ghost') ? 'dark' : '');
|
||||
btn.innerHTML = `<span class="spinner ${cls}"></span> ${btn.dataset.loadingText || 'Обработка...'}`;
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = btn.dataset.label || btn.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
const t = document.getElementById('toast');
|
||||
if (!t) return;
|
||||
t.textContent = msg;
|
||||
t.classList.add('show', 'error');
|
||||
clearTimeout(t._errTimer);
|
||||
t._errTimer = setTimeout(() => t.classList.remove('error'), 3400);
|
||||
}
|
||||
|
||||
function showFieldError(id, msg) {
|
||||
const el = document.getElementById(id);
|
||||
const err = document.getElementById(`${id}Error`);
|
||||
if (el) el.classList.add('invalid');
|
||||
if (err) { err.textContent = msg; err.hidden = false; }
|
||||
}
|
||||
|
||||
function clearFieldError(id) {
|
||||
const el = document.getElementById(id);
|
||||
const err = document.getElementById(`${id}Error`);
|
||||
if (el) el.classList.remove('invalid');
|
||||
if (err) err.hidden = true;
|
||||
}
|
||||
|
||||
function plural(n, one, few, many) {
|
||||
const a = n % 10, b = n % 100;
|
||||
if (a === 1 && b !== 11) return one;
|
||||
if (a >= 2 && a <= 4 && (b < 12 || b > 14)) return few;
|
||||
return many;
|
||||
}
|
||||
|
||||
function humanizeMinutes(min) {
|
||||
const n = Number(min);
|
||||
if (!Number.isFinite(n) || n < 1) return '';
|
||||
if (n === 1440) return 'сутки';
|
||||
if (n > 1440 && n % 1440 === 0) {
|
||||
const d = n / 1440;
|
||||
return `${d} ${plural(d, 'сутки', 'суток', 'суток')}`;
|
||||
}
|
||||
const h = Math.floor(n / 60);
|
||||
const m = n % 60;
|
||||
const parts = [];
|
||||
if (h) parts.push(`${h} ${plural(h, 'час', 'часа', 'часов')}`);
|
||||
if (m) parts.push(`${m} ${plural(m, 'минута', 'минуты', 'минут')}`);
|
||||
if (!parts.length) parts.push(`${n} ${plural(n, 'минута', 'минуты', 'минут')}`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function updateDirty() {
|
||||
const dirty = DIRTY_FIELDS.some(id => {
|
||||
const dirtyCount = DIRTY_FIELDS.filter(id => {
|
||||
const el = document.getElementById(id);
|
||||
return String(el.dataset.original ?? '') !== fieldValue(el);
|
||||
});
|
||||
document.getElementById('saveBar').classList.toggle('show', dirty);
|
||||
}).length;
|
||||
document.getElementById('saveBar').classList.toggle('show', dirtyCount > 0);
|
||||
}
|
||||
|
||||
function bindDirty() {
|
||||
DIRTY_FIELDS.forEach(id => {
|
||||
document.getElementById(id).addEventListener('input', updateDirty);
|
||||
const el = document.getElementById(id);
|
||||
el.addEventListener('input', updateDirty);
|
||||
el.addEventListener('change', updateDirty);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,12 +109,61 @@ function snapshotDirty() {
|
||||
updateDirty();
|
||||
}
|
||||
|
||||
function revertChanges() {
|
||||
DIRTY_FIELDS.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
const original = el.dataset.original ?? '';
|
||||
if (el.type === 'checkbox') el.checked = original === 'true';
|
||||
else el.value = original;
|
||||
});
|
||||
currentLogo = logoOriginal;
|
||||
renderLogoPreview();
|
||||
clearFieldError('spamInterval');
|
||||
updateSpamUi();
|
||||
updateDirty();
|
||||
showToast('Изменения отменены');
|
||||
}
|
||||
|
||||
function initSectionNav() {
|
||||
const nav = document.getElementById('settingsNav');
|
||||
if (!nav) return;
|
||||
const links = [...nav.querySelectorAll('a')];
|
||||
const smooth = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth';
|
||||
let ticking = false;
|
||||
const setActive = () => {
|
||||
ticking = false;
|
||||
let current = SECTION_IDS[0];
|
||||
for (const id of SECTION_IDS) {
|
||||
const el = document.getElementById(id);
|
||||
if (el && el.getBoundingClientRect().top <= 170) current = id;
|
||||
}
|
||||
links.forEach(a => a.classList.toggle('active', a.getAttribute('href') === `#${current}`));
|
||||
};
|
||||
window.addEventListener('scroll', () => {
|
||||
if (!ticking) { ticking = true; requestAnimationFrame(setActive); }
|
||||
}, { passive: true });
|
||||
setActive();
|
||||
nav.addEventListener('click', e => {
|
||||
const a = e.target.closest('a');
|
||||
if (!a) return;
|
||||
e.preventDefault();
|
||||
const target = document.getElementById(a.getAttribute('href').slice(1));
|
||||
if (target) target.scrollIntoView({ behavior: smooth, block: 'start' });
|
||||
history.replaceState(null, '', a.getAttribute('href'));
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
const [sRes, stRes] = await Promise.all([
|
||||
fetch(`${API}/api/settings`, { headers: hdr() }),
|
||||
fetch(`${API}/api/stats`, { headers: hdr() }),
|
||||
]);
|
||||
if (!sRes.ok) { showError('Не удалось загрузить настройки'); return; }
|
||||
const s = await sRes.json();
|
||||
document.getElementById('systemName').value = s.system_name !== undefined ? s.system_name : 'WhatIDo';
|
||||
currentLogo = s.system_logo || '';
|
||||
logoOriginal = currentLogo;
|
||||
renderLogoPreview();
|
||||
if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min;
|
||||
document.getElementById('footerLeft').value = s.footer_left || '';
|
||||
document.getElementById('footerRight').value = s.footer_right || '';
|
||||
@@ -52,27 +174,60 @@ async function loadSettings() {
|
||||
document.getElementById('shareShowNames').checked = s.share_show_student_names !== 'false';
|
||||
document.getElementById('shareShowPhotos').checked = s.share_show_group_photos !== 'false';
|
||||
document.getElementById('cookieNoticeText').value = s.cookie_notice_text || '';
|
||||
const pr = document.getElementById('photoResolution');
|
||||
if (s.photo_capture_resolution) pr.value = s.photo_capture_resolution;
|
||||
if (!pr.value) pr.value = '640x480';
|
||||
document.getElementById('photoQuality').value = s.photo_capture_quality !== undefined && s.photo_capture_quality !== '' ? s.photo_capture_quality : '0.92';
|
||||
const pe = document.getElementById('photoEngine');
|
||||
pe.value = ['auto', 'server', 'client'].includes(s.photo_enhance_engine) ? s.photo_enhance_engine : 'auto';
|
||||
if (!pe.value) pe.value = 'auto';
|
||||
snapshotDirty();
|
||||
updateSpamUi();
|
||||
loadAiQueue();
|
||||
if (stRes.ok) {
|
||||
const st = await stRes.json();
|
||||
document.getElementById('dataStats').innerHTML = `
|
||||
loadAiProfiles();
|
||||
renderStats(stRes);
|
||||
loadSystemInfo();
|
||||
}
|
||||
|
||||
async function renderStats(res) {
|
||||
const el = document.getElementById('dataStats');
|
||||
if (!el) return;
|
||||
if (!res || !res.ok) { el.innerHTML = '<span class="chip">Статистика недоступна</span>'; return; }
|
||||
const st = await res.json();
|
||||
el.innerHTML = `
|
||||
<span class="chip">Записей: <b>${st.entries}</b></span>
|
||||
<span class="chip">Учеников: <b>${st.students}</b></span>
|
||||
<span class="chip">Групп: <b>${st.groups}</b></span>
|
||||
<span class="chip">Сегодня: <b>${st.today}</b></span>
|
||||
<span class="chip">${st.trash ? `В корзине: <b>${st.trash}</b> <a href="trash.html">→</a>` : ''}</span>
|
||||
${st.trash ? `<span class="chip">В корзине: <b>${st.trash}</b> <a href="trash.html">→</a></span>` : ''}
|
||||
`;
|
||||
} else {
|
||||
document.getElementById('dataStats').innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
const siRes = await fetch(`${API}/api/system-info`, { headers: hdr() });
|
||||
if (siRes.ok) {
|
||||
const si = await siRes.json();
|
||||
renderSystemInfo(si);
|
||||
} else {
|
||||
document.getElementById('systemInfo').innerHTML = '<div class="sys-card err">Ошибка загрузки</div>';
|
||||
async function refreshStats() {
|
||||
const el = document.getElementById('dataStats');
|
||||
if (!el) return;
|
||||
el.innerHTML = '<span class="chip">Обновление…</span>';
|
||||
try {
|
||||
await renderStats(await fetch(`${API}/api/stats`, { headers: hdr() }));
|
||||
} catch {
|
||||
el.innerHTML = '<span class="chip">Ошибка сети</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSystemInfo() {
|
||||
const el = document.getElementById('systemInfo');
|
||||
if (!el) return;
|
||||
try {
|
||||
const res = await fetch(`${API}/api/system-info`, { headers: hdr() });
|
||||
if (res.ok) {
|
||||
renderSystemInfo(await res.json());
|
||||
const live = document.getElementById('systemLive');
|
||||
if (live) live.textContent = 'обновлено ' + new Date().toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' });
|
||||
return;
|
||||
}
|
||||
el.innerHTML = '<div class="sys-card err">Ошибка загрузки</div>';
|
||||
} catch {
|
||||
el.innerHTML = '<div class="sys-card err">Ошибка сети</div>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +241,7 @@ function renderSystemInfo(si) {
|
||||
let tablesHtml = '';
|
||||
if (db.tables && db.tables.length) {
|
||||
tablesHtml = '<details class="sys-details"><summary>Таблицы БД</summary><div class="sys-table-list">' +
|
||||
db.tables.map(t => `<div class="sys-table-row"><span>${t.name}</span><span>${t.size}</span></div>`).join('') +
|
||||
db.tables.map(t => `<div class="sys-table-row"><span>${esc(t.name)}</span><span>${esc(t.size)}</span></div>`).join('') +
|
||||
'</div></details>';
|
||||
}
|
||||
|
||||
@@ -94,29 +249,28 @@ function renderSystemInfo(si) {
|
||||
if (disk) {
|
||||
const pct = disk.used_pct;
|
||||
const color = pct > 90 ? '#ef4444' : pct > 70 ? '#f59e0b' : '#16a34a';
|
||||
const cardCls = pct > 90 ? ' danger' : pct > 70 ? ' warn' : '';
|
||||
diskHtml = `
|
||||
<div class="sys-item">
|
||||
<span class="sys-label">Диск</span>
|
||||
<span class="sys-value">${disk.used} / ${disk.total}</span>
|
||||
</div>
|
||||
<div class="sys-bar-wrap">
|
||||
<div class="sys-bar" style="width:${pct}%; background:${color}"></div>
|
||||
</div>
|
||||
<div class="sys-meta">Свободно: ${disk.free} (${100 - pct}%)</div>
|
||||
`;
|
||||
<div class="sys-card${cardCls}">
|
||||
<div class="sys-title">Диск</div>
|
||||
<div class="sys-item"><span class="sys-label">Заполнено</span><span class="sys-value">${pct}%</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Использовано</span><span class="sys-value">${esc(disk.used)} / ${esc(disk.total)}</span></div>
|
||||
<div class="sys-bar-wrap"><div class="sys-bar" style="width:${pct}%; background:${color}"></div></div>
|
||||
<div class="sys-meta">Свободно: ${esc(disk.free)} (${100 - pct}%)</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
document.getElementById('systemInfo').innerHTML = `
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">База данных</div>
|
||||
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${db.size || '—'}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${esc(db.size || '—')}</span></div>
|
||||
${tablesHtml}
|
||||
</div>
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">Фото</div>
|
||||
<div class="sys-item"><span class="sys-label">Фото групп</span><span class="sys-value">${photos.group_photos?.count || 0} · ${photos.group_photos?.size || '0 B'}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Фото в записях</span><span class="sys-value">${photos.entry_photos?.count || 0} · ${photos.entry_photos?.size || '0 B'}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Всего</span><span class="sys-value"><b>${photos.total_count || 0}</b> · <b>${photos.total_size || '0 B'}</b></span></div>
|
||||
<div class="sys-item"><span class="sys-label">Фото групп</span><span class="sys-value">${photos.group_photos?.count || 0} · ${esc(photos.group_photos?.size || '0 B')}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Фото в записях</span><span class="sys-value">${photos.entry_photos?.count || 0} · ${esc(photos.entry_photos?.size || '0 B')}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Всего</span><span class="sys-value"><b>${photos.total_count || 0}</b> · <b>${esc(photos.total_size || '0 B')}</b></span></div>
|
||||
</div>
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">Файлы</div>
|
||||
@@ -125,15 +279,72 @@ function renderSystemInfo(si) {
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">Загрузки</div>
|
||||
<div class="sys-item"><span class="sys-label">Файлов</span><span class="sys-value">${uploads.count || 0}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${uploads.size || '0 B'}</span></div>
|
||||
</div>
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">Диск</div>
|
||||
${diskHtml || '<div class="sys-meta">Недоступно</div>'}
|
||||
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${esc(uploads.size || '0 B')}</span></div>
|
||||
</div>
|
||||
${diskHtml || '<div class="sys-card"><div class="sys-title">Диск</div><div class="sys-meta">Недоступно</div></div>'}
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadAiProfiles() {
|
||||
const listEl = document.getElementById('aiProfilesList');
|
||||
const select = document.getElementById('aiActiveProfile');
|
||||
if (!listEl) return;
|
||||
try {
|
||||
const res = await fetch(`${API}/api/ai/profiles`, { headers: hdr() });
|
||||
if (!res.ok) { listEl.innerHTML = '<span class="chip">Не удалось загрузить профили</span>'; return; }
|
||||
const data = await res.json();
|
||||
const profiles = data.profiles || [];
|
||||
if (select) {
|
||||
select.innerHTML = `<option value="native">Нативная — ${esc(data.native.model)}</option>` +
|
||||
profiles.map(p => `<option value="${esc(p.id)}">${esc(p.name)} — ${esc(p.model)}</option>`).join('');
|
||||
select.value = data.active;
|
||||
}
|
||||
const items = [{ id: 'native', name: 'Нативная (llama.cpp в Docker)', base_url: data.native.base_url, model: data.native.model, native: true }].concat(profiles);
|
||||
listEl.innerHTML = items.map(p => `
|
||||
<div class="ai-profile-item${data.active === p.id ? ' active' : ''}">
|
||||
<div class="pi-info">
|
||||
<div class="pi-name">${esc(p.name)}${data.active === p.id ? ' <span class="pi-badge">по умолчанию</span>' : ''}</div>
|
||||
<div class="pi-meta">${esc(p.model)} · ${esc(p.base_url)}${p.max_tokens ? ` · max_tokens: ${esc(p.max_tokens)}` : ''}</div>
|
||||
</div>
|
||||
<div class="pi-actions">
|
||||
${p.native ? '' : `<button type="button" class="pi-btn" data-edit-profile="${esc(p.id)}">Изменить</button>`}
|
||||
${p.native ? '' : `<button type="button" class="pi-btn danger" data-del-profile="${esc(p.id)}" data-del-name="${esc(p.name)}">Удалить</button>`}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch { listEl.innerHTML = '<span class="chip">Ошибка загрузки</span>'; }
|
||||
}
|
||||
|
||||
let aiProfileEditingId = null;
|
||||
|
||||
function openAiProfileForm(profile) {
|
||||
aiProfileEditingId = profile ? profile.id : null;
|
||||
document.getElementById('aiProfileModalTitle').textContent = profile ? 'Изменить профиль ИИ' : 'Добавить профиль ИИ';
|
||||
document.getElementById('aiProfileModal').classList.add('open');
|
||||
document.getElementById('aiProfileName').value = profile ? profile.name : '';
|
||||
document.getElementById('aiProfileModel').value = profile ? profile.model : '';
|
||||
document.getElementById('aiProfileBaseUrl').value = profile ? profile.base_url : '';
|
||||
document.getElementById('aiProfileApiKey').value = '';
|
||||
document.getElementById('aiProfileMaxTokens').value = profile && profile.max_tokens ? profile.max_tokens : '';
|
||||
document.getElementById('aiProfileTestStatus').textContent = '';
|
||||
document.getElementById('aiProfileName').focus();
|
||||
}
|
||||
|
||||
function closeAiProfileForm() {
|
||||
aiProfileEditingId = null;
|
||||
document.getElementById('aiProfileModal').classList.remove('open');
|
||||
}
|
||||
|
||||
function aiProfileFormData() {
|
||||
return {
|
||||
name: document.getElementById('aiProfileName').value,
|
||||
model: document.getElementById('aiProfileModel').value,
|
||||
base_url: document.getElementById('aiProfileBaseUrl').value,
|
||||
api_key: document.getElementById('aiProfileApiKey').value,
|
||||
max_tokens: document.getElementById('aiProfileMaxTokens').value.trim() === '' ? null : document.getElementById('aiProfileMaxTokens').value,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadAiQueue() {
|
||||
const el = document.getElementById('aiQueueInfo');
|
||||
if (!el) return;
|
||||
@@ -142,14 +353,153 @@ async function loadAiQueue() {
|
||||
if (!res.ok) { el.textContent = ''; return; }
|
||||
const q = await res.json();
|
||||
const c = q.counts || {};
|
||||
const state = q.enabled ? 'включена' : 'выключена';
|
||||
el.innerHTML = `Автопроверка: <b>${state}</b> · Модель: <b>${esc(q.model)}</b> · В очереди: <b>${c.pending || 0}</b> · Проверено: <b>${c.done || 0}</b> · Ошибок: <b>${c.error || 0}</b> · Пропущено: <b>${c.skipped || 0}</b> · <a href="worker.html">Монитор воркера →</a>`;
|
||||
el.innerHTML = `
|
||||
<span class="chip">Автопроверка: <b>${q.enabled ? 'включена' : 'выключена'}</b></span>
|
||||
<span class="chip">Модель: <b>${esc(q.model || '—')}</b></span>
|
||||
<span class="chip">В очереди: <b>${c.pending || 0}</b></span>
|
||||
<span class="chip">Проверено: <b>${c.done || 0}</b></span>
|
||||
<span class="chip">Ошибок: <b>${c.error || 0}</b></span>
|
||||
<span class="chip">Пропущено: <b>${c.skipped || 0}</b></span>
|
||||
<a class="queue-link" href="worker.html">Монитор воркера →</a>`;
|
||||
} catch { el.textContent = ''; }
|
||||
}
|
||||
|
||||
async function saveAiProfile() {
|
||||
const body = aiProfileFormData();
|
||||
const st = document.getElementById('aiProfileTestStatus');
|
||||
st.innerHTML = '<span class="spinner dark"></span> Сохранение...';
|
||||
const res = await fetch(aiProfileEditingId ? `${API}/api/ai/profiles/${aiProfileEditingId}` : `${API}/api/ai/profiles`, {
|
||||
method: aiProfileEditingId ? 'PUT' : 'POST',
|
||||
headers: hdrJson(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (res.ok) {
|
||||
st.textContent = '';
|
||||
showToast(aiProfileEditingId ? 'Профиль обновлён' : 'Профиль добавлен');
|
||||
closeAiProfileForm();
|
||||
loadAiProfiles();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
st.innerHTML = `<span class="fail">${esc(err.error || 'Ошибка сохранения')}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function testAiProfile() {
|
||||
const st = document.getElementById('aiProfileTestStatus');
|
||||
st.textContent = 'Проверка…';
|
||||
const res = await fetch(`${API}/api/ai/profiles/test`, { method: 'POST', headers: hdrJson(), body: JSON.stringify(aiProfileFormData()) });
|
||||
const d = await res.json().catch(() => ({ ok: false, error: 'Ошибка' }));
|
||||
st.innerHTML = d.ok
|
||||
? `<span class="ok">✓ Работает (${d.latency_ms} мс)${d.sample ? ` — «${esc(d.sample)}»` : ''}</span>`
|
||||
: `<span class="fail">✗ ${esc(d.error || 'Не удалось подключиться')}</span>`;
|
||||
}
|
||||
|
||||
async function activateAiProfile(id) {
|
||||
const res = await fetch(`${API}/api/ai/profiles/activate`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ id }) });
|
||||
if (res.ok) {
|
||||
showToast('Модель по умолчанию обновлена');
|
||||
loadAiProfiles();
|
||||
loadAiQueue();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
showError(err.error || 'Ошибка');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAiProfile(id, name, btn) {
|
||||
if (btn && !btn.dataset.confirming) {
|
||||
btn.dataset.confirming = '1';
|
||||
btn.classList.add('confirming');
|
||||
btn.textContent = 'Точно?';
|
||||
setTimeout(() => {
|
||||
if (btn.isConnected) {
|
||||
delete btn.dataset.confirming;
|
||||
btn.classList.remove('confirming');
|
||||
btn.textContent = 'Удалить';
|
||||
}
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`${API}/api/ai/profiles/${id}`, { method: 'DELETE', headers: hdr() });
|
||||
if (res.ok) {
|
||||
showToast('Профиль удалён');
|
||||
loadAiProfiles();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
showError(err.error || 'Ошибка удаления');
|
||||
}
|
||||
}
|
||||
|
||||
const spamInput = document.getElementById('spamInterval');
|
||||
|
||||
function updateSpamUi() {
|
||||
const raw = spamInput.value;
|
||||
const n = Math.round(Number(raw));
|
||||
const valid = raw !== '' && Number.isFinite(n) && n >= 1;
|
||||
const hint = document.getElementById('spamIntervalHint');
|
||||
if (hint) hint.textContent = valid ? `Следующая отправка будет доступна через ${humanizeMinutes(n)}` : 'Минимальное значение — 1 минута';
|
||||
document.querySelectorAll('#spamPresets .preset-btn').forEach(b => b.classList.toggle('active', valid && Number(b.dataset.min) === n));
|
||||
}
|
||||
|
||||
function clampSpam(v) {
|
||||
let n = Math.round(Number(v));
|
||||
if (!Number.isFinite(n) || n < 1) n = 1;
|
||||
if (n > SPAM_MAX) n = SPAM_MAX;
|
||||
return n;
|
||||
}
|
||||
|
||||
spamInput.addEventListener('change', () => {
|
||||
const n = Math.round(Number(spamInput.value));
|
||||
if (spamInput.value === '' || !Number.isFinite(n) || n < 1) {
|
||||
spamInput.value = clampSpam(spamInput.dataset.original ?? 30);
|
||||
updateDirty();
|
||||
}
|
||||
updateSpamUi();
|
||||
});
|
||||
spamInput.addEventListener('input', updateSpamUi);
|
||||
|
||||
document.getElementById('spamPresets').addEventListener('click', e => {
|
||||
const btn = e.target.closest('[data-min]');
|
||||
if (!btn) return;
|
||||
spamInput.value = btn.dataset.min;
|
||||
spamInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
spamInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
|
||||
document.getElementById('aiProfileAddBtn').addEventListener('click', () => openAiProfileForm(null));
|
||||
document.getElementById('aiProfileSaveBtn').addEventListener('click', saveAiProfile);
|
||||
document.getElementById('aiProfileTestBtn').addEventListener('click', testAiProfile);
|
||||
document.getElementById('aiProfileCancelBtn').addEventListener('click', closeAiProfileForm);
|
||||
document.getElementById('aiProfileModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeAiProfileForm(); });
|
||||
document.getElementById('aiActiveProfile').addEventListener('change', e => activateAiProfile(e.target.value));
|
||||
document.getElementById('aiProfilesList').addEventListener('click', e => {
|
||||
const del = e.target.closest('[data-del-profile]');
|
||||
if (del) { deleteAiProfile(del.dataset.delProfile, del.dataset.delName, del); return; }
|
||||
const edit = e.target.closest('[data-edit-profile]');
|
||||
if (edit) {
|
||||
fetch(`${API}/api/ai/profiles`, { headers: hdr() })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const p = (data.profiles || []).find(x => x.id === edit.dataset.editProfile);
|
||||
if (p) openAiProfileForm(p);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape' && document.getElementById('aiProfileModal').classList.contains('open')) closeAiProfileForm();
|
||||
});
|
||||
|
||||
async function saveSettings() {
|
||||
const v = document.getElementById('spamInterval').value;
|
||||
if (v === '' || Number(v) < 1) { alert('Введите число от 1'); return; }
|
||||
const v = spamInput.value;
|
||||
const n = Number(v);
|
||||
if (v === '' || !Number.isFinite(n) || Math.round(n) < 1) {
|
||||
showFieldError('spamInterval', 'Укажите целое число минут (минимум 1)');
|
||||
spamInput.focus();
|
||||
showError('Проверьте поле «Интервал ожидания»');
|
||||
return;
|
||||
}
|
||||
clearFieldError('spamInterval');
|
||||
const btn = document.getElementById('saveBtn');
|
||||
btn.dataset.loadingText = 'Сохранение...';
|
||||
setBtnLoading(btn, true);
|
||||
@@ -158,6 +508,7 @@ async function saveSettings() {
|
||||
method: 'PUT',
|
||||
headers: hdrJson(),
|
||||
body: JSON.stringify({ settings: {
|
||||
system_name: document.getElementById('systemName').value.trim(),
|
||||
spam_interval_min: String(Math.round(Number(v))),
|
||||
footer_left: document.getElementById('footerLeft').value.trim(),
|
||||
footer_right: document.getElementById('footerRight').value.trim(),
|
||||
@@ -168,12 +519,22 @@ async function saveSettings() {
|
||||
share_show_student_names: document.getElementById('shareShowNames').checked ? 'true' : 'false',
|
||||
share_show_group_photos: document.getElementById('shareShowPhotos').checked ? 'true' : 'false',
|
||||
cookie_notice_text: document.getElementById('cookieNoticeText').value.trim(),
|
||||
photo_capture_resolution: document.getElementById('photoResolution').value,
|
||||
photo_capture_quality: String(document.getElementById('photoQuality').value),
|
||||
photo_enhance_engine: document.getElementById('photoEngine').value,
|
||||
} })
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast('Настройки сохранены');
|
||||
logoOriginal = currentLogo;
|
||||
snapshotDirty();
|
||||
} else { const e = await res.json(); alert(e.error); }
|
||||
updateSpamUi();
|
||||
} else {
|
||||
const e = await res.json().catch(() => ({}));
|
||||
showError(e.error || 'Ошибка сохранения');
|
||||
}
|
||||
} catch {
|
||||
showError('Ошибка сети при сохранении');
|
||||
} finally {
|
||||
setBtnLoading(btn, false);
|
||||
}
|
||||
@@ -185,7 +546,7 @@ async function downloadBackup() {
|
||||
setBtnLoading(btn, true);
|
||||
try {
|
||||
const res = await fetch(`${API}/api/backup`, { headers: hdr() });
|
||||
if (!res.ok) { const t = await res.text(); alert('Ошибка: ' + t); return; }
|
||||
if (!res.ok) { showError('Ошибка скачивания: ' + (await res.text())); return; }
|
||||
const blob = await res.blob();
|
||||
const cd = res.headers.get('Content-Disposition') || '';
|
||||
const m = cd.match(/filename="([^"]+)"/);
|
||||
@@ -197,6 +558,8 @@ async function downloadBackup() {
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast('Бэкап скачан');
|
||||
} catch {
|
||||
showError('Ошибка сети при формировании бэкапа');
|
||||
} finally {
|
||||
setBtnLoading(btn, false);
|
||||
}
|
||||
@@ -220,7 +583,7 @@ document.getElementById('restoreFile').addEventListener('change', async (e) => {
|
||||
res = await fetch(`${API}/api/restore`, { method: 'POST', headers: hdr(), body: fd });
|
||||
} catch (err) {
|
||||
st.textContent = '';
|
||||
alert('Ошибка сети: ' + err.message);
|
||||
showError('Ошибка сети: ' + err.message);
|
||||
e.target.value = '';
|
||||
return;
|
||||
} finally {
|
||||
@@ -234,8 +597,7 @@ document.getElementById('restoreFile').addEventListener('change', async (e) => {
|
||||
} else {
|
||||
st.classList.add('err');
|
||||
st.innerHTML = 'Ошибка восстановления';
|
||||
const t = await res.text();
|
||||
alert('Ошибка: ' + t);
|
||||
showError(await res.text());
|
||||
}
|
||||
e.target.value = '';
|
||||
});
|
||||
@@ -243,35 +605,37 @@ document.getElementById('restoreFile').addEventListener('change', async (e) => {
|
||||
async function loadBans() {
|
||||
const el = document.getElementById('bansList');
|
||||
const res = await fetch(`${API}/api/bans`, { headers: hdr() });
|
||||
if (res.status === 403) { document.getElementById('bansCard').style.display = 'none'; return; }
|
||||
if (res.status === 403) { document.getElementById('sec-bans').style.display = 'none'; return; }
|
||||
if (!res.ok) { el.innerHTML = '<span class="chip">Не удалось загрузить</span>'; return; }
|
||||
const bans = await res.json();
|
||||
if (!bans.length) { el.innerHTML = '<span class="chip">Активных блокировок нет</span>'; return; }
|
||||
el.innerHTML = bans.map(b => `
|
||||
<span class="chip" data-chip="${esc(b.ip)}">
|
||||
<b>${esc(b.ip)}</b> — ${esc(b.reason)} до ${new Date(b.banned_until).toLocaleString('ru')}
|
||||
<button type="button" data-unban="${esc(b.ip)}" title="Разблокировать" style="background:none;border:none;color:var(--primary);cursor:pointer;font-weight:700">✕</button>
|
||||
<span class="chip ban-chip" data-chip="${esc(b.ip)}">
|
||||
<b>${esc(b.ip)}</b>
|
||||
<span class="chip-sub">${esc(BAN_REASONS[b.reason] || b.reason || '—')} · до ${new Date(b.banned_until).toLocaleString('ru')}</span>
|
||||
<button type="button" class="unban-btn" data-unban="${esc(b.ip)}" title="Разблокировать" aria-label="Разблокировать ${esc(b.ip)}">✕</button>
|
||||
</span>`).join('');
|
||||
}
|
||||
|
||||
async function doUnban(ip) {
|
||||
const res = await fetch(`${API}/api/bans/${encodeURIComponent(ip)}`, { method: 'DELETE', headers: hdr() });
|
||||
if (res.ok) { showToast('IP разблокирован: ' + ip); loadBans(); }
|
||||
else showToast('Ошибка разблокировки');
|
||||
else showError('Ошибка разблокировки');
|
||||
}
|
||||
|
||||
document.getElementById('bansList').addEventListener('click', e => {
|
||||
const ip = e.target.closest('[data-unban]')?.dataset.unban;
|
||||
if (!ip) return;
|
||||
const chip = e.target.closest('[data-chip]');
|
||||
if (chip.dataset.confirming) return;
|
||||
const btn = e.target.closest('[data-unban]');
|
||||
if (!btn) return;
|
||||
const ip = btn.dataset.unban;
|
||||
const chip = btn.closest('[data-chip]');
|
||||
if (!chip || chip.dataset.confirming) return;
|
||||
chip.dataset.confirming = '1';
|
||||
chip.style.background = 'rgba(239,68,68,.08)';
|
||||
e.target.outerHTML = `
|
||||
<span style="white-space:nowrap">
|
||||
Убрать из блокировки?
|
||||
<button type="button" data-unban-yes style="background:none;border:none;color:#16a34a;cursor:pointer;font-weight:700">Да</button>
|
||||
<button type="button" data-unban-no style="background:none;border:none;color:var(--muted);cursor:pointer;font-weight:700">Нет</button>
|
||||
chip.classList.add('confirming');
|
||||
btn.outerHTML = `
|
||||
<span class="chip-confirm">
|
||||
Разблокировать?
|
||||
<button type="button" class="chip-yes" data-unban-yes>Да</button>
|
||||
<button type="button" class="chip-no" data-unban-no>Нет</button>
|
||||
</span>`;
|
||||
chip.querySelector('[data-unban-yes]').addEventListener('click', () => doUnban(ip));
|
||||
chip.querySelector('[data-unban-no]').addEventListener('click', () => loadBans());
|
||||
@@ -280,12 +644,109 @@ document.getElementById('bansList').addEventListener('click', e => {
|
||||
document.getElementById('backupBtn').addEventListener('click', downloadBackup);
|
||||
document.getElementById('restoreBtn').addEventListener('click', () => document.getElementById('restoreFile').click());
|
||||
document.getElementById('saveBtn').addEventListener('click', saveSettings);
|
||||
document.getElementById('resetBtn').addEventListener('click', revertChanges);
|
||||
|
||||
const systemRefreshBtn = document.getElementById('systemRefreshBtn');
|
||||
if (systemRefreshBtn) systemRefreshBtn.addEventListener('click', async () => {
|
||||
systemRefreshBtn.classList.add('spinning');
|
||||
await loadSystemInfo();
|
||||
systemRefreshBtn.classList.remove('spinning');
|
||||
});
|
||||
|
||||
const statsRefreshBtn = document.getElementById('statsRefreshBtn');
|
||||
if (statsRefreshBtn) statsRefreshBtn.addEventListener('click', async () => {
|
||||
statsRefreshBtn.classList.add('spinning');
|
||||
await refreshStats();
|
||||
statsRefreshBtn.classList.remove('spinning');
|
||||
});
|
||||
|
||||
function logoStatusText(html) {
|
||||
const st = document.getElementById('logoUploadStatus');
|
||||
if (st) st.innerHTML = html;
|
||||
}
|
||||
|
||||
document.getElementById('logoUploadBtn').addEventListener('click', () => document.getElementById('logoInput').click());
|
||||
|
||||
document.getElementById('logoInput').addEventListener('change', async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const btn = document.getElementById('logoUploadBtn');
|
||||
if (file.size > 1024 * 1024) {
|
||||
e.target.value = '';
|
||||
showError('Логотип не должен превышать 1 МБ');
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
logoStatusText('<span class="spinner dark"></span> Загрузка...');
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('logo', file);
|
||||
const res = await fetch(`${API}/api/settings/logo`, { method: 'POST', headers: hdr(), body: fd });
|
||||
const d = await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
currentLogo = d.system_logo || '';
|
||||
renderLogoPreview();
|
||||
logoStatusText('');
|
||||
showToast('Логотип обновлён');
|
||||
} else {
|
||||
logoStatusText(`<span class="fail">${esc(d.error || 'Ошибка загрузки')}</span>`);
|
||||
}
|
||||
} catch {
|
||||
logoStatusText('<span class="fail">Ошибка сети</span>');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
e.target.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('logoRemoveBtn').addEventListener('click', async () => {
|
||||
if (!confirm('Удалить логотип? Название системы сохранится.')) return;
|
||||
logoStatusText('<span class="spinner dark"></span> Удаление...');
|
||||
try {
|
||||
const res = await fetch(`${API}/api/settings/logo`, { method: 'DELETE', headers: hdr() });
|
||||
if (res.ok) {
|
||||
currentLogo = '';
|
||||
logoOriginal = '';
|
||||
renderLogoPreview();
|
||||
logoStatusText('');
|
||||
showToast('Логотип удалён');
|
||||
} else {
|
||||
logoStatusText('<span class="fail">Ошибка удаления</span>');
|
||||
}
|
||||
} catch {
|
||||
logoStatusText('<span class="fail">Ошибка сети</span>');
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault();
|
||||
const modal = document.getElementById('aiProfileModal');
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
if (!modal.classList.contains('open') && document.getElementById('saveBar').classList.contains('show') && !saveBtn.disabled) saveSettings();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', e => {
|
||||
if (document.getElementById('saveBar').classList.contains('show')) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
}
|
||||
});
|
||||
|
||||
(async () => {
|
||||
if (await requireAdminPage()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
bindDirty();
|
||||
loadSettings();
|
||||
initSectionNav();
|
||||
updateSpamUi();
|
||||
const authStatus = document.getElementById('authStatus');
|
||||
try {
|
||||
await loadSettings();
|
||||
if (authStatus) authStatus.classList.add('loaded');
|
||||
} catch {
|
||||
if (authStatus) { authStatus.classList.remove('loaded'); authStatus.classList.add('err'); authStatus.textContent = 'Ошибка загрузки настроек'; }
|
||||
}
|
||||
loadBans();
|
||||
}
|
||||
})();
|
||||
+45
-2
@@ -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);
|
||||
|
||||
+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>
|
||||
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>
|
||||
+196
-66
@@ -12,84 +12,178 @@
|
||||
<div class="main">
|
||||
<div class="page-head">
|
||||
<h2>Настройки</h2>
|
||||
<p class="page-sub">Параметры формы и данные системы</p>
|
||||
<p class="page-sub">Параметры формы, публичных страниц и данные системы</p>
|
||||
<span class="page-status" id="authStatus"><span class="spinner dark"></span> Загрузка…</span>
|
||||
<button class="head-btn" id="reloadBtn" type="button" title="Перезагрузить настройки"><i data-lucide="rotate-ccw"></i> Сбросить</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-layout">
|
||||
<aside class="settings-nav" id="settingsNav">
|
||||
<a href="#sec-system" data-nav="sec-system" class="active"><i data-lucide="bar-chart-3"></i> Система</a>
|
||||
<a href="#sec-brand" data-nav="sec-brand"><i data-lucide="palette"></i> Брендинг</a>
|
||||
<a href="#sec-antispam" data-nav="sec-antispam"><i data-lucide="shield"></i> Антиспам</a>
|
||||
<a href="#sec-share" data-nav="sec-share"><i data-lucide="link"></i> Публичные ссылки</a>
|
||||
<a href="#sec-form" data-nav="sec-form"><i data-lucide="file-text"></i> Форма ответа</a>
|
||||
<a href="#sec-photo" data-nav="sec-photo"><i data-lucide="camera"></i> Фото</a>
|
||||
<a href="#sec-ai" data-nav="sec-ai"><i data-lucide="bot"></i> ИИ</a>
|
||||
<a href="#sec-backup" data-nav="sec-backup"><i data-lucide="database"></i> Бэкапы</a>
|
||||
<a href="#sec-bans" data-nav="sec-bans"><i data-lucide="shield-off"></i> Блокировки</a>
|
||||
</aside>
|
||||
|
||||
<div class="settings-stack settings-stack-grid">
|
||||
<div class="settings-card wide">
|
||||
<div class="settings-card wide" id="sec-system">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="bar-chart-3"></i></div>
|
||||
<h3>Системная информация</h3>
|
||||
<button class="icon-btn" id="systemRefreshBtn" type="button" title="Обновить" aria-label="Обновить"><i data-lucide="refresh-cw"></i></button>
|
||||
<span class="head-live" id="systemLive" title="Данные кэшируются на сервере"></span>
|
||||
</div>
|
||||
<div id="systemInfo" class="sys-grid">
|
||||
<div class="sys-card loading">Загрузка…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="settings-card" id="sec-brand">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="palette"></i></div>
|
||||
<h3>Брендинг</h3>
|
||||
</div>
|
||||
<p class="hint">Название и логотип отображаются в админ-панели, на форме ответа ученика, странице входа и в отчёте.</p>
|
||||
<div class="settings-field">
|
||||
<label for="systemName">Название системы</label>
|
||||
<input type="text" id="systemName" class="settings-input" maxlength="60" placeholder="WhatIDo">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Логотип</label>
|
||||
<div class="logo-picker">
|
||||
<div class="logo-preview" id="logoPreview">
|
||||
<span id="logoPreviewFallback">—</span>
|
||||
<img id="logoPreviewImg" alt="Логотип">
|
||||
</div>
|
||||
<div class="logo-picker-actions">
|
||||
<div class="logo-picker-btns">
|
||||
<button class="btn-primary ghost" id="logoUploadBtn" type="button"><i data-lucide="upload"></i> Загрузить логотип</button>
|
||||
<button class="btn-primary danger-ghost" id="logoRemoveBtn" type="button" hidden><i data-lucide="trash-2"></i> Удалить</button>
|
||||
</div>
|
||||
<input type="file" id="logoInput" accept="image/*" style="display:none">
|
||||
<div class="logo-upload-status" id="logoUploadStatus"></div>
|
||||
<span class="hint">Рекомендуется широкое изображение с прозрачным фоном (PNG/WebP), не более 1 МБ</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card" id="sec-antispam">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="shield"></i></div>
|
||||
<h3>Защита от повторов</h3>
|
||||
</div>
|
||||
<p class="hint">Если ученик уже отправил ответ, следующая отправка от него будет отклонена в течение заданного времени. Минимальное значение — 1 минута.</p>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field">
|
||||
<label for="spamInterval">Интервал ожидания</label>
|
||||
<div class="unit-row">
|
||||
<input type="number" id="spamInterval" class="settings-input" min="1" max="1440" step="1">
|
||||
<span class="unit">минут</span>
|
||||
</div>
|
||||
<span class="field-error" id="spamIntervalError"></span>
|
||||
<div class="preset-btns" id="spamPresets">
|
||||
<button class="preset-btn" type="button" data-min="1">1 мин</button>
|
||||
<button class="preset-btn" type="button" data-min="15">15 мин</button>
|
||||
<button class="preset-btn" type="button" data-min="30">30 мин</button>
|
||||
<button class="preset-btn" type="button" data-min="60">1 час</button>
|
||||
<button class="preset-btn" type="button" data-min="1440">Сутки</button>
|
||||
</div>
|
||||
<span class="hint" id="spamIntervalHint"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="settings-card" id="sec-share">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="link"></i></div>
|
||||
<h3>Страница публичной ссылки /s/<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">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="shareShowMessage">
|
||||
Показать сообщение ученика
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
<span class="toggle-text"><b>Сообщение ученика</b><small>Текст «Что делала(а)» на странице по ссылке</small></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="shareShowDate">
|
||||
Показать дату в карточке записи
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
<span class="toggle-text"><b>Дата записи</b><small>Дата в карточке записи</small></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="shareShowNames">
|
||||
Показывать фамилии и имена учеников
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
<span class="toggle-text"><b>Фамилии и имена</b><small>Имена учеников в галерее работ</small></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="shareShowPhotos">
|
||||
Показывать фото группы
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
<span class="toggle-text"><b>Фото группы</b><small>Групповое фото в шапке страницы</small></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-field" style="flex:1">
|
||||
<div class="settings-field">
|
||||
<label for="cookieNoticeText">Уведомление о cookie</label>
|
||||
<textarea id="cookieNoticeText" class="settings-input" rows="3" style="min-height:80px"></textarea>
|
||||
</div>
|
||||
<span class="hint">Пусто — баннер не показывается</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card" id="sec-ai">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="bot"></i></div>
|
||||
<h3>Промпт ИИ для исправления текста</h3>
|
||||
</div>
|
||||
<p class="hint">Инструкция для нейросети при исправлении ошибок в записи «Что делала(а)».</p>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="aiAutoCheck">
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
<span class="toggle-text"><b>Автопроверка новых записей</b><small>Без участия человека — воркер сам обрабатывает очередь</small></span>
|
||||
</label>
|
||||
<div class="settings-field">
|
||||
<label for="aiPrompt">Промпт</label>
|
||||
<textarea id="aiPrompt" class="settings-input mono" rows="5" spellcheck="false"></textarea>
|
||||
</div>
|
||||
<div class="ai-queue" id="aiQueueInfo"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="boxes"></i></div>
|
||||
<h3>Профили моделей ИИ</h3>
|
||||
</div>
|
||||
<p class="hint">Модель, выбранная по умолчанию, обрабатывает записи учеников. «Нативная» — llama.cpp в Docker. Можно добавить любого провайдера с OpenAI-совместимым API (OpenRouter, OpenAI, Ollama и др.).</p>
|
||||
<div class="ai-queue" id="aiProfileActive"></div>
|
||||
<div class="card-foot" style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
|
||||
<label style="display:flex;align-items:center;gap:8px;font-size:.88rem;min-width:0;max-width:100%">
|
||||
Модель по умолчанию:
|
||||
<select id="aiActiveProfile" class="settings-input" style="width:auto;min-width:0;max-width:100%;flex:1 1 200px">
|
||||
<option value="native">Нативная (llama.cpp)</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="btn-primary ghost" id="aiProfileAddBtn" type="button"><i data-lucide="plus"></i> Добавить профиль</button>
|
||||
</div>
|
||||
<div id="aiProfilesList" class="ai-profiles-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card" id="sec-backup">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="database"></i></div>
|
||||
<h3>Резервное копирование</h3>
|
||||
<button class="icon-btn" id="statsRefreshBtn" type="button" title="Обновить статистику" aria-label="Обновить статистику"><i data-lucide="refresh-cw"></i></button>
|
||||
</div>
|
||||
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Восстановление полностью заменит текущие данные.</p>
|
||||
<div class="data-stats" id="dataStats"><span class="chip">Загрузка статистики…</span></div>
|
||||
<div class="card-foot">
|
||||
<button class="btn-primary ghost" id="backupBtn" type="button"><i data-lucide="download"></i> Скачать бэкап</button>
|
||||
<button class="btn-primary ghost danger" id="restoreBtn" type="button"><i data-lucide="upload"></i> Восстановить из файла</button>
|
||||
<input type="file" id="restoreFile" accept=".tar.gz,.gz,application/gzip" style="display:none">
|
||||
</div>
|
||||
<div class="restore-status" id="restoreStatus"></div>
|
||||
</div>
|
||||
|
||||
<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 +201,43 @@
|
||||
</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>
|
||||
<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="card-head">
|
||||
<div class="ic"><i data-lucide="database"></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">
|
||||
</div>
|
||||
<div class="restore-status" id="restoreStatus"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card" id="bansCard">
|
||||
<div class="settings-card" id="sec-bans">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="shield-off"></i></div>
|
||||
<h3>Блокировки IP</h3>
|
||||
@@ -155,12 +247,50 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="save-bar" id="saveBar">
|
||||
<span class="hint" id="saveHint" style="display:inline-flex;align-items:center;gap:6px"><i data-lucide="triangle-alert"></i> Есть несохранённые изменения</span>
|
||||
<button class="btn-primary" id="saveBtn" type="button">Сохранить изменения</button>
|
||||
<span class="hint" id="saveHint"><i data-lucide="triangle-alert"></i> Есть несохранённые изменения</span>
|
||||
<span class="save-bar-actions">
|
||||
<button class="btn-primary ghost" id="resetBtn" type="button"><i data-lucide="rotate-ccw"></i> Отменить</button>
|
||||
<button class="btn-primary" id="saveBtn" type="button"><i data-lucide="check"></i> Сохранить</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="aiProfileModal">
|
||||
<div class="edit-modal" style="max-width:560px">
|
||||
<h3 id="aiProfileModalTitle">Добавить профиль ИИ</h3>
|
||||
<div class="settings-field">
|
||||
<label for="aiProfileName">Название профиля <span style="color:#ef4444">*</span></label>
|
||||
<input type="text" id="aiProfileName" class="settings-input" placeholder="Например: OpenRouter GPT-4o-mini">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="aiProfileModel">Модель <span style="color:#ef4444">*</span></label>
|
||||
<input type="text" id="aiProfileModel" class="settings-input" placeholder="openai/gpt-4o-mini">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="aiProfileBaseUrl">Base URL (OpenAI-совместимый API) <span style="color:#ef4444">*</span></label>
|
||||
<input type="text" id="aiProfileBaseUrl" class="settings-input" placeholder="https://openrouter.ai/api/v1">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="aiProfileApiKey">API-ключ (если требуется)</label>
|
||||
<input type="password" id="aiProfileApiKey" class="settings-input" placeholder="sk-...">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="aiProfileMaxTokens">Max tokens (лимит ответа модели)</label>
|
||||
<input type="number" id="aiProfileMaxTokens" class="settings-input" min="16" max="32768" placeholder="Авто (1024–4096)">
|
||||
<span class="hint">Оставьте пустым для авто. Для reasoning-моделей (gpt-oss, deepseek-r1) нужно 1024+ — иначе рассуждения не уместятся и ответ придёт пустым.</span>
|
||||
</div>
|
||||
<div class="ai-test-status" id="aiProfileTestStatus"></div>
|
||||
<div class="actions" style="flex-wrap:wrap">
|
||||
<button class="btn-primary" id="aiProfileSaveBtn" type="button">Сохранить</button>
|
||||
<button class="btn-primary ghost" id="aiProfileTestBtn" type="button"><i data-lucide="plug-zap"></i> Проверить</button>
|
||||
<button class="btn-primary ghost" id="aiProfileCancelBtn" type="button">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="vendor/lucide.min.js"></script>
|
||||
<script src="admin.js"></script>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+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 |
@@ -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)
|
||||
);
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
ROOT="$(git rev-parse --show-toplevel)"
|
||||
node "$ROOT/scripts/gen-version.js"
|
||||
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,6 +3,252 @@ 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 PHOTO_MAX_ATTEMPTS = 3;
|
||||
const PHOTO_AI_TIMEOUT_MS = 300000;
|
||||
|
||||
function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntries, sharp, photoAiUrl, uploadsDir, originalsDir }) {
|
||||
const AI_URL = photoAiUrl || process.env.PHOTO_AI_URL || '';
|
||||
const IDLE_MIN = 2000;
|
||||
const IDLE_MAX = 30000;
|
||||
|
||||
let started = false;
|
||||
let stopped = false;
|
||||
let idleMs = IDLE_MIN;
|
||||
let wake = null;
|
||||
let processing = false;
|
||||
let currentId = null;
|
||||
let startedAt = null;
|
||||
const stats = { jobs: 0, done: 0, errors: 0, last_at: null, last_error: null };
|
||||
const CONFIG = {
|
||||
idle_min_ms: IDLE_MIN,
|
||||
idle_max_ms: IDLE_MAX,
|
||||
ai_timeout_ms: PHOTO_AI_TIMEOUT_MS,
|
||||
max_attempts: PHOTO_MAX_ATTEMPTS,
|
||||
ai_url: AI_URL,
|
||||
};
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => {
|
||||
const t = setTimeout(() => { wake = null; resolve(); }, ms);
|
||||
wake = () => { clearTimeout(t); wake = null; resolve(); };
|
||||
});
|
||||
}
|
||||
|
||||
function notify() {
|
||||
if (wake) wake();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
function safeUnlinkPath(p) {
|
||||
try {
|
||||
if (!p) return;
|
||||
const abs = path.resolve(p);
|
||||
const root = path.resolve(uploadsDir);
|
||||
if (abs !== root && abs.startsWith(root + path.sep)) fs.unlinkSync(abs);
|
||||
} catch (e) { console.error('photo worker unlink:', e.message); }
|
||||
}
|
||||
|
||||
function backupOldFile(oldRelPath) {
|
||||
if (!oldRelPath) return null;
|
||||
const oldAbs = path.join(uploadsDir, String(oldRelPath).replace(/^\/+/, '').replace(/^uploads\//, ''));
|
||||
if (!fs.existsSync(oldAbs)) return null;
|
||||
const backupName = crypto.randomBytes(12).toString('hex') + (path.extname(oldAbs) || '.jpg');
|
||||
const backupPath = path.join(originalsDir, backupName);
|
||||
try {
|
||||
fs.renameSync(oldAbs, backupPath);
|
||||
return `/uploads/.originals/${backupName}`;
|
||||
} catch (e) {
|
||||
console.error('photo worker backup failed:', e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function enhanceWithSharp(srcAbs, 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);
|
||||
let pipeline = sharp(srcAbs).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';
|
||||
await pipeline.jpeg({ quality: 92, mozjpeg: true }).toFile(path.join(uploadsDir, newName));
|
||||
return `/uploads/${newName}`;
|
||||
}
|
||||
|
||||
async function runAiEnhance(srcAbs) {
|
||||
if (!AI_URL) throw new Error('PHOTO_AI_URL не настроен');
|
||||
const buf = fs.readFileSync(srcAbs);
|
||||
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';
|
||||
fs.writeFileSync(path.join(uploadsDir, newName), out);
|
||||
return `/uploads/${newName}`;
|
||||
}
|
||||
async function applyResult(job, newPath) {
|
||||
await pool.query(
|
||||
`UPDATE photo_jobs SET status = 'done', after_path = $1, error = NULL, finished_at = now() WHERE id = $2`,
|
||||
[newPath, job.id]
|
||||
);
|
||||
if (logAudit) await logAudit(null, 'photo.job.preview', { entry_id: job.entry_id, job_id: job.id, action: job.action, after_path: newPath });
|
||||
}
|
||||
async function processOne(job) {
|
||||
stats.jobs++;
|
||||
const { rows } = await pool.query('SELECT photo_path FROM entries WHERE id = $1', [job.entry_id]);
|
||||
if (!rows.length || !rows[0].photo_path) {
|
||||
await pool.query(
|
||||
`UPDATE photo_jobs SET status = 'error', error = 'У записи нет фото', finished_at = now() WHERE id = $1`,
|
||||
[job.id]
|
||||
);
|
||||
stats.errors++;
|
||||
stats.last_at = new Date().toISOString();
|
||||
stats.last_error = 'У записи нет фото';
|
||||
return true;
|
||||
}
|
||||
const photoPath = rows[0].photo_path;
|
||||
const srcAbs = path.join(uploadsDir, String(photoPath).replace(/^\/+/, '').replace(/^uploads\//, ''));
|
||||
try {
|
||||
const newPath = job.action === 'ai' ? await runAiEnhance(srcAbs) : await enhanceWithSharp(srcAbs, job.params);
|
||||
await applyResult(job, newPath);
|
||||
stats.done++;
|
||||
stats.last_at = new Date().toISOString();
|
||||
stats.last_error = null;
|
||||
return true;
|
||||
} catch (e) {
|
||||
const message = (e && e.message ? e.message : 'error').slice(0, 500);
|
||||
stats.last_at = new Date().toISOString();
|
||||
stats.last_error = message;
|
||||
const { rows: cur } = await pool.query('SELECT attempts FROM photo_jobs WHERE id = $1', [job.id]);
|
||||
const tries = ((cur[0] && cur[0].attempts) || 0) + 1;
|
||||
if (tries < PHOTO_MAX_ATTEMPTS) {
|
||||
await pool.query(`UPDATE photo_jobs SET status = 'pending', attempts = $1, error = $2 WHERE id = $3`, [tries, message, job.id]);
|
||||
if (logAudit) await logAudit(null, 'photo.job.retry', { entry_id: job.entry_id, job_id: job.id, attempt: tries, error: message });
|
||||
return false;
|
||||
}
|
||||
await pool.query(
|
||||
`UPDATE photo_jobs SET status = 'error', attempts = $1, error = $2, finished_at = now() WHERE id = $3`,
|
||||
[tries, message, job.id]
|
||||
);
|
||||
stats.errors++;
|
||||
if (logAudit) await logAudit(null, 'photo.job.error', { entry_id: job.entry_id, job_id: job.id, error: message });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async function loop() {
|
||||
while (!stopped) {
|
||||
let job = null;
|
||||
try {
|
||||
if (!(await isEnabled())) {
|
||||
await sleep(IDLE_MAX);
|
||||
continue;
|
||||
}
|
||||
job = await claimNext();
|
||||
} catch (e) {
|
||||
console.error('Photo worker claim error:', e);
|
||||
await sleep(IDLE_MAX);
|
||||
continue;
|
||||
}
|
||||
if (!job) {
|
||||
await sleep(idleMs);
|
||||
idleMs = Math.min(idleMs * 2, IDLE_MAX);
|
||||
continue;
|
||||
}
|
||||
idleMs = IDLE_MIN;
|
||||
processing = true;
|
||||
currentId = job.id;
|
||||
let ok = true;
|
||||
try {
|
||||
ok = await processOne(job);
|
||||
} catch (e) {
|
||||
console.error('Photo worker process error:', e);
|
||||
} finally {
|
||||
processing = false;
|
||||
currentId = null;
|
||||
}
|
||||
if (!ok) await sleep(IDLE_MAX);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetStale() {
|
||||
await pool.query(`UPDATE photo_jobs SET status = 'pending' WHERE status = 'processing'`);
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
startedAt = Date.now();
|
||||
resetStale()
|
||||
.catch((e) => console.error('Photo worker reset error:', e))
|
||||
.finally(() => { loop().catch((e) => console.error('Photo worker loop error:', e)); });
|
||||
}
|
||||
|
||||
function getInfo() {
|
||||
return {
|
||||
started_at: startedAt ? new Date(startedAt).toISOString() : null,
|
||||
uptime_ms: startedAt ? Date.now() - startedAt : 0,
|
||||
processing,
|
||||
current_id: currentId,
|
||||
config: CONFIG,
|
||||
...stats,
|
||||
};
|
||||
}
|
||||
|
||||
return { start, notify, getInfo };
|
||||
}
|
||||
|
||||
function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultPrompt, model }) {
|
||||
const AI_URL = aiUrl || process.env.AI_URL || 'http://text-corrector:8080';
|
||||
@@ -70,27 +316,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);
|
||||
}
|
||||
@@ -215,4 +504,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