push web
This commit is contained in:
506
README.md
506
README.md
@@ -1,6 +1,510 @@
|
|||||||
# api-copp
|
# api-copp
|
||||||
|
|
||||||
Веб-приложение для создания, проведения и анализа психологических тестов. Включает API-сервер на FastAPI и пользовательский интерфейс на Flet. Поставляется в Docker и поддерживает готовый тест по методике профессиональной ориентации Голланда.
|
Платформа ЦОПП Челябинской области для проведения профориентационных тестирований. Включает REST API на FastAPI и пользовательский интерфейс на Flet. Поддерживает тесты Голланда, Климова и Гломштока с паутинными диаграммами результатов.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Содержание
|
||||||
|
|
||||||
|
- [Архитектура](#архитектура)
|
||||||
|
- [Быстрый старт](#быстрый-старт)
|
||||||
|
- [Переменные окружения](#переменные-окружения)
|
||||||
|
- [Структура проекта](#структура-проекта)
|
||||||
|
- [База данных](#база-данных)
|
||||||
|
- [API](#api)
|
||||||
|
- [Тест Голланда](#тест-голланда)
|
||||||
|
- [Паутинная диаграмма](#паутинная-диаграмма)
|
||||||
|
- [Фронтенд](#фронтенд)
|
||||||
|
- [Дизайн-система](#дизайн-система)
|
||||||
|
- [Деплой с внешним прокси](#деплой-с-внешним-прокси)
|
||||||
|
- [Известные ошибки и просчёты](#известные-ошибки-и-просчёты)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
Пользователь (браузер)
|
||||||
|
│
|
||||||
|
│ :80
|
||||||
|
[ Внешний прокси / nginx ] ← необязателен для локальной сети
|
||||||
|
│
|
||||||
|
│ :80
|
||||||
|
[ web — Flet ASGI ] ← единственный публичный сервис
|
||||||
|
│
|
||||||
|
│ http://api:8000 (только внутри Docker-сети)
|
||||||
|
[ api — FastAPI ]
|
||||||
|
│ │
|
||||||
|
[ PostgreSQL :5432 ] [ Redis :6379 ]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно:** сервис `api` не публикует порты наружу — он доступен только контейнеру `web` внутри Docker-сети. Пользователь никогда не обращается к API напрямую.
|
||||||
|
|
||||||
|
**Сетевая топология Docker:** PostgreSQL и Redis запускаются отдельным compose-файлом `docker-compose_db.yml` и подключаются к той же Docker network.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
### 1. Инфраструктура (PostgreSQL + Redis)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose_db.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Приложение (API + Web)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
После запуска:
|
||||||
|
|
||||||
|
| Адрес | Что |
|
||||||
|
|---|---|
|
||||||
|
| `http://localhost` | Веб-интерфейс (Flet) |
|
||||||
|
| `http://localhost:8000/docs` | Swagger UI — **только с хоста сервера**, порт не опубликован |
|
||||||
|
| `http://localhost:8000/redoc` | ReDoc |
|
||||||
|
|
||||||
|
> В продакшне API недоступен снаружи. Для работы с `/docs` используйте SSH-туннель:
|
||||||
|
> ```bash
|
||||||
|
> ssh -L 8000:localhost:8000 user@your-server
|
||||||
|
> ```
|
||||||
|
|
||||||
|
Доступ к документации — логин/пароль из переменных `DOCS_USERNAME` / `DOCS_PASSWORD`.
|
||||||
|
|
||||||
|
### 3. Инициализация БД
|
||||||
|
|
||||||
|
После первого запуска выполните (с хоста сервера или через туннель):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Создать таблицы
|
||||||
|
curl -X POST http://localhost:8000/db/create-tables \
|
||||||
|
-H "X-Admin-Key: <ADMIN_KEY>"
|
||||||
|
|
||||||
|
# Применить миграции (добавляет колонки, если БД уже была создана ранее)
|
||||||
|
curl -X POST http://localhost:8000/db/migrate \
|
||||||
|
-H "X-Admin-Key: <ADMIN_KEY>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Загрузка теста Голланда
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Создать тест (возвращает poll_id)
|
||||||
|
curl -X POST http://localhost:8000/polls/holland \
|
||||||
|
-H "X-Admin-Key: <ADMIN_KEY>"
|
||||||
|
|
||||||
|
# Загрузить ключ расшифровки (42 пары → 6 типов × 14 баллов)
|
||||||
|
curl -X POST http://localhost:8000/polls/holland/<poll_id>/seed-scores \
|
||||||
|
-H "X-Admin-Key: <ADMIN_KEY>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
> **Важно:** перед деплоем в продакшн обязательно смените `SECRET_KEY`, `ADMIN_KEY`, `DOCS_PASSWORD` и пароли БД.
|
||||||
|
|
||||||
|
| Переменная | Описание | Пример |
|
||||||
|
|---|---|---|
|
||||||
|
| `APP_PATH` | Абсолютный путь к директории приложения внутри контейнера | `/app` |
|
||||||
|
| `DB_HOST` | Хост PostgreSQL | `postgres` |
|
||||||
|
| `DB_PORT` | Порт PostgreSQL | `5432` |
|
||||||
|
| `DB_USER` | Пользователь БД | `postgres` |
|
||||||
|
| `DB_PASS` | Пароль БД | `postgres` |
|
||||||
|
| `DB_NAME` | Имя базы данных | `profi` |
|
||||||
|
| `REDIS_HOST` | Хост Redis | `redis` |
|
||||||
|
| `REDIS_PORT` | Порт Redis | `6379` |
|
||||||
|
| `REDIS_DB` | Номер БД Redis | `0` |
|
||||||
|
| `REDIS_PASSWORD` | Пароль Redis | — |
|
||||||
|
| `SECRET_KEY` | Секрет для подписи JWT-токенов (min 32 символа) | `python -c "import secrets; print(secrets.token_hex(32))"` |
|
||||||
|
| `ADMIN_KEY` | Ключ для защищённых admin-эндпоинтов | — |
|
||||||
|
| `DOCS_USERNAME` | Логин для доступа к `/docs` и `/redoc` | `admin` |
|
||||||
|
| `DOCS_PASSWORD` | Пароль для доступа к `/docs` и `/redoc` | — |
|
||||||
|
| `API_URL` | Внутренний URL API для фронтенда (только Docker-сеть) | `http://api:8000` |
|
||||||
|
|
||||||
|
Файл `docker-compose.yml` содержит **демо-значения** — не используйте их в продакшн.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
api-copp/
|
||||||
|
├── main.py # точка входа FastAPI, регистрация роутеров
|
||||||
|
├── requirements.txt # зависимости API
|
||||||
|
├── pyproject.toml # метаданные проекта
|
||||||
|
├── Dockerfile.api # образ API (python:3.14-slim)
|
||||||
|
├── Dockerfile.web # образ фронтенда (python:3.11-slim + flet), порт 80
|
||||||
|
├── docker-compose.yml # API (без портов) + Web (порт 80)
|
||||||
|
├── docker-compose_db.yml # PostgreSQL + Redis
|
||||||
|
├── nginx.conf # пример конфига nginx (для внешнего прокси)
|
||||||
|
│
|
||||||
|
├── bd/ # слой доступа к данным
|
||||||
|
│ ├── __init__.py # Settings (pydantic), make_engine()
|
||||||
|
│ └── tables/
|
||||||
|
│ ├── simple_base.py # общий Base для SQLAlchemy
|
||||||
|
│ ├── users.py # User
|
||||||
|
│ ├── organization.py # Organization
|
||||||
|
│ ├── group.py # Group
|
||||||
|
│ ├── poll.py # Poll
|
||||||
|
│ ├── question.py # Question
|
||||||
|
│ ├── choice.py # Choice
|
||||||
|
│ ├── response.py # Response, Answer
|
||||||
|
│ ├── scale.py # ScaleDimension, ChoiceScore
|
||||||
|
│ └── radar_result.py # RadarResult, RadarResultItem
|
||||||
|
│
|
||||||
|
├── route/ # FastAPI-роутеры
|
||||||
|
│ ├── auth_utils.py # JWT, bcrypt, require_admin_key
|
||||||
|
│ ├── auth.py # /auth/register, /auth/login
|
||||||
|
│ ├── base.py # /health, /version
|
||||||
|
│ ├── init_data_base.py # /db/create-tables, /db/migrate
|
||||||
|
│ ├── users_crud.py # CRUD пользователей
|
||||||
|
│ ├── groups_crud.py # CRUD групп
|
||||||
|
│ ├── organizations_crud.py# CRUD организаций
|
||||||
|
│ ├── poll_crud.py # CRUD опросов
|
||||||
|
│ ├── question_crud.py # CRUD вопросов
|
||||||
|
│ ├── choice_crud.py # CRUD вариантов ответов
|
||||||
|
│ ├── response_crud.py # приём ответов + авто-расчёт радара
|
||||||
|
│ ├── holland_crud.py # генерация теста Голланда + seed-scores
|
||||||
|
│ ├── glomshtok_crud.py # генерация теста Гломштока
|
||||||
|
│ ├── klimov_crud.py # генерация теста Климова
|
||||||
|
│ ├── scale_crud.py # CRUD осей и расшифровок
|
||||||
|
│ ├── radar_crud.py # расчёт и чтение радарных результатов
|
||||||
|
│ └── radar_svg_gen.py # серверная генерация SVG-диаграмм
|
||||||
|
│
|
||||||
|
├── web/ # Flet-фронтенд
|
||||||
|
│ ├── main.py # точка входа Flet ASGI + применение темы
|
||||||
|
│ ├── router.py # маршрутизация страниц
|
||||||
|
│ ├── api_client.py # httpx-обёртки над API (API_URL из env)
|
||||||
|
│ ├── designer.py # дизайн-система: colors, компоненты
|
||||||
|
│ ├── theme_manager.py # менеджер тем, сохранение в client_storage
|
||||||
|
│ ├── radar_chart.py # виджет RadarChart (flet.canvas)
|
||||||
|
│ ├── radar_svg.py # генератор SVG (устаревший, не используется)
|
||||||
|
│ └── views/
|
||||||
|
│ ├── login_view.py
|
||||||
|
│ ├── reg_view.py
|
||||||
|
│ ├── user_view.py
|
||||||
|
│ ├── profile_view.py
|
||||||
|
│ ├── poll_view.py
|
||||||
|
│ ├── response_view.py
|
||||||
|
│ ├── my_tests_view.py
|
||||||
|
│ ├── take_test_view.py
|
||||||
|
│ └── content_users/
|
||||||
|
│ ├── main_page.py # главная: «Все тесты» / «Мои тесты»
|
||||||
|
│ ├── poll_page.py # прохождение теста
|
||||||
|
│ ├── response_page.py # результат с паутинной диаграммой
|
||||||
|
│ ├── my_tests_page.py # список пройденных тестов
|
||||||
|
│ ├── take_test_page.py # список доступных тестов
|
||||||
|
│ └── profile_page.py # редактирование профиля + выбор темы
|
||||||
|
│
|
||||||
|
└── data/ # смонтированный том Docker (./data:/app/data)
|
||||||
|
├── postgres/ # данные PostgreSQL
|
||||||
|
└── radar/ # сохранённые SVG-файлы диаграмм
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## База данных
|
||||||
|
|
||||||
|
### Схема таблиц
|
||||||
|
|
||||||
|
```
|
||||||
|
organizations ──< groups ──< users
|
||||||
|
│
|
||||||
|
polls ──< questions ──< choices ──< choice_scores
|
||||||
|
│ │
|
||||||
|
└──< responses ──< answers │
|
||||||
|
│ │
|
||||||
|
└──< radar_results ──< radar_result_items
|
||||||
|
(image_path) └── (dimension_id FK → scale_dimensions)
|
||||||
|
↑
|
||||||
|
polls ──< scale_dimensions ──────────────────-─┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ключевые таблицы
|
||||||
|
|
||||||
|
| Таблица | Описание |
|
||||||
|
|---|---|
|
||||||
|
| `users` | Испытуемые: `username`, `hashed_password`, `first_name`, `last_name`, FK на `groups` и `organizations` |
|
||||||
|
| `polls` | Тест/опрос: `title`, `description` |
|
||||||
|
| `questions` | Вопросы теста с `type` (`single` / `multiple`) |
|
||||||
|
| `choices` | Варианты ответов к вопросам |
|
||||||
|
| `responses` | Один факт прохождения теста (user + poll + timestamp) |
|
||||||
|
| `answers` | Конкретные ответы внутри прохождения |
|
||||||
|
| `scale_dimensions` | Оси радарной диаграммы: `name`, `color`, `position`, FK на `polls` |
|
||||||
|
| `choice_scores` | Расшифровка: какой вариант ответа сколько баллов вносит в какую ось |
|
||||||
|
| `radar_results` | Итоговый результат по осям + `image_path` (путь к SVG-файлу) |
|
||||||
|
| `radar_result_items` | Снимок значений по каждой оси на момент расчёта |
|
||||||
|
|
||||||
|
### Миграции
|
||||||
|
|
||||||
|
Таблицы создаются через `POST /db/create-tables`. Для уже существующих БД применяются дополнительные ALTER-миграции через `POST /db/migrate`. Текущие миграции:
|
||||||
|
|
||||||
|
- Добавление колонок `username` / `hashed_password` в `users`
|
||||||
|
- Добавление колонки `image_path` в `radar_results`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### Аутентификация
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /auth/register — регистрация (username ≥ 3 симв., password ≥ 8 симв.)
|
||||||
|
POST /auth/login — получить JWT-токен (OAuth2 password flow)
|
||||||
|
```
|
||||||
|
|
||||||
|
JWT-токен передаётся заголовком `Authorization: Bearer <token>`. Admin-операции требуют заголовок `X-Admin-Key`.
|
||||||
|
|
||||||
|
### Основные эндпоинты
|
||||||
|
|
||||||
|
```
|
||||||
|
# Тесты
|
||||||
|
GET /polls — список тестов
|
||||||
|
POST /polls — создать тест
|
||||||
|
GET /polls/{id} — тест с вопросами и вариантами
|
||||||
|
DELETE /polls/{id} — удалить тест
|
||||||
|
|
||||||
|
# Вопросы / Варианты ответов
|
||||||
|
POST /polls/{id}/questions
|
||||||
|
POST /questions/{id}/choices
|
||||||
|
|
||||||
|
# Прохождение
|
||||||
|
POST /polls/{poll_id}/responses — сдать тест (автоматически считает радар)
|
||||||
|
GET /polls/users/{user_id}/responses — история пользователя
|
||||||
|
GET /responses/{id} — конкретный результат
|
||||||
|
|
||||||
|
# Результаты (радар)
|
||||||
|
POST /responses/{id}/radar/compute — (пере)считать радарный результат
|
||||||
|
GET /responses/{id}/radar — данные для диаграммы
|
||||||
|
GET /responses/{id}/radar/image — SVG-файл диаграммы
|
||||||
|
GET /users/{user_id}/radar-results — история радарных результатов
|
||||||
|
|
||||||
|
# Оси и расшифровки
|
||||||
|
POST /polls/{poll_id}/dimensions — создать ось
|
||||||
|
GET /polls/{poll_id}/dimensions — список осей
|
||||||
|
PUT /dimensions/{id}
|
||||||
|
DELETE /dimensions/{id}
|
||||||
|
POST /dimensions/{id}/scores — задать расшифровку варианта ответа
|
||||||
|
GET /choices/{choice_id}/scores
|
||||||
|
|
||||||
|
# Готовые тесты (admin)
|
||||||
|
POST /polls/holland — создать тест Голланда (42 пары)
|
||||||
|
POST /polls/holland/{poll_id}/seed-scores — загрузить ключ расшифровки Голланда
|
||||||
|
POST /polls/glomshtok — создать тест Гломштока
|
||||||
|
POST /polls/klimov — создать тест Климова
|
||||||
|
|
||||||
|
# База данных (admin)
|
||||||
|
POST /db/create-tables
|
||||||
|
POST /db/migrate
|
||||||
|
|
||||||
|
# Служебные
|
||||||
|
GET /health
|
||||||
|
GET /version
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Тест Голланда
|
||||||
|
|
||||||
|
Реализована **модификация Г.В. Резапкиной** теста профессиональной ориентации Дж. Голланда. 42 пары профессий, испытуемый выбирает предпочтительную в каждой паре.
|
||||||
|
|
||||||
|
### Типы личности
|
||||||
|
|
||||||
|
| Код | Название | Цвет |
|
||||||
|
|---|---|---|
|
||||||
|
| Р | Реалистичный | `#FF8C00` |
|
||||||
|
| И | Интеллектуальный | `#4169E1` |
|
||||||
|
| С | Социальный | `#32CD32` |
|
||||||
|
| К | Конвенциональный | `#9370DB` |
|
||||||
|
| П | Предприимчивый | `#DC143C` |
|
||||||
|
| А | Артистический | `#FF69B4` |
|
||||||
|
|
||||||
|
Каждый тип получает ровно 14 вопросов (= 14 максимально возможных баллов). Результат — паутинная диаграмма с 6 осями.
|
||||||
|
|
||||||
|
### Процедура загрузки
|
||||||
|
|
||||||
|
1. `POST /polls/holland` — создаёт тест с 42 вопросами-парами
|
||||||
|
2. `POST /polls/holland/{poll_id}/seed-scores` — создаёт 6 `ScaleDimension` и 84 `ChoiceScore`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Паутинная диаграмма
|
||||||
|
|
||||||
|
### Как работает
|
||||||
|
|
||||||
|
При сдаче теста (`POST /polls/{id}/responses`) сервер автоматически:
|
||||||
|
|
||||||
|
1. Суммирует баллы по каждой оси (`ChoiceScore.score`) для выбранных вариантов
|
||||||
|
2. Сохраняет результат в `radar_results` + `radar_result_items`
|
||||||
|
3. Генерирует SVG-файл и сохраняет в `{APP_PATH}/data/radar/{result_id}.svg`
|
||||||
|
4. Записывает относительный путь в `image_path` — всё в одном `commit()`
|
||||||
|
|
||||||
|
### Скачивание SVG
|
||||||
|
|
||||||
|
Фронтенд получает SVG-байты с API через внутреннюю Docker-сеть (`api_client.get_radar_svg_bytes()`), кодирует в `data:application/octet-stream;base64,...` и передаёт в `page.launch_url()`. Браузер скачивает файл **напрямую из data URI** — никаких запросов к API из браузера.
|
||||||
|
|
||||||
|
### Генерация SVG
|
||||||
|
|
||||||
|
SVG генерируется чистым Python без зависимостей (`route/radar_svg_gen.py`):
|
||||||
|
|
||||||
|
- 5 концентрических сеток
|
||||||
|
- Подписи с именем типа и баллом
|
||||||
|
- Размер холста: 520×520 пикселей
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Фронтенд
|
||||||
|
|
||||||
|
Написан на **Flet** — Python-фреймворке поверх Flutter. Работает как ASGI-приложение через uvicorn на порту 80.
|
||||||
|
|
||||||
|
### Маршруты
|
||||||
|
|
||||||
|
| Путь | Страница |
|
||||||
|
|---|---|
|
||||||
|
| `/login` | Вход |
|
||||||
|
| `/reg` | Регистрация |
|
||||||
|
| `/user/{url_key}` | Главная страница пользователя |
|
||||||
|
| `/take_test/{url_key}` | Список тестов для прохождения |
|
||||||
|
| `/poll/{url_key}/{poll_id}` | Прохождение теста |
|
||||||
|
| `/response/{url_key}/{response_id}` | Результат с диаграммой |
|
||||||
|
| `/my_tests/{url_key}` | Мои результаты |
|
||||||
|
| `/profile/{url_key}` | Профиль пользователя + выбор темы |
|
||||||
|
|
||||||
|
`{url_key}` — одноразовый 32-символьный hex-ключ, генерируется при успешном входе (`secrets.token_hex(16)`). Обращение к защищённому маршруту с чужим ключом перенаправляет на `/login`.
|
||||||
|
|
||||||
|
### Варианты ответов в тесте
|
||||||
|
|
||||||
|
Вместо стандартного `ft.RadioGroup` / `ft.Radio` (лейбл которого не переносится) используются кастомные кликабельные контейнеры (`poll_page.py`). При выборе варианта:
|
||||||
|
- фон меняется с `surface_alt` → `primary_light`
|
||||||
|
- иконка меняется с `RADIO_BUTTON_UNCHECKED` → `CHECK_CIRCLE`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Дизайн-система
|
||||||
|
|
||||||
|
Все UI-компоненты сосредоточены в `web/designer.py`. Управление темой — в `web/theme_manager.py`.
|
||||||
|
|
||||||
|
### Глобальный масштаб
|
||||||
|
|
||||||
|
```python
|
||||||
|
SCALE: float = 1.7 # 1.0 = 100%, меняйте только эту константу
|
||||||
|
s(14) # → round(14 * 1.7) = 24px
|
||||||
|
```
|
||||||
|
|
||||||
|
### Цветовая палитра (стиль copp74.ru)
|
||||||
|
|
||||||
|
| Имя | HEX | Применение |
|
||||||
|
|---|---|---|
|
||||||
|
| `colors.primary` | `#F85A40` | Кнопки, акценты, логотип |
|
||||||
|
| `colors.primary_dark` | `#D94530` | Hover-состояние кнопок |
|
||||||
|
| `colors.primary_light` | `#FFEAB7` | Выбранный вариант ответа |
|
||||||
|
| `colors.background` | `#F4F6F8` | Фон страниц |
|
||||||
|
| `colors.surface` | `#FFFFFF` | Карточки, формы |
|
||||||
|
| `colors.surface_alt` | `#F0F2F5` | Невыбранные варианты, значки |
|
||||||
|
| `colors.accent` | `#FFC845` | Жёлтый акцент |
|
||||||
|
| `colors.info` | `#007FBD` | Синие кнопки скачивания |
|
||||||
|
| `colors.success` | `#85C446` | Зелёный (успех) |
|
||||||
|
| `colors.text_primary` | `#1E2A3A` | Основной текст |
|
||||||
|
| `colors.text_secondary` | `#5A6779` | Вторичный текст, плейсхолдеры |
|
||||||
|
| `colors.text_on_primary` | `#FFFFFF` | Текст на оранжевых кнопках |
|
||||||
|
| `colors.border` | `#DDE3EA` | Границы полей |
|
||||||
|
|
||||||
|
### Компоненты
|
||||||
|
|
||||||
|
| Класс | Наследует | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| `CastomText` | `ft.Text` | Текст с масштабированием |
|
||||||
|
| `CastomTextField_input` | `ft.TextField` | Поле ввода, белый фон, оранжевый фокус |
|
||||||
|
| `CastomButton` | `ft.Button` | Оранжевая кнопка с белым текстом |
|
||||||
|
| `CastomIconButton` | `ft.Button` | Синяя кнопка с иконкой |
|
||||||
|
| `CastomDropdown` | `ft.Dropdown` | Выпадающий список |
|
||||||
|
| `CastomSwitch` | `ft.Switch` | Переключатель |
|
||||||
|
| `AlterDiaalogApproval` | `ft.AlertDialog` | Диалог пользовательского соглашения |
|
||||||
|
|
||||||
|
### Темы интерфейса
|
||||||
|
|
||||||
|
Пользователь может выбрать тему в профиле. Тема сохраняется в `client_storage` браузера и восстанавливается при следующем входе.
|
||||||
|
|
||||||
|
| ID | Название | Фон |
|
||||||
|
|---|---|---|
|
||||||
|
| `default` | ЦОПП (orange) | `#F4F6F8` |
|
||||||
|
| `light` | Светлая | `#FFFFFF` |
|
||||||
|
| `dark_blue` | Синяя | `#E8F4FD` |
|
||||||
|
| `green` | Зелёная | `#EDF7E7` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Деплой с внешним прокси
|
||||||
|
|
||||||
|
В репозитории есть готовый файл `nginx.conf` для случая, когда nginx развёрнут на отдельном сервере или уже управляет несколькими сайтами.
|
||||||
|
|
||||||
|
Минимальная конфигурация upstream в вашем nginx:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location / {
|
||||||
|
proxy_pass http://<server-ip>:80;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "Upgrade";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Заголовки `Upgrade` / `Connection` обязательны — Flet использует WebSocket.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Известные ошибки и просчёты
|
||||||
|
|
||||||
|
### 1. Лишнее дублирование генерации SVG
|
||||||
|
|
||||||
|
**Проблема:** SVG-генератор написан дважды — `route/radar_svg_gen.py` (сервер) и `web/radar_svg.py` (клиент).
|
||||||
|
|
||||||
|
**Решение:** Кнопка «Сохранить» скачивает SVG через `api_client.get_radar_svg_bytes()`. `web/radar_svg.py` больше не используется, но файл остался — можно удалить при следующем рефакторинге.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Несовместимость Python 3.14 и psycopg ✅ исправлено
|
||||||
|
|
||||||
|
**Проблема:** `pyproject.toml` указывал `psycopg>=3.3.3`, но реально используется синхронный `pg8000`.
|
||||||
|
|
||||||
|
**Решение:** Зависимость заменена на `pg8000>=1.29.0`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Нет транзакции при сохранении SVG ✅ исправлено
|
||||||
|
|
||||||
|
**Проблема:** `image_path` обновлялся отдельным `commit()` после основного — при падении между ними запись оставалась без пути к файлу.
|
||||||
|
|
||||||
|
**Решение:** SVG генерируется до коммита, `image_path` устанавливается до коммита. Один атомарный `session.commit()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Перенос подписей в диаграмме по буквам ✅ исправлено
|
||||||
|
|
||||||
|
**Решение:** `_W_LBL` увеличен до 130, добавлен `max_lines=2, no_wrap=False`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Текст вариантов ответов обрезался ✅ исправлено
|
||||||
|
|
||||||
|
**Проблема:** `ft.Radio(label=...)` в Flet не переносит текст метки — длинные варианты ответов обрезались.
|
||||||
|
|
||||||
|
**Решение:** Реализованы кастомные кликабельные контейнеры с `ft.Text(expand=True, no_wrap=False)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Ошибка `module 'designer' has no attribute 'clolors'` ✅ исправлено
|
||||||
|
|
||||||
|
**Проблема:** Класс цветов был назван `clolors` с опечаткой. После переименования в `colors` все ссылки в 10+ файлах указывали на старое имя.
|
||||||
|
|
||||||
|
**Решение:** Класс переименован в `colors`, все ссылки во всех view-файлах обновлены.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
166
web/designer.py
166
web/designer.py
@@ -1,7 +1,8 @@
|
|||||||
import flet as ft
|
import flet as ft
|
||||||
|
|
||||||
# Глобальный масштаб интерфейса. 1.0 = 100%, 1.7 = 170%.
|
# Глобальный масштаб интерфейса. 1.0 = 100%, 1.7 = 170%.
|
||||||
# Меняйте только эту константу, чтобы масштабировать весь UI.
|
# Меняйте только эту переменную (или через theme_manager.apply_scale),
|
||||||
|
# чтобы масштабировать весь UI.
|
||||||
SCALE: float = 1.7
|
SCALE: float = 1.7
|
||||||
|
|
||||||
|
|
||||||
@@ -10,15 +11,44 @@ def s(v: int | float) -> int:
|
|||||||
return round(v * SCALE)
|
return round(v * SCALE)
|
||||||
|
|
||||||
|
|
||||||
class clolors:
|
class colors:
|
||||||
background = "#C0DE9E"
|
# Основная палитра в стиле copp74.ru
|
||||||
laer1 = "#FFEAB7"
|
primary = "#F85A40" # Основной оранжево-красный (логотип ЦОПП)
|
||||||
laer2 = "#D9F1F4"
|
primary_dark = "#D94530" # Тёмный для hover/активных состояний
|
||||||
laer3 = "#D2EA32"
|
primary_light = "#FFEAB7" # Светлый персиковый (вторичный фон)
|
||||||
ferst = "#85C446"
|
|
||||||
|
# Фоны
|
||||||
|
background = "#F4F6F8" # Светло-серый фон страницы
|
||||||
|
surface = "#FFFFFF" # Белый для карточек и форм
|
||||||
|
surface_alt = "#F0F2F5" # Альтернативные блоки
|
||||||
|
|
||||||
|
# Акцент — жёлтый
|
||||||
|
accent = "#FFC845" # Жёлтый акцент
|
||||||
|
accent_lime = "#FFC845" # Алиас для совместимости
|
||||||
|
secondary_light = "#FFEAB7" # Алиас для совместимости
|
||||||
|
secondary_yellow = "#FFC845" # Алиас
|
||||||
|
|
||||||
|
# Синий — информация, ссылки
|
||||||
|
info = "#007FBD" # Синий
|
||||||
|
info_light = "#D9F1F4" # Светлый голубой
|
||||||
|
tertiary_light = "#D9F1F4" # Алиас для совместимости
|
||||||
|
tertiary_blue = "#007FBD" # Алиас для совместимости
|
||||||
|
secondary_orange = "#F85A40" # Алиас для совместимости
|
||||||
|
|
||||||
|
# Зелёный — успех
|
||||||
|
success = "#85C446" # Зелёный
|
||||||
|
success_light = "#C0DE9E" # Светло-зелёный
|
||||||
|
|
||||||
|
# Текст
|
||||||
|
text_primary = "#1E2A3A" # Почти чёрный для заголовков
|
||||||
|
text_secondary = "#5A6779" # Серый для второстепенного текста
|
||||||
|
text_on_primary = "#FFFFFF" # Белый текст на оранжевом фоне
|
||||||
|
|
||||||
|
# Граница
|
||||||
|
border = "#DDE3EA" # Светло-серая граница
|
||||||
|
|
||||||
class CastomText(ft.Text):
|
class CastomText(ft.Text):
|
||||||
def __init__(self, text, size=14, color=clolors.ferst, weight=ft.FontWeight.NORMAL):
|
def __init__(self, text, size=14, color=colors.text_primary, weight=ft.FontWeight.NORMAL):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
value=text,
|
value=text,
|
||||||
color=color,
|
color=color,
|
||||||
@@ -33,25 +63,26 @@ class CastomTextField_input(ft.TextField):
|
|||||||
super().__init__(
|
super().__init__(
|
||||||
label=label,
|
label=label,
|
||||||
hint_text=hint_text,
|
hint_text=hint_text,
|
||||||
filled=True, # заливка фона
|
filled=True,
|
||||||
bgcolor=clolors.ferst, # цвет заливки
|
bgcolor=colors.surface,
|
||||||
border_color=clolors.laer2, # цвет границы
|
border_color=colors.border,
|
||||||
border_radius=s(12), # скругление углов
|
focused_border_color=colors.primary,
|
||||||
|
border_radius=s(10),
|
||||||
label_style=ft.TextStyle(
|
label_style=ft.TextStyle(
|
||||||
color=clolors.laer3,
|
color=colors.text_secondary,
|
||||||
size=s(14),
|
size=s(13),
|
||||||
weight=ft.FontWeight.BOLD,
|
weight=ft.FontWeight.W_500,
|
||||||
),
|
),
|
||||||
hint_style=ft.TextStyle(
|
hint_style=ft.TextStyle(
|
||||||
color=clolors.laer1,
|
color=colors.border,
|
||||||
size=s(14),
|
size=s(14),
|
||||||
italic=True,
|
italic=True,
|
||||||
),
|
),
|
||||||
text_style=ft.TextStyle(
|
text_style=ft.TextStyle(
|
||||||
color=clolors.laer3,
|
color=colors.text_primary,
|
||||||
size=s(16),
|
size=s(15),
|
||||||
),
|
),
|
||||||
content_padding=ft.padding.symmetric(horizontal=s(12), vertical=s(14)),
|
content_padding=ft.padding.symmetric(horizontal=s(14), vertical=s(14)),
|
||||||
prefix_icon=prefix_icon,
|
prefix_icon=prefix_icon,
|
||||||
suffix_icon=suffix_icon,
|
suffix_icon=suffix_icon,
|
||||||
on_change=on_change,
|
on_change=on_change,
|
||||||
@@ -62,14 +93,20 @@ class CastomTextField_input(ft.TextField):
|
|||||||
class CastomButton(ft.Button):
|
class CastomButton(ft.Button):
|
||||||
def __init__(self, text, on_click):
|
def __init__(self, text, on_click):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
content=text,
|
content=ft.Text(
|
||||||
|
text,
|
||||||
|
color=colors.text_on_primary,
|
||||||
|
size=s(14),
|
||||||
|
weight=ft.FontWeight.W_600,
|
||||||
|
) if isinstance(text, str) else text,
|
||||||
style=ft.ButtonStyle(
|
style=ft.ButtonStyle(
|
||||||
color=clolors.ferst,
|
color=colors.text_on_primary,
|
||||||
bgcolor=clolors.laer1,
|
bgcolor={
|
||||||
text_style=ft.TextStyle(
|
ft.ControlState.DEFAULT: colors.primary,
|
||||||
size=s(14)
|
ft.ControlState.HOVERED: colors.primary_dark,
|
||||||
),
|
},
|
||||||
padding=ft.padding.symmetric(horizontal=s(16), vertical=s(10)),
|
shape=ft.RoundedRectangleBorder(radius=s(10)),
|
||||||
|
padding=ft.padding.symmetric(horizontal=s(20), vertical=s(12)),
|
||||||
),
|
),
|
||||||
on_click=on_click
|
on_click=on_click
|
||||||
)
|
)
|
||||||
@@ -83,15 +120,17 @@ class CastomIconButton(ft.Button):
|
|||||||
spacing=s(8),
|
spacing=s(8),
|
||||||
tight=True,
|
tight=True,
|
||||||
controls=[
|
controls=[
|
||||||
ft.Icon(icon, color=clolors.ferst, size=s(18)),
|
ft.Icon(icon, color=colors.text_on_primary, size=s(18)),
|
||||||
ft.Text(text, color=clolors.ferst, size=s(14)),
|
ft.Text(text, color=colors.text_on_primary, size=s(14), weight=ft.FontWeight.W_600),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
style=ft.ButtonStyle(
|
style=ft.ButtonStyle(
|
||||||
color=clolors.ferst,
|
bgcolor={
|
||||||
bgcolor=clolors.laer2,
|
ft.ControlState.DEFAULT: colors.info,
|
||||||
text_style=ft.TextStyle(size=s(14)),
|
ft.ControlState.HOVERED: "#006AA3",
|
||||||
padding=ft.padding.symmetric(horizontal=s(16), vertical=s(10)),
|
},
|
||||||
|
shape=ft.RoundedRectangleBorder(radius=s(10)),
|
||||||
|
padding=ft.padding.symmetric(horizontal=s(18), vertical=s(12)),
|
||||||
),
|
),
|
||||||
on_click=on_click,
|
on_click=on_click,
|
||||||
)
|
)
|
||||||
@@ -103,27 +142,27 @@ class CastomDropdown(ft.Dropdown):
|
|||||||
hint_text=hint_text,
|
hint_text=hint_text,
|
||||||
options=options or [],
|
options=options or [],
|
||||||
filled=True,
|
filled=True,
|
||||||
fill_color=clolors.laer3,
|
fill_color=colors.surface,
|
||||||
color=clolors.ferst,
|
color=colors.text_primary,
|
||||||
border_color=clolors.laer2,
|
border_color=colors.border,
|
||||||
border_radius=s(12),
|
border_radius=s(10),
|
||||||
focused_border_color=clolors.laer1,
|
focused_border_color=colors.primary,
|
||||||
label_style=ft.TextStyle(
|
label_style=ft.TextStyle(
|
||||||
color=clolors.ferst,
|
color=colors.text_secondary,
|
||||||
size=s(14),
|
size=s(13),
|
||||||
weight=ft.FontWeight.BOLD,
|
weight=ft.FontWeight.W_500,
|
||||||
),
|
),
|
||||||
hint_style=ft.TextStyle(
|
hint_style=ft.TextStyle(
|
||||||
color=clolors.laer1,
|
color=colors.border,
|
||||||
size=s(14),
|
size=s(14),
|
||||||
italic=True,
|
italic=True,
|
||||||
),
|
),
|
||||||
text_style=ft.TextStyle(
|
text_style=ft.TextStyle(
|
||||||
color=clolors.ferst,
|
color=colors.text_primary,
|
||||||
size=s(16),
|
size=s(15),
|
||||||
),
|
),
|
||||||
menu_style=ft.MenuStyle(
|
menu_style=ft.MenuStyle(
|
||||||
bgcolor=clolors.laer3,
|
bgcolor=colors.surface,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if on_change is not None:
|
if on_change is not None:
|
||||||
@@ -134,11 +173,11 @@ class CastomSwitch(ft.Switch):
|
|||||||
def __init__(self, label, on_change):
|
def __init__(self, label, on_change):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
label=label,
|
label=label,
|
||||||
label_text_style=ft.TextStyle(size=s(12), color=clolors.ferst),
|
label_text_style=ft.TextStyle(size=s(13), color=colors.text_primary),
|
||||||
active_color=clolors.ferst,
|
active_color=colors.primary,
|
||||||
active_track_color=clolors.laer1,
|
active_track_color=colors.primary_light,
|
||||||
inactive_thumb_color=clolors.laer2,
|
inactive_thumb_color=colors.border,
|
||||||
inactive_track_color=clolors.laer3,
|
inactive_track_color=colors.surface_alt,
|
||||||
on_change=on_change
|
on_change=on_change
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -148,7 +187,7 @@ class AlterDiaalogApproval(ft.AlertDialog):
|
|||||||
self._on_accept = on_accept
|
self._on_accept = on_accept
|
||||||
|
|
||||||
self.switch_approval = CastomSwitch(
|
self.switch_approval = CastomSwitch(
|
||||||
label="Я согласен с условиями пользовательского соглашения",
|
label="",
|
||||||
on_change=self.switch_changed
|
on_change=self.switch_changed
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -159,15 +198,14 @@ class AlterDiaalogApproval(ft.AlertDialog):
|
|||||||
self.button_approval.disabled = True
|
self.button_approval.disabled = True
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
bgcolor=clolors.laer3,
|
bgcolor=colors.surface,
|
||||||
title=ft.Text(
|
title=ft.Text(
|
||||||
"Пользовательское соглашение",
|
"Пользовательское соглашение",
|
||||||
color=clolors.ferst,
|
color=colors.primary,
|
||||||
size=s(16),
|
size=s(16),
|
||||||
weight=ft.FontWeight.BOLD,
|
weight=ft.FontWeight.BOLD,
|
||||||
),
|
),
|
||||||
content=ft.Container(
|
content=ft.Container(
|
||||||
width=s(350),
|
|
||||||
height=s(400),
|
height=s(400),
|
||||||
content=ft.Column(
|
content=ft.Column(
|
||||||
scroll=ft.ScrollMode.AUTO,
|
scroll=ft.ScrollMode.AUTO,
|
||||||
@@ -293,11 +331,11 @@ class AlterDiaalogApproval(ft.AlertDialog):
|
|||||||
""",
|
""",
|
||||||
expand=True,
|
expand=True,
|
||||||
md_style_sheet=ft.MarkdownStyleSheet(
|
md_style_sheet=ft.MarkdownStyleSheet(
|
||||||
p_text_style=ft.TextStyle(color=clolors.ferst, size=s(12)),
|
p_text_style=ft.TextStyle(color=colors.text_primary, size=s(12)),
|
||||||
h1_text_style=ft.TextStyle(color=clolors.ferst, size=s(18), weight=ft.FontWeight.BOLD),
|
h1_text_style=ft.TextStyle(color=colors.primary, size=s(18), weight=ft.FontWeight.BOLD),
|
||||||
h2_text_style=ft.TextStyle(color=clolors.ferst, size=s(15), weight=ft.FontWeight.BOLD),
|
h2_text_style=ft.TextStyle(color=colors.info, size=s(15), weight=ft.FontWeight.BOLD),
|
||||||
h3_text_style=ft.TextStyle(color=clolors.laer1, size=s(13), weight=ft.FontWeight.BOLD),
|
h3_text_style=ft.TextStyle(color=colors.text_secondary, size=s(13), weight=ft.FontWeight.BOLD),
|
||||||
a_text_style=ft.TextStyle(color=clolors.laer1),
|
a_text_style=ft.TextStyle(color=colors.primary),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
@@ -305,16 +343,20 @@ class AlterDiaalogApproval(ft.AlertDialog):
|
|||||||
),
|
),
|
||||||
actions=[
|
actions=[
|
||||||
ft.Column(
|
ft.Column(
|
||||||
width=s(350),
|
|
||||||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
controls=[
|
controls=[
|
||||||
ft.Row(
|
ft.Row(
|
||||||
expand=True,
|
expand=True,
|
||||||
alignment=ft.MainAxisAlignment.START,
|
alignment=ft.MainAxisAlignment.START,
|
||||||
|
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
controls=[
|
controls=[
|
||||||
ft.Container(
|
self.switch_approval,
|
||||||
width=s(330),
|
ft.Text(
|
||||||
content=self.switch_approval,
|
"Я согласен с условиями пользовательского соглашения",
|
||||||
|
size=s(13),
|
||||||
|
color=colors.text_primary,
|
||||||
|
expand=True,
|
||||||
|
no_wrap=False,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import flet as ft
|
|||||||
import httpx
|
import httpx
|
||||||
import designer as ds
|
import designer as ds
|
||||||
from router import setup_router
|
from router import setup_router
|
||||||
|
import theme_manager as tm
|
||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
from starlette.routing import Route, Mount
|
from starlette.routing import Route, Mount
|
||||||
from starlette.responses import Response as StarletteResponse
|
from starlette.responses import Response as StarletteResponse
|
||||||
@@ -29,7 +30,11 @@ async def _radar_image(request):
|
|||||||
|
|
||||||
async def main(page: ft.Page):
|
async def main(page: ft.Page):
|
||||||
page.title = "COPP"
|
page.title = "COPP"
|
||||||
page.bgcolor = ds.clolors.background
|
|
||||||
|
# Применяем сохранённые тему и масштаб до построения любых виджетов
|
||||||
|
tm.apply_scale(page)
|
||||||
|
tm.apply_theme(page)
|
||||||
|
|
||||||
# Масштабирование глобального text_theme синхронно с ds.SCALE:
|
# Масштабирование глобального text_theme синхронно с ds.SCALE:
|
||||||
# покрывает ft.Text() без явного size (body_medium по умолчанию = 14)
|
# покрывает ft.Text() без явного size (body_medium по умолчанию = 14)
|
||||||
_ts = lambda base: ft.TextStyle(size=ds.s(base))
|
_ts = lambda base: ft.TextStyle(size=ds.s(base))
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ class RadarChart(ft.Stack):
|
|||||||
a = math.radians(-90.0 + 360.0 * i / n)
|
a = math.radians(-90.0 + 360.0 * i / n)
|
||||||
lx = ccx + _LBL_R * math.cos(a)
|
lx = ccx + _LBL_R * math.cos(a)
|
||||||
ly = ccy + _LBL_R * math.sin(a)
|
ly = ccy + _LBL_R * math.sin(a)
|
||||||
col = item.get("dimension_color") or ds.clolors.ferst
|
col = item.get("dimension_color") or ds.colors.primary
|
||||||
|
|
||||||
labels.append(ft.Container(
|
labels.append(ft.Container(
|
||||||
left=lx - _W_LBL / 2,
|
left=lx - _W_LBL / 2,
|
||||||
@@ -119,7 +119,7 @@ class RadarChart(ft.Stack):
|
|||||||
ft.Text(
|
ft.Text(
|
||||||
str(int(item["value"])),
|
str(int(item["value"])),
|
||||||
size=11,
|
size=11,
|
||||||
color=ds.clolors.ferst,
|
color=ds.colors.primary,
|
||||||
text_align=ft.TextAlign.CENTER,
|
text_align=ft.TextAlign.CENTER,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import flet as ft
|
import flet as ft
|
||||||
from view import resolve_view, PUBLIC_ROUTES, PROTECTED_PREFIXES
|
from view import resolve_view, PUBLIC_ROUTES, PROTECTED_PREFIXES
|
||||||
|
import theme_manager as tm
|
||||||
|
|
||||||
|
|
||||||
async def _redirect_to_login(page: ft.Page):
|
async def _redirect_to_login(page: ft.Page):
|
||||||
@@ -42,6 +43,10 @@ async def _handle_route_change(page: ft.Page, e: ft.RouteChangeEvent):
|
|||||||
|
|
||||||
views = resolve_view(page, route)
|
views = resolve_view(page, route)
|
||||||
|
|
||||||
|
# Применяем сохранённые настройки пользователя перед построением виджетов
|
||||||
|
tm.apply_scale(page)
|
||||||
|
tm.apply_theme(page)
|
||||||
|
|
||||||
# Неизвестный маршрут → на страницу пользователя
|
# Неизвестный маршрут → на страницу пользователя
|
||||||
if views is None:
|
if views is None:
|
||||||
await page.push_route(f"/user/{url_key}")
|
await page.push_route(f"/user/{url_key}")
|
||||||
@@ -62,4 +67,17 @@ def setup_router(page: ft.Page):
|
|||||||
async def _on_route_change(e: ft.RouteChangeEvent):
|
async def _on_route_change(e: ft.RouteChangeEvent):
|
||||||
await _handle_route_change(page, e)
|
await _handle_route_change(page, e)
|
||||||
|
|
||||||
|
def _on_resize(e):
|
||||||
|
"""Пересчитать масштаб при изменении размера окна/повороте телефона,
|
||||||
|
но только если пользователь не менял его вручную (нет сохранён. префа)."""
|
||||||
|
uid = tm._get_uid(page)
|
||||||
|
if uid not in tm._user_prefs:
|
||||||
|
new_scale = tm.auto_scale(page)
|
||||||
|
if abs(new_scale - (page.session.store.get("ds_scale_last") or 0)) > 0.05:
|
||||||
|
page.session.store.set("ds_scale_last", new_scale)
|
||||||
|
tm.apply_scale(page, new_scale)
|
||||||
|
# перезагрузим текущий маршрут чтобы виджеты пересоздались
|
||||||
|
page.go(page.route)
|
||||||
|
|
||||||
page.on_route_change = _on_route_change
|
page.on_route_change = _on_route_change
|
||||||
|
page.on_resize = _on_resize
|
||||||
|
|||||||
178
web/theme_manager.py
Normal file
178
web/theme_manager.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
"""Менеджер тем и масштаба приложения с сохранением в localStorage браузера."""
|
||||||
|
import flet as ft
|
||||||
|
import designer as ds
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Темы: каждый цвет surface должен КОНТРАСТИРОВАТЬ с background,
|
||||||
|
# чтобы карточки, поля и кнопки не сливались с фоном.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
THEMES: dict[str, dict] = {
|
||||||
|
"default": {
|
||||||
|
"name": "ЦОПП (оранжевая)",
|
||||||
|
# фон страницы
|
||||||
|
"background": "#F4F6F8",
|
||||||
|
# карточки / формы — светлее фона
|
||||||
|
"surface": "#FFFFFF",
|
||||||
|
# альтернативные блоки — чуть темнее фона
|
||||||
|
"surface_alt": "#E4E8ED",
|
||||||
|
},
|
||||||
|
"warm": {
|
||||||
|
"name": "Тёплая",
|
||||||
|
"background": "#FFF3E6",
|
||||||
|
"surface": "#FFFFFF",
|
||||||
|
"surface_alt": "#FFD9B3",
|
||||||
|
},
|
||||||
|
"blue": {
|
||||||
|
"name": "Синяя",
|
||||||
|
"background": "#E3F2FD",
|
||||||
|
"surface": "#FFFFFF",
|
||||||
|
"surface_alt": "#B8D9F5",
|
||||||
|
},
|
||||||
|
"green": {
|
||||||
|
"name": "Зелёная",
|
||||||
|
"background": "#EDF7E7",
|
||||||
|
"surface": "#FFFFFF",
|
||||||
|
"surface_alt": "#BEE3B0",
|
||||||
|
},
|
||||||
|
"dark": {
|
||||||
|
"name": "Тёмная",
|
||||||
|
"background": "#1E2A3A",
|
||||||
|
"surface": "#2D3F54",
|
||||||
|
"surface_alt": "#3D5068",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_SCALE_KEY = "ui_scale"
|
||||||
|
_THEME_KEY = "ui_theme"
|
||||||
|
|
||||||
|
# Допустимый диапазон масштаба
|
||||||
|
SCALE_MIN = 0.8
|
||||||
|
SCALE_MAX = 2.5
|
||||||
|
SCALE_STEP = 0.1
|
||||||
|
SCALE_DEFAULT = 1.7
|
||||||
|
|
||||||
|
# Автомасштаб по ширине экрана
|
||||||
|
_MOBILE_BREAKPOINT = 600 # px: меньше — телефон, больше — ПК/планшет
|
||||||
|
SCALE_MOBILE = 1.0
|
||||||
|
SCALE_DESKTOP = 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def auto_scale(page: ft.Page) -> float:
|
||||||
|
"""Вернуть масштаб на основе ширины viewport браузера."""
|
||||||
|
try:
|
||||||
|
w = page.width or 0
|
||||||
|
return SCALE_MOBILE if w < _MOBILE_BREAKPOINT else SCALE_DESKTOP
|
||||||
|
except Exception:
|
||||||
|
return SCALE_DEFAULT
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Серверное хранилище настроек по user_id.
|
||||||
|
# В памяти контейнера — переживает F5 в браузере, сбрасывается при рестарте.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_user_prefs: dict[str, dict] = {} # user_id -> {"theme": str, "scale": float}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_uid(page: ft.Page) -> str:
|
||||||
|
return page.session.store.get("user_id") or ""
|
||||||
|
|
||||||
|
|
||||||
|
def _prefs_for(user_id: str) -> dict:
|
||||||
|
return _user_prefs.get(user_id, {"theme": "default", "scale": SCALE_DEFAULT})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Публичный API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def apply_theme(page: ft.Page, theme_id: str | None = None) -> None:
|
||||||
|
"""Применить тему: обновить ds.colors и page.bgcolor."""
|
||||||
|
if theme_id is None:
|
||||||
|
uid = _get_uid(page)
|
||||||
|
theme_id = _prefs_for(uid)["theme"] if uid else "default"
|
||||||
|
if theme_id not in THEMES:
|
||||||
|
theme_id = "default"
|
||||||
|
|
||||||
|
t = THEMES[theme_id]
|
||||||
|
ds.colors.background = t["background"]
|
||||||
|
ds.colors.surface = t["surface"]
|
||||||
|
ds.colors.surface_alt = t["surface_alt"]
|
||||||
|
|
||||||
|
page.bgcolor = t["background"]
|
||||||
|
# кешируем в session.store для быстрого доступа внутри сессии
|
||||||
|
page.session.store.set(_THEME_KEY, theme_id)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_scale(page: ft.Page, scale: float | None = None) -> None:
|
||||||
|
"""Применить масштаб: обновить ds.SCALE.
|
||||||
|
|
||||||
|
Если масштаб не задан явно и у пользователя нет сохранённого —
|
||||||
|
определяется автоматически по ширине экрана (мобильный / ПК).
|
||||||
|
"""
|
||||||
|
if scale is None:
|
||||||
|
uid = _get_uid(page)
|
||||||
|
if uid and uid in _user_prefs:
|
||||||
|
# есть явно сохранённая настройка пользователя
|
||||||
|
scale = _user_prefs[uid]["scale"]
|
||||||
|
else:
|
||||||
|
# первый вход — автоопределение по ширине экрана
|
||||||
|
scale = auto_scale(page)
|
||||||
|
scale = max(SCALE_MIN, min(SCALE_MAX, float(scale)))
|
||||||
|
ds.SCALE = scale
|
||||||
|
page.session.store.set(_SCALE_KEY, str(scale))
|
||||||
|
|
||||||
|
|
||||||
|
def save_preferences(page: ft.Page, theme_id: str, scale: float,
|
||||||
|
user_id: str | None = None) -> None:
|
||||||
|
"""Сохранить тему и масштаб. Хранятся в памяти сервера по user_id."""
|
||||||
|
uid = user_id or _get_uid(page)
|
||||||
|
if uid:
|
||||||
|
_user_prefs[uid] = {"theme": theme_id, "scale": float(scale)}
|
||||||
|
# также пишем в session.store — нужно для apply_* внутри текущей сессии
|
||||||
|
page.session.store.set(_THEME_KEY, theme_id)
|
||||||
|
page.session.store.set(_SCALE_KEY, str(scale))
|
||||||
|
|
||||||
|
|
||||||
|
def load_theme_id(page: ft.Page) -> str:
|
||||||
|
# сначала session.store (уже применено), потом serv dict
|
||||||
|
v = page.session.store.get(_THEME_KEY)
|
||||||
|
if v and v in THEMES:
|
||||||
|
return v
|
||||||
|
uid = _get_uid(page)
|
||||||
|
v2 = _prefs_for(uid)["theme"] if uid else "default"
|
||||||
|
return v2 if v2 in THEMES else "default"
|
||||||
|
|
||||||
|
|
||||||
|
def load_scale(page: ft.Page) -> float:
|
||||||
|
v = page.session.store.get(_SCALE_KEY)
|
||||||
|
try:
|
||||||
|
if v is not None:
|
||||||
|
return float(v)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
uid = _get_uid(page)
|
||||||
|
return _prefs_for(uid)["scale"] if uid else SCALE_DEFAULT
|
||||||
|
|
||||||
|
|
||||||
|
def theme_list() -> list[dict]:
|
||||||
|
return [{"id": k, "name": v["name"]} for k, v in THEMES.items()]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Устаревший алиас (оставлен для совместимости с роутером)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class ThemeManager:
|
||||||
|
THEMES = THEMES
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_theme_list():
|
||||||
|
return theme_list()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def load_theme_preference(page):
|
||||||
|
return load_theme_id(page)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def save_theme_preference(page, theme_id):
|
||||||
|
save_preferences(page, theme_id, load_scale(page))
|
||||||
@@ -44,7 +44,7 @@ class MainPageUser(ft.Column):
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
bgcolor=ds.clolors.laer2,
|
bgcolor=ds.colors.surface,
|
||||||
on_click=self.take_test,
|
on_click=self.take_test,
|
||||||
ink=True,
|
ink=True,
|
||||||
),
|
),
|
||||||
@@ -74,7 +74,7 @@ class MainPageUser(ft.Column):
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
bgcolor=ds.clolors.laer2,
|
bgcolor=ds.colors.surface,
|
||||||
on_click=self.my_test,
|
on_click=self.my_test,
|
||||||
ink=True,
|
ink=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import api_client
|
|||||||
class MyTestsPage(ft.Column):
|
class MyTestsPage(ft.Column):
|
||||||
def __init__(self, page: ft.Page):
|
def __init__(self, page: ft.Page):
|
||||||
self._page = page
|
self._page = page
|
||||||
self._loading = ft.ProgressRing(width=ds.s(40), height=ds.s(40), color=ds.clolors.laer1)
|
self._loading = ft.ProgressRing(width=ds.s(40), height=ds.s(40), color=ds.colors.secondary_light)
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
alignment=ft.MainAxisAlignment.START,
|
alignment=ft.MainAxisAlignment.START,
|
||||||
@@ -58,7 +58,7 @@ class MyTestsPage(ft.Column):
|
|||||||
ft.Container(
|
ft.Container(
|
||||||
border_radius=ds.s(12),
|
border_radius=ds.s(12),
|
||||||
padding=ds.s(16),
|
padding=ds.s(16),
|
||||||
bgcolor=ds.clolors.laer2,
|
bgcolor=ds.colors.surface,
|
||||||
content=ft.Row(
|
content=ft.Row(
|
||||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||||
controls=[
|
controls=[
|
||||||
@@ -77,7 +77,7 @@ class MyTestsPage(ft.Column):
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
ft.Icon(ft.Icons.CHEVRON_RIGHT, color=ds.clolors.ferst),
|
ft.Icon(ft.Icons.CHEVRON_RIGHT, color=ds.colors.primary),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
on_click=lambda e, rid=r["id"]: self._page.go(
|
on_click=lambda e, rid=r["id"]: self._page.go(
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class PollPage(ft.Column):
|
|||||||
controls=[
|
controls=[
|
||||||
ft.Row(
|
ft.Row(
|
||||||
alignment=ft.MainAxisAlignment.CENTER,
|
alignment=ft.MainAxisAlignment.CENTER,
|
||||||
controls=[ft.ProgressRing(width=ds.s(40), height=ds.s(40), color=ds.clolors.laer1)],
|
controls=[ft.ProgressRing(width=ds.s(40), height=ds.s(40), color=ds.colors.secondary_light)],
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -57,46 +57,54 @@ class PollPage(ft.Column):
|
|||||||
tight=True,
|
tight=True,
|
||||||
spacing=6,
|
spacing=6,
|
||||||
controls=[
|
controls=[
|
||||||
ft.Icon(ft.Icons.STOP_CIRCLE_OUTLINED, color=ds.clolors.ferst, size=ds.s(16)),
|
ft.Icon(ft.Icons.STOP_CIRCLE_OUTLINED, color=ds.colors.primary, size=ds.s(16)),
|
||||||
ft.Text("Прервать тест", color=ds.clolors.ferst, size=ds.s(13)),
|
ft.Text("Прервать тест", color=ds.colors.primary, size=ds.s(13)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
style=ft.ButtonStyle(
|
style=ft.ButtonStyle(
|
||||||
bgcolor=ds.clolors.laer3,
|
bgcolor=ds.colors.surface_alt,
|
||||||
color=ds.clolors.ferst,
|
color=ds.colors.primary,
|
||||||
),
|
),
|
||||||
on_click=self._confirm_abort,
|
on_click=self._confirm_abort,
|
||||||
)
|
)
|
||||||
|
|
||||||
counter_badge = ft.Container(
|
counter_badge = ft.Container(
|
||||||
bgcolor=ds.clolors.laer2,
|
bgcolor=ds.colors.surface_alt,
|
||||||
border_radius=ds.s(10),
|
border_radius=ds.s(10),
|
||||||
padding=ft.padding.symmetric(horizontal=ds.s(20), vertical=ds.s(10)),
|
padding=ft.padding.symmetric(horizontal=ds.s(20), vertical=ds.s(10)),
|
||||||
content=ft.Text(
|
content=ft.Text(
|
||||||
f"Вопрос {idx + 1} из {total}",
|
f"Вопрос {idx + 1} из {total}",
|
||||||
color=ds.clolors.ferst,
|
color=ds.colors.text_secondary,
|
||||||
size=ds.s(14),
|
size=ds.s(14),
|
||||||
weight=ft.FontWeight.BOLD,
|
weight=ft.FontWeight.BOLD,
|
||||||
text_align=ft.TextAlign.CENTER,
|
text_align=ft.TextAlign.CENTER,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
nav_row = ft.Row(
|
nav_row = ft.Column(
|
||||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
spacing=ds.s(6),
|
||||||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
controls=[
|
||||||
controls=[prev_btn, counter_badge, stop_btn],
|
ft.Row(
|
||||||
|
alignment=ft.MainAxisAlignment.CENTER,
|
||||||
|
controls=[counter_badge],
|
||||||
|
),
|
||||||
|
ft.Row(
|
||||||
|
wrap=True,
|
||||||
|
spacing=ds.s(8),
|
||||||
|
run_spacing=ds.s(8),
|
||||||
|
controls=[prev_btn, stop_btn],
|
||||||
|
),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Блок вопроса (alignment на Container, не вложенный Column) ---
|
# --- Блок вопроса ---
|
||||||
question_box = ft.Container(
|
question_box = ft.Container(
|
||||||
expand=True,
|
|
||||||
border_radius=ds.s(14),
|
border_radius=ds.s(14),
|
||||||
padding=ds.s(24),
|
padding=ds.s(24),
|
||||||
bgcolor=ds.clolors.laer2,
|
bgcolor=ds.colors.surface,
|
||||||
alignment=ft.Alignment(0.0, 0.0),
|
|
||||||
content=ft.Text(
|
content=ft.Text(
|
||||||
value=q.get("text", ""),
|
value=q.get("text", ""),
|
||||||
color=ds.clolors.ferst,
|
color=ds.colors.text_primary,
|
||||||
size=ds.s(16),
|
size=ds.s(16),
|
||||||
weight=ft.FontWeight.BOLD,
|
weight=ft.FontWeight.BOLD,
|
||||||
text_align=ft.TextAlign.CENTER,
|
text_align=ft.TextAlign.CENTER,
|
||||||
@@ -115,19 +123,19 @@ class PollPage(ft.Column):
|
|||||||
return ft.Container(
|
return ft.Container(
|
||||||
border_radius=ds.s(8),
|
border_radius=ds.s(8),
|
||||||
padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(10)),
|
padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(10)),
|
||||||
bgcolor=ds.clolors.laer1 if is_selected else ds.clolors.laer3,
|
bgcolor=ds.colors.primary_light if is_selected else ds.colors.surface_alt,
|
||||||
content=ft.Row(
|
content=ft.Row(
|
||||||
spacing=ds.s(10),
|
spacing=ds.s(10),
|
||||||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
controls=[
|
controls=[
|
||||||
ft.Icon(
|
ft.Icon(
|
||||||
ft.Icons.CHECK_CIRCLE if is_selected else ft.Icons.RADIO_BUTTON_UNCHECKED,
|
ft.Icons.CHECK_CIRCLE if is_selected else ft.Icons.RADIO_BUTTON_UNCHECKED,
|
||||||
color=ds.clolors.ferst,
|
color=ds.colors.primary,
|
||||||
size=ds.s(20),
|
size=ds.s(20),
|
||||||
),
|
),
|
||||||
ft.Text(
|
ft.Text(
|
||||||
value=c["text"],
|
value=c["text"],
|
||||||
color=ds.clolors.ferst,
|
color=ds.colors.text_primary,
|
||||||
size=ds.s(14),
|
size=ds.s(14),
|
||||||
expand=True,
|
expand=True,
|
||||||
no_wrap=False,
|
no_wrap=False,
|
||||||
@@ -147,7 +155,7 @@ class PollPage(ft.Column):
|
|||||||
|
|
||||||
self.controls = [
|
self.controls = [
|
||||||
ft.Column(
|
ft.Column(
|
||||||
expand=True,
|
scroll=ft.ScrollMode.AUTO,
|
||||||
spacing=ds.s(12),
|
spacing=ds.s(12),
|
||||||
controls=[
|
controls=[
|
||||||
nav_row,
|
nav_row,
|
||||||
@@ -178,14 +186,14 @@ class PollPage(ft.Column):
|
|||||||
finish_btn = ft.Button(
|
finish_btn = ft.Button(
|
||||||
content=ft.Text(
|
content=ft.Text(
|
||||||
"Завершить тест →",
|
"Завершить тест →",
|
||||||
color=ds.clolors.ferst,
|
color=ds.colors.text_on_primary,
|
||||||
size=ds.s(16),
|
size=ds.s(16),
|
||||||
weight=ft.FontWeight.BOLD,
|
weight=ft.FontWeight.BOLD,
|
||||||
),
|
),
|
||||||
data="__finish__",
|
data="__finish__",
|
||||||
style=ft.ButtonStyle(
|
style=ft.ButtonStyle(
|
||||||
bgcolor=ds.clolors.laer1,
|
bgcolor=ds.colors.primary,
|
||||||
color=ds.clolors.ferst,
|
color=ds.colors.text_on_primary,
|
||||||
),
|
),
|
||||||
on_click=self._submit,
|
on_click=self._submit,
|
||||||
)
|
)
|
||||||
@@ -227,7 +235,7 @@ class PollPage(ft.Column):
|
|||||||
),
|
),
|
||||||
ft.TextButton(
|
ft.TextButton(
|
||||||
content="Прервать",
|
content="Прервать",
|
||||||
style=ft.ButtonStyle(color=ds.clolors.laer1),
|
style=ft.ButtonStyle(color=ds.colors.secondary_light),
|
||||||
on_click=_do_abort,
|
on_click=_do_abort,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -250,7 +258,7 @@ class PollPage(ft.Column):
|
|||||||
self.controls = [
|
self.controls = [
|
||||||
ft.Row(
|
ft.Row(
|
||||||
alignment=ft.MainAxisAlignment.CENTER,
|
alignment=ft.MainAxisAlignment.CENTER,
|
||||||
controls=[ft.ProgressRing(width=ds.s(48), height=ds.s(48), color=ds.clolors.laer1)],
|
controls=[ft.ProgressRing(width=ds.s(48), height=ds.s(48), color=ds.colors.secondary_light)],
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
self.update()
|
self.update()
|
||||||
@@ -272,7 +280,7 @@ class PollPage(ft.Column):
|
|||||||
alignment=ft.MainAxisAlignment.CENTER,
|
alignment=ft.MainAxisAlignment.CENTER,
|
||||||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
controls=[
|
controls=[
|
||||||
ft.Icon(ft.Icons.ERROR_OUTLINE, size=ds.s(48), color=ds.clolors.laer1),
|
ft.Icon(ft.Icons.ERROR_OUTLINE, size=ds.s(48), color=ds.colors.secondary_light),
|
||||||
ds.CastomText(text=error, size=14),
|
ds.CastomText(text=error, size=14),
|
||||||
ds.CastomButton(
|
ds.CastomButton(
|
||||||
text="Попробовать снова",
|
text="Попробовать снова",
|
||||||
@@ -292,7 +300,7 @@ class PollPage(ft.Column):
|
|||||||
)
|
)
|
||||||
|
|
||||||
success_controls: list[ft.Control] = [
|
success_controls: list[ft.Control] = [
|
||||||
ft.Icon(ft.Icons.CHECK_CIRCLE_OUTLINE, size=ds.s(48), color=ds.clolors.laer1),
|
ft.Icon(ft.Icons.CHECK_CIRCLE_OUTLINE, size=ds.s(48), color=ds.colors.secondary_light),
|
||||||
ds.CastomText(text="Тест пройден!", size=20, weight=ft.FontWeight.BOLD),
|
ds.CastomText(text="Тест пройден!", size=20, weight=ft.FontWeight.BOLD),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -339,7 +347,7 @@ class PollPage(ft.Column):
|
|||||||
self.controls = [
|
self.controls = [
|
||||||
ft.Container(
|
ft.Container(
|
||||||
padding=ds.s(20),
|
padding=ds.s(20),
|
||||||
bgcolor=ds.clolors.laer3,
|
bgcolor=ds.colors.accent_lime,
|
||||||
border_radius=ds.s(16),
|
border_radius=ds.s(16),
|
||||||
expand=True,
|
expand=True,
|
||||||
content=ft.Column(
|
content=ft.Column(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import asyncio
|
|||||||
import flet as ft
|
import flet as ft
|
||||||
import designer as ds
|
import designer as ds
|
||||||
import api_client
|
import api_client
|
||||||
|
import theme_manager as tm
|
||||||
|
|
||||||
|
|
||||||
class ProfilePage(ft.Column):
|
class ProfilePage(ft.Column):
|
||||||
@@ -31,21 +32,53 @@ class ProfilePage(ft.Column):
|
|||||||
hint_text="Загрузка...",
|
hint_text="Загрузка...",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── Выбор темы ─────────────────────────────────────────────────
|
||||||
|
self._theme_dropdown = ds.CastomDropdown(
|
||||||
|
label="Тема интерфейса",
|
||||||
|
hint_text="Выберите тему...",
|
||||||
|
options=[
|
||||||
|
ft.DropdownOption(key=t["id"], text=t["name"])
|
||||||
|
for t in tm.theme_list()
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self._theme_dropdown.on_select = self._on_theme_preview
|
||||||
|
|
||||||
|
# ── Слайдер масштаба ───────────────────────────────────────────
|
||||||
|
self._scale_value = ds.SCALE
|
||||||
|
self._scale_label = ft.Text(
|
||||||
|
value=f"Масштаб: {ds.SCALE:.1f}×",
|
||||||
|
size=ds.s(13),
|
||||||
|
color=ds.colors.text_secondary,
|
||||||
|
)
|
||||||
|
self._scale_slider = ft.Slider(
|
||||||
|
min=tm.SCALE_MIN,
|
||||||
|
max=tm.SCALE_MAX,
|
||||||
|
divisions=round((tm.SCALE_MAX - tm.SCALE_MIN) / tm.SCALE_STEP),
|
||||||
|
value=ds.SCALE,
|
||||||
|
label="{value}×",
|
||||||
|
active_color=ds.colors.primary,
|
||||||
|
inactive_color=ds.colors.surface_alt,
|
||||||
|
thumb_color=ds.colors.primary,
|
||||||
|
on_change=self._on_scale_preview,
|
||||||
|
on_change_end=self._on_scale_apply,
|
||||||
|
)
|
||||||
|
|
||||||
self._loading = ft.ProgressRing(visible=False, width=ds.s(24), height=ds.s(24))
|
self._loading = ft.ProgressRing(visible=False, width=ds.s(24), height=ds.s(24))
|
||||||
self._status = ft.Text(value="", size=ds.s(14))
|
self._status = ft.Text(value="", size=ds.s(14))
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
spacing=ds.s(16),
|
spacing=ds.s(16),
|
||||||
controls=[
|
controls=[
|
||||||
ds.CastomText(
|
ds.CastomText("Редактировать профиль", size=18, weight=ft.FontWeight.BOLD),
|
||||||
text="Редактировать профиль",
|
|
||||||
size=18,
|
|
||||||
weight=ft.FontWeight.BOLD,
|
|
||||||
),
|
|
||||||
self._first_name,
|
self._first_name,
|
||||||
self._last_name,
|
self._last_name,
|
||||||
self._org_dropdown,
|
self._org_dropdown,
|
||||||
self._group_dropdown,
|
self._group_dropdown,
|
||||||
|
ft.Divider(height=ds.s(8), color=ds.colors.border),
|
||||||
|
ds.CastomText("Внешний вид", size=16, weight=ft.FontWeight.BOLD),
|
||||||
|
self._theme_dropdown,
|
||||||
|
self._scale_label,
|
||||||
|
self._scale_slider,
|
||||||
ft.Row(
|
ft.Row(
|
||||||
spacing=ds.s(12),
|
spacing=ds.s(12),
|
||||||
controls=[
|
controls=[
|
||||||
@@ -57,6 +90,8 @@ class ProfilePage(ft.Column):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── загрузка данных ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def load(self):
|
async def load(self):
|
||||||
token = self._page.session.store.get("auth_token") or ""
|
token = self._page.session.store.get("auth_token") or ""
|
||||||
me, orgs, groups = await asyncio.gather(
|
me, orgs, groups = await asyncio.gather(
|
||||||
@@ -66,7 +101,7 @@ class ProfilePage(ft.Column):
|
|||||||
)
|
)
|
||||||
self._orgs = orgs or []
|
self._orgs = orgs or []
|
||||||
|
|
||||||
_opt_style = ft.ButtonStyle(color=ds.clolors.ferst)
|
_opt_style = ft.ButtonStyle(color=ds.colors.text_primary)
|
||||||
|
|
||||||
self._org_dropdown.options = [
|
self._org_dropdown.options = [
|
||||||
ft.DropdownOption(key=o["id"], text=o["name_organization"], style=_opt_style)
|
ft.DropdownOption(key=o["id"], text=o["name_organization"], style=_opt_style)
|
||||||
@@ -80,6 +115,13 @@ class ProfilePage(ft.Column):
|
|||||||
]
|
]
|
||||||
self._group_dropdown.hint_text = "Выберите группу..."
|
self._group_dropdown.hint_text = "Выберите группу..."
|
||||||
|
|
||||||
|
# устанавливаем текущие настройки внешнего вида
|
||||||
|
self._theme_dropdown.value = tm.load_theme_id(self._page)
|
||||||
|
saved_scale = tm.load_scale(self._page)
|
||||||
|
self._scale_slider.value = saved_scale
|
||||||
|
self._scale_value = saved_scale
|
||||||
|
self._scale_label.value = f"Масштаб: {saved_scale:.1f}×"
|
||||||
|
|
||||||
if me:
|
if me:
|
||||||
self._first_name.value = me.get("first_name", "")
|
self._first_name.value = me.get("first_name", "")
|
||||||
self._last_name.value = me.get("last_name", "")
|
self._last_name.value = me.get("last_name", "")
|
||||||
@@ -88,6 +130,8 @@ class ProfilePage(ft.Column):
|
|||||||
|
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
|
# ── обработчики ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _on_org_change(self, e):
|
def _on_org_change(self, e):
|
||||||
selected_id = e.control.value
|
selected_id = e.control.value
|
||||||
for o in self._orgs:
|
for o in self._orgs:
|
||||||
@@ -96,6 +140,33 @@ class ProfilePage(ft.Column):
|
|||||||
break
|
break
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
|
def _on_theme_preview(self, e):
|
||||||
|
"""Применить тему предварительно (только bgcolor страницы)."""
|
||||||
|
theme_id = e.control.value
|
||||||
|
if theme_id:
|
||||||
|
tm.apply_theme(self._page, theme_id)
|
||||||
|
self._page.update()
|
||||||
|
|
||||||
|
def _on_scale_preview(self, e):
|
||||||
|
"""Обновить подпись слайдера во время перетаскивания."""
|
||||||
|
v = round(e.control.value, 1)
|
||||||
|
self._scale_value = v
|
||||||
|
self._scale_label.value = f"Масштаб: {v:.1f}×"
|
||||||
|
self._scale_label.update()
|
||||||
|
|
||||||
|
def _on_scale_apply(self, e):
|
||||||
|
"""Применить масштаб сразу при отпускании слайдера."""
|
||||||
|
scale = round(e.control.value, 1)
|
||||||
|
self._scale_value = scale
|
||||||
|
theme_id = self._theme_dropdown.value or tm.load_theme_id(self._page)
|
||||||
|
tm.save_preferences(self._page, theme_id, scale)
|
||||||
|
tm.apply_scale(self._page, scale)
|
||||||
|
tm.apply_theme(self._page, theme_id)
|
||||||
|
url_key = self._page.session.store.get("url_key") or ""
|
||||||
|
self._page.go(f"/profile/{url_key}")
|
||||||
|
|
||||||
|
# ── сохранение ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def _save(self, e):
|
async def _save(self, e):
|
||||||
self._loading.visible = True
|
self._loading.visible = True
|
||||||
self._status.value = ""
|
self._status.value = ""
|
||||||
@@ -104,8 +175,8 @@ class ProfilePage(ft.Column):
|
|||||||
token = self._page.session.store.get("auth_token") or ""
|
token = self._page.session.store.get("auth_token") or ""
|
||||||
user_id = self._page.session.store.get("user_id") or ""
|
user_id = self._page.session.store.get("user_id") or ""
|
||||||
|
|
||||||
fn = self._first_name.value.strip() if self._first_name.value else None
|
fn = (self._first_name.value or "").strip() or None
|
||||||
ln = self._last_name.value.strip() if self._last_name.value else None
|
ln = (self._last_name.value or "").strip() or None
|
||||||
|
|
||||||
data, err = await api_client.update_user(
|
data, err = await api_client.update_user(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -117,15 +188,26 @@ class ProfilePage(ft.Column):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self._loading.visible = False
|
self._loading.visible = False
|
||||||
|
|
||||||
if err:
|
if err:
|
||||||
self._status.value = err
|
self._status.value = err
|
||||||
self._status.color = "red"
|
self._status.color = "red"
|
||||||
else:
|
self.update()
|
||||||
self._status.value = "Данные успешно сохранены!"
|
return
|
||||||
self._status.color = ds.clolors.ferst
|
|
||||||
# Обновляем сессию для корректного сохранения при прохождении тестов
|
# всё успешно — сохраняем тему и масштаб в localStorage
|
||||||
if data:
|
theme_id = self._theme_dropdown.value or "default"
|
||||||
|
scale = self._scale_value
|
||||||
|
tm.save_preferences(self._page, theme_id, scale)
|
||||||
|
tm.apply_scale(self._page, scale)
|
||||||
|
tm.apply_theme(self._page, theme_id)
|
||||||
|
|
||||||
|
if data:
|
||||||
self._page.session.store.set("group_id", data.get("group_id") or "")
|
self._page.session.store.set("group_id", data.get("group_id") or "")
|
||||||
self._page.session.store.set("org_id", data.get("organization_id") or "")
|
self._page.session.store.set("org_id", data.get("organization_id") or "")
|
||||||
self.update()
|
|
||||||
|
# перезагружаем текущую страницу — пересоздаёт все виджеты
|
||||||
|
# с новым SCALE и новыми цветами
|
||||||
|
url_key = self._page.session.store.get("url_key") or ""
|
||||||
|
self._page.go(f"/profile/{url_key}")
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class ResponsePage(ft.Column):
|
|||||||
def __init__(self, page: ft.Page, response_id: str):
|
def __init__(self, page: ft.Page, response_id: str):
|
||||||
self._page = page
|
self._page = page
|
||||||
self._response_id = response_id
|
self._response_id = response_id
|
||||||
self._loading = ft.ProgressRing(width=ds.s(40), height=ds.s(40), color=ds.clolors.laer1)
|
self._loading = ft.ProgressRing(width=ds.s(40), height=ds.s(40), color=ds.colors.secondary_light)
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
alignment=ft.MainAxisAlignment.START,
|
alignment=ft.MainAxisAlignment.START,
|
||||||
@@ -63,7 +63,7 @@ class ResponsePage(ft.Column):
|
|||||||
controls.append(ft.Container(
|
controls.append(ft.Container(
|
||||||
border_radius=ds.s(16),
|
border_radius=ds.s(16),
|
||||||
padding=ds.s(12),
|
padding=ds.s(12),
|
||||||
bgcolor=ds.clolors.laer3,
|
bgcolor=ds.colors.surface_alt,
|
||||||
content=ft.Column(
|
content=ft.Column(
|
||||||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
spacing=ds.s(10),
|
spacing=ds.s(10),
|
||||||
@@ -105,11 +105,11 @@ class ResponsePage(ft.Column):
|
|||||||
choice_rows.append(ft.Container(
|
choice_rows.append(ft.Container(
|
||||||
border_radius=ds.s(8),
|
border_radius=ds.s(8),
|
||||||
padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(6)),
|
padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(6)),
|
||||||
bgcolor=ds.clolors.laer1 if is_selected else ds.clolors.laer3,
|
bgcolor=ds.colors.primary_light if is_selected else ds.colors.surface_alt,
|
||||||
content=ft.Row(spacing=ds.s(8), controls=[
|
content=ft.Row(spacing=ds.s(8), controls=[
|
||||||
ft.Icon(
|
ft.Icon(
|
||||||
ft.Icons.CHECK_CIRCLE if is_selected else ft.Icons.RADIO_BUTTON_UNCHECKED,
|
ft.Icons.CHECK_CIRCLE if is_selected else ft.Icons.RADIO_BUTTON_UNCHECKED,
|
||||||
color=ds.clolors.ferst, size=ds.s(16),
|
color=ds.colors.primary, size=ds.s(16),
|
||||||
),
|
),
|
||||||
ft.Container(expand=True, content=ds.CastomText(text=c["text"], size=13)),
|
ft.Container(expand=True, content=ds.CastomText(text=c["text"], size=13)),
|
||||||
]),
|
]),
|
||||||
@@ -118,7 +118,7 @@ class ResponsePage(ft.Column):
|
|||||||
answer_cards.append(ft.Container(
|
answer_cards.append(ft.Container(
|
||||||
border_radius=ds.s(12),
|
border_radius=ds.s(12),
|
||||||
padding=ds.s(16),
|
padding=ds.s(16),
|
||||||
bgcolor=ds.clolors.laer2,
|
bgcolor=ds.colors.surface,
|
||||||
content=ft.Column(spacing=ds.s(8), controls=[
|
content=ft.Column(spacing=ds.s(8), controls=[
|
||||||
ds.CastomText(text=q.get("text", ""), size=14, weight=ft.FontWeight.BOLD),
|
ds.CastomText(text=q.get("text", ""), size=14, weight=ft.FontWeight.BOLD),
|
||||||
*choice_rows,
|
*choice_rows,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import api_client
|
|||||||
class TakeTestPage(ft.Column):
|
class TakeTestPage(ft.Column):
|
||||||
def __init__(self, page: ft.Page):
|
def __init__(self, page: ft.Page):
|
||||||
self._page = page
|
self._page = page
|
||||||
self._loading = ft.ProgressRing(width=ds.s(40), height=ds.s(40), color=ds.clolors.laer1)
|
self._loading = ft.ProgressRing(width=ds.s(40), height=ds.s(40), color=ds.colors.secondary_light)
|
||||||
super().__init__(
|
super().__init__(
|
||||||
alignment=ft.MainAxisAlignment.CENTER,
|
alignment=ft.MainAxisAlignment.CENTER,
|
||||||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
@@ -29,7 +29,7 @@ class TakeTestPage(ft.Column):
|
|||||||
ft.Container(
|
ft.Container(
|
||||||
border_radius=ds.s(12),
|
border_radius=ds.s(12),
|
||||||
padding=ds.s(16),
|
padding=ds.s(16),
|
||||||
bgcolor=ds.clolors.laer2,
|
bgcolor=ds.colors.surface,
|
||||||
content=ft.Column(
|
content=ft.Column(
|
||||||
spacing=ds.s(6),
|
spacing=ds.s(6),
|
||||||
controls=[
|
controls=[
|
||||||
|
|||||||
@@ -30,28 +30,28 @@ class LoginView(ft.View):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self._reg_link = ft.TextButton(
|
self._reg_link = ft.TextButton(
|
||||||
content=ft.Text("Нет аккаунта? Зарегистрироваться", color=ds.clolors.ferst, size=ds.s(14)),
|
content=ft.Text("Нет аккаунта? Зарегистрироваться", color=ds.colors.primary, size=ds.s(14)),
|
||||||
on_click=self.open_dialog,
|
on_click=self.open_dialog,
|
||||||
)
|
)
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
route="/login",
|
route="/login",
|
||||||
bgcolor=ds.clolors.background,
|
bgcolor=ds.colors.background,
|
||||||
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
|
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
|
||||||
vertical_alignment=ft.MainAxisAlignment.CENTER,
|
vertical_alignment=ft.MainAxisAlignment.CENTER,
|
||||||
padding=ft.padding.symmetric(horizontal=ds.s(16), vertical=ds.s(24)),
|
padding=ft.padding.symmetric(horizontal=ds.s(16), vertical=ds.s(24)),
|
||||||
scroll=ft.ScrollMode.AUTO,
|
scroll=ft.ScrollMode.AUTO,
|
||||||
)
|
)
|
||||||
self.controls = [
|
self.controls = [
|
||||||
ft.Text("ЦОПП", color=ds.clolors.ferst, size=ds.s(30), weight=ft.FontWeight.BOLD,
|
ft.Text("ЦОПП", color=ds.colors.primary, size=ds.s(30), weight=ft.FontWeight.BOLD,
|
||||||
text_align=ft.TextAlign.CENTER),
|
text_align=ft.TextAlign.CENTER),
|
||||||
ft.Container(height=ds.s(8)),
|
ft.Container(height=ds.s(8)),
|
||||||
ft.Text("Это проект цопп по проведению иследовательских тестирований", color=ds.clolors.ferst, size=ds.s(16),
|
ft.Text("Это проект цопп по проведению иследовательских тестирований", color=ds.colors.primary, size=ds.s(16),
|
||||||
text_align=ft.TextAlign.CENTER),
|
text_align=ft.TextAlign.CENTER),
|
||||||
ft.Container(height=ds.s(24)),
|
ft.Container(height=ds.s(24)),
|
||||||
ft.Container(
|
ft.Container(
|
||||||
padding=ft.padding.all(ds.s(24)),
|
padding=ft.padding.all(ds.s(24)),
|
||||||
bgcolor=ds.clolors.laer3,
|
bgcolor=ds.colors.surface_alt,
|
||||||
border_radius=ds.s(16),
|
border_radius=ds.s(16),
|
||||||
alignment=ft.Alignment.CENTER,
|
alignment=ft.Alignment.CENTER,
|
||||||
content=ft.Column(
|
content=ft.Column(
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ class MainView(ft.View):
|
|||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
route="/main",
|
route="/main",
|
||||||
bgcolor=ds.clolors.background,
|
bgcolor=ds.colors.background,
|
||||||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
vertical_alignment=ft.MainAxisAlignment.CENTER,
|
vertical_alignment=ft.MainAxisAlignment.CENTER,
|
||||||
)
|
)
|
||||||
self.controls = [
|
self.controls = [
|
||||||
ft.Text("ЦОПП",color=ds.clolors.ferst, size=ds.s(30)),
|
ft.Text("ЦОПП",color=ds.colors.primary, size=ds.s(30)),
|
||||||
ft.Text("Это проект цопп по проведению иследовательских тестирований",color=ds.clolors.ferst, size=ds.s(16)),
|
ft.Text("Это проект цопп по проведению иследовательских тестирований",color=ds.colors.primary, size=ds.s(16)),
|
||||||
ft.Container(width=0, height=ds.s(20)),
|
ft.Container(width=0, height=ds.s(20)),
|
||||||
self.button_temp
|
self.button_temp
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class MyTestsView(ft.View):
|
|||||||
|
|
||||||
back_btn = ft.IconButton(
|
back_btn = ft.IconButton(
|
||||||
icon=ft.Icons.ARROW_BACK,
|
icon=ft.Icons.ARROW_BACK,
|
||||||
icon_color=ds.clolors.ferst,
|
icon_color=ds.colors.primary,
|
||||||
on_click=lambda _: page.go(f"/user/{url_key}"),
|
on_click=lambda _: page.go(f"/user/{url_key}"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ class MyTestsView(ft.View):
|
|||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
route=f"/my_tests/{url_key}",
|
route=f"/my_tests/{url_key}",
|
||||||
bgcolor=ds.clolors.background,
|
bgcolor=ds.colors.background,
|
||||||
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
|
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
|
||||||
vertical_alignment=ft.MainAxisAlignment.START,
|
vertical_alignment=ft.MainAxisAlignment.START,
|
||||||
scroll=ft.ScrollMode.AUTO,
|
scroll=ft.ScrollMode.AUTO,
|
||||||
@@ -33,6 +33,7 @@ class MyTestsView(ft.View):
|
|||||||
text="Мои тесты",
|
text="Мои тесты",
|
||||||
size=22,
|
size=22,
|
||||||
weight=ft.FontWeight.BOLD,
|
weight=ft.FontWeight.BOLD,
|
||||||
|
expand=True,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class PollView(ft.View):
|
|||||||
|
|
||||||
back_btn = ft.IconButton(
|
back_btn = ft.IconButton(
|
||||||
icon=ft.Icons.ARROW_BACK,
|
icon=ft.Icons.ARROW_BACK,
|
||||||
icon_color=ds.clolors.ferst,
|
icon_color=ds.colors.primary,
|
||||||
on_click=lambda _: page.go(f"/take_test/{url_key}"),
|
on_click=lambda _: page.go(f"/take_test/{url_key}"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,13 +17,13 @@ class PollView(ft.View):
|
|||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
route=f"/poll/{url_key}/{poll_id}",
|
route=f"/poll/{url_key}/{poll_id}",
|
||||||
bgcolor=ds.clolors.background,
|
bgcolor=ds.colors.background,
|
||||||
padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(8)),
|
padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(8)),
|
||||||
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
|
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
|
||||||
vertical_alignment=ft.MainAxisAlignment.START,
|
vertical_alignment=ft.MainAxisAlignment.START,
|
||||||
|
scroll=ft.ScrollMode.AUTO,
|
||||||
controls=[
|
controls=[
|
||||||
ft.Column(
|
ft.Column(
|
||||||
expand=True,
|
|
||||||
spacing=ds.s(10),
|
spacing=ds.s(10),
|
||||||
controls=[
|
controls=[
|
||||||
ft.Row(
|
ft.Row(
|
||||||
@@ -33,6 +33,7 @@ class PollView(ft.View):
|
|||||||
text="Прохождение теста",
|
text="Прохождение теста",
|
||||||
size=22,
|
size=22,
|
||||||
weight=ft.FontWeight.BOLD,
|
weight=ft.FontWeight.BOLD,
|
||||||
|
expand=True,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class ProfileView(ft.View):
|
|||||||
|
|
||||||
back_btn = ft.IconButton(
|
back_btn = ft.IconButton(
|
||||||
icon=ft.Icons.ARROW_BACK,
|
icon=ft.Icons.ARROW_BACK,
|
||||||
icon_color=ds.clolors.ferst,
|
icon_color=ds.colors.primary,
|
||||||
on_click=lambda _: page.go(f"/user/{url_key}"),
|
on_click=lambda _: page.go(f"/user/{url_key}"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ class ProfileView(ft.View):
|
|||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
route=f"/profile/{url_key}",
|
route=f"/profile/{url_key}",
|
||||||
bgcolor=ds.clolors.background,
|
bgcolor=ds.colors.background,
|
||||||
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
|
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
|
||||||
vertical_alignment=ft.MainAxisAlignment.START,
|
vertical_alignment=ft.MainAxisAlignment.START,
|
||||||
scroll=ft.ScrollMode.AUTO,
|
scroll=ft.ScrollMode.AUTO,
|
||||||
@@ -33,6 +33,7 @@ class ProfileView(ft.View):
|
|||||||
text="Профиль",
|
text="Профиль",
|
||||||
size=22,
|
size=22,
|
||||||
weight=ft.FontWeight.BOLD,
|
weight=ft.FontWeight.BOLD,
|
||||||
|
expand=True,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -60,22 +60,22 @@ class RegView(ft.View):
|
|||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
route="/reg",
|
route="/reg",
|
||||||
bgcolor=ds.clolors.background,
|
bgcolor=ds.colors.background,
|
||||||
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
|
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
|
||||||
vertical_alignment=ft.MainAxisAlignment.CENTER,
|
vertical_alignment=ft.MainAxisAlignment.CENTER,
|
||||||
padding=ft.padding.symmetric(horizontal=ds.s(16), vertical=ds.s(24)),
|
padding=ft.padding.symmetric(horizontal=ds.s(16), vertical=ds.s(24)),
|
||||||
scroll=ft.ScrollMode.AUTO,
|
scroll=ft.ScrollMode.AUTO,
|
||||||
)
|
)
|
||||||
self.controls = [
|
self.controls = [
|
||||||
ft.Text("COPP", color=ds.clolors.ferst, size=ds.s(30), weight=ft.FontWeight.BOLD,
|
ft.Text("COPP", color=ds.colors.primary, size=ds.s(30), weight=ft.FontWeight.BOLD,
|
||||||
text_align=ft.TextAlign.CENTER),
|
text_align=ft.TextAlign.CENTER),
|
||||||
ft.Container(height=ds.s(8)),
|
ft.Container(height=ds.s(8)),
|
||||||
ft.Text("Регистрация в системе", color=ds.clolors.ferst, size=ds.s(16),
|
ft.Text("Регистрация в системе", color=ds.colors.primary, size=ds.s(16),
|
||||||
text_align=ft.TextAlign.CENTER),
|
text_align=ft.TextAlign.CENTER),
|
||||||
ft.Container(height=ds.s(24)),
|
ft.Container(height=ds.s(24)),
|
||||||
ft.Container(
|
ft.Container(
|
||||||
padding=ft.padding.all(ds.s(24)),
|
padding=ft.padding.all(ds.s(24)),
|
||||||
bgcolor=ds.clolors.laer3,
|
bgcolor=ds.colors.surface_alt,
|
||||||
border_radius=ds.s(16),
|
border_radius=ds.s(16),
|
||||||
content=ft.Column(
|
content=ft.Column(
|
||||||
spacing=ds.s(16),
|
spacing=ds.s(16),
|
||||||
@@ -111,7 +111,7 @@ class RegView(ft.View):
|
|||||||
)
|
)
|
||||||
self._orgs = orgs or []
|
self._orgs = orgs or []
|
||||||
|
|
||||||
_opt_style = ft.ButtonStyle(color=ds.clolors.ferst)
|
_opt_style = ft.ButtonStyle(color=ds.colors.text_primary)
|
||||||
self._org_dropdown.options = [
|
self._org_dropdown.options = [
|
||||||
ft.DropdownOption(key=o["id"], text=o["name_organization"], style=_opt_style) for o in self._orgs
|
ft.DropdownOption(key=o["id"], text=o["name_organization"], style=_opt_style) for o in self._orgs
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class ResponseView(ft.View):
|
|||||||
|
|
||||||
back_btn = ft.IconButton(
|
back_btn = ft.IconButton(
|
||||||
icon=ft.Icons.ARROW_BACK,
|
icon=ft.Icons.ARROW_BACK,
|
||||||
icon_color=ds.clolors.ferst,
|
icon_color=ds.colors.primary,
|
||||||
on_click=lambda _: page.go(f"/my_tests/{url_key}"),
|
on_click=lambda _: page.go(f"/my_tests/{url_key}"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ class ResponseView(ft.View):
|
|||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
route=f"/response/{url_key}/{response_id}",
|
route=f"/response/{url_key}/{response_id}",
|
||||||
bgcolor=ds.clolors.background,
|
bgcolor=ds.colors.background,
|
||||||
padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(8)),
|
padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(8)),
|
||||||
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
|
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
|
||||||
vertical_alignment=ft.MainAxisAlignment.START,
|
vertical_alignment=ft.MainAxisAlignment.START,
|
||||||
@@ -34,6 +34,7 @@ class ResponseView(ft.View):
|
|||||||
text="Результаты теста",
|
text="Результаты теста",
|
||||||
size=22,
|
size=22,
|
||||||
weight=ft.FontWeight.BOLD,
|
weight=ft.FontWeight.BOLD,
|
||||||
|
expand=True,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class TakeTestView(ft.View):
|
|||||||
|
|
||||||
back_btn = ft.IconButton(
|
back_btn = ft.IconButton(
|
||||||
icon=ft.Icons.ARROW_BACK,
|
icon=ft.Icons.ARROW_BACK,
|
||||||
icon_color=ds.clolors.ferst,
|
icon_color=ds.colors.primary,
|
||||||
on_click=lambda _: page.go(f"/user/{url_key}"),
|
on_click=lambda _: page.go(f"/user/{url_key}"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,13 +17,14 @@ class TakeTestView(ft.View):
|
|||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
route=f"/take_test/{url_key}",
|
route=f"/take_test/{url_key}",
|
||||||
bgcolor=ds.clolors.background,
|
bgcolor=ds.colors.background,
|
||||||
padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(8)),
|
padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(8)),
|
||||||
horizontal_alignment=ft.CrossAxisAlignment.START,
|
horizontal_alignment=ft.CrossAxisAlignment.START,
|
||||||
vertical_alignment=ft.MainAxisAlignment.START,
|
vertical_alignment=ft.MainAxisAlignment.START,
|
||||||
|
scroll=ft.ScrollMode.AUTO,
|
||||||
controls=[
|
controls=[
|
||||||
ft.Column(
|
ft.Column(
|
||||||
expand=True,
|
spacing=ds.s(10),
|
||||||
controls=[
|
controls=[
|
||||||
ft.Row(
|
ft.Row(
|
||||||
controls=[
|
controls=[
|
||||||
@@ -32,6 +33,7 @@ class TakeTestView(ft.View):
|
|||||||
text="Все тесты",
|
text="Все тесты",
|
||||||
size=22,
|
size=22,
|
||||||
weight=ft.FontWeight.BOLD,
|
weight=ft.FontWeight.BOLD,
|
||||||
|
expand=True,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class UserView(ft.View):
|
|||||||
|
|
||||||
profile_btn = ft.IconButton(
|
profile_btn = ft.IconButton(
|
||||||
icon=ft.Icons.ACCOUNT_CIRCLE,
|
icon=ft.Icons.ACCOUNT_CIRCLE,
|
||||||
icon_color=ds.clolors.ferst,
|
icon_color=ds.colors.primary,
|
||||||
icon_size=ds.s(30),
|
icon_size=ds.s(30),
|
||||||
tooltip="Профиль",
|
tooltip="Профиль",
|
||||||
on_click=lambda _: page.go(f"/profile/{url_key}"),
|
on_click=lambda _: page.go(f"/profile/{url_key}"),
|
||||||
@@ -19,22 +19,22 @@ class UserView(ft.View):
|
|||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
route=f"/user/{url_key}",
|
route=f"/user/{url_key}",
|
||||||
bgcolor=ds.clolors.background,
|
bgcolor=ds.colors.background,
|
||||||
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
|
horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
|
||||||
vertical_alignment=ft.MainAxisAlignment.START,
|
vertical_alignment=ft.MainAxisAlignment.START,
|
||||||
padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(8)),
|
padding=ft.padding.symmetric(horizontal=ds.s(12), vertical=ds.s(8)),
|
||||||
|
scroll=ft.ScrollMode.AUTO,
|
||||||
)
|
)
|
||||||
self.controls = [
|
self.controls = [
|
||||||
ft.Column(
|
ft.Column(
|
||||||
expand=True,
|
spacing=ds.s(12),
|
||||||
alignment=ft.MainAxisAlignment.START,
|
|
||||||
controls=[
|
controls=[
|
||||||
ft.Row(
|
ft.Row(
|
||||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||||
controls=[
|
controls=[
|
||||||
ft.Text(
|
ft.Text(
|
||||||
"Добро пожаловать в COPP!",
|
"Добро пожаловать в COPP!",
|
||||||
color=ds.clolors.ferst,
|
color=ds.colors.primary,
|
||||||
size=ds.s(20),
|
size=ds.s(20),
|
||||||
weight=ft.FontWeight.BOLD,
|
weight=ft.FontWeight.BOLD,
|
||||||
expand=True,
|
expand=True,
|
||||||
|
|||||||
Reference in New Issue
Block a user