feat(web): React-фронтенд вместо Flet + fix restore rmtree на bind-mount
web-react/: SPA Vite+React+TS, nginx раздаёт статику и проксирует /api на api:8000 по локалке. docker-compose: web(Flet)->web-react на :80. transfer_crud.backup_restore: чистим содержимое data/radar вместо rmtree точки монтирования (EBUSY).
This commit is contained in:
@@ -20,27 +20,23 @@ services:
|
|||||||
- REDIS_PORT=6379
|
- REDIS_PORT=6379
|
||||||
- REDIS_DB=0
|
- REDIS_DB=0
|
||||||
- REDIS_PASSWORD=CNXpuhMdxXHo7ZK8bhtXDvgXVZcjRn
|
- REDIS_PASSWORD=CNXpuhMdxXHo7ZK8bhtXDvgXVZcjRn
|
||||||
# JWT — замените на длинную случайную строку (python -c "import secrets; print(secrets.token_hex(32))")
|
|
||||||
- SECRET_KEY=e8fe23297b4664a78ba8e8ebe34bac158398ac4d7dc6b664fd8d52b0094ee67b
|
- SECRET_KEY=e8fe23297b4664a78ba8e8ebe34bac158398ac4d7dc6b664fd8d52b0094ee67b
|
||||||
# Ключ для admin-операций
|
|
||||||
- ADMIN_KEY=b00ac081344a99bf50dffeb942e7f4f1
|
- ADMIN_KEY=b00ac081344a99bf50dffeb942e7f4f1
|
||||||
# Логин и пароль для доступа к /docs и /redoc
|
|
||||||
- DOCS_USERNAME=admin
|
- DOCS_USERNAME=admin
|
||||||
- DOCS_PASSWORD=2cbae90b3d9f6710cf51354f
|
- DOCS_PASSWORD=2cbae90b3d9f6710cf51354f
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/radar:/app/data/radar
|
- ./data/radar:/app/data/radar
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
web:
|
web-react:
|
||||||
build:
|
build:
|
||||||
context: ./web
|
context: ./web-react
|
||||||
dockerfile: ../Dockerfile.web
|
dockerfile: Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
environment:
|
|
||||||
- API_URL=http://api:8000
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- api
|
- api
|
||||||
|
restart: unless-stopped
|
||||||
stats:
|
stats:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -51,3 +47,4 @@ services:
|
|||||||
- API_URL=http://api:8000
|
- API_URL=http://api:8000
|
||||||
depends_on:
|
depends_on:
|
||||||
- api
|
- api
|
||||||
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -309,7 +309,11 @@ def backup_restore(file: UploadFile = File(...)):
|
|||||||
# 3. SVG-диаграммы: полностью очищаем каталог и восстанавливаем
|
# 3. SVG-диаграммы: полностью очищаем каталог и восстанавливаем
|
||||||
svg_names = [n for n in zf.namelist() if n.startswith("radar_svgs/") and n != "radar_svgs/"]
|
svg_names = [n for n in zf.namelist() if n.startswith("radar_svgs/") and n != "radar_svgs/"]
|
||||||
if svg_names and _RADAR_DIR.exists():
|
if svg_names and _RADAR_DIR.exists():
|
||||||
shutil.rmtree(_RADAR_DIR)
|
for child in _RADAR_DIR.iterdir():
|
||||||
|
if child.is_dir():
|
||||||
|
shutil.rmtree(child)
|
||||||
|
else:
|
||||||
|
child.unlink()
|
||||||
_RADAR_DIR.mkdir(parents=True, exist_ok=True)
|
_RADAR_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
svg_written = 0
|
svg_written = 0
|
||||||
|
|||||||
6
web-react/.dockerignore
Normal file
6
web-react/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.vite
|
||||||
|
*.local
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
4
web-react/.gitignore
vendored
Normal file
4
web-react/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.local
|
||||||
|
.vite
|
||||||
14
web-react/Dockerfile
Normal file
14
web-react/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# --- Стадия 1: сборка React-приложения ---
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# --- Стадия 2: раздача статики через nginx + прокси /api ---
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
58
web-react/README.md
Normal file
58
web-react/README.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# web-react — фронтенд на React (Vite + TypeScript)
|
||||||
|
|
||||||
|
Замена Flet-интерфейса (`../web`). Обычный SPA: грузится в браузер один раз и работает
|
||||||
|
локально, обращаясь к API по HTTP. **Нет WebSocket и серверного UI-стейта** — это убирает
|
||||||
|
зависания, которые были у Flet (он рендерил UI на сервере и держал WS на каждого юзера).
|
||||||
|
|
||||||
|
Flet-сервис `web` намеренно оставлен в `docker-compose.yml` как запасной. Этот новый
|
||||||
|
фронтенд публикуется на **порту 3000**.
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
Браузер ──▶ [ nginx :80 (контейнер web-react) ]
|
||||||
|
├── / → статика React (SPA, fallback на index.html)
|
||||||
|
└── /api/* → proxy_pass http://api:8000/ (включая SVG-диаграммы)
|
||||||
|
```
|
||||||
|
|
||||||
|
API-контейнер доступен только внутри Docker-сети, поэтому браузер ходит к нему через
|
||||||
|
nginx-прокси на том же origin (`/api/...`). SVG-диаграмма тянется напрямую `<img>`/`<a download>`.
|
||||||
|
|
||||||
|
## Разработка (локально)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Поднять API + БД (из корня репозитория)
|
||||||
|
docker compose up -d api postgres redis # postgres/redis — из docker-compose_db.yml
|
||||||
|
|
||||||
|
# 2. Dev-сервер фронтенда
|
||||||
|
cd web-react
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5173, /api проксируется на http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Адрес API для dev-прокси переопределяется переменной `API_URL` (см. `vite.config.ts`).
|
||||||
|
|
||||||
|
## Сборка / прод
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# из корня репозитория
|
||||||
|
docker compose up -d --build web-react # http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Либо вручную: `npm run build` → статика в `dist/`, раздаётся nginx (см. `Dockerfile`, `nginx.conf`).
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
| Путь | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `src/api/client.ts` | все запросы к API (порт `web/api_client.py`) |
|
||||||
|
| `src/auth/` | `AuthContext` — токен в localStorage + `/users/me` |
|
||||||
|
| `src/theme/` | 5 тем + слайдер масштаба через CSS-переменные |
|
||||||
|
| `src/components/` | переиспользуемые компоненты (Button, TextField, SearchableDropdown, Layout, Modal, RadarBlock…) |
|
||||||
|
| `src/pages/` | экраны: Login, Register, Home, TestList, Poll, MyTests, Response, Profile |
|
||||||
|
| `src/styles/global.css` | стили на CSS-переменных |
|
||||||
|
|
||||||
|
## Маршруты
|
||||||
|
|
||||||
|
`/login`, `/register`, `/` (домашняя), `/tests`, `/poll/:pollId`, `/my-tests`,
|
||||||
|
`/response/:responseId`, `/profile`. Защищённые маршруты требуют токен (`ProtectedRoute`).
|
||||||
13
web-react/index.html
Normal file
13
web-react/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>ЦОПП</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
36
web-react/nginx.conf
Normal file
36
web-react/nginx.conf
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Статика SPA: всё неизвестное отдаём index.html (клиентский роутинг)
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Прокси на внутренний API-контейнер (браузер к api:8000 напрямую не ходит).
|
||||||
|
# Префикс /api срезается: /api/auth/login -> http://api:8000/auth/login
|
||||||
|
#
|
||||||
|
# Используем встроенный DNS Docker (127.0.0.11) и переменную в proxy_pass,
|
||||||
|
# чтобы nginx переразрешал имя `api` на каждый запрос. Иначе nginx кэширует
|
||||||
|
# IP при старте и ломается (502), если контейнер api перезапустился и сменил IP.
|
||||||
|
location /api/ {
|
||||||
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
|
set $api_backend api;
|
||||||
|
rewrite ^/api/(.*)$ /$1 break;
|
||||||
|
proxy_pass http://$api_backend:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Кэш статических ассетов с хэшем в имени
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
1774
web-react/package-lock.json
generated
Normal file
1774
web-react/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
web-react/package.json
Normal file
23
web-react/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "copp-web-react",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.26.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.5",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"vite": "^5.4.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
web-react/public/favicon.png
Normal file
BIN
web-react/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 851 B |
4
web-react/public/icons/clipboard-question_16542596.svg
Normal file
4
web-react/public/icons/clipboard-question_16542596.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24">
|
||||||
|
<path d="M15.829,2c-.413-1.164-1.525-2-2.829-2h-2c-1.304,0-2.416,.836-2.829,2H3V21c0,1.654,1.346,3,3,3h12c1.654,0,3-1.346,3-3V2h-5.171Zm3.171,19c0,.551-.449,1-1,1H6c-.551,0-1-.449-1-1V4h5v-1c0-.551,.449-1,1-1h2c.551,0,1,.449,1,1v1h5V21Zm-8-4h2v2.013h-2v-2.013Zm1.917-9.911c1.19,.308,2.158,1.276,2.465,2.467,.429,1.66-.376,3.381-1.913,4.092-.281,.13-.47,.472-.47,.852v.5h-2v-.5c0-1.162,.64-2.208,1.629-2.666,.548-.254,1.038-.919,.816-1.778-.126-.49-.54-.904-1.03-1.03-.483-.127-.956-.034-1.333,.258-.371,.287-.583,.72-.583,1.188h-2c0-1.091,.495-2.1,1.358-2.769,.863-.67,1.978-.894,3.059-.614Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 737 B |
4
web-react/public/icons/leaderboard-trophy_14227576.svg
Normal file
4
web-react/public/icons/leaderboard-trophy_14227576.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24">
|
||||||
|
<path d="m19,0H5C2.243,0,0,2.243,0,5v14c0,2.757,2.243,5,5,5h14c2.757,0,5-2.243,5-5V5c0-2.757-2.243-5-5-5Zm3,19c0,1.654-1.346,3-3,3H5c-1.654,0-3-1.346-3-3V5c0-1.654,1.346-3,3-3h14c1.654,0,3,1.346,3,3v14Zm-3-1c0,.552-.447,1-1,1H6c-.552,0-1-.448-1-1s.448-1,1-1h12c.553,0,1,.448,1,1Zm-8.665-7h.665v.725c0,1.333-1.222,1.275-1.222,1.275,0,0-.778.448-.778,1s.448,1,1,1h4c.553,0,1-.448,1-1s-.778-1-.778-1c0,0-1.222.058-1.222-1.275v-.725h.664c2.548,0,4.503-1.258,5.229-3.366.206-.599.107-1.267-.264-1.787-.378-.53-.991-.847-1.641-.847h-1.008c.067-.526-.35-1-.891-1-1.557,0-4.621,0-6.177,0-.541,0-.959.474-.891,1h-1.008c-.649,0-1.263.317-1.641.847-.371.52-.469,1.188-.263,1.787.726,2.108,2.68,3.366,5.228,3.366Zm6.667-4.018c-.441,1.282-1.658,2.018-3.338,2.018h-.262c.138-.188.297-.364.492-.506.566-.412.999-.939,1.335-1.502l1.773-.01Zm-8.226.018c.335.56.767,1.084,1.33,1.494.195.142.353.317.492.506h-.262c-1.679,0-2.896-.735-3.324-2h1.765Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
38
web-react/src/App.tsx
Normal file
38
web-react/src/App.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
import { AuthProvider } from "./auth/AuthContext";
|
||||||
|
import { ThemeProvider } from "./theme/ThemeContext";
|
||||||
|
import { ProtectedRoute } from "./components/ProtectedRoute";
|
||||||
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
|
import { RegisterPage } from "./pages/RegisterPage";
|
||||||
|
import { HomePage } from "./pages/HomePage";
|
||||||
|
import { TestListPage } from "./pages/TestListPage";
|
||||||
|
import { PollPage } from "./pages/PollPage";
|
||||||
|
import { MyTestsPage } from "./pages/MyTestsPage";
|
||||||
|
import { ResponsePage } from "./pages/ResponsePage";
|
||||||
|
import { ProfilePage } from "./pages/ProfilePage";
|
||||||
|
|
||||||
|
function protect(el: React.ReactNode) {
|
||||||
|
return <ProtectedRoute>{el}</ProtectedRoute>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
<Route path="/" element={protect(<HomePage />)} />
|
||||||
|
<Route path="/tests" element={protect(<TestListPage />)} />
|
||||||
|
<Route path="/poll/:pollId" element={protect(<PollPage />)} />
|
||||||
|
<Route path="/my-tests" element={protect(<MyTestsPage />)} />
|
||||||
|
<Route path="/response/:responseId" element={protect(<ResponsePage />)} />
|
||||||
|
<Route path="/profile" element={protect(<ProfilePage />)} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
283
web-react/src/api/client.ts
Normal file
283
web-react/src/api/client.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
// Единое место для всех HTTP-запросов к бэкенду.
|
||||||
|
// Порт web/api_client.py. Базовый путь /api проксируется nginx (прод) или Vite (dev) на api:8000.
|
||||||
|
|
||||||
|
const BASE = "/api";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Типы
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export interface Me {
|
||||||
|
id: string;
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
group_id?: string | null;
|
||||||
|
organization_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Organization {
|
||||||
|
id: string;
|
||||||
|
name_organization: string;
|
||||||
|
group_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Group {
|
||||||
|
id: string;
|
||||||
|
name_group: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Choice {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Question {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
choices: Choice[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Poll {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
questions?: Question[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Answer {
|
||||||
|
question_id: string;
|
||||||
|
choice_id: string;
|
||||||
|
text?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseDetail {
|
||||||
|
id: string;
|
||||||
|
poll_id: string;
|
||||||
|
submitted_at: string;
|
||||||
|
answers: Answer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserResponse {
|
||||||
|
id: string;
|
||||||
|
poll_id: string;
|
||||||
|
submitted_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RadarResult {
|
||||||
|
image_url?: string | null;
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Result<T> = { data: T | null; error: string | null };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Низкоуровневые помощники
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function authHeaders(token?: string | null): Record<string, string> {
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeJson(resp: Response): Promise<any> {
|
||||||
|
try {
|
||||||
|
return await resp.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET, возвращает распарсенный JSON или null при любой ошибке (как в api_client.py)
|
||||||
|
async function getJson<T>(path: string, token?: string | null): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(BASE + path, { headers: authHeaders(token) });
|
||||||
|
if (resp.ok) return (await resp.json()) as T;
|
||||||
|
} catch {
|
||||||
|
/* network error -> null */
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auth
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export async function login(username: string, password: string): Promise<Result<string>> {
|
||||||
|
try {
|
||||||
|
const body = new URLSearchParams({ username, password });
|
||||||
|
const resp = await fetch(BASE + "/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
const json = await resp.json();
|
||||||
|
return { data: json.access_token, error: null };
|
||||||
|
}
|
||||||
|
if (resp.status === 401) return { data: null, error: "Неверный логин или пароль" };
|
||||||
|
return { data: null, error: `Ошибка сервера: ${resp.status}` };
|
||||||
|
} catch {
|
||||||
|
return { data: null, error: "Не удалось подключиться к серверу" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(token: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fetch(BASE + "/auth/logout", { method: "POST", headers: authHeaders(token) });
|
||||||
|
} catch {
|
||||||
|
/* локальный logout произойдёт в любом случае */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMe(token: string): Promise<Me | null> {
|
||||||
|
return getJson<Me>("/users/me", token);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD_NAMES: Record<string, string> = { password: "Пароль", username: "Логин" };
|
||||||
|
const MSG_MAP: Record<string, string> = {
|
||||||
|
"Password must be at least 8 characters": "Пароль должен быть не менее 8 символов",
|
||||||
|
"Username must be at least 3 characters": "Логин должен быть не менее 3 символов",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface RegisterPayload {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
group_id?: string | null;
|
||||||
|
organization_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Возвращает строку с ошибкой или null при успехе (как register в api_client.py)
|
||||||
|
export async function register(p: RegisterPayload): Promise<string | null> {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
username: p.username,
|
||||||
|
password: p.password,
|
||||||
|
first_name: p.first_name,
|
||||||
|
last_name: p.last_name,
|
||||||
|
};
|
||||||
|
if (p.group_id) payload.group_id = p.group_id;
|
||||||
|
if (p.organization_id) payload.organization_id = p.organization_id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(BASE + "/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (resp.status === 201) return null;
|
||||||
|
const json = await safeJson(resp);
|
||||||
|
if (resp.status === 400) return json?.detail ?? "Ошибка регистрации";
|
||||||
|
if (resp.status === 422) {
|
||||||
|
const errors: any[] = json?.detail ?? [];
|
||||||
|
for (const err of errors) {
|
||||||
|
const raw = err?.ctx?.error || err?.msg || "";
|
||||||
|
if (raw in MSG_MAP) return MSG_MAP[raw];
|
||||||
|
const loc: any[] = err?.loc ?? [];
|
||||||
|
const field = FIELD_NAMES[loc[loc.length - 1]] ?? "";
|
||||||
|
const msg = (err?.msg ?? "Ошибка валидации").replace(/^Value error, /, "");
|
||||||
|
return `${field}: ${msg}`.replace(/^: |:?\s*$/g, "").trim() || "Проверьте правильность введённых данных";
|
||||||
|
}
|
||||||
|
return "Проверьте правильность введённых данных";
|
||||||
|
}
|
||||||
|
return `Ошибка сервера: ${resp.status}`;
|
||||||
|
} catch {
|
||||||
|
return "Не удалось подключиться к серверу";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Organizations & Groups
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export async function getOrganizations(): Promise<Organization[]> {
|
||||||
|
return (await getJson<Organization[]>("/organizations/")) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGroups(): Promise<Group[]> {
|
||||||
|
return (await getJson<Group[]>("/groups/")) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Polls
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export async function getPolls(): Promise<Poll[]> {
|
||||||
|
return (await getJson<Poll[]>("/polls/")) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPoll(pollId: string): Promise<Poll | null> {
|
||||||
|
return getJson<Poll>(`/polls/${pollId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getResponse(responseId: string): Promise<ResponseDetail | null> {
|
||||||
|
return getJson<ResponseDetail>(`/polls/responses/${responseId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRadarResult(responseId: string): Promise<RadarResult | null> {
|
||||||
|
return getJson<RadarResult>(`/responses/${responseId}/radar`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL SVG-диаграммы — браузер тянет напрямую с того же origin (через nginx-прокси).
|
||||||
|
export function radarImageUrl(responseId: string): string {
|
||||||
|
return `${BASE}/responses/${responseId}/radar/image`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmitPayload {
|
||||||
|
user_id?: string | null;
|
||||||
|
group_id?: string | null;
|
||||||
|
organization_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitResponse(
|
||||||
|
pollId: string,
|
||||||
|
answers: Answer[],
|
||||||
|
extra: SubmitPayload = {},
|
||||||
|
): Promise<Result<string>> {
|
||||||
|
const payload: Record<string, unknown> = { answers };
|
||||||
|
if (extra.user_id) payload.user_id = extra.user_id;
|
||||||
|
if (extra.group_id) payload.group_id = extra.group_id;
|
||||||
|
if (extra.organization_id) payload.organization_id = extra.organization_id;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(BASE + `/polls/${pollId}/responses`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (resp.status === 201) {
|
||||||
|
const json = await resp.json();
|
||||||
|
return { data: json.id, error: null };
|
||||||
|
}
|
||||||
|
return { data: null, error: `Ошибка сервера: ${resp.status}` };
|
||||||
|
} catch {
|
||||||
|
return { data: null, error: "Не удалось подключиться к серверу" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserResponses(userId: string): Promise<UserResponse[]> {
|
||||||
|
return (await getJson<UserResponse[]>(`/polls/users/${userId}/responses`)) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserPayload {
|
||||||
|
first_name?: string | null;
|
||||||
|
last_name?: string | null;
|
||||||
|
group_id?: string | null;
|
||||||
|
organization_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(
|
||||||
|
userId: string,
|
||||||
|
token: string,
|
||||||
|
payload: UpdateUserPayload,
|
||||||
|
): Promise<Result<Me>> {
|
||||||
|
const body: Record<string, unknown> = {};
|
||||||
|
if (payload.first_name !== undefined) body.first_name = payload.first_name;
|
||||||
|
if (payload.last_name !== undefined) body.last_name = payload.last_name;
|
||||||
|
if (payload.group_id !== undefined) body.group_id = payload.group_id;
|
||||||
|
if (payload.organization_id !== undefined) body.organization_id = payload.organization_id;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(BASE + `/users/${userId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json", ...authHeaders(token) },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (resp.ok) return { data: await resp.json(), error: null };
|
||||||
|
return { data: null, error: `Ошибка сервера: ${resp.status}` };
|
||||||
|
} catch {
|
||||||
|
return { data: null, error: "Не удалось подключиться к серверу" };
|
||||||
|
}
|
||||||
|
}
|
||||||
95
web-react/src/auth/AuthContext.tsx
Normal file
95
web-react/src/auth/AuthContext.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||||
|
import * as api from "../api/client";
|
||||||
|
|
||||||
|
const TOKEN_KEY = "auth_token";
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: string;
|
||||||
|
groupId: string;
|
||||||
|
orgId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthCtx {
|
||||||
|
token: string | null;
|
||||||
|
user: AuthUser | null;
|
||||||
|
ready: boolean; // завершена ли первичная проверка токена
|
||||||
|
login: (username: string, password: string) => Promise<string | null>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
setUserFromMe: (me: api.Me) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Ctx = createContext<AuthCtx | null>(null);
|
||||||
|
|
||||||
|
function meToUser(me: api.Me): AuthUser {
|
||||||
|
return {
|
||||||
|
id: me.id,
|
||||||
|
groupId: me.group_id ?? "",
|
||||||
|
orgId: me.organization_id ?? "",
|
||||||
|
firstName: me.first_name ?? "",
|
||||||
|
lastName: me.last_name ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
|
||||||
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
// При старте: если есть токен — проверяем его через /users/me
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
if (!token) {
|
||||||
|
setReady(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const me = await api.getMe(token);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (me) {
|
||||||
|
setUser(meToUser(me));
|
||||||
|
} else {
|
||||||
|
// токен невалиден/просрочен
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
setToken(null);
|
||||||
|
}
|
||||||
|
setReady(true);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (username: string, password: string): Promise<string | null> => {
|
||||||
|
const { data, error } = await api.login(username, password);
|
||||||
|
if (error || !data) return error ?? "Ошибка входа";
|
||||||
|
localStorage.setItem(TOKEN_KEY, data);
|
||||||
|
setToken(data);
|
||||||
|
const me = await api.getMe(data);
|
||||||
|
if (me) setUser(meToUser(me));
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
if (token) await api.logout(token);
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setUserFromMe = (me: api.Me) => setUser(meToUser(me));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Ctx.Provider value={{ token, user, ready, login, logout, setUserFromMe }}>
|
||||||
|
{children}
|
||||||
|
</Ctx.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthCtx {
|
||||||
|
const ctx = useContext(Ctx);
|
||||||
|
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
193
web-react/src/components/AgreementDialog.tsx
Normal file
193
web-react/src/components/AgreementDialog.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "./ui";
|
||||||
|
|
||||||
|
// Текст пользовательского соглашения (перенос из AlterDiaalogApproval в web/designer.py).
|
||||||
|
const AGREEMENT = `# ПОЛЬЗОВАТЕЛЬСКОЕ СОГЛАШЕНИЕ
|
||||||
|
|
||||||
|
## 1. ОБЩИЕ ПОЛОЖЕНИЯ
|
||||||
|
|
||||||
|
Настоящее Пользовательское соглашение (далее — «Соглашение») регулирует отношения между администрацией сайта https://copp74.ru (далее — «Сайт», «Мы», «Администрация») и пользователем (далее — «Пользователь», «Вы»).
|
||||||
|
|
||||||
|
Используя Сайт, Вы автоматически соглашаетесь со всеми условиями настоящего Соглашения. Если Вы не согласны с этими условиями, пожалуйста, не используйте Сайт.
|
||||||
|
|
||||||
|
## 2. ОПИСАНИЕ САЙТА
|
||||||
|
|
||||||
|
Сайт https://copp74.ru представляет собой онлайн-платформу для прохождения тестов и проверки знаний. Основная функция Сайта — предоставление Пользователям возможности проходить различные тесты и получать результаты.
|
||||||
|
|
||||||
|
## 3. РЕГИСТРАЦИЯ И ЛИЧНЫЕ ДАННЫЕ
|
||||||
|
|
||||||
|
### 3.1 Регистрация
|
||||||
|
|
||||||
|
Для использования Сайта Пользователь должен зарегистрироваться и указать следующую информацию:
|
||||||
|
- Фамилию
|
||||||
|
- Имя
|
||||||
|
- Учебное заведение (образовательную организацию)
|
||||||
|
|
||||||
|
### 3.2 Назначение личных данных
|
||||||
|
|
||||||
|
Указанные фамилия, имя и учебное заведение используются исключительно для:
|
||||||
|
- Идентификации Пользователя в системе
|
||||||
|
- Отображения результатов тестирования
|
||||||
|
- Ведения статистики прохождения тестов
|
||||||
|
- Организации пользователей по учебным заведениям для аналитики
|
||||||
|
- Внутреннего анализа и улучшения качества тестирования
|
||||||
|
|
||||||
|
### 3.3 Защита личных данных
|
||||||
|
|
||||||
|
**Мы гарантируем, что Ваши персональные данные (фамилия, имя и информация об учебном заведении):**
|
||||||
|
- Не будут переданы третьим лицам или организациям без Вашего согласия
|
||||||
|
- Не будут использованы в коммерческих целях
|
||||||
|
- Не будут распространяться без Вашего согласия
|
||||||
|
- Хранятся в защищенной базе данных
|
||||||
|
|
||||||
|
Данные будут удалены в соответствии с законодательством Российской Федерации по защите персональных данных при закрытии аккаунта или по запросу Пользователя.
|
||||||
|
|
||||||
|
## 4. ПРАВА И ОБЯЗАННОСТИ ПОЛЬЗОВАТЕЛЯ
|
||||||
|
|
||||||
|
### 4.1 Права Пользователя
|
||||||
|
|
||||||
|
Пользователь имеет право:
|
||||||
|
- Проходить тесты, размещенные на Сайте
|
||||||
|
- Получать результаты своего тестирования
|
||||||
|
- Запросить удаление своего аккаунта и данных
|
||||||
|
- Обратиться в поддержку по вопросам использования Сайта
|
||||||
|
- Получить информацию о том, как его данные обрабатываются
|
||||||
|
|
||||||
|
### 4.2 Обязанности Пользователя
|
||||||
|
|
||||||
|
Пользователь обязуется:
|
||||||
|
- Предоставлять достоверную информацию при регистрации
|
||||||
|
- Указывать корректное название учебного заведения
|
||||||
|
- Не использовать Сайт в противоправных целях
|
||||||
|
- Не пытаться получить несанкционированный доступ к системам Сайта
|
||||||
|
- Не воспроизводить, не копировать и не распространять содержимое Сайта без разрешения
|
||||||
|
- Соблюдать все применимые законы Российской Федерации
|
||||||
|
|
||||||
|
## 5. ОГРАНИЧЕНИЕ ОТВЕТСТВЕННОСТИ
|
||||||
|
|
||||||
|
### 5.1 Предоставление Сайта «Как есть»
|
||||||
|
|
||||||
|
Сайт предоставляется в режиме «как есть» без каких-либо явных или подразумеваемых гарантий. Администрация не гарантирует:
|
||||||
|
- Непрерывную работу Сайта
|
||||||
|
- Отсутствие ошибок в тестах или результатах
|
||||||
|
- Соответствие Сайта конкретным целям Пользователя
|
||||||
|
|
||||||
|
### 5.2 Ограничение ответственности
|
||||||
|
|
||||||
|
В максимальной степени, допускаемой законодательством Российской Федерации, Администрация не несет ответственность за:
|
||||||
|
- Убытки, возникшие в результате использования или невозможности использования Сайта
|
||||||
|
- Технические сбои, потерю данных
|
||||||
|
- Ущерб, причиненный прямо или косвенно в связи с использованием Сайта
|
||||||
|
|
||||||
|
## 6. ИНТЕЛЛЕКТУАЛЬНАЯ СОБСТВЕННОСТЬ
|
||||||
|
|
||||||
|
Все содержимое Сайта, включая тесты, вопросы, задания, дизайн, логотипы и тексты, защищено авторским правом и законами об интеллектуальной собственности Российской Федерации. Пользователь не имеет права использовать это содержимое в коммерческих целях без письменного разрешения Администрации.
|
||||||
|
|
||||||
|
## 7. ИЗМЕНЕНИЕ СОГЛАШЕНИЯ
|
||||||
|
|
||||||
|
Администрация оставляет за собой право изменять условия настоящего Соглашения в любое время. Изменения вступают в силе с момента их публикации на Сайте. Продолжение использования Сайта после публикации изменений означает согласие Пользователя с новыми условиями.
|
||||||
|
|
||||||
|
## 8. ПРЕКРАЩЕНИЕ ДОСТУПА
|
||||||
|
|
||||||
|
Администрация может заблокировать доступ Пользователя к Сайту без предварительного уведомления, если:
|
||||||
|
- Пользователь нарушает условия настоящего Соглашения
|
||||||
|
- Пользователь использует Сайт в противоправных целях
|
||||||
|
- Пользователь предоставил поддельную информацию при регистрации
|
||||||
|
|
||||||
|
## 9. КОНФИДЕНЦИАЛЬНОСТЬ
|
||||||
|
|
||||||
|
Пожалуйста, ознакомьтесь с нашей Политикой конфиденциальности, которая описывает, как мы обращаемся с Вашими личными данными и какие меры безопасности мы применяем.
|
||||||
|
|
||||||
|
## 10. ПРИМЕНИМОЕ ПРАВО
|
||||||
|
|
||||||
|
Настоящее Соглашение регулируется законодательством Российской Федерации. Все споры, возникающие в связи с Соглашением, подлежат разрешению в судах Российской Федерации.
|
||||||
|
|
||||||
|
## 11. КОНТАКТНАЯ ИНФОРМАЦИЯ
|
||||||
|
|
||||||
|
Если у Вас есть вопросы по данному Соглашению или функционированию Сайта https://copp74.ru, свяжитесь с нами через форму обратной связи на Сайте.
|
||||||
|
|
||||||
|
## 12. ЗАКЛЮЧИТЕЛЬНЫЕ ПОЛОЖЕНИЯ
|
||||||
|
|
||||||
|
Если какая-либо часть настоящего Соглашения будет признана недействительной, остальные положения остаются в полной силе.
|
||||||
|
|
||||||
|
Пользователь подтверждает, что прочитал, понял и согласен со всеми условиями настоящего Соглашения перед использованием Сайта.
|
||||||
|
|
||||||
|
**Дата последнего обновления:** 19.03.2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Соглашение соответствует законодательству Российской Федерации, включая Федеральный закон «О защите персональных данных» и Федеральный закон «О защите прав потребителей».*`;
|
||||||
|
|
||||||
|
// Рендер inline-жирного (**...**) внутри строки.
|
||||||
|
function inline(text: string): React.ReactNode {
|
||||||
|
const parts = text.split(/\*\*(.+?)\*\*/g);
|
||||||
|
return parts.map((p, i) => (i % 2 === 1 ? <strong key={i}>{p}</strong> : p));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Мини-рендер markdown-подмножества (заголовки/списки/абзацы/hr).
|
||||||
|
function renderMarkdown(src: string) {
|
||||||
|
const lines = src.split("\n");
|
||||||
|
const out: React.ReactNode[] = [];
|
||||||
|
let list: string[] = [];
|
||||||
|
const flushList = (key: number) => {
|
||||||
|
if (list.length) {
|
||||||
|
out.push(
|
||||||
|
<ul key={`ul-${key}`} className="agr-ul">
|
||||||
|
{list.map((it, i) => (
|
||||||
|
<li key={i}>{inline(it)}</li>
|
||||||
|
))}
|
||||||
|
</ul>,
|
||||||
|
);
|
||||||
|
list = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
lines.forEach((raw, i) => {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (line.startsWith("- ")) {
|
||||||
|
list.push(line.slice(2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
flushList(i);
|
||||||
|
if (!line) return;
|
||||||
|
if (line === "---") out.push(<hr key={i} className="divider" />);
|
||||||
|
else if (line.startsWith("### ")) out.push(<h3 key={i} className="agr-h3">{line.slice(4)}</h3>);
|
||||||
|
else if (line.startsWith("## ")) out.push(<h2 key={i} className="agr-h2">{line.slice(3)}</h2>);
|
||||||
|
else if (line.startsWith("# ")) out.push(<h1 key={i} className="agr-h1">{line.slice(2)}</h1>);
|
||||||
|
else if (line.startsWith("*") && line.endsWith("*") && !line.startsWith("**"))
|
||||||
|
out.push(<p key={i} className="agr-p muted" style={{ fontStyle: "italic" }}>{line.slice(1, -1)}</p>);
|
||||||
|
else out.push(<p key={i} className="agr-p">{inline(line)}</p>);
|
||||||
|
});
|
||||||
|
flushList(lines.length);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Диалог пользовательского соглашения: текст + переключатель согласия + «Принять».
|
||||||
|
// «Принять» неактивна, пока не включён переключатель (как в оригинале).
|
||||||
|
export function AgreementDialog({
|
||||||
|
onAccept,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
onAccept: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [agreed, setAgreed] = useState(false);
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<div className="modal agr-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-title" style={{ color: "var(--primary)" }}>
|
||||||
|
Пользовательское соглашение
|
||||||
|
</div>
|
||||||
|
<div className="agr-body">{renderMarkdown(AGREEMENT)}</div>
|
||||||
|
<label className="agr-switch-row">
|
||||||
|
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />
|
||||||
|
<span>Я согласен с условиями пользовательского соглашения</span>
|
||||||
|
</label>
|
||||||
|
<div className="row" style={{ justifyContent: "center" }}>
|
||||||
|
<Button disabled={!agreed} onClick={onAccept}>
|
||||||
|
Принять
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
web-react/src/components/Layout.tsx
Normal file
40
web-react/src/components/Layout.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { IconArrowBack, IconAccount } from "./icons";
|
||||||
|
|
||||||
|
// Оболочка экранов: заголовок + опциональная кнопка «назад» + кнопка профиля.
|
||||||
|
export function Layout({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
back,
|
||||||
|
showProfile = true,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
back?: string; // путь для кнопки «назад»
|
||||||
|
showProfile?: boolean;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<header className="app-header">
|
||||||
|
<div className="row row-gap" style={{ minWidth: 0 }}>
|
||||||
|
{back && (
|
||||||
|
<button className="icon-btn" onClick={() => navigate(back)} aria-label="Назад">
|
||||||
|
<IconArrowBack />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="app-title" style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{showProfile && (
|
||||||
|
<button className="icon-btn" onClick={() => navigate("/profile")} aria-label="Профиль">
|
||||||
|
<IconAccount />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
web-react/src/components/ProtectedRoute.tsx
Normal file
18
web-react/src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
import { Spinner } from "./ui";
|
||||||
|
|
||||||
|
// Пускает дальше только при наличии токена. Пока идёт первичная проверка — спиннер.
|
||||||
|
export function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||||
|
const { token, ready } = useAuth();
|
||||||
|
if (!ready) {
|
||||||
|
return (
|
||||||
|
<div className="spinner-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!token) return <Navigate to="/login" replace />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
19
web-react/src/components/RadarBlock.tsx
Normal file
19
web-react/src/components/RadarBlock.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { radarImageUrl } from "../api/client";
|
||||||
|
import { Button } from "./ui";
|
||||||
|
import { IconDownload } from "./icons";
|
||||||
|
|
||||||
|
// Блок с паутинной диаграммой: картинка + кнопка скачивания.
|
||||||
|
// SVG тянется напрямую с того же origin через nginx-прокси (/api/.../radar/image).
|
||||||
|
export function RadarBlock({ responseId }: { responseId: string }) {
|
||||||
|
const url = radarImageUrl(responseId);
|
||||||
|
return (
|
||||||
|
<div className="center-col" style={{ width: "100%" }}>
|
||||||
|
<img className="radar-img" src={url} alt="Паутинная диаграмма результата" />
|
||||||
|
<a href={url} download="diagram.svg">
|
||||||
|
<Button variant="info" icon={<IconDownload />}>
|
||||||
|
Сохранить диаграмму
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
web-react/src/components/SearchableDropdown.tsx
Normal file
83
web-react/src/components/SearchableDropdown.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { IconCaretDown } from "./icons";
|
||||||
|
|
||||||
|
export interface Option {
|
||||||
|
key: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выпадающий список с поиском — порт SearchableDropdown из web/designer.py.
|
||||||
|
// При клике открывается модальный диалог с полем поиска и списком (как в оригинале).
|
||||||
|
export function SearchableDropdown({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label?: string;
|
||||||
|
hint?: string;
|
||||||
|
options: Option[];
|
||||||
|
value: string | null;
|
||||||
|
onChange: (key: string | null) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const selected = useMemo(() => options.find((o) => o.key === value) ?? null, [options, value]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return options;
|
||||||
|
return options.filter((o) => o.text.toLowerCase().includes(q));
|
||||||
|
}, [options, query]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="field">
|
||||||
|
{label && <span className="field-label">{label}</span>}
|
||||||
|
<div
|
||||||
|
className="dropdown-control"
|
||||||
|
onClick={() => {
|
||||||
|
setQuery("");
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={"dropdown-value" + (selected ? "" : " placeholder")}>
|
||||||
|
{selected ? selected.text : hint ?? "Выберите..."}
|
||||||
|
</span>
|
||||||
|
<IconCaretDown style={{ color: "var(--text-secondary)", fontSize: "1.2rem", flexShrink: 0 }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="modal-backdrop" onClick={() => setOpen(false)}>
|
||||||
|
<div className="modal sd-dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-title">{label}</div>
|
||||||
|
<input
|
||||||
|
className="dropdown-search"
|
||||||
|
placeholder="Введите для поиска..."
|
||||||
|
value={query}
|
||||||
|
autoFocus
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
<hr className="divider" />
|
||||||
|
<div className="sd-list">
|
||||||
|
{filtered.length === 0 && <div className="dropdown-empty">Ничего не найдено</div>}
|
||||||
|
{filtered.map((o) => (
|
||||||
|
<div
|
||||||
|
key={o.key}
|
||||||
|
className={"dropdown-option" + (o.key === value ? " active" : "")}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(o.key);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{o.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
web-react/src/components/icons.tsx
Normal file
90
web-react/src/components/icons.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
// Небольшой набор inline-SVG иконок (замена Material Icons из Flet).
|
||||||
|
// Наследуют цвет через currentColor и размер через 1em.
|
||||||
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
|
type P = SVGProps<SVGSVGElement>;
|
||||||
|
const base = (p: P) => ({
|
||||||
|
width: "1em",
|
||||||
|
height: "1em",
|
||||||
|
viewBox: "0 0 24 24",
|
||||||
|
fill: "currentColor",
|
||||||
|
...p,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const IconPerson = (p: P) => (
|
||||||
|
<svg {...base(p)}>
|
||||||
|
<path d="M12 12a5 5 0 1 0-5-5 5 5 0 0 0 5 5Zm0 2c-3.3 0-10 1.7-10 5v3h20v-3c0-3.3-6.7-5-10-5Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconLock = (p: P) => (
|
||||||
|
<svg {...base(p)}>
|
||||||
|
<path d="M17 9V7a5 5 0 0 0-10 0v2H5v13h14V9h-2ZM9 7a3 3 0 0 1 6 0v2H9V7Zm3 11a2 2 0 1 1 2-2 2 2 0 0 1-2 2Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconAt = (p: P) => (
|
||||||
|
<svg {...base(p)}>
|
||||||
|
<path d="M12 2a10 10 0 1 0 4 19.2l-.8-1.8A8 8 0 1 1 20 12v1a1.5 1.5 0 0 1-3 0V8h-2v.7A4 4 0 1 0 16 15a3.5 3.5 0 0 0 6-2v-1A10 10 0 0 0 12 2Zm0 12a2 2 0 1 1 2-2 2 2 0 0 1-2 2Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconCheckCircle = (p: P) => (
|
||||||
|
<svg {...base(p)}>
|
||||||
|
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2Zm-2 15-5-5 1.4-1.4L10 14.2l7.6-7.6L19 8Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconRadioUnchecked = (p: P) => (
|
||||||
|
<svg {...base(p)} fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconDownload = (p: P) => (
|
||||||
|
<svg {...base(p)}>
|
||||||
|
<path d="M12 16 6 10l1.4-1.4L11 12.2V3h2v9.2l3.6-3.6L18 10Zm-8 3h16v2H4Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconChevronRight = (p: P) => (
|
||||||
|
<svg {...base(p)}>
|
||||||
|
<path d="M9 6 7.6 7.4 12.2 12l-4.6 4.6L9 18l6-6Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconArrowBack = (p: P) => (
|
||||||
|
<svg {...base(p)}>
|
||||||
|
<path d="M20 11H7.8l5.6-5.6L12 4l-8 8 8 8 1.4-1.4L7.8 13H20Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconAccount = (p: P) => (
|
||||||
|
<svg {...base(p)}>
|
||||||
|
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2Zm0 4a3 3 0 1 1-3 3 3 3 0 0 1 3-3Zm0 14a8 8 0 0 1-6-2.7c0-2 4-3.1 6-3.1s6 1.1 6 3.1A8 8 0 0 1 12 20Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconStop = (p: P) => (
|
||||||
|
<svg {...base(p)}>
|
||||||
|
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2ZM8 8h8v8H8Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconError = (p: P) => (
|
||||||
|
<svg {...base(p)}>
|
||||||
|
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2Zm1 15h-2v-2h2Zm0-4h-2V7h2Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconEye = (p: P) => (
|
||||||
|
<svg {...base(p)}>
|
||||||
|
<path d="M12 5C6 5 2 12 2 12s4 7 10 7 10-7 10-7-4-7-10-7Zm0 11a4 4 0 1 1 4-4 4 4 0 0 1-4 4Zm0-6a2 2 0 1 0 2 2 2 2 0 0 0-2-2Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconCaretDown = (p: P) => (
|
||||||
|
<svg {...base(p)}>
|
||||||
|
<path d="m7 10 5 5 5-5Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
139
web-react/src/components/ui.tsx
Normal file
139
web-react/src/components/ui.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import {
|
||||||
|
useState,
|
||||||
|
type ButtonHTMLAttributes,
|
||||||
|
type InputHTMLAttributes,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { IconEye } from "./icons";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Spinner
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export function Spinner() {
|
||||||
|
return <div className="spinner" role="status" aria-label="Загрузка" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CenterSpinner() {
|
||||||
|
return (
|
||||||
|
<div className="spinner-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Button
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
|
variant?: "primary" | "info" | "ghost";
|
||||||
|
block?: boolean;
|
||||||
|
icon?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
variant = "primary",
|
||||||
|
block,
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
...rest
|
||||||
|
}: ButtonProps) {
|
||||||
|
const cls = [
|
||||||
|
"btn",
|
||||||
|
variant === "info" ? "btn-info" : "",
|
||||||
|
variant === "ghost" ? "btn-ghost" : "",
|
||||||
|
block ? "btn-block" : "",
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
return (
|
||||||
|
<button className={cls} {...rest}>
|
||||||
|
{icon}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Card
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export function Card({
|
||||||
|
children,
|
||||||
|
alt,
|
||||||
|
onClick,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
alt?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const cls = ["card", alt ? "card-alt" : "", onClick ? "clickable" : "", className]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
return (
|
||||||
|
<div className={cls} onClick={onClick}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TextField
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
type FieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, "prefix"> & {
|
||||||
|
label?: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
password?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TextField({ label, icon, password, ...rest }: FieldProps) {
|
||||||
|
const [revealed, setRevealed] = useState(false);
|
||||||
|
const type = password ? (revealed ? "text" : "password") : rest.type ?? "text";
|
||||||
|
return (
|
||||||
|
<label className="field">
|
||||||
|
{label && <span className="field-label">{label}</span>}
|
||||||
|
<span className="field-input-wrap">
|
||||||
|
{icon && <span className="ficon">{icon}</span>}
|
||||||
|
<input {...rest} type={type} />
|
||||||
|
{password && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="reveal-btn"
|
||||||
|
onClick={() => setRevealed((v) => !v)}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label="Показать пароль"
|
||||||
|
>
|
||||||
|
<IconEye />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Modal
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export function Modal({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
actions,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
actions: ReactNode;
|
||||||
|
onClose?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-title">{title}</div>
|
||||||
|
{children && <div className="muted">{children}</div>}
|
||||||
|
<div className="modal-actions">{actions}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
web-react/src/main.tsx
Normal file
10
web-react/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import "./styles/global.css";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
20
web-react/src/pages/HomePage.tsx
Normal file
20
web-react/src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Layout } from "../components/Layout";
|
||||||
|
|
||||||
|
export function HomePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<Layout title="Добро пожаловать в ЦОПП!">
|
||||||
|
<div className="home-grid">
|
||||||
|
<div className="home-tile" onClick={() => navigate("/tests")}>
|
||||||
|
<img src="/icons/clipboard-question_16542596.svg" alt="" />
|
||||||
|
<span>Все тесты</span>
|
||||||
|
</div>
|
||||||
|
<div className="home-tile" onClick={() => navigate("/my-tests")}>
|
||||||
|
<img src="/icons/leaderboard-trophy_14227576.svg" alt="" />
|
||||||
|
<span>Мои тесты</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
web-react/src/pages/LoginPage.tsx
Normal file
79
web-react/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
import { Button, TextField } from "../components/ui";
|
||||||
|
import { AgreementDialog } from "../components/AgreementDialog";
|
||||||
|
import { IconPerson, IconLock } from "../components/icons";
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [askReg, setAskReg] = useState(false);
|
||||||
|
|
||||||
|
const doLogin = async () => {
|
||||||
|
if (!username.trim() || !password) {
|
||||||
|
setError("Заполните логин и пароль");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
const err = await login(username.trim(), password);
|
||||||
|
setBusy(false);
|
||||||
|
if (err) {
|
||||||
|
setError(err);
|
||||||
|
} else {
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-wrap">
|
||||||
|
<div className="auth-logo">ЦОПП</div>
|
||||||
|
<div className="auth-sub">
|
||||||
|
Это проект ЦОПП по проведению исследовательских тестирований
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
className="auth-card"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
doLogin();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label="Логин"
|
||||||
|
placeholder="Введите логин..."
|
||||||
|
icon={<IconPerson />}
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Пароль"
|
||||||
|
placeholder="Введите пароль..."
|
||||||
|
icon={<IconLock />}
|
||||||
|
password
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
{error && <div className="error-text">{error}</div>}
|
||||||
|
<Button type="submit" disabled={busy} block>
|
||||||
|
{busy ? "Вход..." : "Войти"}
|
||||||
|
</Button>
|
||||||
|
<button type="button" className="link-btn" onClick={() => setAskReg(true)}>
|
||||||
|
Нет аккаунта? Зарегистрироваться
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{askReg && (
|
||||||
|
<AgreementDialog
|
||||||
|
onClose={() => setAskReg(false)}
|
||||||
|
onAccept={() => navigate("/register")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
web-react/src/pages/MyTestsPage.tsx
Normal file
77
web-react/src/pages/MyTestsPage.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import * as api from "../api/client";
|
||||||
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
import { Layout } from "../components/Layout";
|
||||||
|
import { Card, CenterSpinner } from "../components/ui";
|
||||||
|
import { IconChevronRight } from "../components/icons";
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
submittedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MyTestsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [rows, setRows] = useState<Row[] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
if (!user) return;
|
||||||
|
const responses = await api.getUserResponses(user.id);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (responses.length === 0) {
|
||||||
|
setRows([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// параллельно подгружаем названия опросов
|
||||||
|
const pollIds = [...new Set(responses.map((r) => r.poll_id))];
|
||||||
|
const polls = await Promise.all(pollIds.map((id) => api.getPoll(id)));
|
||||||
|
if (cancelled) return;
|
||||||
|
const titleById = new Map<string, string>();
|
||||||
|
polls.forEach((p) => {
|
||||||
|
if (p) titleById.set(p.id, p.title);
|
||||||
|
});
|
||||||
|
const mapped: Row[] = responses
|
||||||
|
.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
title: titleById.get(r.poll_id) ?? "Неизвестный тест",
|
||||||
|
submittedAt: r.submitted_at,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.submittedAt.localeCompare(a.submittedAt));
|
||||||
|
setRows(mapped);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="Мои тесты" back="/" showProfile={false}>
|
||||||
|
{rows === null ? (
|
||||||
|
<CenterSpinner />
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="muted">Вы ещё не проходили тесты</p>
|
||||||
|
) : (
|
||||||
|
<div className="stack">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<Card key={r.id} onClick={() => navigate(`/response/${r.id}`)}>
|
||||||
|
<div className="list-card">
|
||||||
|
<div className="col-gap" style={{ gap: "0.25rem" }}>
|
||||||
|
<span className="bold">{r.title}</span>
|
||||||
|
<span className="text-sm muted">
|
||||||
|
Пройден: {r.submittedAt.slice(0, 19).replace("T", " ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<IconChevronRight className="chevron" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
web-react/src/pages/PollPage.tsx
Normal file
192
web-react/src/pages/PollPage.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import * as api from "../api/client";
|
||||||
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
import { Layout } from "../components/Layout";
|
||||||
|
import { Button, Card, CenterSpinner, Modal } from "../components/ui";
|
||||||
|
import { RadarBlock } from "../components/RadarBlock";
|
||||||
|
import { IconCheckCircle, IconRadioUnchecked, IconStop, IconError } from "../components/icons";
|
||||||
|
|
||||||
|
type Phase =
|
||||||
|
| { kind: "loading" }
|
||||||
|
| { kind: "empty"; message: string }
|
||||||
|
| { kind: "running" }
|
||||||
|
| { kind: "submitting" }
|
||||||
|
| { kind: "error"; message: string }
|
||||||
|
| { kind: "success"; responseId: string; hasRadar: boolean };
|
||||||
|
|
||||||
|
export function PollPage() {
|
||||||
|
const { pollId = "" } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const [questions, setQuestions] = useState<api.Question[]>([]);
|
||||||
|
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||||
|
const [current, setCurrent] = useState(0);
|
||||||
|
const [phase, setPhase] = useState<Phase>({ kind: "loading" });
|
||||||
|
const [askAbort, setAskAbort] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const p = await api.getPoll(pollId);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!p) {
|
||||||
|
setPhase({ kind: "empty", message: "Тест не найден" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const qs = p.questions ?? [];
|
||||||
|
setQuestions(qs);
|
||||||
|
if (qs.length === 0) {
|
||||||
|
setPhase({ kind: "empty", message: "В тесте нет вопросов" });
|
||||||
|
} else {
|
||||||
|
setPhase({ kind: "running" });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [pollId]);
|
||||||
|
|
||||||
|
const total = questions.length;
|
||||||
|
const allAnswered = total > 0 && questions.every((q) => answers[q.id]);
|
||||||
|
|
||||||
|
const selectAnswer = (questionId: string, choiceId: string) => {
|
||||||
|
setAnswers((prev) => ({ ...prev, [questionId]: choiceId }));
|
||||||
|
// авто-переход к следующему вопросу, если он не последний
|
||||||
|
if (current < total - 1) {
|
||||||
|
setCurrent((c) => c + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
const firstUnanswered = questions.findIndex((q) => !answers[q.id]);
|
||||||
|
if (firstUnanswered !== -1) {
|
||||||
|
setCurrent(firstUnanswered);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPhase({ kind: "submitting" });
|
||||||
|
const payload: api.Answer[] = Object.entries(answers).map(([question_id, choice_id]) => ({
|
||||||
|
question_id,
|
||||||
|
choice_id,
|
||||||
|
text: null,
|
||||||
|
}));
|
||||||
|
const { data: responseId, error } = await api.submitResponse(pollId, payload, {
|
||||||
|
user_id: user?.id,
|
||||||
|
group_id: user?.groupId || null,
|
||||||
|
organization_id: user?.orgId || null,
|
||||||
|
});
|
||||||
|
if (error || !responseId) {
|
||||||
|
setPhase({ kind: "error", message: error ?? "Не удалось отправить ответы" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const radar = await api.getRadarResult(responseId);
|
||||||
|
setPhase({ kind: "success", responseId, hasRadar: !!radar?.image_url });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── рендер по фазам ──────────────────────────────────────────────────
|
||||||
|
let body: React.ReactNode;
|
||||||
|
|
||||||
|
if (phase.kind === "loading") {
|
||||||
|
body = <CenterSpinner />;
|
||||||
|
} else if (phase.kind === "empty") {
|
||||||
|
body = <p className="muted">{phase.message}</p>;
|
||||||
|
} else if (phase.kind === "submitting") {
|
||||||
|
body = <CenterSpinner />;
|
||||||
|
} else if (phase.kind === "error") {
|
||||||
|
body = (
|
||||||
|
<div className="center-col">
|
||||||
|
<span style={{ color: "var(--primary)", fontSize: "2.5rem" }}>
|
||||||
|
<IconError />
|
||||||
|
</span>
|
||||||
|
<span>{phase.message}</span>
|
||||||
|
<Button onClick={() => setPhase({ kind: "running" })}>Попробовать снова</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (phase.kind === "success") {
|
||||||
|
body = (
|
||||||
|
<Card className="success-box">
|
||||||
|
<div className="center-col">
|
||||||
|
<span style={{ color: "var(--primary-light)", fontSize: "2.6rem" }}>
|
||||||
|
<IconCheckCircle />
|
||||||
|
</span>
|
||||||
|
<span className="title-lg">Тест пройден!</span>
|
||||||
|
{phase.hasRadar && <RadarBlock responseId={phase.responseId} />}
|
||||||
|
<Button onClick={() => navigate("/tests")}>Вернуться к тестам</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// running
|
||||||
|
const q = questions[current];
|
||||||
|
const selected = answers[q.id];
|
||||||
|
body = (
|
||||||
|
<div className="stack">
|
||||||
|
<div className="center-col" style={{ gap: "0.5rem" }}>
|
||||||
|
<span className="counter-badge">
|
||||||
|
Вопрос {current + 1} из {total}
|
||||||
|
</span>
|
||||||
|
<div className="wrap-row" style={{ justifyContent: "center" }}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
disabled={current === 0}
|
||||||
|
onClick={() => setCurrent((c) => Math.max(0, c - 1))}
|
||||||
|
>
|
||||||
|
{current > 0 ? "← Предыдущий вопрос" : "←"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" icon={<IconStop />} onClick={() => setAskAbort(true)}>
|
||||||
|
Прервать тест
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="question-box">{q.text}</div>
|
||||||
|
|
||||||
|
<div className="col-gap" style={{ gap: "0.4rem" }}>
|
||||||
|
{q.choices.map((c) => {
|
||||||
|
const isSel = c.id === selected;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
className={"choice-tile" + (isSel ? " selected" : "")}
|
||||||
|
onClick={() => selectAnswer(q.id, c.id)}
|
||||||
|
>
|
||||||
|
<span className="check">{isSel ? <IconCheckCircle /> : <IconRadioUnchecked />}</span>
|
||||||
|
<span>{c.text}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{current === total - 1 && allAnswered && (
|
||||||
|
<div className="row" style={{ justifyContent: "flex-end" }}>
|
||||||
|
<Button onClick={submit}>Завершить тест →</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="Прохождение теста" back="/tests" showProfile={false}>
|
||||||
|
{body}
|
||||||
|
|
||||||
|
{askAbort && (
|
||||||
|
<Modal
|
||||||
|
title="Прервать тест?"
|
||||||
|
onClose={() => setAskAbort(false)}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setAskAbort(false)}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => navigate("/tests")}>Прервать</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Все ответы будут потеряны. Вы уверены?
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
web-react/src/pages/ProfilePage.tsx
Normal file
133
web-react/src/pages/ProfilePage.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import * as api from "../api/client";
|
||||||
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
import { useTheme } from "../theme/ThemeContext";
|
||||||
|
import { THEMES, SCALE_MIN, SCALE_MAX, SCALE_STEP } from "../theme/themes";
|
||||||
|
import { Layout } from "../components/Layout";
|
||||||
|
import { Button, TextField } from "../components/ui";
|
||||||
|
import { SearchableDropdown, type Option } from "../components/SearchableDropdown";
|
||||||
|
import { IconPerson } from "../components/icons";
|
||||||
|
|
||||||
|
export function ProfilePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { token, user, logout, setUserFromMe } = useAuth();
|
||||||
|
const { themeId, scale, setThemeId, setScale } = useTheme();
|
||||||
|
|
||||||
|
const [firstName, setFirstName] = useState("");
|
||||||
|
const [lastName, setLastName] = useState("");
|
||||||
|
const [orgId, setOrgId] = useState<string | null>(null);
|
||||||
|
const [groupId, setGroupId] = useState<string | null>(null);
|
||||||
|
const [orgs, setOrgs] = useState<api.Organization[]>([]);
|
||||||
|
const [groups, setGroups] = useState<api.Group[]>([]);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [status, setStatus] = useState<{ text: string; error: boolean } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
if (!token) return;
|
||||||
|
const [me, o, g] = await Promise.all([
|
||||||
|
api.getMe(token),
|
||||||
|
api.getOrganizations(),
|
||||||
|
api.getGroups(),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
setOrgs(o);
|
||||||
|
setGroups(g);
|
||||||
|
if (me) {
|
||||||
|
setFirstName(me.first_name ?? "");
|
||||||
|
setLastName(me.last_name ?? "");
|
||||||
|
setOrgId(me.organization_id ?? null);
|
||||||
|
setGroupId(me.group_id ?? null);
|
||||||
|
}
|
||||||
|
setLoaded(true);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const onOrgChange = (id: string | null) => {
|
||||||
|
setOrgId(id);
|
||||||
|
const org = orgs.find((o) => o.id === id);
|
||||||
|
if (org?.group_id) setGroupId(org.group_id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const orgOptions: Option[] = orgs.map((o) => ({ key: o.id, text: o.name_organization }));
|
||||||
|
const groupOptions: Option[] = groups.map((g) => ({ key: g.id, text: g.name_group }));
|
||||||
|
const themeOptions: Option[] = THEMES.map((t) => ({ key: t.id, text: t.name }));
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!token || !user) return;
|
||||||
|
setBusy(true);
|
||||||
|
setStatus(null);
|
||||||
|
const { data, error } = await api.updateUser(user.id, token, {
|
||||||
|
first_name: firstName.trim() || null,
|
||||||
|
last_name: lastName.trim() || null,
|
||||||
|
group_id: groupId,
|
||||||
|
organization_id: orgId,
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (error) {
|
||||||
|
setStatus({ text: error, error: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data) setUserFromMe(data);
|
||||||
|
setStatus({ text: "Сохранено", error: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
const doLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
navigate("/login");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="Профиль" back="/" showProfile={false}>
|
||||||
|
{!loaded ? null : (
|
||||||
|
<div className="stack">
|
||||||
|
<span className="title-md">Редактировать профиль</span>
|
||||||
|
<TextField label="Имя" placeholder="Введите имя..." icon={<IconPerson />} value={firstName} onChange={(e) => setFirstName(e.target.value)} />
|
||||||
|
<TextField label="Фамилия" placeholder="Введите фамилию..." icon={<IconPerson />} value={lastName} onChange={(e) => setLastName(e.target.value)} />
|
||||||
|
<SearchableDropdown label="Организация" hint="Выберите организацию..." options={orgOptions} value={orgId} onChange={onOrgChange} />
|
||||||
|
<SearchableDropdown label="Группа" hint="Выберите группу..." options={groupOptions} value={groupId} onChange={setGroupId} />
|
||||||
|
|
||||||
|
<hr className="divider" />
|
||||||
|
<span className="title-md">Внешний вид</span>
|
||||||
|
<SearchableDropdown
|
||||||
|
label="Тема интерфейса"
|
||||||
|
hint="Выберите тему..."
|
||||||
|
options={themeOptions}
|
||||||
|
value={themeId}
|
||||||
|
onChange={(id) => id && setThemeId(id)}
|
||||||
|
/>
|
||||||
|
<div className="field">
|
||||||
|
<span className="field-label">Масштаб: {scale.toFixed(1)}×</span>
|
||||||
|
<input
|
||||||
|
className="scale-slider"
|
||||||
|
type="range"
|
||||||
|
min={SCALE_MIN}
|
||||||
|
max={SCALE_MAX}
|
||||||
|
step={SCALE_STEP}
|
||||||
|
value={scale}
|
||||||
|
onChange={(e) => setScale(parseFloat(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="wrap-row" style={{ alignItems: "center" }}>
|
||||||
|
<Button onClick={save} disabled={busy}>
|
||||||
|
{busy ? "Сохранение..." : "Сохранить"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={doLogout}>
|
||||||
|
Выйти
|
||||||
|
</Button>
|
||||||
|
{status && (
|
||||||
|
<span className={status.error ? "error-text" : "muted"}>{status.text}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
web-react/src/pages/RegisterPage.tsx
Normal file
97
web-react/src/pages/RegisterPage.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import * as api from "../api/client";
|
||||||
|
import { Button, TextField } from "../components/ui";
|
||||||
|
import { SearchableDropdown, type Option } from "../components/SearchableDropdown";
|
||||||
|
import { IconPerson, IconAt, IconLock } from "../components/icons";
|
||||||
|
|
||||||
|
export function RegisterPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [firstName, setFirstName] = useState("");
|
||||||
|
const [lastName, setLastName] = useState("");
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [orgId, setOrgId] = useState<string | null>(null);
|
||||||
|
const [groupId, setGroupId] = useState<string | null>(null);
|
||||||
|
const [orgs, setOrgs] = useState<api.Organization[]>([]);
|
||||||
|
const [groups, setGroups] = useState<api.Group[]>([]);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const [o, g] = await Promise.all([api.getOrganizations(), api.getGroups()]);
|
||||||
|
if (cancelled) return;
|
||||||
|
setOrgs(o);
|
||||||
|
setGroups(g);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Автоподстановка группы по выбранной организации (как _on_org_change)
|
||||||
|
const onOrgChange = (id: string | null) => {
|
||||||
|
setOrgId(id);
|
||||||
|
const org = orgs.find((o) => o.id === id);
|
||||||
|
if (org?.group_id) setGroupId(org.group_id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const orgOptions: Option[] = orgs.map((o) => ({ key: o.id, text: o.name_organization }));
|
||||||
|
const groupOptions: Option[] = groups.map((g) => ({ key: g.id, text: g.name_group }));
|
||||||
|
|
||||||
|
const doRegister = async () => {
|
||||||
|
if (!firstName.trim() || !lastName.trim() || !username.trim() || !password) {
|
||||||
|
setError("Заполните все обязательные поля");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
const err = await api.register({
|
||||||
|
username: username.trim(),
|
||||||
|
password,
|
||||||
|
first_name: firstName.trim(),
|
||||||
|
last_name: lastName.trim(),
|
||||||
|
group_id: groupId,
|
||||||
|
organization_id: orgId,
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (err) {
|
||||||
|
setError(err);
|
||||||
|
} else {
|
||||||
|
navigate("/login");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-wrap">
|
||||||
|
<div className="auth-logo">ЦОПП</div>
|
||||||
|
<div className="auth-sub">Регистрация в системе</div>
|
||||||
|
<form
|
||||||
|
className="auth-card"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
doRegister();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField label="Имя" placeholder="Введите имя..." icon={<IconPerson />} value={firstName} onChange={(e) => setFirstName(e.target.value)} />
|
||||||
|
<TextField label="Фамилия" placeholder="Введите фамилию..." icon={<IconPerson />} value={lastName} onChange={(e) => setLastName(e.target.value)} />
|
||||||
|
<TextField label="Логин" placeholder="Введите логин..." icon={<IconAt />} value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||||
|
<TextField label="Пароль" placeholder="Введите пароль..." icon={<IconLock />} password value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||||
|
<SearchableDropdown label="Организация" hint="Выберите организацию..." options={orgOptions} value={orgId} onChange={onOrgChange} />
|
||||||
|
<SearchableDropdown label="Группа" hint="Выберите группу..." options={groupOptions} value={groupId} onChange={setGroupId} />
|
||||||
|
{error && <div className="error-text">{error}</div>}
|
||||||
|
<div className="wrap-row">
|
||||||
|
<Button type="button" variant="ghost" onClick={() => navigate("/login")}>
|
||||||
|
Назад
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={busy}>
|
||||||
|
{busy ? "Регистрация..." : "Зарегистрироваться"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
web-react/src/pages/ResponsePage.tsx
Normal file
102
web-react/src/pages/ResponsePage.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import * as api from "../api/client";
|
||||||
|
import { Layout } from "../components/Layout";
|
||||||
|
import { Card, CenterSpinner } from "../components/ui";
|
||||||
|
import { RadarBlock } from "../components/RadarBlock";
|
||||||
|
import { IconCheckCircle, IconRadioUnchecked } from "../components/icons";
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
response: api.ResponseDetail;
|
||||||
|
poll: api.Poll;
|
||||||
|
hasRadar: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResponsePage() {
|
||||||
|
const { responseId = "" } = useParams();
|
||||||
|
const [state, setState] = useState<State | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const response = await api.getResponse(responseId);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!response) {
|
||||||
|
setError("Результат не найден");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const poll = await api.getPoll(response.poll_id);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!poll) {
|
||||||
|
setError("Тест не найден");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const radar = await api.getRadarResult(responseId);
|
||||||
|
if (cancelled) return;
|
||||||
|
setState({ response, poll, hasRadar: !!radar?.image_url });
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [responseId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="Результаты теста" back="/my-tests" showProfile={false}>
|
||||||
|
{error ? (
|
||||||
|
<p className="error-text">{error}</p>
|
||||||
|
) : !state ? (
|
||||||
|
<CenterSpinner />
|
||||||
|
) : (
|
||||||
|
<ResultBody {...state} responseId={responseId} />
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResultBody({ response, poll, hasRadar, responseId }: State & { responseId: string }) {
|
||||||
|
const questions = new Map(poll.questions?.map((q) => [q.id, q]) ?? []);
|
||||||
|
const submittedAt = response.submitted_at.slice(0, 19).replace("T", " ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stack">
|
||||||
|
<div className="col-gap" style={{ gap: "0.25rem" }}>
|
||||||
|
<span className="title-lg">{poll.title}</span>
|
||||||
|
<span className="text-sm muted">Пройден: {submittedAt}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasRadar && (
|
||||||
|
<Card alt>
|
||||||
|
<div className="center-col">
|
||||||
|
<span className="title-md">Результат</span>
|
||||||
|
<RadarBlock responseId={responseId} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="title-md">Ответы</span>
|
||||||
|
{response.answers.map((ans, i) => {
|
||||||
|
const q = questions.get(ans.question_id);
|
||||||
|
if (!q) return null;
|
||||||
|
return (
|
||||||
|
<Card key={i}>
|
||||||
|
<div className="col-gap">
|
||||||
|
<span className="bold">{q.text}</span>
|
||||||
|
{q.choices.map((c) => {
|
||||||
|
const selected = c.id === ans.choice_id;
|
||||||
|
return (
|
||||||
|
<div key={c.id} className={"choice-tile" + (selected ? " selected" : "")}>
|
||||||
|
<span className="check">
|
||||||
|
{selected ? <IconCheckCircle /> : <IconRadioUnchecked />}
|
||||||
|
</span>
|
||||||
|
<span>{c.text}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
web-react/src/pages/TestListPage.tsx
Normal file
41
web-react/src/pages/TestListPage.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import * as api from "../api/client";
|
||||||
|
import { Layout } from "../components/Layout";
|
||||||
|
import { Card, CenterSpinner } from "../components/ui";
|
||||||
|
|
||||||
|
export function TestListPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [polls, setPolls] = useState<api.Poll[] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
api.getPolls().then((p) => {
|
||||||
|
if (!cancelled) setPolls(p);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="Все тесты" back="/" showProfile={false}>
|
||||||
|
{polls === null ? (
|
||||||
|
<CenterSpinner />
|
||||||
|
) : polls.length === 0 ? (
|
||||||
|
<p className="muted">Тестов пока нет</p>
|
||||||
|
) : (
|
||||||
|
<div className="stack">
|
||||||
|
{polls.map((poll) => (
|
||||||
|
<Card key={poll.id} onClick={() => navigate(`/poll/${poll.id}`)}>
|
||||||
|
<div className="col-gap" style={{ gap: "0.3rem" }}>
|
||||||
|
<span className="title-md">{poll.title || "—"}</span>
|
||||||
|
{poll.description && <span className="text-sm muted">{poll.description}</span>}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
600
web-react/src/styles/global.css
Normal file
600
web-react/src/styles/global.css
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Глобальные стили. Цвета и масштаб — через CSS-переменные,
|
||||||
|
которые задаёт ThemeContext. 1rem масштабируется слайдером.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* статическая палитра (web/designer.py) */
|
||||||
|
--primary: #f85a40;
|
||||||
|
--primary-dark: #d94530;
|
||||||
|
--primary-light: #ffeab7;
|
||||||
|
--accent: #ffc845;
|
||||||
|
--info: #007fbd;
|
||||||
|
--info-dark: #006aa3;
|
||||||
|
--success: #85c446;
|
||||||
|
|
||||||
|
/* задаются ThemeContext, но даём дефолты */
|
||||||
|
--bg: #f4f6f8;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-alt: #e4e8ed;
|
||||||
|
--text-primary: #1e2a3a;
|
||||||
|
--text-secondary: #5a6779;
|
||||||
|
--border: #dde3ea;
|
||||||
|
|
||||||
|
--scale: 1.3;
|
||||||
|
font-size: calc(13px * var(--scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: "Segoe UI", system-ui, -apple-system, Roboto, Arial, sans-serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Layout ---------- */
|
||||||
|
.page {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 56rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem 1.2rem 3rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title {
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--primary);
|
||||||
|
padding: 0.4rem;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
line-height: 1;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
.icon-btn:hover {
|
||||||
|
background: var(--surface-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Card ---------- */
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
padding: 1.1rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(30, 42, 58, 0.06);
|
||||||
|
}
|
||||||
|
.card-alt {
|
||||||
|
background: var(--surface-alt);
|
||||||
|
}
|
||||||
|
.card.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||||
|
}
|
||||||
|
.card.clickable:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(30, 42, 58, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Buttons ---------- */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.7rem;
|
||||||
|
padding: 0.75rem 1.3rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-on-primary, #fff);
|
||||||
|
background: var(--primary);
|
||||||
|
transition: background 0.15s ease, opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: var(--primary-dark);
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.btn-info {
|
||||||
|
background: var(--info);
|
||||||
|
}
|
||||||
|
.btn-info:hover:not(:disabled) {
|
||||||
|
background: var(--info-dark);
|
||||||
|
}
|
||||||
|
.btn-ghost {
|
||||||
|
background: var(--surface-alt);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.btn-ghost:hover:not(:disabled) {
|
||||||
|
background: var(--surface-alt);
|
||||||
|
filter: brightness(0.95);
|
||||||
|
}
|
||||||
|
.btn-block {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.link-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Fields ---------- */
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
.field-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.field-input-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.7rem;
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
.field-input-wrap:focus-within {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
.field-input-wrap .ficon {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.field-input-wrap input {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.field-input-wrap input::placeholder {
|
||||||
|
color: var(--border);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.reveal-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Dropdown ---------- */
|
||||||
|
.dropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.dropdown-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.7rem;
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.dropdown-value {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dropdown-value.placeholder {
|
||||||
|
color: var(--border);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 30;
|
||||||
|
top: calc(100% + 0.3rem);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.7rem;
|
||||||
|
box-shadow: 0 6px 20px rgba(30, 42, 58, 0.18);
|
||||||
|
max-height: 16rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.dropdown-search {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
outline: none;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.dropdown-option {
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.dropdown-option:hover,
|
||||||
|
.dropdown-option.active {
|
||||||
|
background: var(--surface-alt);
|
||||||
|
}
|
||||||
|
.dropdown-empty {
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Auth screens ---------- */
|
||||||
|
.auth-wrap {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
}
|
||||||
|
.auth-logo {
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 1.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.auth-sub {
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0.5rem 0 1.4rem;
|
||||||
|
max-width: 28rem;
|
||||||
|
}
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 26rem;
|
||||||
|
background: var(--surface-alt);
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Misc ---------- */
|
||||||
|
.error-text {
|
||||||
|
color: #e23b3b;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.center-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.row-gap {
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.col-gap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
.stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
.wrap-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Spinner ---------- */
|
||||||
|
.spinner {
|
||||||
|
width: 2.4rem;
|
||||||
|
height: 2.4rem;
|
||||||
|
border: 0.28rem solid var(--surface-alt);
|
||||||
|
border-top-color: var(--primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
.spinner-center {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem 0;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Modal ---------- */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 22, 33, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 50;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 1.4rem;
|
||||||
|
max-width: 24rem;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.modal-title {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Home cards ---------- */
|
||||||
|
.home-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.home-tile {
|
||||||
|
width: 11rem;
|
||||||
|
height: 11rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||||
|
}
|
||||||
|
.home-tile:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 6px 16px rgba(30, 42, 58, 0.14);
|
||||||
|
}
|
||||||
|
.home-tile img {
|
||||||
|
width: 5.5rem;
|
||||||
|
height: 5.5rem;
|
||||||
|
}
|
||||||
|
.home-tile span {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Poll / take test ---------- */
|
||||||
|
.counter-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--surface-alt);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 0.7rem;
|
||||||
|
padding: 0.5rem 1.2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.question-box {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 1.4rem;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.choice-tile {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
background: var(--surface-alt);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s ease;
|
||||||
|
}
|
||||||
|
.choice-tile.selected {
|
||||||
|
background: var(--primary-light);
|
||||||
|
}
|
||||||
|
.choice-tile .check {
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.radar-img {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 30rem;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.success-box {
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 1.1rem;
|
||||||
|
padding: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Slider ---------- */
|
||||||
|
.scale-slider {
|
||||||
|
width: 100%;
|
||||||
|
accent-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- List card row ---------- */
|
||||||
|
.list-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
.chevron {
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
.bold {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.title-lg {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.title-md {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.text-sm {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
border: none;
|
||||||
|
margin: 0.4rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Agreement dialog ---------- */
|
||||||
|
.agr-modal {
|
||||||
|
max-width: 34rem;
|
||||||
|
max-height: 90vh;
|
||||||
|
}
|
||||||
|
.agr-body {
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 50vh;
|
||||||
|
padding-right: 0.4rem;
|
||||||
|
}
|
||||||
|
.agr-h1 {
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0.6rem 0 0.4rem;
|
||||||
|
}
|
||||||
|
.agr-h2 {
|
||||||
|
color: var(--info);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0.7rem 0 0.3rem;
|
||||||
|
}
|
||||||
|
.agr-h3 {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0.5rem 0 0.2rem;
|
||||||
|
}
|
||||||
|
.agr-p {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0.35rem 0;
|
||||||
|
}
|
||||||
|
.agr-ul {
|
||||||
|
margin: 0.2rem 0 0.4rem 1.1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.agr-ul li {
|
||||||
|
margin: 0.15rem 0;
|
||||||
|
}
|
||||||
|
.agr-switch-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.55rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.agr-switch-row input {
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
accent-color: var(--primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- SearchableDropdown dialog ---------- */
|
||||||
|
.sd-dialog {
|
||||||
|
max-width: 25rem;
|
||||||
|
height: 26rem;
|
||||||
|
max-height: 85vh;
|
||||||
|
}
|
||||||
|
.sd-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin: 0 -0.4rem;
|
||||||
|
}
|
||||||
|
.sd-dialog .dropdown-search {
|
||||||
|
position: static;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
}
|
||||||
86
web-react/src/theme/ThemeContext.tsx
Normal file
86
web-react/src/theme/ThemeContext.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
THEMES,
|
||||||
|
DEFAULT_THEME_ID,
|
||||||
|
SCALE_DEFAULT,
|
||||||
|
SCALE_MIN,
|
||||||
|
SCALE_MAX,
|
||||||
|
getTheme,
|
||||||
|
autoScale,
|
||||||
|
} from "./themes";
|
||||||
|
|
||||||
|
const THEME_KEY = "ui_theme";
|
||||||
|
const SCALE_KEY = "ui_scale";
|
||||||
|
|
||||||
|
interface ThemeCtx {
|
||||||
|
themeId: string;
|
||||||
|
scale: number;
|
||||||
|
setThemeId: (id: string) => void;
|
||||||
|
setScale: (s: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Ctx = createContext<ThemeCtx | null>(null);
|
||||||
|
|
||||||
|
function clampScale(s: number): number {
|
||||||
|
return Math.max(SCALE_MIN, Math.min(SCALE_MAX, s));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(themeId: string) {
|
||||||
|
const t = getTheme(themeId);
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.style.setProperty("--bg", t.background);
|
||||||
|
root.style.setProperty("--surface", t.surface);
|
||||||
|
root.style.setProperty("--surface-alt", t.surfaceAlt);
|
||||||
|
// тёмная тема — светлый текст
|
||||||
|
const isDark = themeId === "dark";
|
||||||
|
root.style.setProperty("--text-primary", isDark ? "#F4F6F8" : "#1E2A3A");
|
||||||
|
root.style.setProperty("--text-secondary", isDark ? "#AEB9C7" : "#5A6779");
|
||||||
|
root.style.setProperty("--border", isDark ? "#4A5C72" : "#DDE3EA");
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyScale(scale: number) {
|
||||||
|
document.documentElement.style.setProperty("--scale", String(scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [themeId, setThemeIdState] = useState<string>(() => {
|
||||||
|
const saved = localStorage.getItem(THEME_KEY);
|
||||||
|
return saved && THEMES.some((t) => t.id === saved) ? saved : DEFAULT_THEME_ID;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [scale, setScaleState] = useState<number>(() => {
|
||||||
|
const saved = localStorage.getItem(SCALE_KEY);
|
||||||
|
if (saved != null) {
|
||||||
|
const n = parseFloat(saved);
|
||||||
|
if (!Number.isNaN(n)) return clampScale(n);
|
||||||
|
}
|
||||||
|
return autoScale() || SCALE_DEFAULT;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyTheme(themeId);
|
||||||
|
}, [themeId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyScale(scale);
|
||||||
|
}, [scale]);
|
||||||
|
|
||||||
|
const setThemeId = (id: string) => {
|
||||||
|
setThemeIdState(id);
|
||||||
|
localStorage.setItem(THEME_KEY, id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setScale = (s: number) => {
|
||||||
|
const c = clampScale(s);
|
||||||
|
setScaleState(c);
|
||||||
|
localStorage.setItem(SCALE_KEY, String(c));
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Ctx.Provider value={{ themeId, scale, setThemeId, setScale }}>{children}</Ctx.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme(): ThemeCtx {
|
||||||
|
const ctx = useContext(Ctx);
|
||||||
|
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
58
web-react/src/theme/themes.ts
Normal file
58
web-react/src/theme/themes.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// Темы и палитра. Перенос из web/theme_manager.py (THEMES) и web/designer.py (colors).
|
||||||
|
// Каждая тема переопределяет только фоновые цвета; остальная палитра общая.
|
||||||
|
|
||||||
|
export interface Theme {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
background: string;
|
||||||
|
surface: string;
|
||||||
|
surfaceAlt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const THEMES: Theme[] = [
|
||||||
|
{ id: "default", name: "ЦОПП (оранжевая)", background: "#F4F6F8", surface: "#FFFFFF", surfaceAlt: "#E4E8ED" },
|
||||||
|
{ id: "warm", name: "Тёплая", background: "#FFF3E6", surface: "#FFFFFF", surfaceAlt: "#FFD9B3" },
|
||||||
|
{ id: "blue", name: "Синяя", background: "#E3F2FD", surface: "#FFFFFF", surfaceAlt: "#B8D9F5" },
|
||||||
|
{ id: "green", name: "Зелёная", background: "#EDF7E7", surface: "#FFFFFF", surfaceAlt: "#BEE3B0" },
|
||||||
|
{ id: "dark", name: "Тёмная", background: "#1E2A3A", surface: "#2D3F54", surfaceAlt: "#3D5068" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_THEME_ID = "default";
|
||||||
|
|
||||||
|
// Постоянная палитра (web/designer.py: class colors)
|
||||||
|
export const PALETTE = {
|
||||||
|
primary: "#F85A40",
|
||||||
|
primaryDark: "#D94530",
|
||||||
|
primaryLight: "#FFEAB7",
|
||||||
|
accent: "#FFC845",
|
||||||
|
accentLime: "#FFC845",
|
||||||
|
secondaryLight: "#FFEAB7",
|
||||||
|
info: "#007FBD",
|
||||||
|
infoDark: "#006AA3",
|
||||||
|
infoLight: "#D9F1F4",
|
||||||
|
success: "#85C446",
|
||||||
|
successLight: "#C0DE9E",
|
||||||
|
textPrimary: "#1E2A3A",
|
||||||
|
textSecondary: "#5A6779",
|
||||||
|
textOnPrimary: "#FFFFFF",
|
||||||
|
border: "#DDE3EA",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Масштаб (web/theme_manager.py)
|
||||||
|
export const SCALE_MIN = 0.8;
|
||||||
|
export const SCALE_MAX = 2.5;
|
||||||
|
export const SCALE_STEP = 0.1;
|
||||||
|
export const SCALE_DEFAULT = 1.3;
|
||||||
|
// Автоопределение по ширине экрана
|
||||||
|
export const MOBILE_BREAKPOINT = 600;
|
||||||
|
export const SCALE_MOBILE = 1.0;
|
||||||
|
export const SCALE_DESKTOP = 1.5;
|
||||||
|
|
||||||
|
export function getTheme(id: string | null | undefined): Theme {
|
||||||
|
return THEMES.find((t) => t.id === id) ?? THEMES[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function autoScale(): number {
|
||||||
|
if (typeof window === "undefined") return SCALE_DEFAULT;
|
||||||
|
return window.innerWidth < MOBILE_BREAKPOINT ? SCALE_MOBILE : SCALE_DESKTOP;
|
||||||
|
}
|
||||||
1
web-react/src/vite-env.d.ts
vendored
Normal file
1
web-react/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
21
web-react/tsconfig.json
Normal file
21
web-react/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
18
web-react/vite.config.ts
Normal file
18
web-react/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
// В dev-режиме проксируем /api на запущенный локально API (docker compose up -d api).
|
||||||
|
// В проде эту роль выполняет nginx (web-react/nginx.conf).
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: process.env.API_URL || "http://localhost:8000",
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, ""),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user