Compare commits
15 Commits
71887b084a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
485d5128bf | ||
|
|
0f13ef5256 | ||
|
|
eb110f677c | ||
|
|
1728450778 | ||
|
|
db76401cef | ||
|
|
d8438da334 | ||
|
|
94299d4af6 | ||
|
|
dd0c29ccad | ||
|
|
31ee79bd97 | ||
|
|
c197a59c3f | ||
|
|
ef1d442f35 | ||
|
|
ab1788c57f | ||
|
|
6bda64ccbc | ||
|
|
fd098f917c | ||
|
|
eac61923f4 |
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
||||
.git
|
||||
.venv
|
||||
data
|
||||
web-react
|
||||
stats-react
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
*.egg-info
|
||||
.env
|
||||
*.log
|
||||
*.docx
|
||||
24
.env.example
Normal file
@@ -0,0 +1,24 @@
|
||||
APP_PATH=/app
|
||||
DEBUG=False
|
||||
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_USER=postgres
|
||||
DB_PASS=change-me
|
||||
DB_NAME=profi
|
||||
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_DB=0
|
||||
REDIS_PASSWORD=change-me
|
||||
|
||||
# python -c "import secrets; print(secrets.token_hex(32))"
|
||||
SECRET_KEY=change-me
|
||||
ADMIN_KEY=change-me
|
||||
DOCS_USERNAME=admin
|
||||
DOCS_PASSWORD=change-me
|
||||
|
||||
API_URL=http://api:8000
|
||||
|
||||
# Пароль локального Postgres из docker-compose_db.yml (если не используете внешнюю БД)
|
||||
POSTGRES_PASSWORD=change-me
|
||||
@@ -2,12 +2,16 @@ FROM python:3.14-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Копируем зависимости
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
# Зависимости ставим через uv по pyproject.toml/uv.lock — тот же источник,
|
||||
# что и в локальной разработке, версии не расходятся.
|
||||
RUN pip install --no-cache-dir uv
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-install-project --no-dev
|
||||
|
||||
# Копируем приложение
|
||||
COPY . .
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
# Стартуем приложение
|
||||
CMD ["python", "main.py"]
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# matplotlib нужен fontconfig для кириллицы
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
fontconfig \
|
||||
fonts-dejavu-core \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install --no-cache-dir \
|
||||
"flet>=0.82.2" \
|
||||
"flet-web>=0.82.2" \
|
||||
"uvicorn[standard]" \
|
||||
httpx \
|
||||
matplotlib
|
||||
|
||||
COPY stats/ .
|
||||
|
||||
EXPOSE 8081
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8081"]
|
||||
@@ -1,14 +0,0 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Устанавливаем flet (uvicorn уже входит в зависимости flet)
|
||||
RUN pip install --no-cache-dir flet flet-web "uvicorn[standard]" httpx
|
||||
|
||||
# Копируем исходники веб-приложения
|
||||
COPY . .
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# Запускаем Flet как ASGI-приложение через uvicorn
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "80"]
|
||||
117
README.md
@@ -1,6 +1,6 @@
|
||||
# api-copp
|
||||
|
||||
Платформа ЦОПП Челябинской области для проведения профориентационных тестирований. REST API на FastAPI + веб-интерфейс на Flet. Поддерживает тесты Голланда, Климова и Гломштока с паутинными диаграммами результатов.
|
||||
Платформа ЦОПП Челябинской области для проведения профориентационных тестирований. REST API на FastAPI + веб-интерфейс на React (SPA). Поддерживает тесты Голланда, Климова и Гломштока с паутинными диаграммами результатов. Тест можно пройти анонимно — достаточно выбрать школу и класс, аккаунт не нужен.
|
||||
|
||||
---
|
||||
|
||||
@@ -25,42 +25,42 @@
|
||||
```
|
||||
Браузер пользователя
|
||||
│ :80
|
||||
[ web — Flet ASGI ] ← единственный публичный сервис
|
||||
[ web-react — nginx (SPA + reverse-proxy /api) ] ← единственный публичный сервис
|
||||
│ http://api:8000 (только Docker-сеть)
|
||||
[ api — FastAPI :8000 ]
|
||||
│ │
|
||||
[ PostgreSQL :5432 ] [ Redis :6379 ]
|
||||
```
|
||||
|
||||
Сервис `api` **не публикует порты наружу** — доступен только контейнеру `web` внутри Docker-сети. Пользователь никогда не обращается к API напрямую.
|
||||
Сервис `api` **не публикует порты наружу** — доступен только контейнеру `web-react` внутри Docker-сети. Браузер обращается к `/api/*`, nginx внутри `web-react` перенаписывает и проксирует запрос на `api:8000` (см. `web-react/nginx.conf`). Пользователь никогда не обращается к API напрямую.
|
||||
|
||||
PostgreSQL и Redis могут запускаться как:
|
||||
- отдельным compose-файлом `docker-compose_db.yml` (локально в Docker)
|
||||
- внешними сервисами (отдельный сервер, managed service) — достаточно прописать нужные `REDIS_HOST`/`DB_HOST` в переменных окружения
|
||||
- сервисами `postgres`/`redis` в том же `docker-compose.yml`, за профилем `local-db` (локально в Docker)
|
||||
- внешними сервисами (отдельный сервер, managed service) — достаточно прописать нужные `REDIS_HOST`/`DB_HOST` в `.env`, профиль `local-db` не поднимать
|
||||
|
||||
---
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### 1. Инфраструктура (PostgreSQL + Redis)
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose_db.yml up -d
|
||||
```
|
||||
|
||||
### 2. Приложение
|
||||
Один файл `docker-compose.yml` на всё. По умолчанию поднимает только приложение (`api` + `web-react` + `stats`) — БД и Redis ожидаются внешними (`DB_HOST`/`REDIS_HOST` в `.env`, см. «Переменные окружения»).
|
||||
|
||||
```bash
|
||||
# Только приложение (внешние БД/Redis из .env)
|
||||
docker compose up -d --build
|
||||
|
||||
# + локальные PostgreSQL и Redis в Docker (профиль local-db)
|
||||
docker compose --profile local-db up -d --build
|
||||
```
|
||||
|
||||
| Адрес | Что |
|
||||
|---|---|
|
||||
| `http://localhost` | Веб-интерфейс (Flet) |
|
||||
| `http://localhost` | Веб-интерфейс (React SPA) |
|
||||
| `http://localhost:8000/docs` | Swagger UI (HTTP Basic Auth) |
|
||||
| `http://localhost:8000/redoc` | ReDoc |
|
||||
| `http://localhost:8081` | Дашборд статистики |
|
||||
|
||||
> Реальные значения переменных окружения (пароли, ключи) держите в `.env` в корне репозитория — `docker compose` подставляет их автоматически. Шаблон без секретов — в `.env.example`. Файл `.env` в `.gitignore`, в репозиторий не попадает.
|
||||
|
||||
### 3. Инициализация БД (первый запуск)
|
||||
|
||||
```bash
|
||||
@@ -99,11 +99,12 @@ curl -X POST http://localhost:8000/polls/glomshtok/<poll_id>/seed-scores -H "X-A
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
> Файл `docker-compose.yml` содержит **демо-значения**. Перед продакшн-деплоем обязательно смените все пароли и ключи.
|
||||
> `docker-compose.yml` читает значения из `.env` (не коммитится, см. `.env.example` за шаблоном). Перед продакшн-деплоем обязательно смените все пароли и ключи в своём `.env`.
|
||||
|
||||
| Переменная | Описание | Пример |
|
||||
|---|---|---|
|
||||
| `APP_PATH` | Путь к директории приложения внутри контейнера | `/app` |
|
||||
| `DEBUG` | Режим отладки API | `False` |
|
||||
| `DB_HOST` | Хост PostgreSQL | `postgres` |
|
||||
| `DB_PORT` | Порт PostgreSQL | `5432` |
|
||||
| `DB_USER` | Пользователь БД | `postgres` |
|
||||
@@ -126,12 +127,11 @@ curl -X POST http://localhost:8000/polls/glomshtok/<poll_id>/seed-scores -H "X-A
|
||||
```
|
||||
api-copp/
|
||||
├── main.py # точка входа FastAPI, регистрация роутеров
|
||||
├── requirements.txt # зависимости API
|
||||
├── Dockerfile.api # образ API
|
||||
├── Dockerfile.web # образ фронтенда (Flet)
|
||||
├── Dockerfile.stats # образ дашборда статистики
|
||||
├── docker-compose.yml # API + Web + Stats
|
||||
├── docker-compose_db.yml # PostgreSQL + Redis
|
||||
├── pyproject.toml / uv.lock # зависимости API (единственный источник — читает Dockerfile.api через uv)
|
||||
├── .dockerignore # что не попадает в build context образов
|
||||
├── .env.example # шаблон переменных окружения без секретов
|
||||
├── Dockerfile.api # образ API
|
||||
├── docker-compose.yml # api + web-react + stats-react (+ postgres/redis за профилем local-db)
|
||||
│
|
||||
├── bd/
|
||||
│ ├── __init__.py # Settings (pydantic), make_engine()
|
||||
@@ -168,13 +168,16 @@ api-copp/
|
||||
│ ├── stats_crud.py # статистика (кэшируется в Redis, TTL 5 мин)
|
||||
│ └── transfer_crud.py # экспорт/импорт/бэкап/восстановление БД
|
||||
│
|
||||
├── web/ # Flet-фронтенд (порт 80)
|
||||
│ ├── main.py
|
||||
│ ├── router.py
|
||||
│ ├── api_client.py
|
||||
│ ├── designer.py # дизайн-система
|
||||
│ ├── radar_chart.py # виджет RadarChart
|
||||
│ └── views/content_users/
|
||||
├── web-react/ # React SPA-фронтенд (порт 80, свой Dockerfile+nginx)
|
||||
│ ├── src/api/client.ts # единая точка HTTP-запросов к API
|
||||
│ ├── src/auth/AuthContext.tsx # токен-логин + анонимная сессия (школа+класс без аккаунта)
|
||||
│ ├── src/pages/ # страницы (Login, AnonymousEntry, Poll, MyTests, ...)
|
||||
│ └── nginx.conf # раздаёт статику SPA, проксирует /api → api:8000
|
||||
│
|
||||
├── stats-react/ # дашборд статистики, React SPA (порт 8081, свой Dockerfile+nginx)
|
||||
│ ├── src/api/client.ts # HTTP-клиент к /stats/*, /polls/, /organizations/, /groups/
|
||||
│ ├── src/components/charts/ # BarChart, LineChart, PartToWholeBar — свои SVG/HTML, без сторонних libs
|
||||
│ └── src/App.tsx # фильтры + два таба (прохождения / результаты тестов)
|
||||
│
|
||||
└── data/ # том Docker (./data:/app/data)
|
||||
├── postgres/ # данные PostgreSQL
|
||||
@@ -259,6 +262,8 @@ GET /polls/users/{user_id}/responses — история пользовате
|
||||
GET /responses/{id} — конкретный результат с ответами
|
||||
```
|
||||
|
||||
`POST /polls/{poll_id}/responses` не требует авторизации: `user_id` необязателен. `web-react` использует это для анонимного прохождения — школьник на `/anonymous` выбирает только школу и класс (без пароля и аккаунта), выбор кладётся в `group_id`/`organization_id` тела запроса вместо `user_id`. Результат сохраняется в базе как снимок школы/класса без привязки к пользователю; истории результатов после закрытия вкладки у анонимной сессии нет — только у настоящих аккаунтов (`/my-tests`).
|
||||
|
||||
### Результаты (паутинная диаграмма)
|
||||
|
||||
```
|
||||
@@ -446,7 +451,7 @@ radar_svgs/ ← все SVG-файлы (структура папок
|
||||
|
||||
### Скачивание из браузера
|
||||
|
||||
Фронтенд скачивает SVG-байты через внутренний API (httpx, Docker-сеть), кодирует в base64 data URI и открывает в браузере. Браузер не обращается к API напрямую — это важно, так как `api` не имеет публичного адреса.
|
||||
`web-react` отдаёт SVG через тот же same-origin прокси, что и остальной API: `<img src="/api/responses/{id}/radar/image">`, nginx внутри контейнера `web-react` перенаправляет запрос на `api:8000` (см. `web-react/nginx.conf`). Браузер по-прежнему не обращается к `api` напрямую — `api` не публикует порты.
|
||||
|
||||
---
|
||||
|
||||
@@ -497,7 +502,7 @@ tcp-keepalive 60
|
||||
|
||||
## Деплой с внешним прокси
|
||||
|
||||
Минимальная конфигурация nginx:
|
||||
`web-react` — статический SPA + reverse-proxy внутри своего же контейнера (`web-react/nginx.conf`), долгоживущих WebSocket-соединений (как было у Flet) больше нет. Минимальная конфигурация внешнего nginx перед контейнером:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
@@ -505,24 +510,20 @@ location / {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
}
|
||||
```
|
||||
|
||||
Заголовки `Upgrade` / `Connection` обязательны — Flet использует WebSocket.
|
||||
|
||||
В репозитории есть `nginx.conf` и `nginx.production.conf` с готовыми примерами.
|
||||
> **⚠ `nginx.conf` и `nginx.production.conf` в репозитории всё ещё написаны под старый Flet-фронтенд** (`upstream flet_app`, отдельный `location /ws` для WebSocket, `location /radar-image/`). После перехода на `web-react` эти файлы не актуальны и не обновлялись — `nginx.production.conf`, судя по всему, конфиг боевого внешнего nginx для `test.cloud-copp74.ru`, поэтому его правка не входит в эту уборку без отдельного подтверждения (ошибка здесь ломает публичный домен). Если используете внешний прокси в проде — сверьте его с примером выше перед следующим деплоем.
|
||||
|
||||
---
|
||||
|
||||
## Известные ошибки и решения
|
||||
|
||||
### 1. `api` недоступен из браузера ✅
|
||||
### 1. `api` недоступен из браузера ✅ (относилось к старому Flet-фронтенду)
|
||||
|
||||
**Проблема:** Docker-имя `api` не резолвится у браузера пользователя. Любой URL вида `http://api:8000/...` в `page.launch_url()` давал ошибку DNS.
|
||||
**Проблема:** Docker-имя `api` не резолвится у браузера пользователя. Любой URL вида `http://api:8000/...` в `page.launch_url()` (Flet) давал ошибку DNS.
|
||||
|
||||
**Решение:** Фронтенд скачивает SVG-байты через httpx (внутренняя сеть), конвертирует в `data:image/svg+xml;base64,...` и передаёт готовый data URI в браузер. Прямых обращений к API из браузера нет.
|
||||
**Решение в `web-react`:** same-origin прокси `/api/*` в `web-react/nginx.conf` — браузер всегда обращается к тому же origin, что и отдал SPA, прямых обращений к `api` из браузера нет в принципе (см. «Скачивание из браузера» выше).
|
||||
|
||||
---
|
||||
|
||||
@@ -540,11 +541,11 @@ location / {
|
||||
|
||||
---
|
||||
|
||||
### 4. Варианты ответов обрезались ✅
|
||||
### 4. Варианты ответов обрезались ✅ (относилось к старому Flet-фронтенду)
|
||||
|
||||
**Проблема:** `ft.Radio(label=...)` в Flet не переносит текст метки — длинные варианты обрезались.
|
||||
|
||||
**Решение:** Кастомные кликабельные контейнеры с `ft.Text(expand=True, no_wrap=False)`.
|
||||
**Решение:** актуально не для этого проекта — в `web-react` варианты ответа рендерятся обычной HTML-разметкой (`PollPage.tsx`), перенос текста работает из коробки.
|
||||
|
||||
---
|
||||
|
||||
@@ -592,6 +593,40 @@ ALTER TABLE radar_results ADD COLUMN IF NOT EXISTS image_path VARCHAR(512);
|
||||
|
||||
---
|
||||
|
||||
### 10. Дублирование SVG-генератора
|
||||
### 10. Дублирование SVG-генератора ✅
|
||||
|
||||
**Состояние:** `route/radar_svg_gen.py` (сервер) и `web/radar_svg.py` (клиент) — два разных генератора. Кнопка «Сохранить» скачивает серверный файл. `web/radar_svg.py` не используется кнопками, но файл остался в репозитории.
|
||||
**Было:** `route/radar_svg_gen.py` (сервер) и `web/radar_svg.py` (клиент, Flet) — два разных генератора, второй нигде не вызывался.
|
||||
|
||||
**Решение:** старый Flet-фронтенд `web/` целиком удалён вместе с `Dockerfile.web` (заменён на `web-react` в коммите `eac6192`). Единственный генератор SVG теперь — `route/radar_svg_gen.py`.
|
||||
|
||||
---
|
||||
|
||||
### 11. Реальные секреты в `docker-compose.yml` ✅
|
||||
|
||||
**Проблема:** пароли БД/Redis, `SECRET_KEY`, `ADMIN_KEY` были прописаны в открытом виде прямо в `docker-compose.yml` (тогда ещё вместе с отдельным `docker-compose_db.yml`), закоммичены в git.
|
||||
|
||||
**Решение:** значения вынесены в `.env` (в `.gitignore`, не коммитится), compose-файлы читают их через `${VAR}`. Шаблон без секретов — `.env.example`. **Важно:** старые значения уже есть в истории git (коммит `71887b0`) — сама по себе эта правка их не отзывает, реальные пароли на серверах БД/Redis нужно ротировать отдельно.
|
||||
|
||||
---
|
||||
|
||||
### 12. Зависимости API расходились между `pyproject.toml` и `requirements.txt` ✅
|
||||
|
||||
**Проблема:** `Dockerfile.api` ставил зависимости из `requirements.txt`, а не из `pyproject.toml`/`uv.lock` — версии успели разойтись (`redis==5.0.0` vs `>=7.1.0`, `sqlalchemy==2.0.32` vs `>=2.0.47`).
|
||||
|
||||
**Решение:** `Dockerfile.api` теперь ставит зависимости через `uv sync --frozen` напрямую из `pyproject.toml`/`uv.lock`. `requirements.txt` удалён — единственный источник правды. При переходе обнаружилась ещё одна расхождение: `python-multipart` (нужен FastAPI для `UploadFile`/`Form`, используется в `organizations_crud.py` при импорте .docx) был в `requirements.txt`, но отсутствовал в `pyproject.toml` — без него API падал при старте с `RuntimeError`. Добавлен в зависимости.
|
||||
|
||||
---
|
||||
|
||||
### 13. Деплой требовал двух отдельных compose-файлов ✅
|
||||
|
||||
**Было:** `docker-compose.yml` (api + web-react + stats) и `docker-compose_db.yml` (postgres + redis) — два отдельных файла и две команды на разворачивание.
|
||||
|
||||
**Решение:** `postgres`/`redis` перенесены в тот же `docker-compose.yml`, за профилем `local-db` — не поднимаются командой `docker compose up -d` по умолчанию (приложение по умолчанию ходит на внешние `DB_HOST`/`REDIS_HOST` из `.env`), но доступны через `docker compose --profile local-db up -d`, когда нужна локальная БД. `docker-compose_db.yml` удалён.
|
||||
|
||||
---
|
||||
|
||||
### 14. Дашборд статистики был на Flet — переписан на React ✅
|
||||
|
||||
**Проблема:** `stats/` (Flet, `Dockerfile.stats`) генерировал графики через matplotlib в PNG на сервере и отдавал их клиенту через Flutter web c долгоживущим WebSocket-соединением — та же архитектура, что подвела основной фронтенд (см. записи №1–4 выше). Плавающая версия `flet>=0.82.2` в какой-то момент подтянула мажорный `0.86.0` с несовместимым протоколом фреймирования, и дашборд перестал открываться в браузере.
|
||||
|
||||
**Решение:** `stats/` и `Dockerfile.stats` удалены, дашборд переписан на React SPA (`stats-react/`, тот же паттерн, что и `web-react/`) — same-origin прокси `/api/*` вместо WebSocket, графики — свой SVG/HTML без сторонних библиотек (bar/line/part-to-whole с hover-тултипами), функциональность 1:1 с оригиналом (2 таба, 4+4 графика, топ-10 таблица, все фильтры). Категориальная палитра графиков провалидирована на CVD-безопасность (`node scripts/validate_palette.js`) — у исходной 8-цветной палитры из `designer.py` эта проверка проваливалась по нескольким пунктам.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# Database configuration package
|
||||
from pydantic import BaseModel
|
||||
import re
|
||||
import os
|
||||
import urllib.parse
|
||||
|
||||
@@ -11,23 +10,6 @@ class Settings(BaseModel):
|
||||
DB_PASS: str = os.getenv("DB_PASS", "")
|
||||
DB_NAME: str = os.getenv("DB_NAME", "profi")
|
||||
|
||||
@classmethod
|
||||
def validate_db_port(cls, v):
|
||||
# Проверка и преобразование DB_PORT
|
||||
if isinstance(v, str):
|
||||
# Используем регулярное выражение для извлечения целого числа
|
||||
match = re.search(r'\d+', v)
|
||||
if match:
|
||||
return int(match.group())
|
||||
else:
|
||||
raise ValueError(f"Invalid DB_PORT value: {v}")
|
||||
return v
|
||||
|
||||
@property
|
||||
def DATABASE_URL_asyncpg(self) -> str:
|
||||
pwd = urllib.parse.quote_plus(str(self.DB_PASS))
|
||||
return f"postgresql+asyncpg://{self.DB_USER}:{pwd}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
|
||||
|
||||
@property
|
||||
def DATABASE_URL_syncpg(self) -> str:
|
||||
pwd = urllib.parse.quote_plus(str(self.DB_PASS))
|
||||
|
||||
@@ -7,47 +7,84 @@ services:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- APP_PATH=/app
|
||||
- DEBUG=True
|
||||
- DEBUG=${DEBUG}
|
||||
|
||||
- DB_HOST=192.168.1.11
|
||||
- DB_PORT=5432
|
||||
- DB_USER=admin1
|
||||
- DB_PASS=Y6%r35RpckeP^F
|
||||
- DB_NAME=profi
|
||||
- DB_HOST=${DB_HOST}
|
||||
- DB_PORT=${DB_PORT}
|
||||
- DB_USER=${DB_USER}
|
||||
- DB_PASS=${DB_PASS}
|
||||
- DB_NAME=${DB_NAME}
|
||||
|
||||
# Redis connection (external)
|
||||
- REDIS_HOST=192.168.1.19
|
||||
- REDIS_PORT=6379
|
||||
- REDIS_DB=0
|
||||
- REDIS_PASSWORD=CNXpuhMdxXHo7ZK8bhtXDvgXVZcjRn
|
||||
# JWT — замените на длинную случайную строку (python -c "import secrets; print(secrets.token_hex(32))")
|
||||
- SECRET_KEY=e8fe23297b4664a78ba8e8ebe34bac158398ac4d7dc6b664fd8d52b0094ee67b
|
||||
# Ключ для admin-операций
|
||||
- ADMIN_KEY=b00ac081344a99bf50dffeb942e7f4f1
|
||||
# Логин и пароль для доступа к /docs и /redoc
|
||||
- DOCS_USERNAME=admin
|
||||
- DOCS_PASSWORD=2cbae90b3d9f6710cf51354f
|
||||
- REDIS_HOST=${REDIS_HOST}
|
||||
- REDIS_PORT=${REDIS_PORT}
|
||||
- REDIS_DB=${REDIS_DB}
|
||||
- REDIS_PASSWORD=${REDIS_PASSWORD}
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- ADMIN_KEY=${ADMIN_KEY}
|
||||
- DOCS_USERNAME=${DOCS_USERNAME}
|
||||
- DOCS_PASSWORD=${DOCS_PASSWORD}
|
||||
volumes:
|
||||
- ./data/radar:/app/data/radar
|
||||
restart: unless-stopped
|
||||
|
||||
web:
|
||||
web-react:
|
||||
build:
|
||||
context: ./web
|
||||
dockerfile: ../Dockerfile.web
|
||||
context: ./web-react
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "80:80"
|
||||
environment:
|
||||
- API_URL=http://api:8000
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
stats:
|
||||
restart: unless-stopped
|
||||
stats-react:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.stats
|
||||
context: ./stats-react
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8081:8081"
|
||||
environment:
|
||||
- API_URL=http://api:8000
|
||||
- "8081:80"
|
||||
depends_on:
|
||||
- api
|
||||
restart: unless-stopped
|
||||
|
||||
# ── Локальные БД (опционально) ─────────────────────────────────────────
|
||||
# По умолчанию api ходит на внешние DB_HOST/REDIS_HOST из .env. Эти два
|
||||
# сервиса поднимаются только явным флагом профиля:
|
||||
# docker compose --profile local-db up -d
|
||||
postgres:
|
||||
image: postgres:latest
|
||||
container_name: postgres
|
||||
profiles: ["local-db"]
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||
volumes:
|
||||
- ./data/postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
|
||||
redis:
|
||||
image: redis:alpine
|
||||
container_name: redis
|
||||
profiles: ["local-db"]
|
||||
command: >
|
||||
redis-server
|
||||
--requirepass ${REDIS_PASSWORD}
|
||||
--appendonly yes
|
||||
--appendfsync everysec
|
||||
--maxmemory 256mb
|
||||
--maxmemory-policy volatile-lru
|
||||
--tcp-keepalive 60
|
||||
--timeout 300
|
||||
--loglevel notice
|
||||
ports:
|
||||
- 6379:6379
|
||||
volumes:
|
||||
- ./data/redis:/data
|
||||
restart: always
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
services:
|
||||
|
||||
redis:
|
||||
image: redis:alpine
|
||||
container_name: redis
|
||||
command: >
|
||||
redis-server
|
||||
--requirepass CNXpuhMdxXHo7ZK8bhtXDvgXVZcjRn
|
||||
--appendonly yes
|
||||
--appendfsync everysec
|
||||
--maxmemory 256mb
|
||||
--maxmemory-policy volatile-lru
|
||||
--tcp-keepalive 60
|
||||
--timeout 300
|
||||
--loglevel notice
|
||||
ports:
|
||||
- 6379:6379
|
||||
volumes:
|
||||
- ./data/redis:/data
|
||||
restart: always
|
||||
|
||||
postgres:
|
||||
image: postgres:latest
|
||||
container_name: postgres
|
||||
#restart: always
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||
volumes:
|
||||
- ./data/postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
@@ -1,10 +0,0 @@
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: ./web
|
||||
dockerfile: ./Dockerfile.web
|
||||
ports:
|
||||
- "80:80"
|
||||
environment:
|
||||
- API_URL=http://api:8000
|
||||
|
||||
@@ -21,9 +21,12 @@ dependencies = [
|
||||
"pg8000>=1.29.0",
|
||||
"flet>=0.82.2",
|
||||
"jose>=1.0.0",
|
||||
"passlib>=1.7.4",
|
||||
"passlib[bcrypt]>=1.7.4",
|
||||
"bcrypt==4.0.1",
|
||||
"python-jose>=3.5.0",
|
||||
"matplotlib>=3.10.8",
|
||||
"python-docx>=1.2.0",
|
||||
"python-multipart>=0.0.20",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
python-docx==1.2.0
|
||||
annotated-doc==0.0.4
|
||||
annotated-types==0.7.0
|
||||
anyio==4.12.1
|
||||
click==8.3.1
|
||||
fastapi==0.128.0
|
||||
h11==0.16.0
|
||||
idna==3.11
|
||||
pydantic==2.12.5
|
||||
pydantic_core==2.41.5
|
||||
python-dotenv==1.2.1
|
||||
python-multipart==0.0.20
|
||||
redis==5.0.0
|
||||
starlette==0.50.0
|
||||
typing-inspection==0.4.2
|
||||
typing_extensions==4.15.0
|
||||
uvicorn==0.40.0
|
||||
sqlalchemy==2.0.32
|
||||
pg8000==1.29.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.0.1
|
||||
@@ -43,7 +43,7 @@ def collect_metadatas():
|
||||
|
||||
|
||||
@router.post("/db/create-tables", dependencies=[Depends(require_admin_key)])
|
||||
async def create_tables():
|
||||
def create_tables():
|
||||
"""Создаёт все таблицы, описанные в модулях `db.tables`.
|
||||
|
||||
Endpoint вызывается по нажатию кнопки в UI (POST).
|
||||
@@ -71,7 +71,7 @@ async def create_tables():
|
||||
|
||||
|
||||
@router.post("/db/migrate-cascade-user", dependencies=[Depends(require_admin_key)])
|
||||
async def migrate_cascade_user():
|
||||
def migrate_cascade_user():
|
||||
"""Добавляет FK responses.user_id → users.id ON DELETE CASCADE, если ещё не существует."""
|
||||
engine = get_engine()
|
||||
try:
|
||||
@@ -95,7 +95,7 @@ async def migrate_cascade_user():
|
||||
|
||||
|
||||
@router.post("/db/migrate-users", dependencies=[Depends(require_admin_key)])
|
||||
async def migrate_users():
|
||||
def migrate_users():
|
||||
"""Добавляет колонки username и hashed_password в таблицу users, если они ещё не существуют."""
|
||||
engine = get_engine()
|
||||
try:
|
||||
@@ -116,7 +116,7 @@ class ClearDBIn(BaseModel):
|
||||
|
||||
|
||||
@router.post("/db/migrate", dependencies=[Depends(require_admin_key)])
|
||||
async def migrate_tables():
|
||||
def migrate_tables():
|
||||
"""Приводит схему БД в соответствие с моделями: убирает устаревшие колонки, добавляет новые."""
|
||||
engine = get_engine()
|
||||
migrations = [
|
||||
@@ -142,7 +142,7 @@ async def migrate_tables():
|
||||
|
||||
|
||||
@router.post("/db/clear", dependencies=[Depends(require_admin_key)])
|
||||
async def clear_tables(payload: ClearDBIn):
|
||||
def clear_tables(payload: ClearDBIn):
|
||||
"""Полная очистка всех таблиц, описанных в `bd.tables`.
|
||||
|
||||
Требуется явное подтверждение: POST с телом {"confirm": true}.
|
||||
|
||||
@@ -309,7 +309,11 @@ def backup_restore(file: UploadFile = File(...)):
|
||||
# 3. SVG-диаграммы: полностью очищаем каталог и восстанавливаем
|
||||
svg_names = [n for n in zf.namelist() if n.startswith("radar_svgs/") and n != "radar_svgs/"]
|
||||
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)
|
||||
|
||||
svg_written = 0
|
||||
|
||||
6
stats-react/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
*.local
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
5
stats-react/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
*.local
|
||||
.vite
|
||||
*.tsbuildinfo
|
||||
14
stats-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;"]
|
||||
13
stats-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>
|
||||
41
stats-react/nginx.conf
Normal file
@@ -0,0 +1,41 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Статика SPA: всё неизвестное отдаём index.html (клиентский роутинг).
|
||||
# no-cache на index.html — см. web-react/nginx.conf, тот же приём: без
|
||||
# него браузер кэширует HTML эвристически и после деплоя показывает
|
||||
# старую версию до hard refresh. Хэшированные /assets/ ниже кэшируются
|
||||
# честно на год, инвалидация им не нужна — имя меняется при сборке.
|
||||
location / {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Прокси на внутренний API-контейнер (браузер к api:8000 напрямую не ходит).
|
||||
# Префикс /api срезается: /api/stats/summary -> http://api:8000/stats/summary
|
||||
#
|
||||
# Используем встроенный 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";
|
||||
}
|
||||
}
|
||||
1732
stats-react/package-lock.json
generated
Normal file
22
stats-react/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "copp-stats-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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 851 B After Width: | Height: | Size: 851 B |
342
stats-react/src/App.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import * as api from "./api/client";
|
||||
import type { AvgDatum, ChartDatum, Filters, TopUserRow } from "./api/client";
|
||||
import { SearchableDropdown, type Option } from "./components/SearchableDropdown";
|
||||
import { ChartCard } from "./components/ChartCard";
|
||||
import { SummaryTile } from "./components/SummaryTile";
|
||||
import { TopUsersTable } from "./components/TopUsersTable";
|
||||
import { BarChart } from "./components/charts/BarChart";
|
||||
import { LineChart } from "./components/charts/LineChart";
|
||||
import { PartToWholeBar } from "./components/charts/PartToWholeBar";
|
||||
import { useIsDark } from "./theme/useIsDark";
|
||||
import { categoricalColor } from "./theme/palette";
|
||||
import {
|
||||
IconAssignment,
|
||||
IconBarChart,
|
||||
IconBusiness,
|
||||
IconFilterOff,
|
||||
IconInsights,
|
||||
IconPlayCircle,
|
||||
IconQuiz,
|
||||
IconRefresh,
|
||||
} from "./components/icons";
|
||||
|
||||
function toValueData(data: ChartDatum[]): { label: string; value: number }[] {
|
||||
return data.map((d) => ({ label: d.label, value: d.count }));
|
||||
}
|
||||
|
||||
const COMPARE_OPTIONS: Option[] = [
|
||||
{ key: "organization", text: "Организациям" },
|
||||
{ key: "group", text: "Группам" },
|
||||
];
|
||||
|
||||
export default function App() {
|
||||
const dark = useIsDark();
|
||||
|
||||
// ── справочники для фильтров ──────────────────────────────────────────
|
||||
const [pollOptions, setPollOptions] = useState<Option[]>([]);
|
||||
const [orgOptions, setOrgOptions] = useState<Option[]>([]);
|
||||
const [groupOptions, setGroupOptions] = useState<Option[]>([]);
|
||||
const [yearOptions, setYearOptions] = useState<Option[]>([]);
|
||||
const [dimensionOptions, setDimensionOptions] = useState<Option[]>([]);
|
||||
|
||||
// ── общие фильтры ────────────────────────────────────────────────────
|
||||
const [pollId, setPollId] = useState<string | null>(null);
|
||||
const [orgId, setOrgId] = useState<string | null>(null);
|
||||
const [groupId, setGroupId] = useState<string | null>(null);
|
||||
const [year, setYear] = useState<string | null>(null);
|
||||
|
||||
// ── фильтры таба 2 ───────────────────────────────────────────────────
|
||||
const [dimension, setDimension] = useState<string | null>(null);
|
||||
const [compareBy, setCompareBy] = useState<string>("organization");
|
||||
|
||||
const [tab, setTab] = useState<0 | 1>(0);
|
||||
|
||||
// ── summary ──────────────────────────────────────────────────────────
|
||||
const [summary, setSummary] = useState<api.Summary>({
|
||||
total_responses: 0,
|
||||
total_polls: 0,
|
||||
active_polls: 0,
|
||||
orgs_active: 0,
|
||||
});
|
||||
|
||||
// ── tab1 данные ──────────────────────────────────────────────────────
|
||||
const [tab1Loading, setTab1Loading] = useState(true);
|
||||
const [pollData, setPollData] = useState<ChartDatum[]>([]);
|
||||
const [orgData, setOrgData] = useState<ChartDatum[]>([]);
|
||||
const [groupData, setGroupData] = useState<ChartDatum[]>([]);
|
||||
const [monthData, setMonthData] = useState<ChartDatum[]>([]);
|
||||
|
||||
// ── tab2 данные ──────────────────────────────────────────────────────
|
||||
const [tab2Loading, setTab2Loading] = useState(true);
|
||||
const [avgDimData, setAvgDimData] = useState<AvgDatum[]>([]);
|
||||
const [leadingData, setLeadingData] = useState<ChartDatum[]>([]);
|
||||
const [compareData, setCompareData] = useState<AvgDatum[]>([]);
|
||||
const [trendData, setTrendData] = useState<AvgDatum[]>([]);
|
||||
const [topUsers, setTopUsers] = useState<TopUserRow[]>([]);
|
||||
|
||||
const filters = (): Filters => ({
|
||||
poll_id: pollId,
|
||||
org_id: orgId,
|
||||
group_id: groupId,
|
||||
year: year ? parseInt(year, 10) : null,
|
||||
});
|
||||
|
||||
async function refreshTab1() {
|
||||
setTab1Loading(true);
|
||||
const f = filters();
|
||||
const [p, o, g, m] = await Promise.all([
|
||||
api.getResponsesGrouped("poll", f),
|
||||
api.getResponsesGrouped("organization", f),
|
||||
api.getResponsesGrouped("group", f),
|
||||
api.getResponsesGrouped("month", f),
|
||||
]);
|
||||
setPollData(p);
|
||||
setOrgData(o);
|
||||
setGroupData(g);
|
||||
setMonthData(m);
|
||||
setTab1Loading(false);
|
||||
}
|
||||
|
||||
async function refreshTab2() {
|
||||
setTab2Loading(true);
|
||||
const f = filters();
|
||||
const dim = dimension;
|
||||
const [avg, lead] = await Promise.all([api.getAvgByDimension(f), api.getLeadingType(f)]);
|
||||
let top: TopUserRow[] = [];
|
||||
let compare: AvgDatum[] = [];
|
||||
let trend: AvgDatum[] = [];
|
||||
if (dim) {
|
||||
[top, compare, trend] = await Promise.all([
|
||||
api.getTopUsers(dim, f),
|
||||
api.getCompareGroups(dim, compareBy as api.CompareBy, f.poll_id, f.year),
|
||||
api.getDimensionTrend(dim, f),
|
||||
]);
|
||||
}
|
||||
setAvgDimData(avg);
|
||||
setLeadingData(lead);
|
||||
setTopUsers(top);
|
||||
setCompareData(compare);
|
||||
setTrendData(trend);
|
||||
setTab2Loading(false);
|
||||
}
|
||||
|
||||
function refreshCurrentTab() {
|
||||
if (tab === 0) void refreshTab1();
|
||||
else void refreshTab2();
|
||||
}
|
||||
|
||||
// ── первичная загрузка ───────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const [polls, orgs, groups, years, summaryData, dims] = await Promise.all([
|
||||
api.getPolls(),
|
||||
api.getOrganizations(),
|
||||
api.getGroups(),
|
||||
api.getYears(),
|
||||
api.getSummary(),
|
||||
api.getDimensions(),
|
||||
]);
|
||||
setPollOptions(polls.map((p) => ({ key: p.id, text: p.title })));
|
||||
setOrgOptions(orgs.map((o) => ({ key: o.id, text: o.name_organization })));
|
||||
setGroupOptions(groups.map((g) => ({ key: g.id, text: g.name_group })));
|
||||
setYearOptions(years.map((y) => ({ key: String(y), text: String(y) })));
|
||||
setDimensionOptions(dims.map((d) => ({ key: d, text: d })));
|
||||
setSummary(summaryData);
|
||||
if (dims.length > 0) setDimension(dims[0]);
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Первый прогон обоих табов, как только известна выбранная ось (dimension).
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
useEffect(() => {
|
||||
if (initialized || dimensionOptions.length === 0) return;
|
||||
setInitialized(true);
|
||||
void refreshTab1();
|
||||
void refreshTab2();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dimensionOptions]);
|
||||
|
||||
const onGeneralFilterChange = () => {
|
||||
if (!initialized) return;
|
||||
refreshCurrentTab();
|
||||
};
|
||||
|
||||
const onResultsFilterChange = () => {
|
||||
if (!initialized) return;
|
||||
void refreshTab2();
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setPollId(null);
|
||||
setOrgId(null);
|
||||
setGroupId(null);
|
||||
setYear(null);
|
||||
setTimeout(refreshCurrentTab, 0);
|
||||
};
|
||||
|
||||
const dimColor = (i: number) => categoricalColor(i, dark);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="app-header">
|
||||
<span className="brand">ЦОПП</span>
|
||||
<span className="divider" />
|
||||
<span className="subtitle">Статистика прохождений</span>
|
||||
</header>
|
||||
|
||||
<div className="container">
|
||||
<div className="summary-row">
|
||||
<SummaryTile title="Всего ответов" value={summary.total_responses} icon={<IconAssignment />} color="var(--primary)" />
|
||||
<SummaryTile title="Тестов всего" value={summary.total_polls} icon={<IconQuiz />} color="var(--info)" />
|
||||
<SummaryTile title="Активных тестов" value={summary.active_polls} icon={<IconPlayCircle />} color="var(--success)" />
|
||||
<SummaryTile title="Организаций с ответами" value={summary.orgs_active} icon={<IconBusiness />} color="var(--accent)" />
|
||||
</div>
|
||||
|
||||
<div className="filters-card">
|
||||
<span className="filters-label">Фильтры</span>
|
||||
<div className="filters-row">
|
||||
<SearchableDropdown
|
||||
label="Тест"
|
||||
hint="Все тесты"
|
||||
options={pollOptions}
|
||||
value={pollId}
|
||||
onChange={(v) => {
|
||||
setPollId(v);
|
||||
setTimeout(onGeneralFilterChange, 0);
|
||||
}}
|
||||
/>
|
||||
<SearchableDropdown
|
||||
label="Организация"
|
||||
hint="Все организации"
|
||||
options={orgOptions}
|
||||
value={orgId}
|
||||
onChange={(v) => {
|
||||
setOrgId(v);
|
||||
setTimeout(onGeneralFilterChange, 0);
|
||||
}}
|
||||
/>
|
||||
<SearchableDropdown
|
||||
label="Группа"
|
||||
hint="Все группы"
|
||||
options={groupOptions}
|
||||
value={groupId}
|
||||
onChange={(v) => {
|
||||
setGroupId(v);
|
||||
setTimeout(onGeneralFilterChange, 0);
|
||||
}}
|
||||
/>
|
||||
<SearchableDropdown
|
||||
label="Год"
|
||||
hint="Все годы"
|
||||
options={yearOptions}
|
||||
value={year}
|
||||
onChange={(v) => {
|
||||
setYear(v);
|
||||
setTimeout(onGeneralFilterChange, 0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="filters-actions">
|
||||
<button className="btn btn-ghost" onClick={resetFilters}>
|
||||
<IconFilterOff /> Сбросить фильтры
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={refreshCurrentTab}>
|
||||
<IconRefresh /> Обновить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tabs-bar">
|
||||
<button className={"tab-btn" + (tab === 0 ? " active" : "")} onClick={() => setTab(0)}>
|
||||
<IconBarChart /> Прохождения
|
||||
</button>
|
||||
<button className={"tab-btn" + (tab === 1 ? " active" : "")} onClick={() => setTab(1)}>
|
||||
<IconInsights /> Результаты тестов
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 0 ? (
|
||||
<div className="chart-grid">
|
||||
<ChartCard title="По тестам" loading={tab1Loading}>
|
||||
<BarChart data={toValueData(pollData)} colorFor={() => "var(--primary)"} />
|
||||
</ChartCard>
|
||||
<ChartCard title="По организациям" loading={tab1Loading}>
|
||||
<BarChart data={toValueData(orgData)} colorFor={() => "var(--info)"} />
|
||||
</ChartCard>
|
||||
<ChartCard title="По группам" loading={tab1Loading}>
|
||||
<BarChart data={toValueData(groupData)} colorFor={() => "var(--success)"} />
|
||||
</ChartCard>
|
||||
<ChartCard title="Динамика по месяцам" loading={tab1Loading}>
|
||||
<LineChart data={toValueData(monthData)} formatValue={(v) => String(v)} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
) : (
|
||||
<div className="chart-grid">
|
||||
<div className="filters-card">
|
||||
<span className="filters-label">Фильтр по оси/типу</span>
|
||||
<div className="filters-row">
|
||||
<SearchableDropdown
|
||||
label="Ось/Тип"
|
||||
hint="Выберите ось"
|
||||
options={dimensionOptions}
|
||||
value={dimension}
|
||||
onChange={(v) => {
|
||||
setDimension(v);
|
||||
setTimeout(onResultsFilterChange, 0);
|
||||
}}
|
||||
/>
|
||||
<SearchableDropdown
|
||||
label="Сравнение по"
|
||||
hint="Организациям"
|
||||
options={COMPARE_OPTIONS}
|
||||
value={compareBy}
|
||||
onChange={(v) => {
|
||||
setCompareBy(v ?? "organization");
|
||||
setTimeout(onResultsFilterChange, 0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChartCard title="Средний балл по осям" loading={tab2Loading}>
|
||||
<BarChart
|
||||
data={avgDimData.map((d) => ({ label: d.label, value: d.avg }))}
|
||||
colorFor={dimColor}
|
||||
formatValue={(v) => v.toFixed(1)}
|
||||
/>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="Распределение ведущего типа" loading={tab2Loading}>
|
||||
<PartToWholeBar data={leadingData.map((d) => ({ label: d.label, count: d.count }))} dark={dark} />
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title={`Сравнение по ${compareBy === "organization" ? "организациям" : "группам"}`} loading={tab2Loading}>
|
||||
<BarChart
|
||||
data={compareData.map((d) => ({ label: d.label, value: d.avg }))}
|
||||
colorFor={dimColor}
|
||||
formatValue={(v) => v.toFixed(1)}
|
||||
/>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="Динамика среднего балла" loading={tab2Loading}>
|
||||
<LineChart data={trendData.map((d) => ({ label: d.label, value: d.avg }))} formatValue={(v) => v.toFixed(1)} />
|
||||
</ChartCard>
|
||||
|
||||
<div className="chart-card">
|
||||
<div className="chart-card-title">Топ-10 участников по выбранной оси</div>
|
||||
{tab2Loading ? (
|
||||
<div className="chart-loading">
|
||||
<div className="spinner" style={{ margin: "0 auto" }} />
|
||||
</div>
|
||||
) : (
|
||||
<TopUsersTable rows={topUsers} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
stats-react/src/api/client.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
// Единое место для всех HTTP-запросов к бэкенду.
|
||||
// Порт stats/api_client.py. Базовый путь /api проксируется nginx (прод) или Vite (dev) на api:8000.
|
||||
// Все эндпоинты публичные — дашборд не требует авторизации (как и раньше).
|
||||
|
||||
const BASE = "/api";
|
||||
|
||||
async function getJson<T>(path: string): Promise<T | null> {
|
||||
try {
|
||||
const resp = await fetch(BASE + path);
|
||||
if (resp.status !== 200) return null;
|
||||
return (await resp.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function qs(params: Record<string, string | number | null | undefined>): string {
|
||||
const usp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== null && v !== undefined && v !== "") usp.set(k, String(v));
|
||||
}
|
||||
const s = usp.toString();
|
||||
return s ? `?${s}` : "";
|
||||
}
|
||||
|
||||
export interface Summary {
|
||||
total_responses: number;
|
||||
total_polls: number;
|
||||
active_polls: number;
|
||||
orgs_active: number;
|
||||
}
|
||||
|
||||
export interface Poll {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface Organization {
|
||||
id: string;
|
||||
name_organization: string;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: string;
|
||||
name_group: string;
|
||||
}
|
||||
|
||||
export interface ChartDatum {
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface AvgDatum {
|
||||
label: string;
|
||||
avg: number;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface TopUserRow {
|
||||
name: string;
|
||||
poll: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export type GroupBy = "poll" | "organization" | "group" | "month";
|
||||
export type CompareBy = "organization" | "group";
|
||||
|
||||
export interface Filters {
|
||||
poll_id?: string | null;
|
||||
org_id?: string | null;
|
||||
group_id?: string | null;
|
||||
year?: number | null;
|
||||
}
|
||||
|
||||
export async function getSummary(): Promise<Summary> {
|
||||
return (
|
||||
(await getJson<Summary>("/stats/summary")) ?? {
|
||||
total_responses: 0,
|
||||
total_polls: 0,
|
||||
active_polls: 0,
|
||||
orgs_active: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function getYears(): Promise<number[]> {
|
||||
return (await getJson<number[]>("/stats/years")) ?? [];
|
||||
}
|
||||
|
||||
export async function getPolls(): Promise<Poll[]> {
|
||||
return (await getJson<Poll[]>("/polls/")) ?? [];
|
||||
}
|
||||
|
||||
export async function getOrganizations(): Promise<Organization[]> {
|
||||
return (await getJson<Organization[]>("/organizations/")) ?? [];
|
||||
}
|
||||
|
||||
export async function getGroups(): Promise<Group[]> {
|
||||
return (await getJson<Group[]>("/groups/")) ?? [];
|
||||
}
|
||||
|
||||
export async function getResponsesGrouped(groupBy: GroupBy, f: Filters): Promise<ChartDatum[]> {
|
||||
const query = qs({ group_by: groupBy, poll_id: f.poll_id, org_id: f.org_id, group_id: f.group_id, year: f.year });
|
||||
return (await getJson<ChartDatum[]>(`/stats/responses${query}`)) ?? [];
|
||||
}
|
||||
|
||||
export async function getDimensions(pollId?: string | null): Promise<string[]> {
|
||||
const query = qs({ poll_id: pollId });
|
||||
return (await getJson<string[]>(`/stats/results/dimensions${query}`)) ?? [];
|
||||
}
|
||||
|
||||
export async function getAvgByDimension(f: Filters): Promise<AvgDatum[]> {
|
||||
const query = qs({ poll_id: f.poll_id, org_id: f.org_id, group_id: f.group_id, year: f.year });
|
||||
return (await getJson<AvgDatum[]>(`/stats/results/avg-by-dimension${query}`)) ?? [];
|
||||
}
|
||||
|
||||
export async function getLeadingType(f: Filters): Promise<ChartDatum[]> {
|
||||
const query = qs({ poll_id: f.poll_id, org_id: f.org_id, group_id: f.group_id, year: f.year });
|
||||
return (await getJson<ChartDatum[]>(`/stats/results/leading-type${query}`)) ?? [];
|
||||
}
|
||||
|
||||
export async function getCompareGroups(
|
||||
dimensionName: string,
|
||||
by: CompareBy,
|
||||
pollId?: string | null,
|
||||
year?: number | null,
|
||||
): Promise<AvgDatum[]> {
|
||||
const query = qs({ dimension_name: dimensionName, by, poll_id: pollId, year });
|
||||
return (await getJson<AvgDatum[]>(`/stats/results/compare-groups${query}`)) ?? [];
|
||||
}
|
||||
|
||||
export async function getTopUsers(
|
||||
dimensionName: string,
|
||||
f: Filters,
|
||||
limit = 10,
|
||||
): Promise<TopUserRow[]> {
|
||||
const query = qs({
|
||||
dimension_name: dimensionName,
|
||||
poll_id: f.poll_id,
|
||||
org_id: f.org_id,
|
||||
group_id: f.group_id,
|
||||
year: f.year,
|
||||
limit,
|
||||
});
|
||||
return (await getJson<TopUserRow[]>(`/stats/results/top-users${query}`)) ?? [];
|
||||
}
|
||||
|
||||
export async function getDimensionTrend(dimensionName: string, f: Filters): Promise<AvgDatum[]> {
|
||||
const query = qs({
|
||||
dimension_name: dimensionName,
|
||||
poll_id: f.poll_id,
|
||||
org_id: f.org_id,
|
||||
group_id: f.group_id,
|
||||
});
|
||||
return (await getJson<AvgDatum[]>(`/stats/results/dimension-trend${query}`)) ?? [];
|
||||
}
|
||||
24
stats-react/src/components/ChartCard.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function ChartCard({
|
||||
title,
|
||||
loading,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
loading?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="chart-card">
|
||||
<div className="chart-card-title">{title}</div>
|
||||
{loading ? (
|
||||
<div className="chart-loading">
|
||||
<div className="spinner" style={{ margin: "0 auto" }} />
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
stats-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;
|
||||
}
|
||||
|
||||
// Выпадающий список с поиском — порт web-react/src/components/SearchableDropdown.tsx
|
||||
// (изначально web/designer.py SearchableDropdown из Flet-версии).
|
||||
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" 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>
|
||||
);
|
||||
}
|
||||
23
stats-react/src/components/SummaryTile.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function SummaryTile({
|
||||
title,
|
||||
value,
|
||||
icon,
|
||||
color,
|
||||
}: {
|
||||
title: string;
|
||||
value: number | string;
|
||||
icon: ReactNode;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="summary-tile">
|
||||
<div className="tile-head">
|
||||
<span style={{ color, fontSize: "1.1rem", display: "flex" }}>{icon}</span>
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
<div className="tile-value">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
stats-react/src/components/TopUsersTable.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { TopUserRow } from "../api/client";
|
||||
|
||||
export function TopUsersTable({ rows }: { rows: TopUserRow[] }) {
|
||||
if (rows.length === 0) {
|
||||
return <div className="chart-empty">Нет данных</div>;
|
||||
}
|
||||
return (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table className="stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Участник</th>
|
||||
<th>Тест</th>
|
||||
<th style={{ textAlign: "right" }}>Балл</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td>{r.name}</td>
|
||||
<td className="poll-cell" title={r.poll}>
|
||||
{r.poll}
|
||||
</td>
|
||||
<td className="value-cell" style={{ textAlign: "right" }}>
|
||||
{r.value}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
stats-react/src/components/charts/BarChart.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useState } from "react";
|
||||
import { ChartTooltip, type TooltipState } from "./ChartTooltip";
|
||||
|
||||
export interface BarDatum {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
// Горизонтальный bar chart: ≤24px толщина бара, 4px скруглённый конец,
|
||||
// подпись значения у края, hover-тултип на каждом баре (сам бар — hit target).
|
||||
export function BarChart({
|
||||
data,
|
||||
colorFor,
|
||||
formatValue = (v) => String(v),
|
||||
xLabel,
|
||||
}: {
|
||||
data: BarDatum[];
|
||||
colorFor: (index: number) => string;
|
||||
formatValue?: (v: number) => string;
|
||||
xLabel?: string;
|
||||
}) {
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||
|
||||
if (data.length === 0) {
|
||||
return <div className="chart-empty">Нет данных</div>;
|
||||
}
|
||||
|
||||
const max = Math.max(...data.map((d) => d.value), 1);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{data.map((d, i) => {
|
||||
const pct = Math.max((d.value / max) * 100, 2);
|
||||
const color = colorFor(i);
|
||||
return (
|
||||
<div
|
||||
key={d.label + i}
|
||||
style={{ display: "flex", alignItems: "center", gap: 10 }}
|
||||
onMouseMove={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const parent = e.currentTarget.parentElement!.parentElement!.getBoundingClientRect();
|
||||
setTooltip({
|
||||
x: rect.left - parent.left + rect.width * 0.3,
|
||||
y: rect.top - parent.top,
|
||||
label: d.label,
|
||||
value: formatValue(d.value),
|
||||
swatch: color,
|
||||
});
|
||||
}}
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
onFocus={() =>
|
||||
setTooltip({ x: 0, y: 0, label: d.label, value: formatValue(d.value), swatch: color })
|
||||
}
|
||||
onBlur={() => setTooltip(null)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<span
|
||||
title={d.label}
|
||||
style={{
|
||||
width: 170,
|
||||
flexShrink: 0,
|
||||
fontSize: "0.78rem",
|
||||
color: "var(--text-secondary)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{d.label}
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
background: "var(--surface-alt)",
|
||||
borderRadius: 4,
|
||||
height: 20,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
height: "100%",
|
||||
background: color,
|
||||
borderRadius: 4,
|
||||
transition: "width 0.3s ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
minWidth: 44,
|
||||
textAlign: "right",
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary)",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{formatValue(d.value)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{xLabel && (
|
||||
<div style={{ fontSize: "0.72rem", color: "var(--text-secondary)", marginTop: 8, marginLeft: 180 }}>
|
||||
{xLabel}
|
||||
</div>
|
||||
)}
|
||||
<ChartTooltip tooltip={tooltip} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
stats-react/src/components/charts/ChartTooltip.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
// Плавающий тултип, привязанный к позиции курсора внутри контейнера графика.
|
||||
// Общий для всех типов графиков — см. dataviz: "значение — сильный элемент, подпись — вторичная".
|
||||
export interface TooltipState {
|
||||
x: number;
|
||||
y: number;
|
||||
label: string;
|
||||
value: string;
|
||||
swatch?: string;
|
||||
}
|
||||
|
||||
export function ChartTooltip({ tooltip }: { tooltip: TooltipState | null }) {
|
||||
if (!tooltip) return null;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: tooltip.x,
|
||||
top: tooltip.y,
|
||||
transform: "translate(-50%, -100%)",
|
||||
marginTop: -10,
|
||||
background: "var(--chart-tooltip-bg)",
|
||||
color: "var(--chart-tooltip-text)",
|
||||
borderRadius: 8,
|
||||
padding: "0.4rem 0.6rem",
|
||||
fontSize: "0.78rem",
|
||||
pointerEvents: "none",
|
||||
whiteSpace: "nowrap",
|
||||
zIndex: 30,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.18)",
|
||||
}}
|
||||
>
|
||||
{tooltip.swatch && (
|
||||
<span
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 2,
|
||||
background: tooltip.swatch,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span style={{ opacity: 0.85 }}>{tooltip.label}</span>
|
||||
<strong>{tooltip.value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
stats-react/src/components/charts/LineChart.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { ChartTooltip, type TooltipState } from "./ChartTooltip";
|
||||
|
||||
export interface LineDatum {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const W = 1000;
|
||||
const H = 300;
|
||||
const PAD_L = 44;
|
||||
const PAD_R = 16;
|
||||
const PAD_T = 16;
|
||||
const PAD_B = 48;
|
||||
|
||||
// Линейный график для одного ряда: 2px линия, area fill ~10%, маркеры ≥8px
|
||||
// с 2px кольцом в цвет поверхности, вертикальный crosshair с тултипом,
|
||||
// прямая подпись значения на последней точке.
|
||||
export function LineChart({
|
||||
data,
|
||||
color = "var(--primary)",
|
||||
formatValue = (v) => String(v),
|
||||
}: {
|
||||
data: LineDatum[];
|
||||
color?: string;
|
||||
formatValue?: (v: number) => string;
|
||||
}) {
|
||||
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
if (data.length === 0) {
|
||||
return <div className="chart-empty">Нет данных</div>;
|
||||
}
|
||||
|
||||
const max = Math.max(...data.map((d) => d.value), 1);
|
||||
const plotW = W - PAD_L - PAD_R;
|
||||
const plotH = H - PAD_T - PAD_B;
|
||||
const n = data.length;
|
||||
|
||||
const xAt = (i: number) => PAD_L + (n === 1 ? plotW / 2 : (i / (n - 1)) * plotW);
|
||||
const yAt = (v: number) => PAD_T + plotH - (v / max) * plotH;
|
||||
|
||||
const points = data.map((d, i) => ({ x: xAt(i), y: yAt(d.value), ...d }));
|
||||
const linePath = points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" ");
|
||||
const areaPath = `${linePath} L ${points[points.length - 1].x} ${PAD_T + plotH} L ${points[0].x} ${PAD_T + plotH} Z`;
|
||||
|
||||
const gridLines = [0, 0.25, 0.5, 0.75, 1];
|
||||
|
||||
const handleMove = (e: React.PointerEvent<SVGSVGElement>) => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return;
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const relX = ((e.clientX - rect.left) / rect.width) * W;
|
||||
let nearest = 0;
|
||||
let best = Infinity;
|
||||
points.forEach((p, i) => {
|
||||
const d = Math.abs(p.x - relX);
|
||||
if (d < best) {
|
||||
best = d;
|
||||
nearest = i;
|
||||
}
|
||||
});
|
||||
setHoverIdx(nearest);
|
||||
const p = points[nearest];
|
||||
setTooltip({
|
||||
x: (p.x / W) * rect.width,
|
||||
y: (p.y / H) * rect.height,
|
||||
label: p.label,
|
||||
value: formatValue(p.value),
|
||||
swatch: color,
|
||||
});
|
||||
};
|
||||
|
||||
const last = points[points.length - 1];
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
style={{ width: "100%", height: "auto", display: "block" }}
|
||||
onPointerMove={handleMove}
|
||||
onPointerLeave={() => {
|
||||
setHoverIdx(null);
|
||||
setTooltip(null);
|
||||
}}
|
||||
>
|
||||
{/* gridlines */}
|
||||
{gridLines.map((g) => {
|
||||
const y = PAD_T + plotH * (1 - g);
|
||||
return (
|
||||
<g key={g}>
|
||||
<line x1={PAD_L} y1={y} x2={W - PAD_R} y2={y} stroke="var(--chart-grid)" strokeWidth={1} />
|
||||
<text x={PAD_L - 8} y={y + 4} fontSize={11} fill="var(--text-secondary)" textAnchor="end">
|
||||
{Math.round(max * g)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* area fill */}
|
||||
<path d={areaPath} fill={color} opacity={0.1} stroke="none" />
|
||||
|
||||
{/* line */}
|
||||
<path d={linePath} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
|
||||
|
||||
{/* crosshair */}
|
||||
{hoverIdx !== null && (
|
||||
<line
|
||||
x1={points[hoverIdx].x}
|
||||
y1={PAD_T}
|
||||
x2={points[hoverIdx].x}
|
||||
y2={PAD_T + plotH}
|
||||
stroke="var(--text-secondary)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* markers */}
|
||||
{points.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={hoverIdx === i ? 6 : 4.5}
|
||||
fill={color}
|
||||
stroke="var(--surface)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* end label */}
|
||||
<text x={last.x} y={last.y - 12} fontSize={12} fontWeight={700} fill="var(--text-primary)" textAnchor="end">
|
||||
{formatValue(last.value)}
|
||||
</text>
|
||||
|
||||
{/* x labels */}
|
||||
{points.map((p, i) => {
|
||||
const anchor = i === 0 ? "start" : i === points.length - 1 ? "end" : "middle";
|
||||
return (
|
||||
<text key={i} x={p.x} y={H - PAD_B + 20} fontSize={11} fill="var(--text-secondary)" textAnchor={anchor}>
|
||||
{p.label}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<ChartTooltip tooltip={tooltip} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
stats-react/src/components/charts/PartToWholeBar.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useState } from "react";
|
||||
import { ChartTooltip, type TooltipState } from "./ChartTooltip";
|
||||
import { categoricalColor } from "../../theme/palette";
|
||||
|
||||
export interface ShareDatum {
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
// Part-to-whole как один горизонтальный 100%-stacked bar вместо pie chart —
|
||||
// dataviz: "part-to-whole rides on the stacked bar chart; donut stays deprioritized".
|
||||
// Легенда обязательна (>=2 сегментов), т.к. на самих сегментах подписи не всегда влезают.
|
||||
// Токен-потолок категориальной палитры — 8 слотов (см. dataviz choosing-a-form:
|
||||
// "series 7-8: token ceiling; past it, fold the tail into Other"). Данные уже
|
||||
// отсортированы бэкендом по убыванию — берём топ-7 и сворачиваем остаток.
|
||||
const MAX_SLOTS = 7;
|
||||
const OTHER_COLOR_LIGHT = "#898781";
|
||||
const OTHER_COLOR_DARK = "#a7b2c0";
|
||||
|
||||
function foldTail(data: ShareDatum[]): ShareDatum[] {
|
||||
if (data.length <= MAX_SLOTS + 1) return data;
|
||||
const head = data.slice(0, MAX_SLOTS);
|
||||
const tail = data.slice(MAX_SLOTS);
|
||||
const otherCount = tail.reduce((s, d) => s + d.count, 0);
|
||||
return [...head, { label: `Другое (${tail.length})`, count: otherCount }];
|
||||
}
|
||||
|
||||
export function PartToWholeBar({ data, dark }: { data: ShareDatum[]; dark: boolean }) {
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||
|
||||
if (data.length === 0) {
|
||||
return <div className="chart-empty">Нет данных</div>;
|
||||
}
|
||||
|
||||
const folded = foldTail(data);
|
||||
const wasFolded = folded.length < data.length;
|
||||
const isOther = (i: number) => wasFolded && i === folded.length - 1;
|
||||
const total = folded.reduce((s, d) => s + d.count, 0) || 1;
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
height: 28,
|
||||
borderRadius: 6,
|
||||
overflow: "hidden",
|
||||
gap: 2,
|
||||
background: "var(--surface-alt)",
|
||||
}}
|
||||
>
|
||||
{folded.map((d, i) => {
|
||||
const pct = (d.count / total) * 100;
|
||||
const color = isOther(i) ? (dark ? OTHER_COLOR_DARK : OTHER_COLOR_LIGHT) : categoricalColor(i, dark);
|
||||
return (
|
||||
<div
|
||||
key={d.label + i}
|
||||
style={{
|
||||
flexBasis: `${pct}%`,
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
minWidth: 2,
|
||||
background: color,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
tabIndex={0}
|
||||
onMouseMove={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const parent = e.currentTarget.parentElement!.parentElement!.getBoundingClientRect();
|
||||
setTooltip({
|
||||
x: rect.left - parent.left + rect.width / 2,
|
||||
y: rect.top - parent.top,
|
||||
label: d.label,
|
||||
value: `${d.count} (${pct.toFixed(1)}%)`,
|
||||
swatch: color,
|
||||
});
|
||||
}}
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
onFocus={() =>
|
||||
setTooltip({ x: 0, y: 0, label: d.label, value: `${d.count} (${pct.toFixed(1)}%)`, swatch: color })
|
||||
}
|
||||
onBlur={() => setTooltip(null)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="chart-legend">
|
||||
{folded.map((d, i) => {
|
||||
const pct = (d.count / total) * 100;
|
||||
const color = isOther(i) ? (dark ? OTHER_COLOR_DARK : OTHER_COLOR_LIGHT) : categoricalColor(i, dark);
|
||||
return (
|
||||
<span className="legend-item" key={d.label + i}>
|
||||
<span className="legend-swatch" style={{ background: color }} />
|
||||
{d.label} — {d.count} ({pct.toFixed(1)}%)
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ChartTooltip tooltip={tooltip} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
stats-react/src/components/icons.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
// Небольшой набор inline-SVG иконок (замена Material Icons из Flet-версии).
|
||||
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 IconAssignment = (p: P) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M19 3h-4.18a3 3 0 0 0-5.64 0H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2Zm-7 0a1 1 0 1 1-1 1 1 1 0 0 1 1-1Zm-1.5 14.5-3.5-3.5 1.4-1.4 2.1 2.1 5.1-5.1L17 11l-6.5 6.5Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconQuiz = (p: P) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2Zm-6.5 15h-1v-1h1Zm1-4.4c-.6.5-.7.7-.7 1.4h-1c0-1.1.3-1.6 1-2.2.5-.4.8-.7.8-1.3 0-.7-.5-1.1-1.3-1.1s-1.3.5-1.4 1.2l-1.1-.2c.2-1.2 1.1-2 2.5-2 1.5 0 2.4.9 2.4 2.1 0 .8-.4 1.4-1.2 2.1Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconPlayCircle = (p: P) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2Zm-2 14.5v-9l7 4.5Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconBusiness = (p: P) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M6 3h8v4h6v14H4V3Zm2 2v14h4v-4h4v4h4V9h-4V5H8Zm2 3h2v2h-2Zm4 0h2v2h-2ZM10 12h2v2h-2Zm4 0h2v2h-2Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconFilterOff = (p: P) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M2.4 3.8 3.8 2.4l18 18-1.4 1.4-5.9-5.9-1.5 1.9v3.8h-2v-3.8L4.5 8.9l1.5-1.5H4V5h2.4Zm5.9 0H20v2h-9.7Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconRefresh = (p: P) => (
|
||||
<svg {...base(p)} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M20 11A8 8 0 1 0 18.5 16" />
|
||||
<path d="M20 4v7h-7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconBarChart = (p: P) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M5 10h3v10H5Zm11-6h3v16h-3ZM10.5 15h3v5h-3Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconInsights = (p: P) => (
|
||||
<svg {...base(p)} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 17l6-6 4 4 8-8" />
|
||||
<path d="M15 6h6v6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconCaretDown = (p: P) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M7 10l5 5 5-5Z" />
|
||||
</svg>
|
||||
);
|
||||
10
stats-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>,
|
||||
);
|
||||
478
stats-react/src/styles/global.css
Normal file
@@ -0,0 +1,478 @@
|
||||
/* Сброс + переменные. Цвета — web-react/src/theme/themes.ts (designer.py),
|
||||
категориальная палитра графиков — theme/palette.ts (провалидирована dataviz-скиллом). */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--primary: #f85a40;
|
||||
--primary-dark: #d94530;
|
||||
--primary-light: #ffeab7;
|
||||
--accent: #ffc845;
|
||||
--info: #007fbd;
|
||||
--success: #85c446;
|
||||
|
||||
--background: #f4f6f8;
|
||||
--surface: #ffffff;
|
||||
--surface-alt: #f0f2f5;
|
||||
--text-primary: #1e2a3a;
|
||||
--text-secondary: #5a6779;
|
||||
--text-on-primary: #ffffff;
|
||||
--border: #dde3ea;
|
||||
|
||||
--chart-grid: #dde3ea;
|
||||
--chart-tooltip-bg: #1e2a3a;
|
||||
--chart-tooltip-text: #ffffff;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--primary: #f85a40;
|
||||
--primary-dark: #d94530;
|
||||
--primary-light: #ffeab7;
|
||||
--accent: #ffc845;
|
||||
--info: #4db3e8;
|
||||
--success: #a2d97a;
|
||||
|
||||
--background: #141a22;
|
||||
--surface: #1e2733;
|
||||
--surface-alt: #263140;
|
||||
--text-primary: #f2f4f7;
|
||||
--text-secondary: #a7b2c0;
|
||||
--text-on-primary: #ffffff;
|
||||
--border: #33404f;
|
||||
|
||||
--chart-grid: #33404f;
|
||||
--chart-tooltip-bg: #f2f4f7;
|
||||
--chart-tooltip-text: #1e2a3a;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── Layout ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: var(--surface);
|
||||
padding: 0.9rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.app-header .brand {
|
||||
color: var(--primary);
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.app-header .divider {
|
||||
width: 1px;
|
||||
height: 1.4rem;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.app-header .subtitle {
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1300px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* ── Summary tiles ───────────────────────────────────────────────────── */
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.summary-tile {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0.9rem 1rem;
|
||||
min-width: 190px;
|
||||
flex: 1 1 190px;
|
||||
}
|
||||
|
||||
.summary-tile .tile-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.72rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.summary-tile .tile-value {
|
||||
font-size: 1.7rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Filters ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.filters-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.filters-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: 0.5rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.filters-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.filters-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.btn {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: var(--text-on-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
/* ── Dropdown ────────────────────────────────────────────────────────── */
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 190px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dropdown-control {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
min-height: 2.3rem;
|
||||
}
|
||||
|
||||
.dropdown-value {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dropdown-value.placeholder {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--surface);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.dropdown-search {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.7rem;
|
||||
font-size: 0.85rem;
|
||||
background: var(--background);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 0.6rem 0;
|
||||
}
|
||||
|
||||
.sd-list {
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dropdown-option {
|
||||
padding: 0.55rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dropdown-option:hover {
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
.dropdown-option.active {
|
||||
background: var(--primary);
|
||||
color: var(--text-on-primary);
|
||||
}
|
||||
|
||||
.dropdown-empty {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
padding: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Tabs ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.tabs-bar {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.7rem 1rem;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Chart cards ─────────────────────────────────────────────────────── */
|
||||
|
||||
.chart-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.chart-card-title {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
|
||||
.chart-empty {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
font-size: 0.85rem;
|
||||
padding: 2rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chart-loading {
|
||||
padding: 2rem 0;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ── Legend ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem 1rem;
|
||||
margin-top: 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.legend-swatch {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Table ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.stats-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.stats-table th {
|
||||
text-align: left;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.stats-table td {
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.stats-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.stats-table .value-cell {
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stats-table .poll-cell {
|
||||
color: var(--text-secondary);
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Spinner ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.spinner {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.spinner-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
43
stats-react/src/theme/palette.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// Палитра дашборда. Бренд-цвета — из web-react/src/theme/themes.ts (designer.py).
|
||||
// Категориальная 8-цветная палитра для графиков (pie/multi-bar) провалидирована
|
||||
// dataviz-скиллом (scripts/validate_palette.js) на CVD-безопасность в light и
|
||||
// dark режимах — порядок слотов и hex не менять без повторной валидации.
|
||||
// Единственная замена относительно референсного набора: слот "orange" — бренд-оранжевый.
|
||||
|
||||
export const BRAND = {
|
||||
primary: "#F85A40",
|
||||
primaryDark: "#D94530",
|
||||
primaryLight: "#FFEAB7",
|
||||
accent: "#FFC845",
|
||||
info: "#007FBD",
|
||||
success: "#85C446",
|
||||
};
|
||||
|
||||
// [PASS] все проверки, light: surface #FFFFFF (WARN contrast на magenta/yellow/aqua — компенсируется прямыми подписями значений)
|
||||
export const CATEGORICAL_LIGHT = [
|
||||
"#2a78d6", // blue
|
||||
"#008300", // green
|
||||
"#e87ba4", // magenta
|
||||
"#eda100", // yellow
|
||||
"#1baf7a", // aqua
|
||||
"#F85A40", // orange (бренд)
|
||||
"#4a3aa7", // violet
|
||||
"#e34948", // red
|
||||
];
|
||||
|
||||
// [PASS] все проверки, dark: surface #1a1a19
|
||||
export const CATEGORICAL_DARK = [
|
||||
"#3987e5",
|
||||
"#008300",
|
||||
"#d55181",
|
||||
"#c98500",
|
||||
"#199e70",
|
||||
"#D94530", // orange (бренд, тёмный шаг)
|
||||
"#9085e9",
|
||||
"#e66767",
|
||||
];
|
||||
|
||||
export function categoricalColor(index: number, dark: boolean): string {
|
||||
const arr = dark ? CATEGORICAL_DARK : CATEGORICAL_LIGHT;
|
||||
return arr[index % arr.length];
|
||||
}
|
||||
18
stats-react/src/theme/useIsDark.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Категориальная палитра графиков зависит от режима (см. palette.ts) —
|
||||
// нужно знать активный prefers-color-scheme, чтобы выбрать нужный набор.
|
||||
export function useIsDark(): boolean {
|
||||
const [dark, setDark] = useState(
|
||||
() => typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const onChange = () => setDark(mq.matches);
|
||||
mq.addEventListener("change", onChange);
|
||||
return () => mq.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return dark;
|
||||
}
|
||||
21
stats-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
stats-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 (stats-react/nginx.conf).
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: process.env.API_URL || "http://localhost:8000",
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ""),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,209 +0,0 @@
|
||||
"""
|
||||
api_client.py — HTTP-клиент для stats-приложения.
|
||||
Запросы идут к основному API без авторизации.
|
||||
"""
|
||||
import os
|
||||
from typing import Optional
|
||||
import httpx
|
||||
|
||||
API_URL = os.getenv("API_URL", "http://api:8000")
|
||||
_TIMEOUT = 15
|
||||
|
||||
|
||||
async def get_summary() -> dict:
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
|
||||
resp = await client.get("/stats/summary")
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
return {"total_responses": 0, "total_polls": 0, "active_polls": 0, "orgs_active": 0}
|
||||
|
||||
|
||||
async def get_years() -> list[int]:
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
|
||||
resp = await client.get("/stats/years")
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
async def get_chart_data(
|
||||
group_by: str,
|
||||
poll_id: Optional[str] = None,
|
||||
org_id: Optional[str] = None,
|
||||
group_id: Optional[str] = None,
|
||||
year: Optional[int] = None,
|
||||
) -> list[dict]:
|
||||
params: dict = {"group_by": group_by}
|
||||
if poll_id:
|
||||
params["poll_id"] = poll_id
|
||||
if org_id:
|
||||
params["org_id"] = org_id
|
||||
if group_id:
|
||||
params["group_id"] = group_id
|
||||
if year:
|
||||
params["year"] = year
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
|
||||
resp = await client.get("/stats/responses", params=params)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
async def get_polls() -> list[dict]:
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
|
||||
resp = await client.get("/polls/")
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
async def get_organizations() -> list[dict]:
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
|
||||
resp = await client.get("/organizations/")
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
async def get_groups() -> list[dict]:
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
|
||||
resp = await client.get("/groups/")
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
async def get_dimensions(poll_id: Optional[str] = None) -> list[str]:
|
||||
params = {}
|
||||
if poll_id:
|
||||
params["poll_id"] = poll_id
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
|
||||
resp = await client.get("/stats/results/dimensions", params=params)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
async def get_avg_by_dimension(
|
||||
poll_id: Optional[str] = None,
|
||||
org_id: Optional[str] = None,
|
||||
group_id: Optional[str] = None,
|
||||
year: Optional[int] = None,
|
||||
) -> list[dict]:
|
||||
params = {}
|
||||
if poll_id: params["poll_id"] = poll_id
|
||||
if org_id: params["org_id"] = org_id
|
||||
if group_id: params["group_id"] = group_id
|
||||
if year: params["year"] = year
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
|
||||
resp = await client.get("/stats/results/avg-by-dimension", params=params)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
async def get_leading_type(
|
||||
poll_id: Optional[str] = None,
|
||||
org_id: Optional[str] = None,
|
||||
group_id: Optional[str] = None,
|
||||
year: Optional[int] = None,
|
||||
) -> list[dict]:
|
||||
params = {}
|
||||
if poll_id: params["poll_id"] = poll_id
|
||||
if org_id: params["org_id"] = org_id
|
||||
if group_id: params["group_id"] = group_id
|
||||
if year: params["year"] = year
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
|
||||
resp = await client.get("/stats/results/leading-type", params=params)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
async def get_compare_groups(
|
||||
dimension_name: str,
|
||||
by: str = "organization",
|
||||
poll_id: Optional[str] = None,
|
||||
year: Optional[int] = None,
|
||||
) -> list[dict]:
|
||||
params = {"dimension_name": dimension_name, "by": by}
|
||||
if poll_id: params["poll_id"] = poll_id
|
||||
if year: params["year"] = year
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
|
||||
resp = await client.get("/stats/results/compare-groups", params=params)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
async def get_top_users(
|
||||
dimension_name: str,
|
||||
poll_id: Optional[str] = None,
|
||||
org_id: Optional[str] = None,
|
||||
group_id: Optional[str] = None,
|
||||
year: Optional[int] = None,
|
||||
limit: int = 10,
|
||||
) -> list[dict]:
|
||||
params = {"dimension_name": dimension_name, "limit": limit}
|
||||
if poll_id: params["poll_id"] = poll_id
|
||||
if org_id: params["org_id"] = org_id
|
||||
if group_id: params["group_id"] = group_id
|
||||
if year: params["year"] = year
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
|
||||
resp = await client.get("/stats/results/top-users", params=params)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
async def get_dimension_trend(
|
||||
dimension_name: str,
|
||||
poll_id: Optional[str] = None,
|
||||
org_id: Optional[str] = None,
|
||||
group_id: Optional[str] = None,
|
||||
) -> list[dict]:
|
||||
params = {"dimension_name": dimension_name}
|
||||
if poll_id: params["poll_id"] = poll_id
|
||||
if org_id: params["org_id"] = org_id
|
||||
if group_id: params["group_id"] = group_id
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
|
||||
resp = await client.get("/stats/results/dimension-trend", params=params)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
Before Width: | Height: | Size: 114 KiB |
@@ -1,3 +0,0 @@
|
||||
# Положи сюда своё изображение с именем loading-animation.png
|
||||
# Это заменит мигающий логотип Flet при загрузке страницы.
|
||||
# Рекомендуемый размер: PNG, минимум 200x200 px.
|
||||
|
Before Width: | Height: | Size: 30 KiB |
@@ -1,745 +0,0 @@
|
||||
"""
|
||||
dashboard.py — главный вид статистического дашборда.
|
||||
Графики строятся через matplotlib и отображаются как изображения (ft.Image).
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import math
|
||||
|
||||
import flet as ft
|
||||
import matplotlib
|
||||
import matplotlib.colors
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.font_manager as fm
|
||||
|
||||
import designer as ds
|
||||
import api_client as ac
|
||||
|
||||
matplotlib.use("Agg") # без GUI, только в память
|
||||
|
||||
# Цвета графиков
|
||||
_PALETTE = [
|
||||
"#F85A40", "#007FBD", "#85C446", "#FFC845",
|
||||
"#D94530", "#5A6779", "#006AA3", "#4A9E21",
|
||||
]
|
||||
|
||||
|
||||
def _wrap_label(text: str, max_len: int = 18) -> str:
|
||||
"""Перенос длинного текста через \n для подписей осей."""
|
||||
if len(text) <= max_len:
|
||||
return text
|
||||
words = text.split()
|
||||
lines, line = [], ""
|
||||
for w in words:
|
||||
if len(line) + len(w) + 1 <= max_len:
|
||||
line = f"{line} {w}".strip()
|
||||
else:
|
||||
if line:
|
||||
lines.append(line)
|
||||
line = w
|
||||
if line:
|
||||
lines.append(line)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_chart_image(data: list[dict], title: str, color: str) -> bytes | None:
|
||||
"""Строит горизонтальную bar chart через matplotlib, возвращает PNG bytes."""
|
||||
if not data:
|
||||
return None
|
||||
|
||||
labels = [_wrap_label(d["label"]) for d in data]
|
||||
counts = [d["count"] for d in data]
|
||||
n = len(labels)
|
||||
|
||||
fig_h = max(4.5, n * 0.55 + 1.2)
|
||||
fig, ax = plt.subplots(figsize=(10, fig_h))
|
||||
fig.patch.set_facecolor("#FFFFFF")
|
||||
ax.set_facecolor("#F4F6F8")
|
||||
|
||||
bars = ax.barh(
|
||||
range(n), counts,
|
||||
color=color,
|
||||
height=0.6,
|
||||
edgecolor="white",
|
||||
linewidth=0.5,
|
||||
)
|
||||
|
||||
# Подписи значений
|
||||
for bar, v in zip(bars, counts):
|
||||
ax.text(
|
||||
v + max(counts) * 0.01,
|
||||
bar.get_y() + bar.get_height() / 2,
|
||||
str(v),
|
||||
va="center",
|
||||
ha="left",
|
||||
fontsize=11,
|
||||
color="#1E2A3A",
|
||||
)
|
||||
|
||||
ax.set_yticks(range(n))
|
||||
ax.set_yticklabels(labels, fontsize=11, color="#5A6779")
|
||||
ax.invert_yaxis()
|
||||
ax.set_xlabel("Количество ответов", fontsize=11, color="#5A6779")
|
||||
ax.xaxis.set_tick_params(labelsize=11, colors="#5A6779")
|
||||
ax.spines["top"].set_visible(False)
|
||||
ax.spines["right"].set_visible(False)
|
||||
ax.spines["left"].set_color("#DDE3EA")
|
||||
ax.spines["bottom"].set_color("#DDE3EA")
|
||||
ax.set_xlim(0, max(counts) * 1.18 if max(counts) > 0 else 1)
|
||||
ax.grid(axis="x", color="#DDE3EA", linewidth=0.5, linestyle="--")
|
||||
|
||||
plt.tight_layout(pad=1.2)
|
||||
|
||||
buf = io.BytesIO()
|
||||
fig.savefig(buf, format="png", dpi=130, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
buf.seek(0)
|
||||
return buf.read()
|
||||
|
||||
|
||||
def _build_line_chart_image(data: list[dict]) -> bytes | None:
|
||||
"""Строит линейный график для данных по месяцам."""
|
||||
if not data:
|
||||
return None
|
||||
|
||||
labels = [d["label"] for d in data]
|
||||
counts = [d["count"] for d in data]
|
||||
n = len(labels)
|
||||
|
||||
fig, ax = plt.subplots(figsize=(max(10, n * 1.2), 5.5))
|
||||
fig.patch.set_facecolor("#FFFFFF")
|
||||
ax.set_facecolor("#F4F6F8")
|
||||
|
||||
ax.plot(range(n), counts, color="#F85A40", linewidth=2.5, marker="o",
|
||||
markersize=7, markerfacecolor="#F85A40", markeredgecolor="white",
|
||||
markeredgewidth=1.5)
|
||||
ax.fill_between(range(n), counts, alpha=0.12, color="#F85A40")
|
||||
|
||||
for i, v in enumerate(counts):
|
||||
ax.text(i, v + max(counts) * 0.03, str(v), ha="center",
|
||||
fontsize=11, color="#1E2A3A")
|
||||
|
||||
ax.set_xticks(range(n))
|
||||
ax.set_xticklabels(labels, rotation=35, ha="right", fontsize=11, color="#5A6779")
|
||||
ax.yaxis.set_tick_params(labelsize=11, colors="#5A6779")
|
||||
ax.set_ylabel("Ответов", fontsize=11, color="#5A6779")
|
||||
ax.spines["top"].set_visible(False)
|
||||
ax.spines["right"].set_visible(False)
|
||||
ax.spines["left"].set_color("#DDE3EA")
|
||||
ax.spines["bottom"].set_color("#DDE3EA")
|
||||
ax.set_ylim(0, max(counts) * 1.25)
|
||||
ax.grid(axis="y", color="#DDE3EA", linewidth=0.5, linestyle="--")
|
||||
|
||||
plt.tight_layout(pad=1.2)
|
||||
buf = io.BytesIO()
|
||||
fig.savefig(buf, format="png", dpi=130, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
buf.seek(0)
|
||||
return buf.read()
|
||||
|
||||
|
||||
def _build_avg_bar(data: list[dict], xlabel: str = "Средний балл") -> bytes | None:
|
||||
"""Горизонтальный bar chart со средними баллами (поле 'avg')."""
|
||||
if not data:
|
||||
return None
|
||||
labels = [_wrap_label(d["label"]) for d in data]
|
||||
values = [d["avg"] for d in data]
|
||||
n = len(labels)
|
||||
colors = [_PALETTE[i % len(_PALETTE)] for i in range(n)]
|
||||
|
||||
fig_h = max(5.5, n * 0.65 + 1.5)
|
||||
fig, ax = plt.subplots(figsize=(14, fig_h))
|
||||
fig.patch.set_facecolor("#FFFFFF")
|
||||
ax.set_facecolor("#F4F6F8")
|
||||
|
||||
bars = ax.barh(range(n), values, color=colors, height=0.6, edgecolor="white", linewidth=0.5)
|
||||
for bar, v in zip(bars, values):
|
||||
ax.text(v + max(values) * 0.01, bar.get_y() + bar.get_height() / 2,
|
||||
f"{v:.1f}", va="center", ha="left", fontsize=11, color="#1E2A3A")
|
||||
|
||||
ax.set_yticks(range(n))
|
||||
ax.set_yticklabels(labels, fontsize=11, color="#5A6779")
|
||||
ax.invert_yaxis()
|
||||
ax.set_xlabel(xlabel, fontsize=11, color="#5A6779")
|
||||
ax.xaxis.set_tick_params(labelsize=11, colors="#5A6779")
|
||||
ax.spines["top"].set_visible(False)
|
||||
ax.spines["right"].set_visible(False)
|
||||
ax.spines["left"].set_color("#DDE3EA")
|
||||
ax.spines["bottom"].set_color("#DDE3EA")
|
||||
ax.set_xlim(0, max(values) * 1.18 if max(values) > 0 else 1)
|
||||
ax.grid(axis="x", color="#DDE3EA", linewidth=0.5, linestyle="--")
|
||||
plt.tight_layout(pad=1.2)
|
||||
buf = io.BytesIO()
|
||||
fig.savefig(buf, format="png", dpi=130, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
buf.seek(0)
|
||||
return buf.read()
|
||||
|
||||
|
||||
def _build_pie_chart(data: list[dict]) -> bytes | None:
|
||||
"""Круговая диаграмма распределения ведущего типа."""
|
||||
if not data:
|
||||
return None
|
||||
labels = [d["label"] for d in data]
|
||||
values = [d["count"] for d in data]
|
||||
n_colors = len(labels)
|
||||
if n_colors <= len(_PALETTE):
|
||||
colors = _PALETTE[:n_colors]
|
||||
else:
|
||||
# Генерируем уникальные цвета через hsv colormap
|
||||
cmap = matplotlib.colormaps["hsv"]
|
||||
colors = [matplotlib.colors.to_hex(cmap(i / n_colors)) for i in range(n_colors)]
|
||||
|
||||
# Размер фигуры растёт с числом элементов для читаемой легенды
|
||||
legend_rows = math.ceil(n_colors / 2)
|
||||
fig_h = max(7, legend_rows * 0.35 + 5)
|
||||
fig, ax = plt.subplots(figsize=(11, fig_h))
|
||||
fig.patch.set_facecolor("#FFFFFF")
|
||||
wedges, texts, autotexts = ax.pie(
|
||||
values,
|
||||
labels=None,
|
||||
autopct="%1.1f%%",
|
||||
colors=colors,
|
||||
startangle=140,
|
||||
wedgeprops=dict(edgecolor="white", linewidth=1.5),
|
||||
pctdistance=0.78,
|
||||
)
|
||||
for t in autotexts:
|
||||
t.set_fontsize(9 if n_colors > 15 else 10)
|
||||
t.set_color("#1E2A3A")
|
||||
ncol = 3 if n_colors > 20 else 2
|
||||
ax.legend(
|
||||
wedges,
|
||||
[f"{l} ({v})" for l, v in zip(labels, values)],
|
||||
loc="lower center",
|
||||
bbox_to_anchor=(0.5, -0.05 - legend_rows * 0.04),
|
||||
ncol=ncol,
|
||||
fontsize=9,
|
||||
frameon=False,
|
||||
)
|
||||
plt.tight_layout(pad=1.5)
|
||||
buf = io.BytesIO()
|
||||
fig.savefig(buf, format="png", dpi=130, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
buf.seek(0)
|
||||
return buf.read()
|
||||
|
||||
|
||||
def _build_avg_line(data: list[dict]) -> bytes | None:
|
||||
"""Линейный тренд средних баллов по месяцам (поле 'avg')."""
|
||||
if not data:
|
||||
return None
|
||||
labels = [d["label"] for d in data]
|
||||
values = [d["avg"] for d in data]
|
||||
n = len(labels)
|
||||
|
||||
fig, ax = plt.subplots(figsize=(max(10, n * 1.2), 5.5))
|
||||
fig.patch.set_facecolor("#FFFFFF")
|
||||
ax.set_facecolor("#F4F6F8")
|
||||
ax.plot(range(n), values, color="#F85A40", linewidth=2.5, marker="o",
|
||||
markersize=7, markerfacecolor="#F85A40", markeredgecolor="white",
|
||||
markeredgewidth=1.5)
|
||||
ax.fill_between(range(n), values, alpha=0.12, color="#F85A40")
|
||||
for i, v in enumerate(values):
|
||||
ax.text(i, v + max(values) * 0.03, f"{v:.1f}", ha="center", fontsize=11, color="#1E2A3A")
|
||||
ax.set_xticks(range(n))
|
||||
ax.set_xticklabels(labels, rotation=35, ha="right", fontsize=11, color="#5A6779")
|
||||
ax.yaxis.set_tick_params(labelsize=11, colors="#5A6779")
|
||||
ax.set_ylabel("Средний балл", fontsize=11, color="#5A6779")
|
||||
ax.spines["top"].set_visible(False)
|
||||
ax.spines["right"].set_visible(False)
|
||||
ax.spines["left"].set_color("#DDE3EA")
|
||||
ax.spines["bottom"].set_color("#DDE3EA")
|
||||
ax.set_ylim(0, max(values) * 1.25 if max(values) > 0 else 1)
|
||||
ax.grid(axis="y", color="#DDE3EA", linewidth=0.5, linestyle="--")
|
||||
plt.tight_layout(pad=1.2)
|
||||
buf = io.BytesIO()
|
||||
fig.savefig(buf, format="png", dpi=130, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
buf.seek(0)
|
||||
return buf.read()
|
||||
|
||||
|
||||
def _image_or_placeholder(img_bytes: bytes | None, width: int = 1100) -> ft.Control:
|
||||
if img_bytes:
|
||||
b64 = base64.b64encode(img_bytes).decode()
|
||||
return ft.Image(
|
||||
src=f"data:image/png;base64,{b64}",
|
||||
fit=ft.BoxFit.FIT_WIDTH,
|
||||
width=width,
|
||||
)
|
||||
return ft.Container(
|
||||
content=ft.Text("Нет данных", color=ds.colors.text_secondary, size=ds.s(13), italic=True),
|
||||
alignment=ft.Alignment(0, 0),
|
||||
height=ds.s(120),
|
||||
width=width,
|
||||
)
|
||||
|
||||
|
||||
class DashboardView(ft.Column):
|
||||
def __init__(self, page: ft.Page):
|
||||
self._page = page
|
||||
|
||||
# ── Общие фильтры ────────────────────────────────────────────────────
|
||||
self._poll_dd = ds.SearchableDropdown(label="Тест", hint_text="Все тесты")
|
||||
self._poll_dd.on_select = self._on_filter_change
|
||||
|
||||
self._org_dd = ds.SearchableDropdown(label="Организация", hint_text="Все организации")
|
||||
self._org_dd.on_select = self._on_filter_change
|
||||
|
||||
self._group_dd = ds.SearchableDropdown(label="Группа", hint_text="Все группы")
|
||||
self._group_dd.on_select = self._on_filter_change
|
||||
|
||||
self._year_dd = ds.SearchableDropdown(label="Год", hint_text="Все годы")
|
||||
self._year_dd.on_select = self._on_filter_change
|
||||
|
||||
self._reset_btn = ft.TextButton(
|
||||
"Сбросить фильтры",
|
||||
icon=ft.Icons.FILTER_ALT_OFF,
|
||||
style=ft.ButtonStyle(color=ds.colors.primary),
|
||||
on_click=self._reset_filters,
|
||||
)
|
||||
|
||||
self._refresh_btn = ft.FilledButton(
|
||||
"Обновить",
|
||||
icon=ft.Icons.REFRESH,
|
||||
style=ft.ButtonStyle(
|
||||
bgcolor={ft.ControlState.DEFAULT: ds.colors.primary,
|
||||
ft.ControlState.HOVERED: ds.colors.primary_dark},
|
||||
color=ds.colors.text_on_primary,
|
||||
shape=ft.RoundedRectangleBorder(radius=ds.s(8)),
|
||||
),
|
||||
on_click=lambda e: page.run_task(self._refresh_current_tab),
|
||||
)
|
||||
|
||||
# ── Summary карточки ─────────────────────────────────────────────────
|
||||
self._card_total = ft.Text("—", color=ds.colors.text_primary, size=ds.s(26), weight=ft.FontWeight.BOLD)
|
||||
self._card_polls = ft.Text("—", color=ds.colors.text_primary, size=ds.s(26), weight=ft.FontWeight.BOLD)
|
||||
self._card_active = ft.Text("—", color=ds.colors.text_primary, size=ds.s(26), weight=ft.FontWeight.BOLD)
|
||||
self._card_orgs = ft.Text("—", color=ds.colors.text_primary, size=ds.s(26), weight=ft.FontWeight.BOLD)
|
||||
|
||||
# ── Таб 1: графики по прохождениям ───────────────────────────────────
|
||||
self._chart_poll = self._make_chart_box()
|
||||
self._chart_org = self._make_chart_box()
|
||||
self._chart_group = self._make_chart_box()
|
||||
self._chart_month = self._make_chart_box()
|
||||
|
||||
# ── Таб 2: результаты — фильтр по оси ────────────────────────────────
|
||||
self._dim_dd = ds.SearchableDropdown(label="Ось/Тип", hint_text="Выберите ось")
|
||||
self._dim_dd.on_select = self._on_results_filter_change
|
||||
|
||||
self._compare_by_dd = ds.SearchableDropdown(label="Сравнение по", hint_text="Организациям")
|
||||
self._compare_by_dd.options = [
|
||||
ft.DropdownOption(key="organization", text="Организациям"),
|
||||
ft.DropdownOption(key="group", text="Группам"),
|
||||
]
|
||||
self._compare_by_dd.value = "organization"
|
||||
self._compare_by_dd.on_select = self._on_results_filter_change
|
||||
|
||||
self._chart_avg_dim = self._make_chart_box()
|
||||
self._chart_leading = self._make_chart_box()
|
||||
self._chart_compare = self._make_chart_box()
|
||||
self._chart_trend = self._make_chart_box()
|
||||
|
||||
# Таблица топ-пользователей
|
||||
self._top_table = ft.DataTable(
|
||||
columns=[
|
||||
ft.DataColumn(ft.Text("Участник", size=ds.s(12), weight=ft.FontWeight.W_600)),
|
||||
ft.DataColumn(ft.Text("Тест", size=ds.s(12), weight=ft.FontWeight.W_600)),
|
||||
ft.DataColumn(ft.Text("Балл", size=ds.s(12), weight=ft.FontWeight.W_600), numeric=True),
|
||||
],
|
||||
rows=[],
|
||||
border=ft.border.all(1, ds.colors.border),
|
||||
border_radius=ds.s(8),
|
||||
horizontal_lines=ft.BorderSide(1, ds.colors.border),
|
||||
column_spacing=ds.s(20),
|
||||
)
|
||||
|
||||
# ── Навигация по табам ────────────────────────────────────────────────
|
||||
self._current_tab = 0
|
||||
|
||||
# ── Содержимое табов ──────────────────────────────────────────────────
|
||||
self._tab1_content = ft.Column(
|
||||
spacing=ds.s(16),
|
||||
controls=[
|
||||
ft.Container(
|
||||
content=self._chart_card_wrap("По тестам", self._chart_poll),
|
||||
padding=ft.padding.symmetric(horizontal=ds.s(16)),
|
||||
),
|
||||
ft.Container(
|
||||
content=self._chart_card_wrap("По организациям", self._chart_org),
|
||||
padding=ft.padding.symmetric(horizontal=ds.s(16)),
|
||||
),
|
||||
ft.Container(
|
||||
content=self._chart_card_wrap("По группам", self._chart_group),
|
||||
padding=ft.padding.symmetric(horizontal=ds.s(16)),
|
||||
),
|
||||
ft.Container(
|
||||
content=self._chart_card_wrap("Динамика по месяцам", self._chart_month),
|
||||
padding=ft.padding.symmetric(horizontal=ds.s(16)),
|
||||
margin=ft.margin.only(bottom=ds.s(24)),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
self._tab2_content = ft.Column(
|
||||
spacing=ds.s(16),
|
||||
controls=[
|
||||
# Дополнительный фильтр по оси
|
||||
ft.Container(
|
||||
content=ft.Column(
|
||||
spacing=ds.s(8),
|
||||
controls=[
|
||||
ft.Text("Фильтр по оси/типу", color=ds.colors.text_secondary,
|
||||
size=ds.s(12), weight=ft.FontWeight.W_600),
|
||||
ft.Row(
|
||||
wrap=True, spacing=ds.s(10), run_spacing=ds.s(10),
|
||||
controls=[self._dim_dd, self._compare_by_dd],
|
||||
),
|
||||
],
|
||||
),
|
||||
bgcolor=ds.colors.surface,
|
||||
border_radius=ds.s(12),
|
||||
padding=ft.padding.all(ds.s(16)),
|
||||
margin=ft.margin.symmetric(horizontal=ds.s(16)),
|
||||
border=ft.border.all(1, ds.colors.border),
|
||||
),
|
||||
ft.Container(
|
||||
content=self._chart_card_wrap("Средний балл по осям", self._chart_avg_dim),
|
||||
padding=ft.padding.symmetric(horizontal=ds.s(16)),
|
||||
),
|
||||
ft.Container(
|
||||
content=self._chart_card_wrap("Распределение ведущего типа", self._chart_leading),
|
||||
padding=ft.padding.symmetric(horizontal=ds.s(16)),
|
||||
),
|
||||
ft.Container(
|
||||
content=self._chart_card_wrap("Сравнение по группам/организациям", self._chart_compare),
|
||||
padding=ft.padding.symmetric(horizontal=ds.s(16)),
|
||||
),
|
||||
ft.Container(
|
||||
content=self._chart_card_wrap("Динамика среднего балла", self._chart_trend),
|
||||
padding=ft.padding.symmetric(horizontal=ds.s(16)),
|
||||
),
|
||||
# Топ участников
|
||||
ft.Container(
|
||||
content=ft.Column(
|
||||
spacing=ds.s(10),
|
||||
controls=[
|
||||
ft.Text("Топ-10 участников по выбранной оси",
|
||||
color=ds.colors.text_primary, size=ds.s(14),
|
||||
weight=ft.FontWeight.W_600),
|
||||
ft.Container(content=self._top_table, expand=True),
|
||||
],
|
||||
),
|
||||
bgcolor=ds.colors.surface,
|
||||
border_radius=ds.s(12),
|
||||
padding=ft.padding.all(ds.s(16)),
|
||||
margin=ft.margin.only(left=ds.s(16), right=ds.s(16), bottom=ds.s(24)),
|
||||
border=ft.border.all(1, ds.colors.border),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
# ── Tabs (TabBar + ручное переключение) ──────────────────────────────
|
||||
self._tab_body = ft.Container(content=self._tab1_content, expand=True)
|
||||
self._tabs_ctrl = ft.Tabs(
|
||||
content=ft.Column(
|
||||
expand=True,
|
||||
spacing=0,
|
||||
controls=[
|
||||
ft.TabBar(
|
||||
tabs=[
|
||||
ft.Tab(label="Прохождения", icon=ft.Icons.BAR_CHART),
|
||||
ft.Tab(label="Результаты тестов", icon=ft.Icons.INSIGHTS),
|
||||
],
|
||||
scrollable=False,
|
||||
),
|
||||
self._tab_body,
|
||||
],
|
||||
),
|
||||
length=2,
|
||||
selected_index=0,
|
||||
on_change=self._on_tab_change,
|
||||
expand=True,
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
spacing=ds.s(16),
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
expand=True,
|
||||
controls=[
|
||||
# Заголовок
|
||||
ft.Container(
|
||||
content=ft.Row(
|
||||
controls=[
|
||||
ft.Text("ЦОПП", color=ds.colors.primary, size=ds.s(22),
|
||||
weight=ft.FontWeight.BOLD),
|
||||
ft.VerticalDivider(width=ds.s(16), color=ds.colors.border),
|
||||
ft.Text("Статистика прохождений", color=ds.colors.text_primary,
|
||||
size=ds.s(16)),
|
||||
],
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
),
|
||||
bgcolor=ds.colors.surface,
|
||||
padding=ft.padding.symmetric(horizontal=ds.s(20), vertical=ds.s(14)),
|
||||
border=ft.border.only(bottom=ft.BorderSide(1, ds.colors.border)),
|
||||
),
|
||||
# Summary карточки
|
||||
ft.Container(
|
||||
content=ft.Row(
|
||||
wrap=True, spacing=ds.s(12), run_spacing=ds.s(12),
|
||||
controls=[
|
||||
self._summary_tile("Всего ответов", self._card_total,
|
||||
ft.Icons.ASSIGNMENT_TURNED_IN, ds.colors.primary),
|
||||
self._summary_tile("Тестов всего", self._card_polls,
|
||||
ft.Icons.QUIZ, ds.colors.info),
|
||||
self._summary_tile("Активных тестов", self._card_active,
|
||||
ft.Icons.PLAY_CIRCLE, ds.colors.success),
|
||||
self._summary_tile("Организаций с ответами", self._card_orgs,
|
||||
ft.Icons.BUSINESS, ds.colors.accent),
|
||||
],
|
||||
),
|
||||
padding=ft.padding.symmetric(horizontal=ds.s(16)),
|
||||
),
|
||||
# Общие фильтры
|
||||
ft.Container(
|
||||
content=ft.Column(
|
||||
spacing=ds.s(10),
|
||||
controls=[
|
||||
ft.Text("Фильтры", color=ds.colors.text_secondary,
|
||||
size=ds.s(12), weight=ft.FontWeight.W_600),
|
||||
ft.Row(
|
||||
wrap=True, spacing=ds.s(10), run_spacing=ds.s(10),
|
||||
controls=[self._poll_dd, self._org_dd,
|
||||
self._group_dd, self._year_dd],
|
||||
),
|
||||
ft.Row(spacing=ds.s(8),
|
||||
controls=[self._reset_btn, self._refresh_btn]),
|
||||
],
|
||||
),
|
||||
bgcolor=ds.colors.surface,
|
||||
border_radius=ds.s(12),
|
||||
padding=ft.padding.all(ds.s(16)),
|
||||
margin=ft.margin.symmetric(horizontal=ds.s(16)),
|
||||
border=ft.border.all(1, ds.colors.border),
|
||||
),
|
||||
# Вкладки
|
||||
self._tabs_ctrl,
|
||||
],
|
||||
)
|
||||
|
||||
# ── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _make_chart_box(self) -> ft.Container:
|
||||
return ft.Container(
|
||||
content=ft.ProgressRing(width=ds.s(24), height=ds.s(24)),
|
||||
alignment=ft.Alignment(0, 0),
|
||||
expand=True,
|
||||
)
|
||||
|
||||
def _summary_tile(self, title: str, value_text: ft.Text, icon: str, color: str) -> ft.Container:
|
||||
return ft.Container(
|
||||
content=ft.Column(
|
||||
spacing=ds.s(4), tight=True,
|
||||
controls=[
|
||||
ft.Row(spacing=ds.s(6), controls=[
|
||||
ft.Icon(icon, color=color, size=ds.s(18)),
|
||||
ft.Text(title, color=ds.colors.text_secondary, size=ds.s(11)),
|
||||
]),
|
||||
value_text,
|
||||
],
|
||||
),
|
||||
bgcolor=ds.colors.surface,
|
||||
border_radius=ds.s(10),
|
||||
padding=ft.padding.all(ds.s(14)),
|
||||
border=ft.border.all(1, ds.colors.border),
|
||||
width=ds.s(200),
|
||||
)
|
||||
|
||||
def _chart_card_wrap(self, title: str, body: ft.Container) -> ft.Container:
|
||||
return ft.Container(
|
||||
content=ft.Column(
|
||||
spacing=ds.s(10), tight=True,
|
||||
controls=[
|
||||
ft.Text(title, color=ds.colors.text_primary, size=ds.s(14),
|
||||
weight=ft.FontWeight.W_600),
|
||||
body,
|
||||
],
|
||||
),
|
||||
bgcolor=ds.colors.surface,
|
||||
border_radius=ds.s(12),
|
||||
padding=ft.padding.all(ds.s(16)),
|
||||
border=ft.border.all(1, ds.colors.border),
|
||||
expand=True,
|
||||
)
|
||||
|
||||
# ── mounting ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _chart_width(self) -> int:
|
||||
"""Ширина графика = ширина страницы минус отступы."""
|
||||
try:
|
||||
w = self.page.width or 1200
|
||||
except Exception:
|
||||
w = 1200
|
||||
return max(600, int(w) - 80)
|
||||
|
||||
def did_mount(self):
|
||||
self.page.run_task(self._initial_load)
|
||||
|
||||
async def _initial_load(self):
|
||||
polls, orgs, groups, years, summary, dimensions = await asyncio.gather(
|
||||
ac.get_polls(),
|
||||
ac.get_organizations(),
|
||||
ac.get_groups(),
|
||||
ac.get_years(),
|
||||
ac.get_summary(),
|
||||
ac.get_dimensions(),
|
||||
)
|
||||
|
||||
_dd_opt = lambda key, text: ft.DropdownOption(key=key, text=text)
|
||||
|
||||
self._poll_dd.options = [_dd_opt(p["id"], p["title"]) for p in (polls or [])]
|
||||
self._org_dd.options = [_dd_opt(o["id"], o["name_organization"]) for o in (orgs or [])]
|
||||
self._group_dd.options = [_dd_opt(g["id"], g["name_group"]) for g in (groups or [])]
|
||||
self._year_dd.options = [_dd_opt(str(y), str(y)) for y in (years or [])]
|
||||
self._dim_dd.options = [_dd_opt(d, d) for d in (dimensions or [])]
|
||||
if dimensions:
|
||||
self._dim_dd.value = dimensions[0]
|
||||
|
||||
self._card_total.value = str(summary.get("total_responses", "—"))
|
||||
self._card_polls.value = str(summary.get("total_polls", "—"))
|
||||
self._card_active.value = str(summary.get("active_polls", "—"))
|
||||
self._card_orgs.value = str(summary.get("orgs_active", "—"))
|
||||
|
||||
self.update()
|
||||
await self._refresh_tab1()
|
||||
await self._refresh_tab2()
|
||||
|
||||
# ── tab switching ─────────────────────────────────────────────────────────
|
||||
|
||||
def _on_tab_change(self, e):
|
||||
self._current_tab = int(e.data)
|
||||
self._tab_body.content = self._tab1_content if self._current_tab == 0 else self._tab2_content
|
||||
self._tab_body.update()
|
||||
|
||||
async def _refresh_current_tab(self):
|
||||
if self._current_tab == 0:
|
||||
await self._refresh_tab1()
|
||||
else:
|
||||
await self._refresh_tab2()
|
||||
|
||||
# ── filters ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _on_filter_change(self, e):
|
||||
self.page.run_task(self._refresh_current_tab)
|
||||
|
||||
def _on_results_filter_change(self, e):
|
||||
self.page.run_task(self._refresh_tab2)
|
||||
|
||||
def _reset_filters(self, e):
|
||||
self._poll_dd.value = None
|
||||
self._org_dd.value = None
|
||||
self._group_dd.value = None
|
||||
self._year_dd.value = None
|
||||
self.update()
|
||||
self.page.run_task(self._refresh_current_tab)
|
||||
|
||||
def _get_filters(self) -> dict:
|
||||
year_str = self._year_dd.value
|
||||
return {
|
||||
"poll_id": self._poll_dd.value,
|
||||
"org_id": self._org_dd.value,
|
||||
"group_id": self._group_dd.value,
|
||||
"year": int(year_str) if year_str else None,
|
||||
}
|
||||
|
||||
# ── TAB 1: прохождения ───────────────────────────────────────────────────
|
||||
|
||||
async def _refresh_tab1(self):
|
||||
f = self._get_filters()
|
||||
spinner = lambda: ft.ProgressRing(width=ds.s(24), height=ds.s(24))
|
||||
for c in [self._chart_poll, self._chart_org, self._chart_group, self._chart_month]:
|
||||
c.content = spinner()
|
||||
self.update()
|
||||
|
||||
d_poll, d_org, d_group, d_month = await asyncio.gather(
|
||||
ac.get_chart_data("poll", **f),
|
||||
ac.get_chart_data("organization", **f),
|
||||
ac.get_chart_data("group", **f),
|
||||
ac.get_chart_data("month", **f),
|
||||
)
|
||||
loop = asyncio.get_event_loop()
|
||||
img_poll, img_org, img_group, img_month = await asyncio.gather(
|
||||
loop.run_in_executor(None, _build_chart_image, d_poll, "По тестам", "#F85A40"),
|
||||
loop.run_in_executor(None, _build_chart_image, d_org, "По организациям", "#007FBD"),
|
||||
loop.run_in_executor(None, _build_chart_image, d_group, "По группам", "#85C446"),
|
||||
loop.run_in_executor(None, _build_line_chart_image, d_month),
|
||||
)
|
||||
w = self._chart_width()
|
||||
self._chart_poll.content = _image_or_placeholder(img_poll, w)
|
||||
self._chart_org.content = _image_or_placeholder(img_org, w)
|
||||
self._chart_group.content = _image_or_placeholder(img_group, w)
|
||||
self._chart_month.content = _image_or_placeholder(img_month, w)
|
||||
self.update()
|
||||
|
||||
# ── TAB 2: результаты ────────────────────────────────────────────────────
|
||||
|
||||
async def _refresh_tab2(self):
|
||||
f = self._get_filters()
|
||||
dim = self._dim_dd.value
|
||||
by = self._compare_by_dd.value or "organization"
|
||||
|
||||
spinner = lambda: ft.ProgressRing(width=ds.s(24), height=ds.s(24))
|
||||
for c in [self._chart_avg_dim, self._chart_leading,
|
||||
self._chart_compare, self._chart_trend]:
|
||||
c.content = spinner()
|
||||
self._top_table.rows = []
|
||||
self.update()
|
||||
|
||||
# Параллельная загрузка
|
||||
d_avg, d_lead, d_top = await asyncio.gather(
|
||||
ac.get_avg_by_dimension(**f),
|
||||
ac.get_leading_type(**f),
|
||||
ac.get_top_users(dim, **f) if dim else asyncio.sleep(0),
|
||||
)
|
||||
if not dim:
|
||||
d_top = []
|
||||
d_compare = await ac.get_compare_groups(dim or "", by=by,
|
||||
poll_id=f["poll_id"],
|
||||
year=f["year"]) if dim else []
|
||||
d_trend = await ac.get_dimension_trend(dim or "",
|
||||
poll_id=f["poll_id"],
|
||||
org_id=f["org_id"],
|
||||
group_id=f["group_id"]) if dim else []
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
img_avg, img_lead, img_compare, img_trend = await asyncio.gather(
|
||||
loop.run_in_executor(None, _build_avg_bar, d_avg, "Средний балл"),
|
||||
loop.run_in_executor(None, _build_pie_chart, d_lead),
|
||||
loop.run_in_executor(None, _build_avg_bar, d_compare,
|
||||
f"Средний балл по {'организациям' if by == 'organization' else 'группам'}"),
|
||||
loop.run_in_executor(None, _build_avg_line, d_trend),
|
||||
)
|
||||
|
||||
w = self._chart_width()
|
||||
self._chart_avg_dim.content = _image_or_placeholder(img_avg, w)
|
||||
self._chart_leading.content = _image_or_placeholder(img_lead, w)
|
||||
self._chart_compare.content = _image_or_placeholder(img_compare, w)
|
||||
self._chart_trend.content = _image_or_placeholder(img_trend, w)
|
||||
|
||||
# Таблица топ
|
||||
self._top_table.rows = [
|
||||
ft.DataRow(cells=[
|
||||
ft.DataCell(ft.Text(row["name"], size=ds.s(12))),
|
||||
ft.DataCell(ft.Text(row["poll"], size=ds.s(11),
|
||||
color=ds.colors.text_secondary,
|
||||
overflow=ft.TextOverflow.ELLIPSIS,
|
||||
max_lines=1)),
|
||||
ft.DataCell(ft.Text(str(row["value"]), size=ds.s(12),
|
||||
weight=ft.FontWeight.BOLD,
|
||||
color=ds.colors.primary)),
|
||||
])
|
||||
for row in (d_top or [])
|
||||
]
|
||||
self.update()
|
||||
@@ -1,223 +0,0 @@
|
||||
"""
|
||||
designer.py для stats-приложения.
|
||||
Та же палитра и компоненты что в web/designer.py, адаптированные для дашборда.
|
||||
"""
|
||||
import flet as ft
|
||||
|
||||
SCALE: float = 1.0
|
||||
|
||||
|
||||
def s(v: int | float) -> int:
|
||||
return round(v * SCALE)
|
||||
|
||||
|
||||
class colors:
|
||||
primary = "#F85A40"
|
||||
primary_dark = "#D94530"
|
||||
primary_light = "#FFEAB7"
|
||||
background = "#F4F6F8"
|
||||
surface = "#FFFFFF"
|
||||
surface_alt = "#F0F2F5"
|
||||
accent = "#FFC845"
|
||||
info = "#007FBD"
|
||||
success = "#85C446"
|
||||
text_primary = "#1E2A3A"
|
||||
text_secondary = "#5A6779"
|
||||
text_on_primary = "#FFFFFF"
|
||||
border = "#DDE3EA"
|
||||
|
||||
|
||||
class SearchableDropdown(ft.Container):
|
||||
"""Выпадающий список с поисковой строкой."""
|
||||
|
||||
def __init__(self, label: str = "", hint_text: str = "", options=None, on_select=None, width: int | None = None):
|
||||
self._label_str = label
|
||||
self._hint_str = hint_text
|
||||
self._options: list = options or []
|
||||
self.on_select = on_select
|
||||
self._value = None
|
||||
|
||||
self._value_text = ft.Text(
|
||||
hint_text,
|
||||
color=colors.border,
|
||||
size=s(14),
|
||||
italic=True,
|
||||
expand=True,
|
||||
no_wrap=True,
|
||||
overflow=ft.TextOverflow.ELLIPSIS,
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
content=ft.Column(
|
||||
spacing=s(2),
|
||||
tight=True,
|
||||
controls=[
|
||||
ft.Text(label, color=colors.text_secondary, size=s(12), weight=ft.FontWeight.W_500),
|
||||
ft.Row(
|
||||
controls=[
|
||||
self._value_text,
|
||||
ft.Icon(ft.Icons.ARROW_DROP_DOWN, color=colors.text_secondary, size=s(20)),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
),
|
||||
],
|
||||
),
|
||||
bgcolor=colors.surface,
|
||||
border=ft.border.all(1, colors.border),
|
||||
border_radius=s(10),
|
||||
padding=ft.padding.symmetric(horizontal=s(12), vertical=s(10)),
|
||||
on_click=self._open_dialog,
|
||||
ink=True,
|
||||
width=width or s(220),
|
||||
)
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
return self._value
|
||||
|
||||
@value.setter
|
||||
def value(self, val):
|
||||
self._value = val
|
||||
if val is None:
|
||||
self._value_text.value = self._hint_str
|
||||
self._value_text.color = colors.border
|
||||
self._value_text.italic = True
|
||||
else:
|
||||
for opt in self._options:
|
||||
if opt.key == val:
|
||||
self._value_text.value = opt.text
|
||||
self._value_text.color = colors.text_primary
|
||||
self._value_text.italic = False
|
||||
break
|
||||
try:
|
||||
if self.page:
|
||||
self.update()
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
@property
|
||||
def options(self):
|
||||
return self._options
|
||||
|
||||
@options.setter
|
||||
def options(self, opts):
|
||||
self._options = opts or []
|
||||
|
||||
@property
|
||||
def hint_text(self):
|
||||
return self._hint_str
|
||||
|
||||
@hint_text.setter
|
||||
def hint_text(self, val):
|
||||
self._hint_str = val
|
||||
if self._value is None:
|
||||
self._value_text.value = val
|
||||
if self.page:
|
||||
self.update()
|
||||
|
||||
def _open_dialog(self, e):
|
||||
search_field = ft.TextField(
|
||||
label="Поиск",
|
||||
hint_text="Введите для поиска...",
|
||||
prefix_icon=ft.Icons.SEARCH,
|
||||
autofocus=True,
|
||||
filled=True,
|
||||
bgcolor=colors.surface,
|
||||
border_color=colors.border,
|
||||
focused_border_color=colors.primary,
|
||||
border_radius=s(8),
|
||||
text_style=ft.TextStyle(color=colors.text_primary, size=s(14)),
|
||||
)
|
||||
items_list = ft.ListView(spacing=0, expand=True)
|
||||
|
||||
def _build_items(query: str = ""):
|
||||
q = query.lower()
|
||||
items_list.controls = [
|
||||
ft.ListTile(
|
||||
title=ft.Text(opt.text, color=colors.text_primary, size=s(13)),
|
||||
hover_color=colors.primary_light,
|
||||
on_click=lambda ev, o=opt: _select(o),
|
||||
)
|
||||
for opt in self._options
|
||||
if not q or q in opt.text.lower()
|
||||
]
|
||||
|
||||
def _on_search(ev):
|
||||
_build_items(search_field.value or "")
|
||||
items_list.update()
|
||||
|
||||
def _select(option):
|
||||
self._value = option.key
|
||||
self._value_text.value = option.text
|
||||
self._value_text.color = colors.text_primary
|
||||
self._value_text.italic = False
|
||||
dlg.open = False
|
||||
self.page.update()
|
||||
self.update()
|
||||
if self.on_select:
|
||||
self.on_select(type("_E", (), {"control": self})())
|
||||
|
||||
search_field.on_change = _on_search
|
||||
_build_items()
|
||||
|
||||
dlg = ft.AlertDialog(
|
||||
title=ft.Text(self._label_str, color=colors.text_primary, size=s(15), weight=ft.FontWeight.W_600),
|
||||
content=ft.Container(
|
||||
content=ft.Column(
|
||||
controls=[search_field, ft.Divider(color=colors.border), items_list],
|
||||
spacing=s(8),
|
||||
tight=True,
|
||||
),
|
||||
height=s(320),
|
||||
width=s(380),
|
||||
),
|
||||
bgcolor=colors.surface,
|
||||
shape=ft.RoundedRectangleBorder(radius=s(12)),
|
||||
)
|
||||
self.page.overlay.append(dlg)
|
||||
dlg.open = True
|
||||
self.page.update()
|
||||
|
||||
|
||||
def stat_card(title: str, value: str, icon: str, color: str) -> ft.Container:
|
||||
"""Карточка с одной цифрой для summary-панели."""
|
||||
return ft.Container(
|
||||
content=ft.Column(
|
||||
spacing=s(6),
|
||||
tight=True,
|
||||
controls=[
|
||||
ft.Row(
|
||||
spacing=s(8),
|
||||
controls=[
|
||||
ft.Icon(icon, color=color, size=s(20)),
|
||||
ft.Text(title, color=colors.text_secondary, size=s(12)),
|
||||
],
|
||||
),
|
||||
ft.Text(value, color=colors.text_primary, size=s(28), weight=ft.FontWeight.BOLD),
|
||||
],
|
||||
),
|
||||
bgcolor=colors.surface,
|
||||
border_radius=s(12),
|
||||
padding=ft.padding.all(s(16)),
|
||||
border=ft.border.all(1, colors.border),
|
||||
expand=True,
|
||||
)
|
||||
|
||||
|
||||
def chart_card(title: str, body: ft.Control) -> ft.Container:
|
||||
"""Обёртка-карточка для графика."""
|
||||
return ft.Container(
|
||||
content=ft.Column(
|
||||
spacing=s(12),
|
||||
controls=[
|
||||
ft.Text(title, color=colors.text_primary, size=s(15), weight=ft.FontWeight.W_600),
|
||||
body,
|
||||
],
|
||||
),
|
||||
bgcolor=colors.surface,
|
||||
border_radius=s(12),
|
||||
padding=ft.padding.all(s(16)),
|
||||
border=ft.border.all(1, colors.border),
|
||||
expand=True,
|
||||
)
|
||||
@@ -1,19 +0,0 @@
|
||||
import flet as ft
|
||||
import designer as ds
|
||||
from dashboard import DashboardView
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Mount
|
||||
|
||||
|
||||
async def main(page: ft.Page):
|
||||
page.title = "ЦОПП — Статистика"
|
||||
page.bgcolor = ds.colors.background
|
||||
page.padding = ft.padding.all(0)
|
||||
page.scroll = ft.ScrollMode.HIDDEN
|
||||
|
||||
view = DashboardView(page)
|
||||
page.add(view)
|
||||
|
||||
|
||||
_flet_app = ft.run(main, export_asgi_app=True, assets_dir="assets")
|
||||
app = Starlette(routes=[Mount("/", _flet_app)])
|
||||
102
uv.lock
generated
@@ -37,15 +37,18 @@ name = "api-copp"
|
||||
version = "0.3.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "bcrypt" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "flet" },
|
||||
{ name = "jose" },
|
||||
{ name = "matplotlib" },
|
||||
{ name = "passlib" },
|
||||
{ name = "passlib", extra = ["bcrypt"] },
|
||||
{ name = "pg8000" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-docx" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "python-jose" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "redis" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
@@ -61,18 +64,21 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "bcrypt", specifier = "==4.0.1" },
|
||||
{ name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" },
|
||||
{ name = "fastapi", specifier = ">=0.128.0" },
|
||||
{ name = "flet", specifier = ">=0.82.2" },
|
||||
{ name = "jose", specifier = ">=1.0.0" },
|
||||
{ name = "matplotlib", specifier = ">=3.10.8" },
|
||||
{ name = "passlib", specifier = ">=1.7.4" },
|
||||
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
|
||||
{ name = "pg8000", specifier = ">=1.29.0" },
|
||||
{ name = "pydantic", specifier = ">=2.12.5" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" },
|
||||
{ name = "python-docx", specifier = ">=1.2.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.2.1" },
|
||||
{ name = "python-jose", specifier = ">=3.5.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.20" },
|
||||
{ name = "redis", specifier = ">=7.1.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0.47" },
|
||||
@@ -89,6 +95,25 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/7f/09065fd9e27da0eda08b4d6897f1c13535066174cc023af248fc2a8d5e5a/asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67", size = 105045, upload-time = "2022-03-15T14:46:51.055Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bcrypt"
|
||||
version = "4.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/ae/3af7d006aacf513975fd1948a6b4d6f8b4a307f8a244e1a3d3774b297aad/bcrypt-4.0.1.tar.gz", hash = "sha256:27d375903ac8261cfe4047f6709d16f7d18d39b1ec92aaf72af989552a650ebd", size = 25498, upload-time = "2022-10-09T15:36:49.775Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/d4/3b2657bd58ef02b23a07729b0df26f21af97169dbd0b5797afa9e97ebb49/bcrypt-4.0.1-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:b1023030aec778185a6c16cf70f359cbb6e0c289fd564a7cfa29e727a1c38f8f", size = 473446, upload-time = "2022-10-09T15:36:25.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/0a/1582790232fef6c2aa201f345577306b8bfe465c2c665dec04c86a016879/bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:08d2947c490093a11416df18043c27abe3921558d2c03e2076ccb28a116cb6d0", size = 583044, upload-time = "2022-10-09T15:37:09.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/16/49ff5146fb815742ad58cafb5034907aa7f166b1344d0ddd7fd1c818bd17/bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0eaa47d4661c326bfc9d08d16debbc4edf78778e6aaba29c1bc7ce67214d4410", size = 583189, upload-time = "2022-10-09T15:37:10.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/48/fd2b197a9741fa790ba0b88a9b10b5e88e62ff5cf3e1bc96d8354d7ce613/bcrypt-4.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae88eca3024bb34bb3430f964beab71226e761f51b912de5133470b649d82344", size = 593473, upload-time = "2022-10-09T15:36:27.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/50/e683d8418974a602ba40899c8a5c38b3decaf5a4d36c32fc65dce454d8a8/bcrypt-4.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:a522427293d77e1c29e303fc282e2d71864579527a04ddcfda6d4f8396c6c36a", size = 593249, upload-time = "2022-10-09T15:36:28.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/a7/ee4561fd9b78ca23c8e5591c150cc58626a5dfb169345ab18e1c2c664ee0/bcrypt-4.0.1-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:fbdaec13c5105f0c4e5c52614d04f0bca5f5af007910daa8b6b12095edaa67b3", size = 583586, upload-time = "2022-10-09T15:37:11.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/fe/da28a5916128d541da0993328dc5cf4b43dfbf6655f2c7a2abe26ca2dc88/bcrypt-4.0.1-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ca3204d00d3cb2dfed07f2d74a25f12fc12f73e606fcaa6975d1f7ae69cacbb2", size = 593659, upload-time = "2022-10-09T15:36:30.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/4f/3632a69ce344c1551f7c9803196b191a8181c6a1ad2362c225581ef0d383/bcrypt-4.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:089098effa1bc35dc055366740a067a2fc76987e8ec75349eb9484061c54f535", size = 613116, upload-time = "2022-10-09T15:37:14.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/69/edacb37481d360d06fc947dab5734aaf511acb7d1a1f9e2849454376c0f8/bcrypt-4.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:e9a51bbfe7e9802b5f3508687758b564069ba937748ad7b9e890086290d2f79e", size = 624290, upload-time = "2022-10-09T15:36:31.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/ca/6a534669890725cbb8c1fb4622019be31813c8edaa7b6d5b62fc9360a17e/bcrypt-4.0.1-cp36-abi3-win32.whl", hash = "sha256:2caffdae059e06ac23fce178d31b4a702f2a3264c20bfb5ff541b338194d8fab", size = 159428, upload-time = "2022-10-09T15:36:32.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/81/d8c22cd7e5e1c6a7d48e41a1d1d46c92f17dae70a54d9814f746e6027dec/bcrypt-4.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:8a68f4341daf7522fe8d73874de8906f3a339048ba406be6ddc1b3ccb16fc0d9", size = 152930, upload-time = "2022-10-09T15:36:34.635Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "black"
|
||||
version = "26.1.0"
|
||||
@@ -259,7 +284,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" },
|
||||
@@ -268,7 +292,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },
|
||||
@@ -389,6 +412,50 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lxml"
|
||||
version = "6.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matplotlib"
|
||||
version = "3.10.8"
|
||||
@@ -513,6 +580,11 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
bcrypt = [
|
||||
{ name = "bcrypt" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "1.0.4"
|
||||
@@ -707,6 +779,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-docx"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "lxml" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.1"
|
||||
@@ -730,6 +815,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.32"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytokens"
|
||||
version = "0.4.1"
|
||||
|
||||
6
web-react/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
*.local
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
4
web-react/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
*.local
|
||||
.vite
|
||||
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
@@ -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
@@ -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>
|
||||
43
web-react/nginx.conf
Normal file
@@ -0,0 +1,43 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Статика SPA: всё неизвестное отдаём index.html (клиентский роутинг).
|
||||
# index.html без явного Cache-Control браузер кэширует эвристически (по
|
||||
# Last-Modified) — после деплоя часть пользователей видит старую версию,
|
||||
# пока не сделают hard refresh. no-cache вынуждает всегда идти на сервер
|
||||
# за свежим HTML (быстрый 304 по ETag, если файл не менялся); хэшированные
|
||||
# /assets/ ниже как кэшировались на год, так и кэшируются — их имя меняется
|
||||
# при каждой сборке, так что инвалидация не нужна.
|
||||
location / {
|
||||
add_header Cache-Control "no-cache";
|
||||
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
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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 851 B After Width: | Height: | Size: 851 B |
|
Before Width: | Height: | Size: 737 B After Width: | Height: | Size: 737 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
46
web-react/src/App.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { AuthProvider } from "./auth/AuthContext";
|
||||
import { ThemeProvider } from "./theme/ThemeContext";
|
||||
import { ProtectedRoute, AccountRoute } from "./components/ProtectedRoute";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { RegisterPage } from "./pages/RegisterPage";
|
||||
import { AnonymousEntryPage } from "./pages/AnonymousEntryPage";
|
||||
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>;
|
||||
}
|
||||
|
||||
// Требует настоящий аккаунт — история и профиль анонимусам недоступны.
|
||||
function account(el: React.ReactNode) {
|
||||
return <AccountRoute>{el}</AccountRoute>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/anonymous" element={<AnonymousEntryPage />} />
|
||||
<Route path="/" element={protect(<HomePage />)} />
|
||||
<Route path="/tests" element={protect(<TestListPage />)} />
|
||||
<Route path="/poll/:pollId" element={protect(<PollPage />)} />
|
||||
<Route path="/my-tests" element={account(<MyTestsPage />)} />
|
||||
<Route path="/response/:responseId" element={account(<ResponsePage />)} />
|
||||
<Route path="/profile" element={account(<ProfilePage />)} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
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: "Не удалось подключиться к серверу" };
|
||||
}
|
||||
}
|
||||
131
web-react/src/auth/AuthContext.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import * as api from "../api/client";
|
||||
|
||||
const TOKEN_KEY = "auth_token";
|
||||
const ANON_KEY = "anon_identity";
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
groupId: string;
|
||||
orgId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}
|
||||
|
||||
// Анонимная сессия: школьник выбрал школу+класс, но не регистрировался —
|
||||
// аккаунта и токена нет, только org/group для снимка в Response.
|
||||
export interface AnonIdentity {
|
||||
orgId: string;
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
interface AuthCtx {
|
||||
token: string | null;
|
||||
user: AuthUser | null;
|
||||
anon: AnonIdentity | null;
|
||||
ready: boolean; // завершена ли первичная проверка токена
|
||||
login: (username: string, password: string) => Promise<string | null>;
|
||||
loginAnonymous: (orgId: string, groupId: string) => void;
|
||||
logout: () => Promise<void>;
|
||||
setUserFromMe: (me: api.Me) => void;
|
||||
}
|
||||
|
||||
function readAnon(): AnonIdentity | null {
|
||||
const raw = localStorage.getItem(ANON_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed.orgId === "string" && typeof parsed.groupId === "string") {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// повреждённое значение — игнорируем
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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 [anon, setAnon] = useState<AnonIdentity | null>(() => readAnon());
|
||||
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.removeItem(ANON_KEY);
|
||||
setAnon(null);
|
||||
localStorage.setItem(TOKEN_KEY, data);
|
||||
setToken(data);
|
||||
const me = await api.getMe(data);
|
||||
if (me) setUser(meToUser(me));
|
||||
return null;
|
||||
};
|
||||
|
||||
// Анонимный вход: только школа+класс, без аккаунта и запроса к API.
|
||||
const loginAnonymous = (orgId: string, groupId: string) => {
|
||||
const identity: AnonIdentity = { orgId, groupId };
|
||||
localStorage.setItem(ANON_KEY, JSON.stringify(identity));
|
||||
setAnon(identity);
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
if (token) await api.logout(token);
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(ANON_KEY);
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setAnon(null);
|
||||
};
|
||||
|
||||
const setUserFromMe = (me: api.Me) => setUser(meToUser(me));
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={{ token, user, anon, ready, login, loginAnonymous, 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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
36
web-react/src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
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, anon, ready } = useAuth();
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="spinner-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!token && !anon) return <Navigate to="/login" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Строже: пускает дальше только при реальном токене (не анонимной сессии).
|
||||
// Для страниц, которым нужен настоящий аккаунт: история тестов, профиль,
|
||||
// просмотр конкретного результата.
|
||||
export function AccountRoute({ 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>,
|
||||
);
|
||||
75
web-react/src/pages/AnonymousEntryPage.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import * as api from "../api/client";
|
||||
import { Button } from "../components/ui";
|
||||
import { SearchableDropdown, type Option } from "../components/SearchableDropdown";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
|
||||
// Вход без регистрации: школьник выбирает только школу и класс, аккаунт
|
||||
// и пароль не нужны. Результат теста запишется в базу со снимком
|
||||
// организации/группы, но без привязки к пользователю.
|
||||
export function AnonymousEntryPage() {
|
||||
const navigate = useNavigate();
|
||||
const { loginAnonymous } = useAuth();
|
||||
|
||||
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("");
|
||||
|
||||
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;
|
||||
};
|
||||
}, []);
|
||||
|
||||
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 proceed = () => {
|
||||
if (!orgId || !groupId) {
|
||||
setError("Выберите школу и класс");
|
||||
return;
|
||||
}
|
||||
loginAnonymous(orgId, groupId);
|
||||
navigate("/");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-wrap">
|
||||
<div className="auth-logo">ЦОПП</div>
|
||||
<div className="auth-sub">Пройти тест без регистрации — выберите школу и класс</div>
|
||||
<form
|
||||
className="auth-card"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
proceed();
|
||||
}}
|
||||
>
|
||||
<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">Продолжить</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
web-react/src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Layout } from "../components/Layout";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
|
||||
export function HomePage() {
|
||||
const navigate = useNavigate();
|
||||
const { token, anon, logout } = useAuth();
|
||||
const isAnonymous = !token && !!anon;
|
||||
|
||||
const exitAnonymous = async () => {
|
||||
await logout();
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Добро пожаловать в ЦОПП!" showProfile={!!token}>
|
||||
<div className="home-grid">
|
||||
<div className="home-tile" onClick={() => navigate("/tests")}>
|
||||
<img src="/icons/clipboard-question_16542596.svg" alt="" />
|
||||
<span>Все тесты</span>
|
||||
</div>
|
||||
{/* «Мои тесты» требует аккаунта — у анонимной сессии нет истории */}
|
||||
{!isAnonymous && (
|
||||
<div className="home-tile" onClick={() => navigate("/my-tests")}>
|
||||
<img src="/icons/leaderboard-trophy_14227576.svg" alt="" />
|
||||
<span>Мои тесты</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isAnonymous && (
|
||||
<button type="button" className="link-btn" onClick={exitAnonymous}>
|
||||
Выйти из анонимного режима
|
||||
</button>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
82
web-react/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
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>
|
||||
<button type="button" className="link-btn" onClick={() => navigate("/anonymous")}>
|
||||
Пройти тест без регистрации
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{askReg && (
|
||||
<AgreementDialog
|
||||
onClose={() => setAskReg(false)}
|
||||
onAccept={() => navigate("/register")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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
@@ -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, anon } = 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 || anon?.groupId || null,
|
||||
organization_id: user?.orgId || anon?.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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
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
@@ -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/, ""),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,300 +0,0 @@
|
||||
"""
|
||||
api_client.py — единое место для всех HTTP-запросов к бэкенду.
|
||||
Не зависит от Flet. Все функции async, возвращают данные или None/ошибку.
|
||||
"""
|
||||
import os
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
API_URL = os.getenv("API_URL", "http://api:8000")
|
||||
_TIMEOUT = 10
|
||||
|
||||
|
||||
def build_radar_download_url(page_url: str, response_id: str) -> str:
|
||||
"""
|
||||
Строит URL для скачивания SVG-диаграммы через веб-сервер (/radar-image/{id}).
|
||||
page_url — значение page.url из Flet (может быть ws:// или wss://).
|
||||
"""
|
||||
parsed = urlparse(page_url)
|
||||
scheme = "https" if parsed.scheme in ("wss", "https") else "http"
|
||||
port = parsed.port
|
||||
port_str = f":{port}" if port and port not in (80, 443) else ""
|
||||
return f"{scheme}://{parsed.hostname}{port_str}/radar-image/{response_id}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def login(username: str, password: str) -> tuple[str | None, str | None]:
|
||||
"""
|
||||
POST /auth/login
|
||||
Возвращает (token, None) при успехе или (None, error_message) при ошибке.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
|
||||
resp = await client.post(
|
||||
"/auth/login",
|
||||
data={"username": username, "password": password},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()["access_token"], None
|
||||
elif resp.status_code == 401:
|
||||
return None, "Неверный логин или пароль"
|
||||
return None, f"Ошибка сервера: {resp.status_code}"
|
||||
except httpx.ConnectError:
|
||||
return None, "Не удалось подключиться к серверу"
|
||||
except Exception as e:
|
||||
return None, f"Ошибка: {e}"
|
||||
|
||||
|
||||
async def logout(token: str) -> None:
|
||||
"""POST /auth/logout — инвалидирует токен на сервере (Redis блэклист)."""
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
|
||||
await client.post(
|
||||
"/auth/logout",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
except Exception:
|
||||
pass # даже если сервер недоступен — локальный logout всё равно произойдёт
|
||||
|
||||
|
||||
async def get_me(token: str) -> dict | None:
|
||||
"""GET /users/me — данные текущего пользователя."""
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
|
||||
resp = await client.get(
|
||||
"/users/me",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def register(
|
||||
username: str,
|
||||
password: str,
|
||||
first_name: str,
|
||||
last_name: str,
|
||||
group_id: str | None = None,
|
||||
organization_id: str | None = None,
|
||||
) -> str | None:
|
||||
"""
|
||||
POST /auth/register
|
||||
Возвращает None при успехе или строку с ошибкой.
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
"username": username,
|
||||
"password": password,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
}
|
||||
if group_id:
|
||||
payload["group_id"] = group_id
|
||||
if organization_id:
|
||||
payload["organization_id"] = organization_id
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
|
||||
resp = await client.post("/auth/register", json=payload)
|
||||
if resp.status_code == 201:
|
||||
return None
|
||||
elif resp.status_code == 400:
|
||||
return resp.json().get("detail", "Ошибка регистрации")
|
||||
elif resp.status_code == 422:
|
||||
# Ошибки валидации Pydantic — извлекаем первое понятное сообщение
|
||||
errors = resp.json().get("detail", [])
|
||||
_field_names = {"password": "Пароль", "username": "Логин"}
|
||||
_msg_map = {
|
||||
"Password must be at least 8 characters": "Пароль должен быть не менее 8 символов",
|
||||
"Username must be at least 3 characters": "Логин должен быть не менее 3 символов",
|
||||
}
|
||||
for err in errors:
|
||||
raw = err.get("ctx", {}).get("error") or err.get("msg", "")
|
||||
if raw in _msg_map:
|
||||
return _msg_map[raw]
|
||||
# fallback: поле + сообщение
|
||||
loc = err.get("loc", [])
|
||||
field = _field_names.get(loc[-1] if loc else "", "")
|
||||
msg = err.get("msg", "Ошибка валидации").removeprefix("Value error, ")
|
||||
return f"{field}: {msg}".strip(": ")
|
||||
return "Проверьте правильность введённых данных"
|
||||
return f"Ошибка сервера: {resp.status_code}"
|
||||
except httpx.ConnectError:
|
||||
return "Не удалось подключиться к серверу"
|
||||
except Exception as e:
|
||||
return f"Ошибка: {e}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Organizations & Groups
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_organizations() -> list:
|
||||
"""GET /organizations/"""
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
|
||||
resp = await client.get("/organizations/")
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
async def get_groups() -> list:
|
||||
"""GET /groups/"""
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
|
||||
resp = await client.get("/groups/")
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Polls
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_polls() -> list:
|
||||
"""GET /polls/ — список всех опросов."""
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
|
||||
resp = await client.get("/polls/")
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
async def get_poll(poll_id: str) -> dict | None:
|
||||
"""GET /polls/{poll_id} — опрос с вопросами и вариантами ответов."""
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
|
||||
resp = await client.get(f"/polls/{poll_id}")
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def get_response(response_id: str) -> dict | None:
|
||||
"""GET /polls/responses/{response_id} — детальный ответ с answers."""
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
|
||||
resp = await client.get(f"/polls/responses/{response_id}")
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def get_radar_result(response_id: str) -> dict | None:
|
||||
"""GET /responses/{response_id}/radar — данные для радарной диаграммы."""
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
|
||||
resp = await client.get(f"/responses/{response_id}/radar")
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def get_radar_svg_bytes(response_id: str) -> bytes | None:
|
||||
"""GET /responses/{response_id}/radar/image — SVG-файл с диаграммой."""
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
|
||||
resp = await client.get(f"/responses/{response_id}/radar/image")
|
||||
if resp.status_code == 200:
|
||||
return resp.content
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def submit_response(
|
||||
poll_id: str,
|
||||
answers: list[dict],
|
||||
user_id: str | None = None,
|
||||
group_id: str | None = None,
|
||||
organization_id: str | None = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""
|
||||
POST /polls/{poll_id}/responses
|
||||
answers: [{"question_id": ..., "choice_id": ..., "text": ...}, ...]
|
||||
Возвращает (response_id, None) или (None, error_message).
|
||||
"""
|
||||
payload: dict[str, Any] = {"answers": answers}
|
||||
if user_id:
|
||||
payload["user_id"] = user_id
|
||||
if group_id:
|
||||
payload["group_id"] = group_id
|
||||
if organization_id:
|
||||
payload["organization_id"] = organization_id
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
|
||||
resp = await client.post(f"/polls/{poll_id}/responses", json=payload)
|
||||
if resp.status_code == 201:
|
||||
return resp.json()["id"], None
|
||||
return None, f"Ошибка сервера: {resp.status_code}"
|
||||
except httpx.ConnectError:
|
||||
return None, "Не удалось подключиться к серверу"
|
||||
except Exception as e:
|
||||
return None, f"Ошибка: {e}"
|
||||
|
||||
|
||||
async def get_user_responses(user_id: str) -> list:
|
||||
"""GET /polls/users/{user_id}/responses — ответы конкретного пользователя."""
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
|
||||
resp = await client.get(f"/polls/users/{user_id}/responses")
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
async def update_user(
|
||||
user_id: str,
|
||||
token: str,
|
||||
first_name: str | None = None,
|
||||
last_name: str | None = None,
|
||||
group_id: str | None = None,
|
||||
organization_id: str | None = None,
|
||||
) -> tuple[dict | None, str | None]:
|
||||
"""PUT /users/{user_id} — обновить данные пользователя."""
|
||||
payload: dict[str, Any] = {}
|
||||
if first_name is not None:
|
||||
payload["first_name"] = first_name
|
||||
if last_name is not None:
|
||||
payload["last_name"] = last_name
|
||||
if group_id is not None:
|
||||
payload["group_id"] = group_id
|
||||
if organization_id is not None:
|
||||
payload["organization_id"] = organization_id
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client:
|
||||
resp = await client.put(
|
||||
f"/users/{user_id}",
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json(), None
|
||||
return None, f"Ошибка сервера: {resp.status_code}"
|
||||
except httpx.ConnectError:
|
||||
return None, "Не удалось подключиться к серверу"
|
||||
except Exception as e:
|
||||
return None, f"Ошибка: {e}"
|
||||
|
Before Width: | Height: | Size: 114 KiB |
@@ -1,3 +0,0 @@
|
||||
# Положи сюда своё изображение с именем loading-animation.png
|
||||
# Это заменит мигающий логотип Flet при загрузке страницы.
|
||||
# Рекомендуемый размер: PNG, минимум 200x200 px.
|
||||
@@ -1,4 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 737 B |
@@ -1,4 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 30 KiB |
546
web/designer.py
@@ -1,546 +0,0 @@
|
||||
import flet as ft
|
||||
|
||||
# Глобальный масштаб интерфейса. 1.0 = 100%, 1.7 = 170%.
|
||||
# Меняйте только эту переменную (или через theme_manager.apply_scale),
|
||||
# чтобы масштабировать весь UI.
|
||||
SCALE: float = 1.7
|
||||
|
||||
|
||||
def s(v: int | float) -> int:
|
||||
"""Применяет SCALE к любому размеру: шрифтам, отступам, ицих."""
|
||||
return round(v * SCALE)
|
||||
|
||||
|
||||
class colors:
|
||||
# Основная палитра в стиле copp74.ru
|
||||
primary = "#F85A40" # Основной оранжево-красный (логотип ЦОПП)
|
||||
primary_dark = "#D94530" # Тёмный для hover/активных состояний
|
||||
primary_light = "#FFEAB7" # Светлый персиковый (вторичный фон)
|
||||
|
||||
# Фоны
|
||||
background = "#F4F6F8" # Светло-серый фон страницы
|
||||
surface = "#FFFFFF" # Белый для карточек и форм
|
||||
surface_alt = "#F0F2F5" # Альтернативные блоки
|
||||
|
||||
# Акцент — жёлтый
|
||||
accent = "#FFC845" # Жёлтый акцент
|
||||
accent_lime = "#FFC845" # Алиас для совместимости
|
||||
secondary_light = "#FFEAB7" # Алиас для совместимости
|
||||
secondary_yellow = "#FFC845" # Алиас
|
||||
|
||||
# Синий — информация, ссылки
|
||||
info = "#007FBD" # Синий
|
||||
info_light = "#D9F1F4" # Светлый голубой
|
||||
tertiary_light = "#D9F1F4" # Алиас для совместимости
|
||||
tertiary_blue = "#007FBD" # Алиас для совместимости
|
||||
secondary_orange = "#F85A40" # Алиас для совместимости
|
||||
|
||||
# Зелёный — успех
|
||||
success = "#85C446" # Зелёный
|
||||
success_light = "#C0DE9E" # Светло-зелёный
|
||||
|
||||
# Текст
|
||||
text_primary = "#1E2A3A" # Почти чёрный для заголовков
|
||||
text_secondary = "#5A6779" # Серый для второстепенного текста
|
||||
text_on_primary = "#FFFFFF" # Белый текст на оранжевом фоне
|
||||
|
||||
# Граница
|
||||
border = "#DDE3EA" # Светло-серая граница
|
||||
|
||||
class CastomText(ft.Text):
|
||||
def __init__(self, text, size=14, color=colors.text_primary, weight=ft.FontWeight.NORMAL, **kwargs):
|
||||
super().__init__(
|
||||
value=text,
|
||||
color=color,
|
||||
size=s(size),
|
||||
weight=weight,
|
||||
max_lines=99,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class CastomTextField_input(ft.TextField):
|
||||
def __init__(self, on_change=None, on_submit=None, label="Ваше имя", hint_text="Введите текст...", prefix_icon=None, suffix_icon=None, **kwargs):
|
||||
super().__init__(
|
||||
label=label,
|
||||
hint_text=hint_text,
|
||||
filled=True,
|
||||
bgcolor=colors.surface,
|
||||
border_color=colors.border,
|
||||
focused_border_color=colors.primary,
|
||||
border_radius=s(10),
|
||||
label_style=ft.TextStyle(
|
||||
color=colors.text_secondary,
|
||||
size=s(13),
|
||||
weight=ft.FontWeight.W_500,
|
||||
),
|
||||
hint_style=ft.TextStyle(
|
||||
color=colors.border,
|
||||
size=s(14),
|
||||
italic=True,
|
||||
),
|
||||
text_style=ft.TextStyle(
|
||||
color=colors.text_primary,
|
||||
size=s(15),
|
||||
),
|
||||
content_padding=ft.padding.symmetric(horizontal=s(14), vertical=s(14)),
|
||||
prefix_icon=prefix_icon,
|
||||
suffix_icon=suffix_icon,
|
||||
on_change=on_change,
|
||||
on_submit=on_submit,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
class CastomButton(ft.Button):
|
||||
def __init__(self, text, on_click):
|
||||
super().__init__(
|
||||
content=ft.Text(
|
||||
text,
|
||||
color=colors.text_on_primary,
|
||||
size=s(14),
|
||||
weight=ft.FontWeight.W_600,
|
||||
) if isinstance(text, str) else text,
|
||||
style=ft.ButtonStyle(
|
||||
color=colors.text_on_primary,
|
||||
bgcolor={
|
||||
ft.ControlState.DEFAULT: colors.primary,
|
||||
ft.ControlState.HOVERED: colors.primary_dark,
|
||||
},
|
||||
shape=ft.RoundedRectangleBorder(radius=s(10)),
|
||||
padding=ft.padding.symmetric(horizontal=s(20), vertical=s(12)),
|
||||
),
|
||||
on_click=on_click
|
||||
)
|
||||
|
||||
|
||||
class CastomIconButton(ft.Button):
|
||||
"""Кнопка с иконкой и текстом."""
|
||||
def __init__(self, text, icon, on_click):
|
||||
super().__init__(
|
||||
content=ft.Row(
|
||||
spacing=s(8),
|
||||
tight=True,
|
||||
controls=[
|
||||
ft.Icon(icon, color=colors.text_on_primary, size=s(18)),
|
||||
ft.Text(text, color=colors.text_on_primary, size=s(14), weight=ft.FontWeight.W_600),
|
||||
],
|
||||
),
|
||||
style=ft.ButtonStyle(
|
||||
bgcolor={
|
||||
ft.ControlState.DEFAULT: colors.info,
|
||||
ft.ControlState.HOVERED: "#006AA3",
|
||||
},
|
||||
shape=ft.RoundedRectangleBorder(radius=s(10)),
|
||||
padding=ft.padding.symmetric(horizontal=s(18), vertical=s(12)),
|
||||
),
|
||||
on_click=on_click,
|
||||
)
|
||||
|
||||
class CastomDropdown(ft.Dropdown):
|
||||
def __init__(self, label="", hint_text="", options=None, on_change=None):
|
||||
super().__init__(
|
||||
label=label,
|
||||
hint_text=hint_text,
|
||||
options=options or [],
|
||||
filled=True,
|
||||
fill_color=colors.surface,
|
||||
color=colors.text_primary,
|
||||
border_color=colors.border,
|
||||
border_radius=s(10),
|
||||
focused_border_color=colors.primary,
|
||||
label_style=ft.TextStyle(
|
||||
color=colors.text_secondary,
|
||||
size=s(13),
|
||||
weight=ft.FontWeight.W_500,
|
||||
),
|
||||
hint_style=ft.TextStyle(
|
||||
color=colors.border,
|
||||
size=s(14),
|
||||
italic=True,
|
||||
),
|
||||
text_style=ft.TextStyle(
|
||||
color=colors.text_primary,
|
||||
size=s(15),
|
||||
),
|
||||
menu_style=ft.MenuStyle(
|
||||
bgcolor=colors.surface,
|
||||
),
|
||||
)
|
||||
if on_change is not None:
|
||||
self.on_select = on_change
|
||||
|
||||
|
||||
class SearchableDropdown(ft.Container):
|
||||
"""Выпадающий список с поисковой строкой — замена CastomDropdown для больших наборов данных."""
|
||||
|
||||
def __init__(self, label: str = "", hint_text: str = "", options=None, on_select=None):
|
||||
self._label_str = label
|
||||
self._hint_str = hint_text
|
||||
self._options: list = options or []
|
||||
self.on_select = on_select
|
||||
self._value = None
|
||||
|
||||
self._value_text = ft.Text(
|
||||
hint_text,
|
||||
color=colors.border,
|
||||
size=s(14),
|
||||
italic=True,
|
||||
expand=True,
|
||||
no_wrap=True,
|
||||
overflow=ft.TextOverflow.ELLIPSIS,
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
content=ft.Column(
|
||||
spacing=s(2),
|
||||
tight=True,
|
||||
controls=[
|
||||
ft.Text(
|
||||
label,
|
||||
color=colors.text_secondary,
|
||||
size=s(13),
|
||||
weight=ft.FontWeight.W_500,
|
||||
),
|
||||
ft.Row(
|
||||
controls=[
|
||||
self._value_text,
|
||||
ft.Icon(ft.Icons.ARROW_DROP_DOWN, color=colors.text_secondary, size=s(20)),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
),
|
||||
],
|
||||
),
|
||||
bgcolor=colors.surface,
|
||||
border=ft.border.all(1, colors.border),
|
||||
border_radius=s(10),
|
||||
padding=ft.padding.symmetric(horizontal=s(14), vertical=s(12)),
|
||||
on_click=self._open_dialog,
|
||||
ink=True,
|
||||
)
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
return self._value
|
||||
|
||||
@value.setter
|
||||
def value(self, val):
|
||||
self._value = val
|
||||
if val is None:
|
||||
self._value_text.value = self._hint_str
|
||||
self._value_text.color = colors.border
|
||||
self._value_text.italic = True
|
||||
else:
|
||||
for opt in self._options:
|
||||
if opt.key == val:
|
||||
self._value_text.value = opt.text
|
||||
self._value_text.color = colors.text_primary
|
||||
self._value_text.italic = False
|
||||
break
|
||||
if self.page:
|
||||
self.update()
|
||||
|
||||
@property
|
||||
def options(self):
|
||||
return self._options
|
||||
|
||||
@options.setter
|
||||
def options(self, opts):
|
||||
self._options = opts or []
|
||||
|
||||
@property
|
||||
def hint_text(self):
|
||||
return self._hint_str
|
||||
|
||||
@hint_text.setter
|
||||
def hint_text(self, val):
|
||||
self._hint_str = val
|
||||
if self._value is None:
|
||||
self._value_text.value = val
|
||||
if self.page:
|
||||
self.update()
|
||||
|
||||
def _open_dialog(self, e):
|
||||
search_field = CastomTextField_input(
|
||||
label="Поиск",
|
||||
hint_text="Введите для поиска...",
|
||||
prefix_icon=ft.Icons.SEARCH,
|
||||
autofocus=True,
|
||||
)
|
||||
items_list = ft.ListView(spacing=0, expand=True)
|
||||
|
||||
def _build_items(query: str = ""):
|
||||
q = query.lower()
|
||||
items_list.controls = [
|
||||
ft.ListTile(
|
||||
title=ft.Text(opt.text, color=colors.text_primary, size=s(14)),
|
||||
hover_color=colors.primary_light,
|
||||
on_click=lambda ev, o=opt: _select(o),
|
||||
)
|
||||
for opt in self._options
|
||||
if not q or q in opt.text.lower()
|
||||
]
|
||||
|
||||
def _on_search(ev):
|
||||
_build_items(search_field.value or "")
|
||||
items_list.update()
|
||||
|
||||
def _select(option):
|
||||
self._value = option.key
|
||||
self._value_text.value = option.text
|
||||
self._value_text.color = colors.text_primary
|
||||
self._value_text.italic = False
|
||||
dlg.open = False
|
||||
self.page.update()
|
||||
self.update()
|
||||
if self.on_select:
|
||||
self.on_select(type("_E", (), {"control": self})())
|
||||
|
||||
search_field.on_change = _on_search
|
||||
_build_items()
|
||||
|
||||
dlg = ft.AlertDialog(
|
||||
title=ft.Text(
|
||||
self._label_str,
|
||||
color=colors.text_primary,
|
||||
size=s(16),
|
||||
weight=ft.FontWeight.W_600,
|
||||
),
|
||||
content=ft.Container(
|
||||
content=ft.Column(
|
||||
controls=[
|
||||
search_field,
|
||||
ft.Divider(color=colors.border),
|
||||
items_list,
|
||||
],
|
||||
spacing=s(8),
|
||||
tight=True,
|
||||
),
|
||||
height=s(350),
|
||||
width=s(400),
|
||||
),
|
||||
bgcolor=colors.surface,
|
||||
shape=ft.RoundedRectangleBorder(radius=s(12)),
|
||||
)
|
||||
|
||||
self.page.overlay.append(dlg)
|
||||
dlg.open = True
|
||||
self.page.update()
|
||||
|
||||
|
||||
class CastomSwitch(ft.Switch):
|
||||
def __init__(self, label, on_change):
|
||||
super().__init__(
|
||||
label=label,
|
||||
label_text_style=ft.TextStyle(size=s(13), color=colors.text_primary),
|
||||
active_color=colors.primary,
|
||||
active_track_color=colors.primary_light,
|
||||
inactive_thumb_color=colors.border,
|
||||
inactive_track_color=colors.surface_alt,
|
||||
on_change=on_change
|
||||
)
|
||||
|
||||
|
||||
class AlterDiaalogApproval(ft.AlertDialog):
|
||||
def __init__(self, on_accept=None):
|
||||
self._on_accept = on_accept
|
||||
|
||||
self.switch_approval = CastomSwitch(
|
||||
label="",
|
||||
on_change=self.switch_changed
|
||||
)
|
||||
|
||||
self.button_approval = CastomButton(
|
||||
text="Принять",
|
||||
on_click=self.accept
|
||||
)
|
||||
self.button_approval.disabled = True
|
||||
|
||||
super().__init__(
|
||||
bgcolor=colors.surface,
|
||||
title=ft.Text(
|
||||
"Пользовательское соглашение",
|
||||
color=colors.primary,
|
||||
size=s(16),
|
||||
weight=ft.FontWeight.BOLD,
|
||||
),
|
||||
content=ft.Container(
|
||||
height=s(400),
|
||||
content=ft.Column(
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
expand=True,
|
||||
controls=[
|
||||
ft.Markdown(
|
||||
"""
|
||||
# ПОЛЬЗОВАТЕЛЬСКОЕ СОГЛАШЕНИЕ
|
||||
|
||||
## 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
|
||||
|
||||
---
|
||||
|
||||
*Соглашение соответствует законодательству Российской Федерации, включая Федеральный закон «О защите персональных данных» и Федеральный закон «О защите прав потребителей».*
|
||||
""",
|
||||
expand=True,
|
||||
md_style_sheet=ft.MarkdownStyleSheet(
|
||||
p_text_style=ft.TextStyle(color=colors.text_primary, size=s(12)),
|
||||
h1_text_style=ft.TextStyle(color=colors.primary, size=s(18), weight=ft.FontWeight.BOLD),
|
||||
h2_text_style=ft.TextStyle(color=colors.info, size=s(15), weight=ft.FontWeight.BOLD),
|
||||
h3_text_style=ft.TextStyle(color=colors.text_secondary, size=s(13), weight=ft.FontWeight.BOLD),
|
||||
a_text_style=ft.TextStyle(color=colors.primary),
|
||||
),
|
||||
)
|
||||
]
|
||||
),
|
||||
),
|
||||
actions=[
|
||||
ft.Column(
|
||||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
controls=[
|
||||
ft.Row(
|
||||
expand=True,
|
||||
alignment=ft.MainAxisAlignment.START,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
controls=[
|
||||
self.switch_approval,
|
||||
ft.Text(
|
||||
"Я согласен с условиями пользовательского соглашения",
|
||||
size=s(13),
|
||||
color=colors.text_primary,
|
||||
expand=True,
|
||||
no_wrap=False,
|
||||
),
|
||||
]
|
||||
),
|
||||
ft.Row(
|
||||
expand=True,
|
||||
alignment=ft.MainAxisAlignment.CENTER,
|
||||
controls=[
|
||||
self.button_approval,
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
],
|
||||
actions_alignment=ft.MainAxisAlignment.CENTER,
|
||||
)
|
||||
|
||||
def switch_changed(self, e):
|
||||
#self.switch_approval.value = not e.control.value
|
||||
self.button_approval.disabled = not e.control.value
|
||||
self.update()
|
||||
|
||||
def accept(self, e):
|
||||
self.open = False
|
||||
self.page.update()
|
||||
if self._on_accept:
|
||||
self._on_accept()
|
||||
else:
|
||||
self.page.run_task(self.page.push_route, "/reg")
|
||||
77
web/info.py
@@ -1,77 +0,0 @@
|
||||
import secrets
|
||||
import flet as ft
|
||||
|
||||
import api_client
|
||||
|
||||
_TOKEN_KEY = "auth_token"
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""Сервис авторизации: логин, логаут, проверка токена.
|
||||
HTTP-запросы делегируются в api_client.
|
||||
"""
|
||||
|
||||
def __init__(self, page: ft.Page):
|
||||
self._page = page
|
||||
|
||||
async def login(self, username: str, password: str) -> str | None:
|
||||
"""POST /auth/login. Возвращает None при успехе, строку с ошибкой при неудаче."""
|
||||
token, error = await api_client.login(username, password)
|
||||
if error:
|
||||
return error
|
||||
self._page.session.store.set(_TOKEN_KEY, token)
|
||||
self._page.session.store.set("url_key", secrets.token_hex(16)) # в адресную строку
|
||||
self._page.session.store.set("session_key", secrets.token_hex(16)) # только в память
|
||||
me = await api_client.get_me(token)
|
||||
if me:
|
||||
self._page.session.store.set("user_id", me["id"])
|
||||
self._page.session.store.set("group_id", me.get("group_id") or "")
|
||||
self._page.session.store.set("org_id", me.get("organization_id") or "")
|
||||
return None
|
||||
|
||||
async def get_me(self) -> dict | None:
|
||||
"""Возвращает данные текущего пользователя или None."""
|
||||
token = self._page.session.store.get(_TOKEN_KEY)
|
||||
if not token:
|
||||
return None
|
||||
return await api_client.get_me(token)
|
||||
|
||||
async def logout(self):
|
||||
"""Инвалидирует токен на сервере и удаляет локальную сессию."""
|
||||
token = self._page.session.store.get(_TOKEN_KEY)
|
||||
if token:
|
||||
await api_client.logout(token)
|
||||
self._page.session.store.remove(_TOKEN_KEY)
|
||||
self._page.session.store.remove("user_id")
|
||||
self._page.session.store.remove("url_key")
|
||||
self._page.session.store.remove("session_key")
|
||||
self._page.session.store.remove("group_id")
|
||||
self._page.session.store.remove("org_id")
|
||||
|
||||
async def get_token(self) -> str | None:
|
||||
"""Возвращает сохранённый токен или None если не авторизован."""
|
||||
return self._page.session.store.get(_TOKEN_KEY)
|
||||
|
||||
async def is_authenticated(self) -> bool:
|
||||
token = await self.get_token()
|
||||
return token is not None and token != ""
|
||||
|
||||
async def register(
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
first_name: str,
|
||||
last_name: str,
|
||||
group_id: str | None = None,
|
||||
organization_id: str | None = None,
|
||||
) -> str | None:
|
||||
"""POST /auth/register. Возвращает None при успехе, строку с ошибкой при неудаче."""
|
||||
return await api_client.register(
|
||||
username, password, first_name, last_name, group_id, organization_id
|
||||
)
|
||||
|
||||
async def get_organizations(self) -> list:
|
||||
return await api_client.get_organizations()
|
||||
|
||||
async def get_groups(self) -> list:
|
||||
return await api_client.get_groups()
|
||||
69
web/main.py
@@ -1,69 +0,0 @@
|
||||
import os
|
||||
import flet as ft
|
||||
import httpx
|
||||
import designer as ds
|
||||
from router import setup_router
|
||||
import theme_manager as tm
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route, Mount
|
||||
from starlette.responses import Response as StarletteResponse
|
||||
|
||||
_API_URL = os.getenv("API_URL", "http://api:8000")
|
||||
|
||||
|
||||
async def _radar_image(request):
|
||||
"""Проксирует SVG-диаграмму с API — браузер скачивает с собственного хоста."""
|
||||
response_id = request.path_params["response_id"]
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.get(f"{_API_URL}/responses/{response_id}/radar/image")
|
||||
if r.status_code == 200:
|
||||
return StarletteResponse(
|
||||
content=r.content,
|
||||
media_type="image/svg+xml",
|
||||
headers={"Content-Disposition": 'attachment; filename="diagram.svg"'},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return StarletteResponse(status_code=404)
|
||||
|
||||
|
||||
async def main(page: ft.Page):
|
||||
page.title = "COPP"
|
||||
|
||||
# Применяем сохранённые тему и масштаб до построения любых виджетов
|
||||
tm.apply_scale(page)
|
||||
tm.apply_theme(page)
|
||||
|
||||
# Масштабирование глобального text_theme синхронно с ds.SCALE:
|
||||
# покрывает ft.Text() без явного size (body_medium по умолчанию = 14)
|
||||
_ts = lambda base: ft.TextStyle(size=ds.s(base))
|
||||
page.theme = ft.Theme(
|
||||
text_theme=ft.TextTheme(
|
||||
body_large=_ts(16),
|
||||
body_medium=_ts(14),
|
||||
body_small=_ts(12),
|
||||
label_large=_ts(14),
|
||||
label_medium=_ts(12),
|
||||
label_small=_ts(11),
|
||||
title_large=_ts(22),
|
||||
title_medium=_ts(16),
|
||||
title_small=_ts(14),
|
||||
headline_medium=_ts(28),
|
||||
headline_small=_ts(24),
|
||||
display_small=_ts(36),
|
||||
),
|
||||
)
|
||||
setup_router(page)
|
||||
initial = page.route if page.route and page.route not in ("/", "") else "/login"
|
||||
await page.push_route(initial)
|
||||
|
||||
|
||||
_flet_app = ft.run(main, export_asgi_app=True, assets_dir="assets")
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/radar-image/{response_id}", _radar_image),
|
||||
Mount("/", _flet_app),
|
||||
]
|
||||
)
|
||||
@@ -1,153 +0,0 @@
|
||||
"""
|
||||
RadarChart — переиспользуемый виджет 2D паутинной диаграммы (ft.Stack).
|
||||
Отрицательные значения — красный полигон, положительные — фиолетовый.
|
||||
|
||||
Параметры:
|
||||
items — список осей из API /responses/{id}/radar:
|
||||
[{dimension_name, dimension_color, dimension_position, value}, ...]
|
||||
max_val — максимум на шкале (default 14 — для теста Голланда)
|
||||
"""
|
||||
import math
|
||||
import flet as ft
|
||||
import flet.canvas as cv
|
||||
import designer as ds
|
||||
|
||||
# ── размеры ─────────────────────────────────────────────────────────────────
|
||||
_CS = 260 # размер canvas
|
||||
_CX = _CS // 2
|
||||
_CY = _CS // 2
|
||||
_R = 78 # радиус внешнего кольца
|
||||
_PAD = 95 # отступ вокруг canvas под подписи
|
||||
_LBL_R = _R + 52 # расстояние от центра до подписи (в координатах Stack)
|
||||
_W_LBL = 130 # ширина контейнера подписи
|
||||
|
||||
|
||||
def _axis_xy(n: int, i: int, frac: float = 1.0) -> tuple[float, float]:
|
||||
a = math.radians(-90.0 + 360.0 * i / n)
|
||||
return _CX + _R * frac * math.cos(a), _CY + _R * frac * math.sin(a)
|
||||
|
||||
|
||||
def _closed_path(pts: list[tuple]) -> list:
|
||||
els = [cv.Path.MoveTo(*pts[0])]
|
||||
for p in pts[1:]:
|
||||
els.append(cv.Path.LineTo(*p))
|
||||
els.append(cv.Path.Close())
|
||||
return els
|
||||
|
||||
|
||||
class RadarChart(ft.Stack):
|
||||
"""2D паутинная диаграмма. Отрицательные=красный, позитивные=синий."""
|
||||
|
||||
def __init__(self, items: list[dict], max_val: float = 14.0):
|
||||
n = len(items)
|
||||
shapes: list = []
|
||||
vals = [it["value"] for it in items]
|
||||
max_abs = max((abs(v) for v in vals), default=1.0) if vals else 1.0
|
||||
if max_val > 0:
|
||||
max_abs = max(max_abs, max_val)
|
||||
max_abs = max(max_abs, 1.0)
|
||||
|
||||
# ── Сетка ─────────────────────────────────────────────────────────────
|
||||
for lvl in range(1, 6):
|
||||
ring = [_axis_xy(n, i, lvl / 5) for i in range(n)]
|
||||
alpha = int((0.10 + 0.09 * (lvl / 5)) * 255)
|
||||
shapes.append(cv.Path(
|
||||
_closed_path(ring),
|
||||
paint=ft.Paint(
|
||||
style=ft.PaintingStyle.STROKE,
|
||||
stroke_width=1,
|
||||
color=f"#{alpha:02X}F4EFFA",
|
||||
),
|
||||
))
|
||||
|
||||
# ── Оси от центра ──────────────────────────────────────────────────────
|
||||
for i in range(n):
|
||||
x, y = _axis_xy(n, i)
|
||||
shapes.append(cv.Path(
|
||||
[cv.Path.MoveTo(_CX, _CY), cv.Path.LineTo(x, y)],
|
||||
paint=ft.Paint(stroke_width=1, color="#38F4EFFA"),
|
||||
))
|
||||
|
||||
# ── Полигоны данных ────────────────────────────────────────────────────
|
||||
neg_pts = [_axis_xy(n, i, max(0.0, min(-it["value"] / max_abs, 1.0))) for i, it in enumerate(items)]
|
||||
pos_pts = [_axis_xy(n, i, max(0.0, min( it["value"] / max_abs, 1.0))) for i, it in enumerate(items)]
|
||||
|
||||
# Красный (отрицательные)
|
||||
shapes.append(cv.Path(_closed_path(neg_pts),
|
||||
paint=ft.Paint(style=ft.PaintingStyle.FILL, color="#50CC3030")))
|
||||
shapes.append(cv.Path(_closed_path(neg_pts),
|
||||
paint=ft.Paint(style=ft.PaintingStyle.STROKE, stroke_width=2.5, color="#FF7070")))
|
||||
|
||||
# Фиолетовый (положительные)
|
||||
shapes.append(cv.Path(_closed_path(pos_pts),
|
||||
paint=ft.Paint(style=ft.PaintingStyle.FILL, color="#505030A0")))
|
||||
shapes.append(cv.Path(_closed_path(pos_pts),
|
||||
paint=ft.Paint(style=ft.PaintingStyle.STROKE, stroke_width=2.5, color="#A080FF")))
|
||||
|
||||
# ── Точки на вершинах ──────────────────────────────────────────────────
|
||||
for i, it in enumerate(items):
|
||||
v = it["value"]
|
||||
if abs(v) > 0.05:
|
||||
px, py = _axis_xy(n, i, abs(v) / max_abs)
|
||||
col = "#FF7070" if v < 0 else "#A080FF"
|
||||
shapes.append(cv.Circle(px, py, 4, paint=ft.Paint(color=col)))
|
||||
shapes.append(cv.Circle(px, py, 4,
|
||||
paint=ft.Paint(style=ft.PaintingStyle.STROKE, stroke_width=1.5, color="#F4EFFA")))
|
||||
|
||||
canvas = cv.Canvas(shapes=shapes, width=_CS, height=_CS)
|
||||
|
||||
# ── Подписи осей в Stack-координатах ─────────────────────────────────
|
||||
sw = _CS + 2 * _PAD
|
||||
sh = _CS + 2 * _PAD
|
||||
ccx = _PAD + _CX # центр графика в Stack
|
||||
ccy = _PAD + _CY
|
||||
|
||||
labels: list[ft.Control] = []
|
||||
for i, item in enumerate(items):
|
||||
a = math.radians(-90.0 + 360.0 * i / n)
|
||||
lx = ccx + _LBL_R * math.cos(a)
|
||||
ly = ccy + _LBL_R * math.sin(a)
|
||||
col = item.get("dimension_color") or ds.colors.primary
|
||||
|
||||
labels.append(ft.Container(
|
||||
left=lx - _W_LBL / 2,
|
||||
top=ly - 24,
|
||||
width=_W_LBL,
|
||||
content=ft.Column(
|
||||
spacing=1,
|
||||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
controls=[
|
||||
ft.Text(
|
||||
item["dimension_name"],
|
||||
size=10,
|
||||
color=col,
|
||||
text_align=ft.TextAlign.CENTER,
|
||||
weight=ft.FontWeight.BOLD,
|
||||
overflow=ft.TextOverflow.FADE,
|
||||
max_lines=2,
|
||||
no_wrap=False,
|
||||
),
|
||||
ft.Text(
|
||||
str(int(item["value"])),
|
||||
size=11,
|
||||
color=ds.colors.primary,
|
||||
text_align=ft.TextAlign.CENTER,
|
||||
),
|
||||
],
|
||||
),
|
||||
))
|
||||
|
||||
super().__init__(
|
||||
width=sw,
|
||||
height=sh,
|
||||
controls=[
|
||||
ft.Container(
|
||||
left=_PAD,
|
||||
top=_PAD,
|
||||
width=_CS,
|
||||
height=_CS,
|
||||
content=canvas,
|
||||
),
|
||||
*labels,
|
||||
],
|
||||
)
|
||||
128
web/radar_svg.py
@@ -1,128 +0,0 @@
|
||||
"""
|
||||
radar_svg.py — генерация SVG паутинной диаграммы для сохранения.
|
||||
Не требует сторонних зависимостей (чистый Python).
|
||||
Отрицательные значения — красный полигон, положительные — фиолетовый.
|
||||
"""
|
||||
import math
|
||||
import base64
|
||||
|
||||
|
||||
def generate_radar_svg(items: list[dict], max_val: float = 14.0, size: int = 520) -> str:
|
||||
"""
|
||||
Возвращает SVG-строку паутинной диаграммы.
|
||||
|
||||
items — список осей (dimension_name, dimension_color, value)
|
||||
max_val — максимум шкалы
|
||||
size — размер холста в пикселях
|
||||
"""
|
||||
n = len(items)
|
||||
if n < 3:
|
||||
return (
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}">'
|
||||
f'<text x="{size//2}" y="{size//2}" text-anchor="middle" fill="#888" '
|
||||
f'font-family="Arial,sans-serif" font-size="16">Нет данных</text></svg>'
|
||||
)
|
||||
|
||||
cx = size / 2
|
||||
cy = size / 2
|
||||
R = size * 0.255
|
||||
lbl_r = R + size * 0.105
|
||||
|
||||
vals = [it["value"] for it in items]
|
||||
max_abs = max((abs(v) for v in vals), default=1.0)
|
||||
if max_val > 0:
|
||||
max_abs = max(max_abs, max_val)
|
||||
max_abs = max(max_abs, 1.0)
|
||||
|
||||
def axis_xy(i: int, frac: float = 1.0) -> tuple[float, float]:
|
||||
a = math.radians(-90.0 + 360.0 * i / n)
|
||||
return cx + R * frac * math.cos(a), cy + R * frac * math.sin(a)
|
||||
|
||||
def poly(pts: list[tuple]) -> str:
|
||||
return " ".join(f"{x:.2f},{y:.2f}" for x, y in pts)
|
||||
|
||||
lines: list[str] = [
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}">',
|
||||
]
|
||||
|
||||
# ── Сетка ─────────────────────────────────────────────────────────────────
|
||||
for lvl in range(1, 6):
|
||||
frac = lvl / 5
|
||||
pts = [axis_xy(i, frac) for i in range(n)]
|
||||
alpha = 0.12 + 0.09 * frac
|
||||
lines.append(
|
||||
f'<polygon points="{poly(pts)}" fill="none" '
|
||||
f'stroke="rgba(170,130,230,{alpha:.2f})" stroke-width="1"/>'
|
||||
)
|
||||
|
||||
# ── Оси от центра ─────────────────────────────────────────────────────────
|
||||
for i in range(n):
|
||||
x, y = axis_xy(i)
|
||||
lines.append(
|
||||
f'<line x1="{cx:.2f}" y1="{cy:.2f}" x2="{x:.2f}" y2="{y:.2f}" '
|
||||
f'stroke="rgba(170,130,230,0.45)" stroke-width="1"/>'
|
||||
)
|
||||
|
||||
# ── Полигоны данных ───────────────────────────────────────────────────────
|
||||
neg_pts = [axis_xy(i, max(0.0, min(-it["value"] / max_abs, 1.0))) for i, it in enumerate(items)]
|
||||
pos_pts = [axis_xy(i, max(0.0, min( it["value"] / max_abs, 1.0))) for i, it in enumerate(items)]
|
||||
|
||||
lines.append(f'<polygon points="{poly(neg_pts)}" fill="rgba(220,50,50,0.30)" stroke="#FF7070" stroke-width="3.5"/>')
|
||||
lines.append(f'<polygon points="{poly(pos_pts)}" fill="rgba(100,60,200,0.32)" stroke="#A080FF" stroke-width="3.5"/>')
|
||||
|
||||
# ── Точки на вершинах ─────────────────────────────────────────────────────
|
||||
for i, it in enumerate(items):
|
||||
v = it["value"]
|
||||
if abs(v) > 0.05:
|
||||
x, y = axis_xy(i, abs(v) / max_abs)
|
||||
col = "#FF7070" if v < 0 else "#A080FF"
|
||||
lines.append(
|
||||
f'<circle cx="{x:.2f}" cy="{y:.2f}" r="6" fill="{col}" '
|
||||
f'stroke="rgba(255,255,255,0.70)" stroke-width="1.5"/>'
|
||||
)
|
||||
|
||||
# ── Метки шкалы (первая ось) ──────────────────────────────────────────────
|
||||
for lvl in range(1, 6):
|
||||
frac = lvl / 5
|
||||
tx, ty = axis_xy(0, frac)
|
||||
lines.append(
|
||||
f'<text x="{tx + 4:.2f}" y="{ty - 2:.2f}" font-family="Arial,sans-serif" '
|
||||
f'font-size="9" fill="rgba(170,130,230,0.65)">{max_abs * frac:.0f}</text>'
|
||||
)
|
||||
|
||||
# ── Подписи осей ───────────────────────────────────────────────────────────
|
||||
for i, item in enumerate(items):
|
||||
a = math.radians(-90.0 + 360.0 * i / n)
|
||||
lx = cx + lbl_r * math.cos(a)
|
||||
ly = cy + lbl_r * math.sin(a)
|
||||
col = item.get("dimension_color") or "#A080FF"
|
||||
name = _xml_escape(item["dimension_name"])
|
||||
val = int(item["value"])
|
||||
val_col = "#FF7070" if item["value"] < 0 else "#A080FF"
|
||||
lines.append(
|
||||
f'<text x="{lx:.2f}" y="{ly:.2f}" text-anchor="middle" '
|
||||
f'font-family="Arial,Helvetica,sans-serif" font-size="14" '
|
||||
f'font-weight="bold" fill="{col}">{name}</text>'
|
||||
)
|
||||
lines.append(
|
||||
f'<text x="{lx:.2f}" y="{ly + 20:.2f}" text-anchor="middle" '
|
||||
f'font-family="Arial,Helvetica,sans-serif" font-size="13" '
|
||||
f'fill="{val_col}">{val}</text>'
|
||||
)
|
||||
|
||||
lines.append("</svg>")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _xml_escape(s: str) -> str:
|
||||
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
def get_radar_data_uri(items: list[dict], max_val: float = 14.0) -> str:
|
||||
"""
|
||||
Возвращает data URI вида «data:image/svg+xml;base64,…».
|
||||
Передайте в page.launch_url(...) чтобы открыть/сохранить изображение.
|
||||
"""
|
||||
svg = generate_radar_svg(items, max_val)
|
||||
b64 = base64.b64encode(svg.encode("utf-8")).decode()
|
||||
return f"data:image/svg+xml;base64,{b64}"
|
||||
@@ -1,83 +0,0 @@
|
||||
import flet as ft
|
||||
from view import resolve_view, PUBLIC_ROUTES, PROTECTED_PREFIXES
|
||||
import theme_manager as tm
|
||||
|
||||
|
||||
async def _redirect_to_login(page: ft.Page):
|
||||
"""Сброс сессии и принудительный редирект на логин."""
|
||||
page.session.store.remove("auth_token")
|
||||
page.session.store.remove("url_key")
|
||||
page.session.store.remove("session_key")
|
||||
page.session.store.remove("user_id")
|
||||
await page.push_route("/login")
|
||||
|
||||
|
||||
async def _handle_route_change(page: ft.Page, e: ft.RouteChangeEvent):
|
||||
route = page.route
|
||||
token = page.session.store.get("auth_token")
|
||||
url_key = page.session.store.get("url_key") or ""
|
||||
session_key = page.session.store.get("session_key") or ""
|
||||
|
||||
# Публичные маршруты — без проверки токена
|
||||
if route in PUBLIC_ROUTES:
|
||||
views = resolve_view(page, route)
|
||||
page.views.clear()
|
||||
page.views.extend(views)
|
||||
page.update()
|
||||
return
|
||||
|
||||
# Защищённый маршрут без токена → логин
|
||||
if not token:
|
||||
await page.push_route("/login")
|
||||
return
|
||||
|
||||
# Маршруты с ключом — первый сегмент после префикса = url_key
|
||||
for prefix in PROTECTED_PREFIXES:
|
||||
if route.startswith(prefix):
|
||||
rest = route[len(prefix):]
|
||||
key_in_url = rest.split("/")[0] # берём только первый сегмент
|
||||
if not url_key or not session_key or key_in_url != url_key:
|
||||
await _redirect_to_login(page)
|
||||
return
|
||||
break
|
||||
|
||||
views = resolve_view(page, route)
|
||||
|
||||
# Применяем сохранённые настройки пользователя перед построением виджетов
|
||||
tm.apply_scale(page)
|
||||
tm.apply_theme(page)
|
||||
|
||||
# Неизвестный маршрут → на страницу пользователя
|
||||
if views is None:
|
||||
await page.push_route(f"/user/{url_key}")
|
||||
return
|
||||
|
||||
page.views.clear()
|
||||
page.views.extend(views)
|
||||
page.update()
|
||||
|
||||
# Запустить асинхронную загрузку данных, если вью её поддерживает
|
||||
last_view = views[-1] if views else None
|
||||
if last_view is not None and hasattr(last_view, "load"):
|
||||
page.run_task(last_view.load)
|
||||
|
||||
|
||||
def setup_router(page: ft.Page):
|
||||
"""Привязывает router к странице. Вызывается один раз в main()."""
|
||||
async def _on_route_change(e: ft.RouteChangeEvent):
|
||||
await _handle_route_change(page, e)
|
||||
|
||||
def _on_resize(e):
|
||||
"""Пересчитать масштаб при изменении размера окна/повороте телефона,
|
||||
но только если пользователь не менял его вручную (нет сохранён. префа)."""
|
||||
uid = tm._get_uid(page)
|
||||
if uid not in tm._user_prefs:
|
||||
new_scale = tm.auto_scale(page)
|
||||
if abs(new_scale - (page.session.store.get("ds_scale_last") or 0)) > 0.05:
|
||||
page.session.store.set("ds_scale_last", new_scale)
|
||||
tm.apply_scale(page, new_scale)
|
||||
# перезагрузим текущий маршрут чтобы виджеты пересоздались
|
||||
page.go(page.route)
|
||||
|
||||
page.on_route_change = _on_route_change
|
||||
page.on_resize = _on_resize
|
||||
@@ -1,178 +0,0 @@
|
||||
"""Менеджер тем и масштаба приложения с сохранением в localStorage браузера."""
|
||||
import flet as ft
|
||||
import designer as ds
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Темы: каждый цвет surface должен КОНТРАСТИРОВАТЬ с background,
|
||||
# чтобы карточки, поля и кнопки не сливались с фоном.
|
||||
# ---------------------------------------------------------------------------
|
||||
THEMES: dict[str, dict] = {
|
||||
"default": {
|
||||
"name": "ЦОПП (оранжевая)",
|
||||
# фон страницы
|
||||
"background": "#F4F6F8",
|
||||
# карточки / формы — светлее фона
|
||||
"surface": "#FFFFFF",
|
||||
# альтернативные блоки — чуть темнее фона
|
||||
"surface_alt": "#E4E8ED",
|
||||
},
|
||||
"warm": {
|
||||
"name": "Тёплая",
|
||||
"background": "#FFF3E6",
|
||||
"surface": "#FFFFFF",
|
||||
"surface_alt": "#FFD9B3",
|
||||
},
|
||||
"blue": {
|
||||
"name": "Синяя",
|
||||
"background": "#E3F2FD",
|
||||
"surface": "#FFFFFF",
|
||||
"surface_alt": "#B8D9F5",
|
||||
},
|
||||
"green": {
|
||||
"name": "Зелёная",
|
||||
"background": "#EDF7E7",
|
||||
"surface": "#FFFFFF",
|
||||
"surface_alt": "#BEE3B0",
|
||||
},
|
||||
"dark": {
|
||||
"name": "Тёмная",
|
||||
"background": "#1E2A3A",
|
||||
"surface": "#2D3F54",
|
||||
"surface_alt": "#3D5068",
|
||||
},
|
||||
}
|
||||
|
||||
_SCALE_KEY = "ui_scale"
|
||||
_THEME_KEY = "ui_theme"
|
||||
|
||||
# Допустимый диапазон масштаба
|
||||
SCALE_MIN = 0.8
|
||||
SCALE_MAX = 2.5
|
||||
SCALE_STEP = 0.1
|
||||
SCALE_DEFAULT = 1.7
|
||||
|
||||
# Автомасштаб по ширине экрана
|
||||
_MOBILE_BREAKPOINT = 600 # px: меньше — телефон, больше — ПК/планшет
|
||||
SCALE_MOBILE = 1.0
|
||||
SCALE_DESKTOP = 2.0
|
||||
|
||||
|
||||
def auto_scale(page: ft.Page) -> float:
|
||||
"""Вернуть масштаб на основе ширины viewport браузера."""
|
||||
try:
|
||||
w = page.width or 0
|
||||
return SCALE_MOBILE if w < _MOBILE_BREAKPOINT else SCALE_DESKTOP
|
||||
except Exception:
|
||||
return SCALE_DEFAULT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Серверное хранилище настроек по user_id.
|
||||
# В памяти контейнера — переживает F5 в браузере, сбрасывается при рестарте.
|
||||
# ---------------------------------------------------------------------------
|
||||
_user_prefs: dict[str, dict] = {} # user_id -> {"theme": str, "scale": float}
|
||||
|
||||
|
||||
def _get_uid(page: ft.Page) -> str:
|
||||
return page.session.store.get("user_id") or ""
|
||||
|
||||
|
||||
def _prefs_for(user_id: str) -> dict:
|
||||
return _user_prefs.get(user_id, {"theme": "default", "scale": SCALE_DEFAULT})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Публичный API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def apply_theme(page: ft.Page, theme_id: str | None = None) -> None:
|
||||
"""Применить тему: обновить ds.colors и page.bgcolor."""
|
||||
if theme_id is None:
|
||||
uid = _get_uid(page)
|
||||
theme_id = _prefs_for(uid)["theme"] if uid else "default"
|
||||
if theme_id not in THEMES:
|
||||
theme_id = "default"
|
||||
|
||||
t = THEMES[theme_id]
|
||||
ds.colors.background = t["background"]
|
||||
ds.colors.surface = t["surface"]
|
||||
ds.colors.surface_alt = t["surface_alt"]
|
||||
|
||||
page.bgcolor = t["background"]
|
||||
# кешируем в session.store для быстрого доступа внутри сессии
|
||||
page.session.store.set(_THEME_KEY, theme_id)
|
||||
|
||||
|
||||
def apply_scale(page: ft.Page, scale: float | None = None) -> None:
|
||||
"""Применить масштаб: обновить ds.SCALE.
|
||||
|
||||
Если масштаб не задан явно и у пользователя нет сохранённого —
|
||||
определяется автоматически по ширине экрана (мобильный / ПК).
|
||||
"""
|
||||
if scale is None:
|
||||
uid = _get_uid(page)
|
||||
if uid and uid in _user_prefs:
|
||||
# есть явно сохранённая настройка пользователя
|
||||
scale = _user_prefs[uid]["scale"]
|
||||
else:
|
||||
# первый вход — автоопределение по ширине экрана
|
||||
scale = auto_scale(page)
|
||||
scale = max(SCALE_MIN, min(SCALE_MAX, float(scale)))
|
||||
ds.SCALE = scale
|
||||
page.session.store.set(_SCALE_KEY, str(scale))
|
||||
|
||||
|
||||
def save_preferences(page: ft.Page, theme_id: str, scale: float,
|
||||
user_id: str | None = None) -> None:
|
||||
"""Сохранить тему и масштаб. Хранятся в памяти сервера по user_id."""
|
||||
uid = user_id or _get_uid(page)
|
||||
if uid:
|
||||
_user_prefs[uid] = {"theme": theme_id, "scale": float(scale)}
|
||||
# также пишем в session.store — нужно для apply_* внутри текущей сессии
|
||||
page.session.store.set(_THEME_KEY, theme_id)
|
||||
page.session.store.set(_SCALE_KEY, str(scale))
|
||||
|
||||
|
||||
def load_theme_id(page: ft.Page) -> str:
|
||||
# сначала session.store (уже применено), потом serv dict
|
||||
v = page.session.store.get(_THEME_KEY)
|
||||
if v and v in THEMES:
|
||||
return v
|
||||
uid = _get_uid(page)
|
||||
v2 = _prefs_for(uid)["theme"] if uid else "default"
|
||||
return v2 if v2 in THEMES else "default"
|
||||
|
||||
|
||||
def load_scale(page: ft.Page) -> float:
|
||||
v = page.session.store.get(_SCALE_KEY)
|
||||
try:
|
||||
if v is not None:
|
||||
return float(v)
|
||||
except ValueError:
|
||||
pass
|
||||
uid = _get_uid(page)
|
||||
return _prefs_for(uid)["scale"] if uid else SCALE_DEFAULT
|
||||
|
||||
|
||||
def theme_list() -> list[dict]:
|
||||
return [{"id": k, "name": v["name"]} for k, v in THEMES.items()]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Устаревший алиас (оставлен для совместимости с роутером)
|
||||
# ---------------------------------------------------------------------------
|
||||
class ThemeManager:
|
||||
THEMES = THEMES
|
||||
|
||||
@staticmethod
|
||||
def get_theme_list():
|
||||
return theme_list()
|
||||
|
||||
@staticmethod
|
||||
def load_theme_preference(page):
|
||||
return load_theme_id(page)
|
||||
|
||||
@staticmethod
|
||||
def save_theme_preference(page, theme_id):
|
||||
save_preferences(page, theme_id, load_scale(page))
|
||||
42
web/view.py
@@ -1,42 +0,0 @@
|
||||
import flet as ft
|
||||
from views import main_view, login_view, reg_view, user_view, take_test_view, poll_view, my_tests_view, response_view, profile_view
|
||||
|
||||
PUBLIC_ROUTES = {"/login", "/reg"}
|
||||
|
||||
# Маршруты вида /prefix/{url_key}/... — первый сегмент после prefix = url_key
|
||||
PROTECTED_PREFIXES = (
|
||||
"/user/",
|
||||
"/take_test/",
|
||||
"/poll/",
|
||||
"/my_tests/",
|
||||
"/response/",
|
||||
"/profile/",
|
||||
)
|
||||
|
||||
|
||||
def resolve_view(page: ft.Page, route: str) -> list[ft.View] | None:
|
||||
if route == "/login":
|
||||
return [login_view.LoginView(page)]
|
||||
elif route == "/reg":
|
||||
return [reg_view.RegView(page)]
|
||||
elif route == "/main":
|
||||
return [main_view.MainView()]
|
||||
elif route.startswith("/user/"):
|
||||
return [user_view.UserView(page)]
|
||||
elif route.startswith("/take_test/"):
|
||||
return [take_test_view.TakeTestView(page)]
|
||||
elif route.startswith("/my_tests/"):
|
||||
return [my_tests_view.MyTestsView(page)]
|
||||
elif route.startswith("/poll/"):
|
||||
# /poll/{url_key}/{poll_id}
|
||||
parts = route.strip("/").split("/")
|
||||
poll_id = parts[-1] if len(parts) >= 3 else ""
|
||||
return [poll_view.PollView(page, poll_id)]
|
||||
elif route.startswith("/response/"):
|
||||
# /response/{url_key}/{response_id}
|
||||
parts = route.strip("/").split("/")
|
||||
response_id = parts[-1] if len(parts) >= 3 else ""
|
||||
return [response_view.ResponseView(page, response_id)]
|
||||
elif route.startswith("/profile/"):
|
||||
return [profile_view.ProfileView(page)]
|
||||
return None
|
||||
@@ -1,92 +0,0 @@
|
||||
import flet as ft
|
||||
import designer as ds
|
||||
|
||||
class MainPageUser(ft.Column):
|
||||
def __init__(self, page: ft.Page):
|
||||
self._page = page
|
||||
self.spacing_groop = ds.s(6)
|
||||
self.border_radius = ds.s(15)
|
||||
|
||||
super().__init__(
|
||||
alignment=ft.MainAxisAlignment.CENTER,
|
||||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
spacing=self.spacing_groop,
|
||||
controls=[
|
||||
ft.Row(
|
||||
alignment=ft.MainAxisAlignment.CENTER,
|
||||
spacing=self.spacing_groop,
|
||||
run_spacing=self.spacing_groop,
|
||||
wrap=True,
|
||||
controls=[
|
||||
ft.Container(
|
||||
border_radius=self.border_radius,
|
||||
height=ds.s(160),
|
||||
width=ds.s(160),
|
||||
alignment=ft.Alignment(0.0, 0.0),
|
||||
content=ft.Column(
|
||||
alignment=ft.MainAxisAlignment.CENTER,
|
||||
controls=[
|
||||
ft.Row(
|
||||
alignment=ft.MainAxisAlignment.CENTER,
|
||||
controls=[
|
||||
ft.Image(
|
||||
src="icons/clipboard-question_16542596.svg",
|
||||
width=ds.s(100),
|
||||
height=ds.s(100),
|
||||
)
|
||||
]
|
||||
),
|
||||
ft.Row(
|
||||
alignment=ft.MainAxisAlignment.CENTER,
|
||||
controls=[
|
||||
ds.CastomText(text="Все тесты")
|
||||
]
|
||||
)
|
||||
]
|
||||
),
|
||||
bgcolor=ds.colors.surface,
|
||||
on_click=self.take_test,
|
||||
ink=True,
|
||||
),
|
||||
ft.Container(
|
||||
border_radius=self.border_radius,
|
||||
height=ds.s(160),
|
||||
width=ds.s(160),
|
||||
alignment=ft.Alignment(0.0, 0.0),
|
||||
content=ft.Column(
|
||||
alignment=ft.MainAxisAlignment.CENTER,
|
||||
controls=[
|
||||
ft.Row(
|
||||
alignment=ft.MainAxisAlignment.CENTER,
|
||||
controls=[
|
||||
ft.Image(
|
||||
src="icons/leaderboard-trophy_14227576.svg",
|
||||
width=ds.s(100),
|
||||
height=ds.s(100),
|
||||
)
|
||||
]
|
||||
),
|
||||
ft.Row(
|
||||
alignment=ft.MainAxisAlignment.CENTER,
|
||||
controls=[
|
||||
ds.CastomText(text="Мои тесты")
|
||||
]
|
||||
)
|
||||
]
|
||||
),
|
||||
bgcolor=ds.colors.surface,
|
||||
on_click=self.my_test,
|
||||
ink=True,
|
||||
)
|
||||
]
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
def take_test(self, e):
|
||||
url_key = self._page.session.store.get("url_key") or ""
|
||||
self._page.go(f"/take_test/{url_key}")
|
||||
|
||||
def my_test(self, e):
|
||||
url_key = self._page.session.store.get("url_key") or ""
|
||||
self._page.go(f"/my_tests/{url_key}")
|
||||
@@ -1,90 +0,0 @@
|
||||
import asyncio
|
||||
import flet as ft
|
||||
import designer as ds
|
||||
import api_client
|
||||
|
||||
|
||||
class MyTestsPage(ft.Column):
|
||||
def __init__(self, page: ft.Page):
|
||||
self._page = page
|
||||
self._loading = ft.ProgressRing(width=ds.s(40), height=ds.s(40), color=ds.colors.secondary_light)
|
||||
|
||||
super().__init__(
|
||||
alignment=ft.MainAxisAlignment.START,
|
||||
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
|
||||
spacing=ds.s(8),
|
||||
controls=[
|
||||
ft.Row(
|
||||
alignment=ft.MainAxisAlignment.CENTER,
|
||||
controls=[self._loading],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
async def load(self):
|
||||
user_id = self._page.session.store.get("user_id")
|
||||
if not user_id:
|
||||
self.controls = [ds.CastomText(text="Нет данных пользователя", size=14)]
|
||||
self.update()
|
||||
return
|
||||
|
||||
responses = await api_client.get_user_responses(user_id)
|
||||
if not responses:
|
||||
self.controls = [ds.CastomText(text="Вы ещё не проходили тесты", size=14)]
|
||||
self.update()
|
||||
return
|
||||
|
||||
# Подгружаем названия опросов параллельно
|
||||
poll_ids = list({r["poll_id"] for r in responses})
|
||||
polls_list = await asyncio.gather(*[api_client.get_poll(pid) for pid in poll_ids])
|
||||
polls_map = {p["id"]: p for p in polls_list if p}
|
||||
|
||||
self._render(responses, polls_map)
|
||||
self.update()
|
||||
|
||||
def _render(self, responses: list, polls_map: dict):
|
||||
url_key = self._page.session.store.get("url_key") or ""
|
||||
|
||||
# Сортируем по дате — новые вверху
|
||||
responses = sorted(responses, key=lambda r: r["submitted_at"], reverse=True)
|
||||
|
||||
cards = []
|
||||
for r in responses:
|
||||
poll = polls_map.get(r["poll_id"])
|
||||
title = poll["title"] if poll else "Неизвестный тест"
|
||||
submitted_at = r["submitted_at"][:19].replace("T", " ")
|
||||
|
||||
cards.append(
|
||||
ft.Container(
|
||||
border_radius=ds.s(12),
|
||||
padding=ds.s(16),
|
||||
bgcolor=ds.colors.surface,
|
||||
content=ft.Row(
|
||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||
controls=[
|
||||
ft.Column(
|
||||
spacing=ds.s(4),
|
||||
expand=True,
|
||||
controls=[
|
||||
ds.CastomText(
|
||||
text=title,
|
||||
size=15,
|
||||
weight=ft.FontWeight.BOLD,
|
||||
),
|
||||
ds.CastomText(
|
||||
text=f"Пройден: {submitted_at}",
|
||||
size=12,
|
||||
),
|
||||
],
|
||||
),
|
||||
ft.Icon(ft.Icons.CHEVRON_RIGHT, color=ds.colors.primary),
|
||||
],
|
||||
),
|
||||
on_click=lambda e, rid=r["id"]: self._page.go(
|
||||
f"/response/{url_key}/{rid}"
|
||||
),
|
||||
ink=True,
|
||||
)
|
||||
)
|
||||
|
||||
self.controls = cards
|
||||