Compare commits
4 Commits
db76401cef
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
485d5128bf | ||
|
|
0f13ef5256 | ||
|
|
eb110f677c | ||
|
|
1728450778 |
@@ -2,7 +2,7 @@
|
|||||||
.venv
|
.venv
|
||||||
data
|
data
|
||||||
web-react
|
web-react
|
||||||
web
|
stats-react
|
||||||
__pycache__
|
__pycache__
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
.pytest_cache
|
.pytest_cache
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
FROM python:3.14-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"]
|
|
||||||
16
README.md
16
README.md
@@ -131,8 +131,7 @@ api-copp/
|
|||||||
├── .dockerignore # что не попадает в build context образов
|
├── .dockerignore # что не попадает в build context образов
|
||||||
├── .env.example # шаблон переменных окружения без секретов
|
├── .env.example # шаблон переменных окружения без секретов
|
||||||
├── Dockerfile.api # образ API
|
├── Dockerfile.api # образ API
|
||||||
├── Dockerfile.stats # образ дашборда статистики
|
├── docker-compose.yml # api + web-react + stats-react (+ postgres/redis за профилем local-db)
|
||||||
├── docker-compose.yml # api + web-react + stats (+ postgres/redis за профилем local-db)
|
|
||||||
│
|
│
|
||||||
├── bd/
|
├── bd/
|
||||||
│ ├── __init__.py # Settings (pydantic), make_engine()
|
│ ├── __init__.py # Settings (pydantic), make_engine()
|
||||||
@@ -175,7 +174,10 @@ api-copp/
|
|||||||
│ ├── src/pages/ # страницы (Login, AnonymousEntry, Poll, MyTests, ...)
|
│ ├── src/pages/ # страницы (Login, AnonymousEntry, Poll, MyTests, ...)
|
||||||
│ └── nginx.conf # раздаёт статику SPA, проксирует /api → api:8000
|
│ └── nginx.conf # раздаёт статику SPA, проксирует /api → api:8000
|
||||||
│
|
│
|
||||||
├── stats/ # отдельный дашборд статистики (Flet, порт 8081)
|
├── 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)
|
└── data/ # том Docker (./data:/app/data)
|
||||||
├── postgres/ # данные PostgreSQL
|
├── postgres/ # данные PostgreSQL
|
||||||
@@ -620,3 +622,11 @@ ALTER TABLE radar_results ADD COLUMN IF NOT EXISTS image_path VARCHAR(512);
|
|||||||
**Было:** `docker-compose.yml` (api + web-react + stats) и `docker-compose_db.yml` (postgres + redis) — два отдельных файла и две команды на разворачивание.
|
**Было:** `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` удалён.
|
**Решение:** `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` эта проверка проваливалась по нескольким пунктам.
|
||||||
|
|||||||
@@ -37,14 +37,12 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- api
|
- api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
stats:
|
stats-react:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: ./stats-react
|
||||||
dockerfile: Dockerfile.stats
|
dockerfile: Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "8081:8081"
|
- "8081:80"
|
||||||
environment:
|
|
||||||
- API_URL=http://api:8000
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- api
|
- api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ dependencies = [
|
|||||||
"pg8000>=1.29.0",
|
"pg8000>=1.29.0",
|
||||||
"flet>=0.82.2",
|
"flet>=0.82.2",
|
||||||
"jose>=1.0.0",
|
"jose>=1.0.0",
|
||||||
"passlib>=1.7.4",
|
"passlib[bcrypt]>=1.7.4",
|
||||||
|
"bcrypt==4.0.1",
|
||||||
"python-jose>=3.5.0",
|
"python-jose>=3.5.0",
|
||||||
"matplotlib>=3.10.8",
|
"matplotlib>=3.10.8",
|
||||||
"python-docx>=1.2.0",
|
"python-docx>=1.2.0",
|
||||||
|
|||||||
6
stats-react/.dockerignore
Normal file
6
stats-react/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.vite
|
||||||
|
*.local
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
5
stats-react/.gitignore
vendored
Normal file
5
stats-react/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.local
|
||||||
|
.vite
|
||||||
|
*.tsbuildinfo
|
||||||
14
stats-react/Dockerfile
Normal file
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
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
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
1732
stats-react/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
stats-react/package.json
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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 []
|
|
||||||
Binary file not shown.
|
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 |
Binary file not shown.
|
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)])
|
|
||||||
30
uv.lock
generated
30
uv.lock
generated
@@ -37,11 +37,12 @@ name = "api-copp"
|
|||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "bcrypt" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "flet" },
|
{ name = "flet" },
|
||||||
{ name = "jose" },
|
{ name = "jose" },
|
||||||
{ name = "matplotlib" },
|
{ name = "matplotlib" },
|
||||||
{ name = "passlib" },
|
{ name = "passlib", extra = ["bcrypt"] },
|
||||||
{ name = "pg8000" },
|
{ name = "pg8000" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "python-docx" },
|
{ name = "python-docx" },
|
||||||
@@ -63,12 +64,13 @@ dev = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "bcrypt", specifier = "==4.0.1" },
|
||||||
{ name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" },
|
{ name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.128.0" },
|
{ name = "fastapi", specifier = ">=0.128.0" },
|
||||||
{ name = "flet", specifier = ">=0.82.2" },
|
{ name = "flet", specifier = ">=0.82.2" },
|
||||||
{ name = "jose", specifier = ">=1.0.0" },
|
{ name = "jose", specifier = ">=1.0.0" },
|
||||||
{ name = "matplotlib", specifier = ">=3.10.8" },
|
{ 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 = "pg8000", specifier = ">=1.29.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.12.5" },
|
{ name = "pydantic", specifier = ">=2.12.5" },
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },
|
||||||
@@ -93,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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "black"
|
name = "black"
|
||||||
version = "26.1.0"
|
version = "26.1.0"
|
||||||
@@ -559,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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pathspec"
|
name = "pathspec"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
|
|||||||
@@ -4,8 +4,15 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
# Статика SPA: всё неизвестное отдаём index.html (клиентский роутинг)
|
# Статика SPA: всё неизвестное отдаём index.html (клиентский роутинг).
|
||||||
|
# index.html без явного Cache-Control браузер кэширует эвристически (по
|
||||||
|
# Last-Modified) — после деплоя часть пользователей видит старую версию,
|
||||||
|
# пока не сделают hard refresh. no-cache вынуждает всегда идти на сервер
|
||||||
|
# за свежим HTML (быстрый 304 по ETag, если файл не менялся); хэшированные
|
||||||
|
# /assets/ ниже как кэшировались на год, так и кэшируются — их имя меняется
|
||||||
|
# при каждой сборке, так что инвалидация не нужна.
|
||||||
location / {
|
location / {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user