@ -1,202 +1,195 @@ |
|||||||
# Система тестирования сотрудников клиники |
# Система тестирования сотрудников клиники |
||||||
|
|
||||||
Веб-приложение для проведения внутреннего тестирования сотрудников клиники. Руководители подразделений и HR-менеджеры создают тесты и назначают их сотрудникам. Система фиксирует все попытки и результаты. |
Веб-приложение для проведения внутреннего тестирования сотрудников клиники. |
||||||
|
Руководители подразделений и HR-менеджеры создают тесты и назначают их |
||||||
|
сотрудникам. Все попытки и результаты сохраняются. |
||||||
|
|
||||||
**Версия ТЗ:** 1.2 |
- **Прод:** **[https://edullm.pirogov.ai/](https://edullm.pirogov.ai/)** |
||||||
**Дата:** 2026-03-21 |
- **Ветка разработки:** `dev` |
||||||
**Статус:** Согласовано |
- **ТЗ:** v1.2 от 2026-03-21 (статус — согласовано) |
||||||
|
|
||||||
**Актуальное состояние кода (не ТЗ, а «что уже есть»):** [docs/PROJECT_STATUS.md](docs/PROJECT_STATUS.md) · [инструкция для проверяющих на dev](docs/DEV_CONTOUR_USER_GUIDE.md) · [кабинет: коротко для врачей/кураторов](docs/РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md). |
|
||||||
**Спринты мобильного UI (чек-лист для разработки):** [docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md](docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md). |
|
||||||
**Унификация стека (текущий этап) и слияние с HR-кабинетом (на будущее):** план и журнал — [docs/migration-final.md](docs/migration-final.md). Этап 1 — Express → Flask + React → Jinja **внутри TestingWebApp** (БД остаётся `clinic_tests`). Этап 2 (на будущее) — слияние с `HR_TG_Bot/tgFlaskForm`: [docs/migration-to-tgflaskform.md](docs/migration-to-tgflaskform.md) · [простым языком](docs/migration-to-tgflaskform-plain.md). Карта Express-функционала и справочный gap-analysis с уже существующим модулем HR-кабинета: [docs/migration-final-inventory.md](docs/migration-final-inventory.md). |
|
||||||
**Заготовка `flask_app/`** (отдельный Flask) больше **не развивается** — выбран сценарий «модуль внутри `tgFlaskForm`». |
|
||||||
|
|
||||||
--- |
--- |
||||||
|
|
||||||
## Стек технологий |
## Стек и состояние |
||||||
|
|
||||||
### Этот репозиторий (TestingWebApp) |
|
||||||
|
|
||||||
| Слой | Технологии | |
|
||||||
|------|------------| |
|
||||||
| **Backend** | Node.js (ESM), **Express** 4, **pg**, миграции SQL; аутентификация — cookie + **JWT** (**jsonwebtoken**), пароли **bcryptjs**; опционально вход через HR (`HR_AUTH`, отдельное подключение к БД HR). | |
|
||||||
| **Frontend** | **React** 18, **React Router** 6, сборка **Vite** 5; статика в проде через Nginx (см. `docker-compose.dev.yml`). | |
|
||||||
| **Данные** | **PostgreSQL**, отдельная БД **`clinic_tests`**: UUID-ключи, таблицы `tests`, `test_versions`, `questions`, `answer_options`, назначения, попытки (см. `backend/src/db/migrations/`). | |
|
||||||
| **Прочее** | Извлечение текста из PDF/DOCX (**pdf-parse**, **mammoth**), опционально LLM для черновиков тестов; **dotenv**, **cors**, **multer**. | |
|
||||||
|
|
||||||
### Целевой стек (Flask, как в кабинете / мини-приложении) |
|
||||||
|
|
||||||
Тот же класс технологий, что в **`HR_TG_Bot/tgFlaskForm`**: Python, Flask, шаблоны, Postgres. Сейчас допускается **отдельный деплой** нового контура из каталога [`flask_app/`](flask_app/README.md); позже — слияние с полным кабинетом при необходимости. |
**Целевой и единственный рабочий стек** — Python 3.11 + Flask 3 + |
||||||
|
Jinja2 + Tailwind CDN + SQLAlchemy / psycopg2, код в |
||||||
|
[`flask_app/`](flask_app/). На нём работает и прод, и dev (`:3108`). |
||||||
|
|
||||||
Эталон реализации модуля в монорепозитории HR — общий веб-кабинет **`HR_TG_Bot/tgFlaskForm`**: |
Старые каталоги `backend/` (Node.js / Express) и `frontend/` |
||||||
|
(React + Vite) — **архив**: не разворачиваются, в `docker-compose.dev.yml` |
||||||
|
поднимается только сервис `testing-flask`, удаление папок запланировано |
||||||
|
в спринте **E1.6**. Использовать их не надо, миграции и SQL-схема |
||||||
|
сохранены в `backend/src/db/migrations/` исключительно как источник |
||||||
|
структуры БД. |
||||||
|
|
||||||
| Слой | Технологии | |
БД — **`clinic_tests`** (PostgreSQL, UUID-ключи). В Этапе 1 схема |
||||||
|------|------------| |
не меняется. |
||||||
| **Приложение** | **Python 3**, **Flask** 3, шаблоны **Jinja2** + **PyPug**, blueprint `/cabinet/testing`; прод-сервер типично **waitress**. | |
|
||||||
| **Данные** | **SQLAlchemy** 2, **psycopg2**, БД **`hr_bot_test`**: таблицы `testing_*`, связи с **`staff_members`**. | |
|
||||||
| **Клиент** | HTML-шаблоны кабинета, JS в `webApp/templates/static/js/cabinet/` (без отдельного SPA в этом репозитории). | |
|
||||||
| **Инфра** | Тот же кластер Postgres, что и у Postgres_TG_Bots / HR (см. раздел установки ниже). | |
|
||||||
|
|
||||||
Подробности переноса и миграции данных: [docs/migration-to-tgflaskform.md](docs/migration-to-tgflaskform.md). |
**Этап 2** — слияние с общим HR-кабинетом `HR_TG_Bot/tgFlaskForm` — |
||||||
Скрипт ETL в монорепозитории HR: [`../HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py`](../HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py) (`--dry-run` / `--apply`, переменные `CLINIC_TESTS_URL` и `HR_BOT_URL`). |
запланирован на будущее, сейчас не делается. План: |
||||||
|
[`docs/migration-to-tgflaskform.md`](docs/migration-to-tgflaskform.md) |
||||||
|
([простыми словами](docs/migration-to-tgflaskform-plain.md)). |
||||||
|
|
||||||
--- |
--- |
||||||
|
|
||||||
## Содержание |
## Что уже работает на новом (Flask) контуре |
||||||
|
|
||||||
- [Стек технологий](#стек-технологий) · [flask_app/ — новый контур](flask_app/README.md) |
E1.0–E1.3 и E1.8 закрыты. Чек-лист и журнал — |
||||||
- [Состояние реализации (сводка)](#состояние-реализации-сводка) |
[`docs/migration-final.md`](docs/migration-final.md). |
||||||
- [Функциональные возможности](#функциональные-возможности) |
|
||||||
- [Роли и права доступа](#роли-и-права-доступа) |
- **Авторизация** через куки-сессию Flask: bcrypt (локальные пользователи |
||||||
- [Установка и запуск](#установка-и-запуск) |
`clinic_tests.users`) или Werkzeug-хеши при включённом `HR_AUTH=1` |
||||||
- [Данные, сотрудники, интеграция с HR](#данные-сотрудники-интеграция-с-hr) |
(UPSERT в `clinic_tests.users` по `staff_id` из `hr_bot_test`). |
||||||
- [Нефункциональные требования](#нефункциональные-требования) |
UI: `/login`, JSON: `/api/auth/{login,logout,me}`. |
||||||
- [Вне scope](#вне-scope-не-реализуется-в-данной-версии) |
- **Каталог тестов** `/tests` (видны активные + блок «Скрытые вами»), |
||||||
|
создание теста через модалку. |
||||||
|
- **Редактор** `/tests/<id>/edit`: правка названия/описания/проходного |
||||||
|
балла, добавление/удаление/перемещение вопросов и вариантов, |
||||||
|
переключатель «Цепочка активна», авто-форк новой версии при правке |
||||||
|
после первой попытки. |
||||||
|
- **AI-помощник** в редакторе: |
||||||
|
- «По названию» — генерация всего теста по теме (количество вопросов |
||||||
|
и вариантов задаёт автор); |
||||||
|
- «По текущей сетке» — генерация по уже расставленным карточкам; |
||||||
|
- «Проверить» — рецензия теста с вердиктом и разделами рекомендаций; |
||||||
|
- «Улучшить» — массовое «было → стало» с чекбоксами; |
||||||
|
- «AI: вопрос/переформулировать» — на отдельной карточке вопроса. |
||||||
|
- **Импорт документа** в редакторе: PDF / DOCX / TXT / MD до 16 МБ, |
||||||
|
через `pypdf` и `python-docx` → AI-черновик. |
||||||
|
- **Настройки** `/settings` — статус общего LLM-ключа из ENV (DeepSeek |
||||||
|
или OpenAI-совместимый), кнопка «Проверить подключение». |
||||||
|
|
||||||
|
Подробная инструкция для тестировщика (только UI, без консоли) — |
||||||
|
[`docs/QA-versioning-and-ai.md`](docs/QA-versioning-and-ai.md). |
||||||
|
|
||||||
|
## Что ещё не реализовано |
||||||
|
|
||||||
|
| Спринт | Что включает | |
||||||
|
|---|---| |
||||||
|
| **E1.4** — Назначение и прохождение | Назначить тест сотруднику, экран прохождения, экран результата с разбором ошибок. | |
||||||
|
| **E1.5** — Трекер и настройки модуля | Единый список попыток с фильтрами, страница настроек цепочки. | |
||||||
|
| **E1.6** — Cutover внутри репозитория | Удаление `backend/` и `frontend/`, чистка `docker-compose.dev.yml` от legacy-сервисов. | |
||||||
|
| **E1.7** — UX-полировка редактора | 4 аккордеона (Шапка / AI / Вопросы / Действия) и drag-n-drop из [Спринта 3](docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md). | |
||||||
|
|
||||||
--- |
--- |
||||||
|
|
||||||
## Состояние реализации (сводка) |
## Установка и запуск |
||||||
|
|
||||||
Коротко и по-человечески: [docs/PROJECT_STATUS.md](docs/PROJECT_STATUS.md) (черновики и версии, разбор попыток, список тестов, dev-стенд). |
|
||||||
Как пользоваться локальным **dev** без чтения кода: [docs/DEV_CONTOUR_USER_GUIDE.md](docs/DEV_CONTOUR_USER_GUIDE.md). |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## Функциональные возможности |
|
||||||
|
|
||||||
### Управление пользователями и подразделениями |
|
||||||
|
|
||||||
- Создание/редактирование/деактивация учётных записей сотрудников |
|
||||||
- Каждый сотрудник принадлежит одному подразделению |
|
||||||
- Создание/редактирование справочника подразделений |
|
||||||
- Назначение роли сотруднику: HR-менеджер / Руководитель подразделения / Сотрудник |
|
||||||
|
|
||||||
### Создание и редактирование тестов |
|
||||||
|
|
||||||
**Тест содержит:** |
|
||||||
- Название теста |
|
||||||
- Описание (опционально) |
|
||||||
- Список вопросов (минимум 7) |
|
||||||
- Порог зачёта — минимальный % правильных ответов |
|
||||||
- Таймер прохождения — лимит в минутах (опционально) |
|
||||||
|
|
||||||
**Вопрос содержит:** |
|
||||||
- Текст вопроса |
|
||||||
- Минимум 3 варианта ответа |
|
||||||
- Один или несколько правильных ответов |
|
||||||
|
|
||||||
**Настройки теста:** |
### Предпосылка: общий Postgres |
||||||
- Разрешить возврат к предыдущему вопросу: да / нет |
|
||||||
|
|
||||||
**Версионирование:** |
Используется **тот же** PostgreSQL, что и в |
||||||
- Автор может редактировать тест пока никто его не проходил |
[Postgres_TG_Bots](../Postgres_TG_Bots) (контейнер `hr_postgres_dev`, |
||||||
- Если тест уже проходили — создаётся новая версия (`version + 1`), старая сохраняется |
сеть `hr_postgres_dev_net`, учётка `hr_bot_user`). |
||||||
- Все версии теста хранятся; результаты привязаны к конкретной версии |
|
||||||
- Активная версия — та, которую видят сотрудники; автор может вручную переключить активную версию |
|
||||||
- Тест можно деактивировать (скрыть из списка, не удалять) |
|
||||||
|
|
||||||
### Назначение теста |
```bash |
||||||
|
# (один раз) создать базу |
||||||
|
psql "postgresql://hr_bot_user:hrbot123@localhost:5432/postgres" \ |
||||||
|
-c "CREATE DATABASE clinic_tests;" |
||||||
|
|
||||||
- Список получателей (отдел или конкретные сотрудники) |
# (один раз) внешняя сеть, если ещё не создана соседом |
||||||
- Срок сдачи — дата дедлайна |
docker network create hr_postgres_dev_net || true |
||||||
- Допустимое количество попыток (1 или более) |
``` |
||||||
|
|
||||||
### Прохождение теста |
### Dev-стенд |
||||||
|
|
||||||
- На главной странице сотрудник видит список назначенных тестов со статусами: |
Выбор интерфейса задаётся через env-переменную `COMPOSE_PROFILES`: |
||||||
- `Не начат` — ещё не открывал |
|
||||||
- `В процессе` — начал, не завершил |
|
||||||
- `Завершён` — сдал/не сдал |
|
||||||
- `Просрочен` — дедлайн прошёл, не сдан |
|
||||||
- Если задан таймер — отображается обратный отсчёт, по истечении тест завершается автоматически |
|
||||||
- Порядок вопросов **случайный** при каждом прохождении |
|
||||||
- Возможность вернуться к предыдущему вопросу — определяется настройкой теста |
|
||||||
|
|
||||||
### Результаты после завершения теста |
- `modern` — основной интерфейс на Flask/Jinja; |
||||||
|
- `legacy` — legacy-раскладка интерфейса на том же Flask-стеке. |
||||||
|
|
||||||
- Итоговый балл и процент правильных ответов |
```bash |
||||||
- Факт зачёта: **сдал / не сдал** |
# Новый стек (рекомендуется) |
||||||
- Разбор ошибок: по каждому вопросу — его ответ и правильный ответ |
COMPOSE_PROFILES=modern docker compose -f docker-compose.dev.yml up -d --build |
||||||
|
|
||||||
### Трекер попыток |
# Legacy-раскладка (тот же Flask) |
||||||
|
COMPOSE_PROFILES=legacy docker compose -f docker-compose.dev.yml up -d --build |
||||||
|
``` |
||||||
|
|
||||||
Единый интерфейс просмотра всех попыток прохождения тестов: |
| Что | URL | |
||||||
- Фильтрация по подразделению, сотруднику, тесту, статусу, результату |
|---|---| |
||||||
- Пагинация и сортировка |
| Приложение (Flask modern) | <http://localhost:3108> | |
||||||
|
| Health-check | <http://localhost:3108/health> | |
||||||
|
| Приложение (Flask legacy) | <http://localhost:3107> | |
||||||
|
|
||||||
### AI-помощник |
`docker-compose.dev.yml` пробрасывает в `testing-flask`: |
||||||
|
- `DATABASE_URL` (по умолчанию на контейнерный Postgres `clinic_tests`); |
||||||
|
- `HR_AUTH=1` / `HR_DATABASE_URL` по умолчанию — вход через HR-кабинет; |
||||||
|
- `DEEPSEEK_API_KEY` / `OPENAI_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` — |
||||||
|
для AI-функций. Достаточно положить ключ в корневой `.env` репозитория. |
||||||
|
|
||||||
Интеграция с LLM для помощи при создании тестов: |
### Локально без Docker |
||||||
|
|
||||||
| Функция | Описание | |
См. [`flask_app/README.md`](flask_app/README.md) — `venv` + |
||||||
|---------|----------| |
`pip install -r requirements.txt` + `python run.py`. |
||||||
| Генерация теста | AI генерирует готовый набор вопросов с вариантами ответов по теме | |
|
||||||
| Улучшение формулировки | AI переформулирует выбранный вопрос более чётко | |
|
||||||
| Добавление дистракторов | AI генерирует правдоподобные неправильные варианты ответов | |
|
||||||
| Проверка качества | AI анализирует весь тест и выдаёт рекомендации | |
|
||||||
|
|
||||||
--- |
--- |
||||||
|
|
||||||
## Роли и права доступа |
## Данные и интеграция с HR |
||||||
|
|
||||||
| Роль | Кто | Создаёт тесты | Назначает тесты | Видит результаты | |
- **Две роли кластера Postgres.** В **`clinic_tests`** — только сущности |
||||||
|------|-----|:---:|:---:|:---:| |
модуля тестирования (тесты, версии, назначения, попытки, локальные |
||||||
| **HR-менеджер** | Руководитель службы HR, Директор клиники | ✅ | Всем сотрудникам клиники | Всех сотрудников | |
технические учётки). В **`hr_bot_test`** (Postgres_TG_Bots / |
||||||
| **Руководитель подразделения** | Главный врач, рук. службы администраторов | ✅ | Только своему подразделению | Только своего подразделения | |
hr_web_viewer) — штат, справочники, RBAC и веб-логины. Схемы не |
||||||
| **Сотрудник** | Все остальные работники | ❌ | ❌ | Только свои | |
смешиваем, второй кадровый учёт в `clinic_tests` не ведём. |
||||||
|
- **Сотрудник** во всех бизнес-процессах — по |
||||||
|
**`staff_members.id`** из `hr_bot_test`. В `clinic_tests` храним тот же |
||||||
|
идентификатор; ФИО / отдел / роли подтягиваем из HR при отображении. |
||||||
|
- **`telegram_id` сотрудника** в бизнес-логике модуля **не участвует** |
||||||
|
(ни вход, ни проверка прав, ни выбор сотрудника, ни фильтрация). |
||||||
|
- **Целевой RBAC** — единая система разрешений HR |
||||||
|
(`staff_role_assignments`, `permissions`). Модуль тестирования |
||||||
|
не дублирует матрицу; пока единый API не готов — в `clinic_tests` |
||||||
|
допустимы временные флаги, явно помеченные как MVP. |
||||||
|
- **`HR_AUTH=1`**: в Flask-контуре включает вход через `hr_bot_test.users` |
||||||
|
(Werkzeug-хеши) с UPSERT в `clinic_tests.users`. См. |
||||||
|
[`flask_app/.env.example`](flask_app/.env.example). |
||||||
|
|
||||||
--- |
--- |
||||||
|
|
||||||
## Установка и запуск |
## Роли и права (по ТЗ) |
||||||
|
|
||||||
### База данных (как в HR_TG_Bot / Postgres_TG_Bots) |
|
||||||
|
|
||||||
Используется **тот же** экземпляр PostgreSQL, что и в [Postgres_TG_Bots](../Postgres_TG_Bots) (`docker-compose.dev.yml`, контейнер `hr_postgres_dev`, учётка `hr_bot_user` / сеть `hr_postgres_dev_net` — см. [HR_TG_Bot docker-compose](../HR_TG_Bot/docker-compose.dev.yml)). |
|
||||||
|
|
||||||
Схема приложения (таблицы `users`, `tests`, `departments`, …) **не** совмещается с БД `hr_bot_test` — для TestingWebApp заведена отдельная база **`clinic_tests`**. |
|
||||||
|
|
||||||
1. Поднять Postgres из `Postgres_TG_Bots` (и при необходимости внешнюю сеть: `docker network create hr_postgres_dev_net` — как в compose этих репозиториев). |
|
||||||
2. Один раз создать базу: |
|
||||||
`psql "postgresql://hr_bot_user:hrbot123@localhost:5432/postgres" -c "CREATE DATABASE clinic_tests;"` |
|
||||||
3. Скопировать `backend/.env.example` в `backend/.env`, при необходимости поправить `DATABASE_URL` (внутри Docker кластера — хост `hr_postgres_dev`, порт `5432`). |
|
||||||
4. Миграции: из каталога `backend/`: `npm run migrate`, затем `npm start` (и фронт из `frontend/` — `npm run dev`). |
|
||||||
|
|
||||||
**Docker (UI + API + общий Postgres):** поднять `Postgres_TG_Bots` (сеть `hr_postgres_dev_net`), создать БД `clinic_tests`, затем из корня `TestingWebApp`: |
|
||||||
`docker compose -f docker-compose.dev.yml up --build` — интерфейс **http://localhost:3107** (Nginx проксирует `/api` в backend), API с хоста **http://localhost:3001** (см. [docker-compose.dev.yml](docker-compose.dev.yml), миграции в entrypoint). **Новый Flask-контур** (тот же стек, что кабинет HR): **http://localhost:3108** — сервис `testing-flask`, см. [flask_app/README.md](flask_app/README.md). Локальный `npm run dev` фронта (Vite) — тоже **:3107**, прокси `/api` на **:3001**. В БД `clinic_tests` для локального логина нужен активный `users` с bcrypt-паролем, либо включите `HR_AUTH=1` + `HR_DATABASE_URL` в compose/`.env` (см. `backend/.env.example`). В `backend/.env` задайте `PORT=3001`, если поднимаете API отдельно от compose. |
|
||||||
|
|
||||||
`docker compose -f docker-compose.dev.yml down` — остановка. |
| Роль | Кто | Создаёт тесты | Назначает | Видит результаты | |
||||||
|
|---|---|:---:|:---:|:---:| |
||||||
|
| **HR-менеджер** | Руководитель HR, директор | ✅ | Всем | Всех | |
||||||
|
| **Руководитель подразделения** | Главврач, рук. отделения | ✅ | Только своему подразделению | Только своего подразделения | |
||||||
|
| **Сотрудник** | Все остальные | ❌ | ❌ | Только свои | |
||||||
|
|
||||||
**Без общего кластера** (только отладка): `docker compose --profile standalone up -d` в TestingWebApp — Postgres на **5433**, тогда в `.env` укажите `DATABASE_URL=...localhost:5433/clinic_tests` или `DB_PORT=5433` с `DB_NAME`/`DB_USER` из compose. |
> На текущем Flask-контуре (E1.0–E1.3, E1.8) проверяется только |
||||||
|
> `@login_required`; разделение по ролям задействуется на E1.4–E1.5. |
||||||
**Если `npm run migrate` пишет `ECONNREFUSED ...:5433`:** в `backend/.env` нет (или кривой) `DATABASE_URL` на **5432**, и сработал старый `DB_PORT=5433`. Задайте `DATABASE_URL` как в `backend/.env.example` для общего Postgres. |
|
||||||
|
|
||||||
### Данные, сотрудники, интеграция с HR |
|
||||||
|
|
||||||
- **Две роли кластера Postgres:** в **`clinic_tests`** — только сущности модуля тестирования (тесты, версии, назначения, попытки, локальные технические учётки при необходимости). В **`hr_bot_test`** (Postgres_TG_Bots / hr_web_viewer) — штат, справочники, существующий **RBAC** и веб-логины. Так мы не смешиваем схемы и не дублируем «источник правды» по людям. |
|
||||||
- **Сотрудник в процессах** (назначения, дашборды, доступ к результатам) — везде по **`staff_members.id`**. Ссылки в `clinic_tests` храним как **тот же идентификатор** (логическая связь с `staff_members` в `hr_bot_test`); **ФИО, отдел, роли** подтягиваем из HR при отображении или кэшируем по согласованной политике, а не ведём второй кадровый учёт. |
|
||||||
- **`telegram_id`** в данных сотрудника **не участвует** в бизнес-логике модуля: ни вход, ни проверка прав, ни выбор сотрудника в сценариях, ни фильтрация — только **справочная** информация при необходимости (отображение, история). |
|
||||||
- **RBAC в перспективе:** единая система разрешений — та, что уже в HR (роли, `staff_role_assignments`, permissions). Модуль тестирования **не** развивает отдельную полную копию матрицы; проверка действий в целевом виде — через **HR** (внутренний API / токен / согласованные запросы к БД). Пока договор и API не готовы — допустимы временные флаги в `clinic_tests`, явно помечаемые как MVP. |
|
||||||
|
|
||||||
Детализация задач и варианты A.x: [docs/revision_task/card1.md](docs/revision_task/card1.md). |
|
||||||
|
|
||||||
--- |
--- |
||||||
|
|
||||||
## Нефункциональные требования |
## Нефункциональные требования |
||||||
|
|
||||||
| Параметр | Значение | |
| Параметр | Значение | |
||||||
|----------|----------| |
|---|---| |
||||||
| Количество пользователей | 50–200 человек | |
| Количество пользователей | 50–200 человек | |
||||||
| Платформа | Веб-приложение, браузер (desktop-first) | |
| Платформа | Веб, браузер; mobile-friendly | |
||||||
| Доступность | Внутренняя сеть клиники | |
| Доступность | Внутренняя сеть клиники | |
||||||
| Язык интерфейса | Русский | |
| Язык интерфейса | Русский | |
||||||
| Время отклика | < 2 секунды | |
| Время отклика | < 2 секунды | |
||||||
|
|
||||||
--- |
## Вне scope (в текущей версии не делаем) |
||||||
|
|
||||||
|
- Интеграция с AD / LDAP. |
||||||
|
- Нативное мобильное приложение. |
||||||
|
- Вопросы с вложениями (картинки, видео). |
||||||
|
- Экспорт отчётов в Excel / PDF. |
||||||
|
- Уведомления в MAX (отдельный спринт). |
||||||
|
|
||||||
## Вне scope (не реализуется в данной версии) |
--- |
||||||
|
|
||||||
- Интеграция с AD/LDAP |
## Документация |
||||||
- Мобильное приложение |
|
||||||
- Вопросы с вложениями (изображения, видео) |
| Файл | О чём | |
||||||
- Экспорт отчётов в Excel / PDF |
|---|---| |
||||||
- Уведомления в MAX (отдельный спринт) |
| [`docs/PROJECT_STATUS.md`](docs/PROJECT_STATUS.md) | Что работает «прямо сейчас», что в работе, что в бэклоге. | |
||||||
|
| [`docs/migration-final.md`](docs/migration-final.md) | Главный трекер миграции: спринты Этапа 1, журнал, критерии готовности. | |
||||||
|
| [`docs/migration-final-inventory.md`](docs/migration-final-inventory.md) | Карта 22 эндпоинтов Express + gap-analysis с `tgFlaskForm`. | |
||||||
|
| [`docs/migration-to-tgflaskform.md`](docs/migration-to-tgflaskform.md) | План Этапа 2 (слияние с HR-кабинетом). | |
||||||
|
| [`docs/QA-versioning-and-ai.md`](docs/QA-versioning-and-ai.md) | Инструкция для тестировщика — только через сайт. | |
||||||
|
| [`docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md`](docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md) | Целевой мобильный UX редактора (база для E1.7). | |
||||||
|
| [`docs/РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md`](docs/РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md) | Кратко для врачей-кураторов. | |
||||||
|
| [`flask_app/README.md`](flask_app/README.md) | Конкретные команды для нового контура. | |
||||||
|
| [`docs/ТЗ.md`](docs/ТЗ.md) | Исходное ТЗ заказчика. | |
||||||
|
|||||||
@ -0,0 +1,340 @@ |
|||||||
|
# UX-аудит страницы теста и предложение новой информационной архитектуры |
||||||
|
|
||||||
|
**Продукт:** HR system — модуль тестирования |
||||||
|
**Платформа:** Цифровые сервисы клиники им. Е. Н. Оленевой |
||||||
|
**Объект анализа:** страница `/tests/{id}` — создание/редактирование теста |
||||||
|
**Дата:** 29 апреля 2026 |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Краткая сводка |
||||||
|
|
||||||
|
Текущая страница `/tests/{id}` совмещает три разные пользовательские задачи в одном экране: |
||||||
|
|
||||||
|
1. **Авторскую** — придумать и оформить тест (название, описание, вопросы, варианты). |
||||||
|
2. **Управленческую** — назначить тест 1–N сотрудникам. |
||||||
|
3. **Аналитическую** — посмотреть, кто из сотрудников и какие версии проходил. |
||||||
|
|
||||||
|
Эти задачи различаются по ролям, частоте, объёму данных и контексту. Сейчас они смешаны в одном длинном аккордеоне, что приводит к ряду проблем — от потери изменений до невозможности масштабировать список аудитории за пределы 100–200 человек. |
||||||
|
|
||||||
|
Документ состоит из двух частей: |
||||||
|
|
||||||
|
- **Часть 1** — аудит текущей страницы с приоритизированными проблемами (critical / major / minor) и ссылками на скриншоты. |
||||||
|
- **Часть 2** — предложение новой IA с раздельными разделами «Тесты», «Назначения», «Отчёты», ролевой моделью и описанием жизненного цикла версии теста. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
# Часть 1. Аудит текущей страницы |
||||||
|
|
||||||
|
Все скриншоты сделаны 29.04.2026 на странице `https://edullm.pirogov.ai/tests/298a64af-...` под ролью `employee` (см. п. M-3). |
||||||
|
|
||||||
|
## 1.1. Шапка, заголовок и баннер версионирования |
||||||
|
|
||||||
|
 |
||||||
|
|
||||||
|
Что мы видим: глобальная шапка «Тестирование», подпись пользователя `Разорвин А. М. · employee`, кнопка «Выйти». Ниже — хлебная крошка «← к списку», название теста, автор, дата обновления, **жёлтый баннер «При сохранении будет создана новая версия теста.»** и схлопнутый аккордеон «О тесте». |
||||||
|
|
||||||
|
Замечания: |
||||||
|
|
||||||
|
- **C-1 [critical] Баннер о новой версии показывается ВСЕГДА**, независимо от того, изменил ли пользователь хоть что-то. Это сбивает с толку: автор открывает существующий тест, ничего не трогает — и думает, что версия уже создана. Должен показываться только при наличии несохранённых изменений (dirty state). |
||||||
|
- **m-2 [minor] Роль `employee` написана по-английски** в шапке. В русском интерфейсе должно быть «сотрудник» / «автор» / другое (см. ролевую модель в Части 2). |
||||||
|
- **m-3 [minor] Опечатка** в `<title>` вкладки: «Система тестирования» (нет «и»). Влияет на закладки, историю, поиск. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 1.2. Секция «О тесте» |
||||||
|
|
||||||
|
 |
||||||
|
|
||||||
|
Замечания: |
||||||
|
|
||||||
|
- **M-1 [major] Аккордеон по умолчанию схлопнут.** Чтобы начать редактировать главный объект страницы (вопросы), нужно сделать лишний клик. На странице редактирования теста раздел «Вопросы» (а возможно, и «О тесте») должен быть открыт по умолчанию. |
||||||
|
- **M-2 [major] Поле «Порог зачёта, %» не имеет валидации min/max.** Что произойдёт при вводе 0, 100, 150, –5, 70.5, или вообще буквы? Минимум: подсказка «от 1 до 100», атрибуты `min/max/step` на input, инлайн-ошибка при некорректном вводе. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 1.3. Раздел «Вопросы» — генерация и Вопрос 1 |
||||||
|
|
||||||
|
 |
||||||
|
|
||||||
|
Что мы видим: блок **«Генерация сетки вопросов (ИИ)»** с полями «Тема», «Вопросов: 7», «Вариантов: 3» и кнопкой «Сгенерировать тест (ИИ)». Ниже — Вопрос 1 с собственной кнопкой «Сгенерировать вопрос (ИИ)» в правом верхнем углу, чекбоксом «Несколько верных ответов», тремя вариантами с радиокнопками и крестиками «удалить». |
||||||
|
|
||||||
|
Замечания: |
||||||
|
|
||||||
|
- **C-2 [critical] ИИ-генерация без подтверждения и без отображения хода работы.** Кнопка «Сгенерировать тест (ИИ)» одной нажатием перезаписывает существующие вопросы — а они уже могут быть наполовину написаны вручную. То же касается кнопки «Сгенерировать вопрос (ИИ)» рядом с уже заполненным вопросом. |
||||||
|
- Нужно: confirm-диалог «Заменить текущие N вопросов?», индикатор прогресса генерации, возможность откатить (undo) последний результат генерации. |
||||||
|
- **M-3 [major] Чекбокс «Несколько верных ответов» меняет семантику варианта без явного намёка.** Когда выкл — радиокнопки (один верный), когда вкл — должны стать чекбоксами (несколько). Лучше переписать подпись в зависимости от состояния: «один верный» / «несколько верных», и/или показать рядом подсказку, как изменится контрол. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 1.4. Вопросы 3–5 |
||||||
|
|
||||||
|
 |
||||||
|
|
||||||
|
Замечания: |
||||||
|
|
||||||
|
- **M-4 [major] Нет нумерации/перетаскивания вопросов.** «Вопрос 1, 2, 3…» — порядок фиксирован тем, в каком порядке добавляли. Для длинных тестов нужен drag-handle или хотя бы стрелки «вверх / вниз». |
||||||
|
- **M-5 [major] «Удалить вопрос» без подтверждения.** Случайный клик уничтожит написанный вопрос. Минимум — confirm-диалог; лучше — undo-toast «Вопрос удалён · Отменить». |
||||||
|
- **m-4 [minor] Маленькая видимая «вода» между вопросами.** Карточки вопросов мало отделены друг от друга визуально, при пролистывании они сливаются в стену форм. Стоит увеличить вертикальный отступ между карточками или добавить разделитель. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 1.5. Вопрос 7 — обрыв длинного варианта |
||||||
|
|
||||||
|
 |
||||||
|
|
||||||
|
Это один из самых наглядных багов: |
||||||
|
|
||||||
|
- **M-6 [major] Длинный текст варианта обрезается.** В Q7 первый вариант отображается как «Максимальное количество токенов, которое модель может о…» — текст уходит за правый край однострочного `<input>`. Для содержательных тестов (особенно медицинских) ответы часто длинные. Нужно: либо `<textarea>` с автовысотой, либо горизонтальный скролл с tooltip всего текста на ховере. |
||||||
|
- **m-5 [minor] Загрузка файла «Документ в вопросы» — без drag-and-drop, без ограничений по размеру/формату на UI, без обратной связи.** Подсказка «PDF, Word или текст — вставьте в черновик вопросов» — хорошая по-человечески, но не объясняет, что произойдёт после загрузки: заменит ли существующие вопросы, добавит ли в конец, есть ли превью результата. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 1.6. Кнопка «Сохранить черновик» в середине + История |
||||||
|
|
||||||
|
 |
||||||
|
|
||||||
|
Здесь главная архитектурная проблема страницы: |
||||||
|
|
||||||
|
- **C-3 [critical] Кнопка «Сохранить черновик» расположена в середине страницы.** Сразу после неё ниже идут ещё две большие секции — «История» и «Показ в каталоге». Пользователь, открывший «Показ в каталоге» и поменявший там аудиторию, психологически ищет «Сохранить» внизу страницы — но там его нет. Очень высокий риск потерять изменения. |
||||||
|
- Решения, любое или все: (а) sticky-панель сохранения внизу страницы; (б) дубль кнопки после последней секции; (в) автосохранение черновика; (г) предупреждение перед уходом со страницы при наличии несохранённых изменений. |
||||||
|
- **M-7 [major] Раздел «Прохождения» показывает сырые ENUM-значения.** Видно `v1 · in_progress` — это техническое значение, а не пользовательский текст. Должно быть «в процессе» / «пройдено» / «не пройдено», лучше с цветной плашкой-индикатором. |
||||||
|
- **M-8 [major] Дубль кнопки «К списку».** Хлебная крошка «← к списку» наверху + кнопка «К списку» рядом с «Сохранить черновик» — две точки выхода с разным визуальным весом. Кнопка справа от primary-кнопки создаёт ложное ощущение симметричности с действием. Оставить либо крошку, либо превратить вторую кнопку в текстовую ссылку «Отмена». |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 1.7. «Показ в каталоге» — Видимость и фильтры |
||||||
|
|
||||||
|
 |
||||||
|
|
||||||
|
- **M-9 [major] Контрол «Видимость» неясен по текущему состоянию.** Кнопка «Скрыть из списка» — это сейчас действие или текущее состояние? Если тест уже скрыт — кнопка должна называться «Показать в списке». Лучше — переключатель (toggle/switch) с подписью «Тест виден в каталоге», чтобы текущее состояние читалось без действий. |
||||||
|
- **m-6 [minor] Поле поиска и два селекта** «Все отделы» / «Все» расположены без подписей — что делает второй селект, без раскрытия не понятно. Нужны явные label или persistent placeholder. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 1.8. Список «Кому выдать» — 147 сотрудников |
||||||
|
|
||||||
|
 |
||||||
|
|
||||||
|
Этот блок — корень главной IA-проблемы (см. Часть 2): |
||||||
|
|
||||||
|
- **C-4 [critical] Назначение тестов не должно жить на странице теста.** Это управленческая задача отдельной роли (HR-менеджер, руководитель отделения), а не авторская. Подробно — в Часть 2. |
||||||
|
- **M-10 [major] Список из 147 человек без виртуализации и счётчика выбранных.** Нужно как минимум: счётчик «выбрано N из 147», фильтр «только выбранные», сохранение выбранного при изменении фильтра, виртуальный скролл (на 1000+ сотрудников страница встанет колом). |
||||||
|
- **M-11 [major] «Назначить выбранных» внутри контейнера списка.** Кнопка стоит на нижней границе скролл-контейнера — её очень легко не заметить. И непонятно: «Назначить» — это отдельное действие или часть общего «Сохранить черновик» наверху? |
||||||
|
- **m-7 [minor] Подпись «нет учётки (создадим при назначении)»** — хорошая идея (ленивая выдача учёток), но требует пояснения: что значит «при назначении», что получит сотрудник после, как ему придёт первый пароль. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 1.9. Сводная таблица замечаний |
||||||
|
|
||||||
|
| ID | Приоритет | Что | Место | |
||||||
|
|---|---|---|---| |
||||||
|
| C-1 | critical | Баннер «новая версия» виден всегда, не только при изменениях | 1.1 | |
||||||
|
| C-2 | critical | ИИ-генерация без confirm и без прогресса | 1.3 | |
||||||
|
| C-3 | critical | Кнопка «Сохранить» в середине страницы | 1.6 | |
||||||
|
| C-4 | critical | Назначение сотрудников не должно жить на странице теста | 1.8 | |
||||||
|
| M-1 | major | Аккордеоны схлопнуты по умолчанию, включая «Вопросы» | 1.2 | |
||||||
|
| M-2 | major | «Порог зачёта» без валидации min/max | 1.2 | |
||||||
|
| M-3 | major | Чекбокс «Несколько верных» меняет семантику без подсказки | 1.3 | |
||||||
|
| M-4 | major | Нет переупорядочивания вопросов | 1.4 | |
||||||
|
| M-5 | major | «Удалить вопрос» без подтверждения и undo | 1.4 | |
||||||
|
| M-6 | major | Длинный текст варианта ответа обрезается | 1.5 | |
||||||
|
| M-7 | major | Сырые ENUM-значения в статусах прохождений | 1.6 | |
||||||
|
| M-8 | major | Дубль точек выхода («← к списку» + «К списку») | 1.6 | |
||||||
|
| M-9 | major | Контрол «Видимость» неясен по состоянию | 1.7 | |
||||||
|
| M-10 | major | Список 147 сотрудников без виртуализации/счётчиков | 1.8 | |
||||||
|
| M-11 | major | «Назначить выбранных» теряется внутри контейнера | 1.8 | |
||||||
|
| m-1 | minor | Логотип на странице логина обрезан | вне скрина | |
||||||
|
| m-2 | minor | Роль `employee` латиницей в шапке | 1.1 | |
||||||
|
| m-3 | minor | Опечатка «тестирования» в `<title>` | 1.1 | |
||||||
|
| m-4 | minor | Карточки вопросов слабо отделены друг от друга | 1.4 | |
||||||
|
| m-5 | minor | Загрузка файла без drag-and-drop и описания результата | 1.5 | |
||||||
|
| m-6 | minor | Селекты в фильтрах без явных label | 1.7 | |
||||||
|
| m-7 | minor | «Нет учётки (создадим при назначении)» — нужно пояснение | 1.8 | |
||||||
|
|
||||||
|
Не проверено и стоит протестировать отдельно: валидация при сохранении пустого вопроса/вариантов, мобильная вёрстка, клавиатурная навигация и focus ring, контрастность по WCAG 2.2, поведение под другими ролями (руководитель, HR, директор). |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
# Часть 2. Предлагаемая новая IA |
||||||
|
|
||||||
|
## 2.1. Что не так с текущей IA |
||||||
|
|
||||||
|
Сейчас одна страница `/tests/{id}` решает три разные задачи разных ролей: |
||||||
|
|
||||||
|
| Задача | Кто делает | Как часто | Какие данные | |
||||||
|
|---|---|---|---| |
||||||
|
| Сочинить тест | автор / методолог | один раз при создании, далее редко | вопросы, варианты, порог | |
||||||
|
| Назначить кому проходить | автор (иногда) или HR / руководитель | каждый раз для нового сотрудника или потока | список из 100–10 000 сотрудников, фильтры | |
||||||
|
| Посмотреть кто прошёл | руководитель / HR / директор | регулярно | результаты, динамика, агрегаты | |
||||||
|
|
||||||
|
Это три разных пользовательских ритма, три разных набора фильтров, три разных уровня доступа. Складывать их в один аккордеон — экономия на маршрутизации и проигрыш во всём остальном (см. C-3, C-4, M-9, M-10, M-11). |
||||||
|
|
||||||
|
## 2.2. Карта разделов после редизайна |
||||||
|
|
||||||
|
``` |
||||||
|
HR system (модуль «Тестирование») |
||||||
|
│ |
||||||
|
├── Главная / Дашборд |
||||||
|
│ сводка: «назначено N тестов, X% прошли, Y просрочены» |
||||||
|
│ (вид зависит от роли — см. 2.4) |
||||||
|
│ |
||||||
|
├── Тесты |
||||||
|
│ ├── Каталог тестов ← список, поиск, фильтры |
||||||
|
│ ├── Создать тест ← минимальный wizard: название → пустой черновик |
||||||
|
│ └── /tests/{id} ← страница теста |
||||||
|
│ ├── Просмотр ← все, у кого есть доступ |
||||||
|
│ │ краткая сводка прохождений (89 / 147, средний 6.2/7) |
||||||
|
│ │ кнопка «Назначить» (открывает модалку из 2.3) |
||||||
|
│ │ кнопка «Редактировать» (если есть права) |
||||||
|
│ └── Редактирование ← только автор / методолог |
||||||
|
│ ├── О тесте |
||||||
|
│ ├── Вопросы |
||||||
|
│ └── Версии теста ← (вместо «История» — только версии) |
||||||
|
│ |
||||||
|
├── Назначения ← новый раздел |
||||||
|
│ ├── Список назначений ← таблица «тест × сотрудник × срок × статус» |
||||||
|
│ ├── Создать назначение ← массовый wizard (см. 2.3) |
||||||
|
│ └── /assignments/{id} ← страница назначения, где можно отозвать, |
||||||
|
│ продлить срок, посмотреть прогресс |
||||||
|
│ |
||||||
|
├── Отчёты ← новый раздел |
||||||
|
│ ├── По тесту ← кто прошёл, средний балл, кривые |
||||||
|
│ ├── По сотруднику ← все тесты сотрудника, история |
||||||
|
│ └── По отделу ← агрегаты для руководителей |
||||||
|
│ |
||||||
|
├── Сотрудники ← справочник, синхронизация с кадрами |
||||||
|
│ |
||||||
|
└── Настройки ← роли, подразделения, шаблоны уведомлений |
||||||
|
``` |
||||||
|
|
||||||
|
## 2.3. Сценарий «Назначить тест» через модалку |
||||||
|
|
||||||
|
Поскольку автор иногда сам назначает тест, а иногда передаёт это HR/руководителю, кнопка «Назначить» нужна **в двух местах**: |
||||||
|
|
||||||
|
- На странице теста (для автора, который сразу выдаёт тест). |
||||||
|
- В разделе «Назначения → Создать» (для HR/руководителя, который отбирает аудиторию массово). |
||||||
|
|
||||||
|
Обе точки открывают **одну и ту же модалку / визард** с шагами: |
||||||
|
|
||||||
|
1. **Кому.** Сначала фильтры по отделу/должности → одной кнопкой «Все из отделения хирургии (38)» или вручную чекбоксами. Сохранение выбранного при смене фильтра. Виртуализированный список. |
||||||
|
2. **Когда.** Дедлайн, опционально дата старта (например, новый сотрудник получает тест на 3-й рабочий день). |
||||||
|
3. **Параметры.** Сколько попыток допустимо, нужен ли пересдача после неуспеха, кому уведомления о результате. |
||||||
|
4. **Подтверждение.** «Назначить тест „Введение про LLM v1“ 38 сотрудникам отделения хирургии до 15 мая 2026 — назначить?» |
||||||
|
|
||||||
|
После назначения автор/HR попадает на страницу созданного назначения, где видит прогресс: кто открыл, кто проходит, кто завершил. |
||||||
|
|
||||||
|
## 2.4. Ролевая модель и матрица доступа |
||||||
|
|
||||||
|
Четыре роли из ваших пояснений: **сотрудник**, **руководитель подразделения**, **HR-менеджер**, **директор**. Плюс отдельно — **методолог/автор**, которая может присваиваться поверх любой из роли (директор, HR или руководитель могут также быть авторами). |
||||||
|
|
||||||
|
| Раздел / действие | Сотрудник | Рук. подр. | HR | Директор | Автор | |
||||||
|
|---|---|---|---|---|---| |
||||||
|
| Главная | свои назначения | свой отдел | вся клиника | вся клиника | свои тесты | |
||||||
|
| Каталог тестов — просмотр | да (только видимые) | да | да | да | да | |
||||||
|
| Создать тест | — | — | да | да | да | |
||||||
|
| Редактировать тест | — | — | (свои) | да | свои | |
||||||
|
| Опубликовать новую версию | — | — | (свои) | да | свои | |
||||||
|
| Удалить/архивировать тест | — | — | (свои) | да | свои | |
||||||
|
| Назначить тест | — | свой отдел | вся клиника | вся клиника | (если сам назначает) | |
||||||
|
| Отозвать назначение | — | свои | свои + HR-уровня | все | свои | |
||||||
|
| Отчёты по сотруднику | свои | подчинённые | все | все | свои тесты | |
||||||
|
| Отчёты по отделу | — | свой отдел | все | все | — | |
||||||
|
| Настройки ролей | — | — | да | да | — | |
||||||
|
|
||||||
|
«—» — действие не доступно. Точные границы (например, может ли HR редактировать чужой тест) уточняются на этапе требований. |
||||||
|
|
||||||
|
## 2.5. Жизненный цикл версии теста и поведение при активных прохождениях |
||||||
|
|
||||||
|
Версионирование уже сделано правильно — оно фиксирует, какую именно версию проходил сотрудник, и не ломает прошлые результаты. Но в UI нужно явно показать состояния и поведение при апдейте. |
||||||
|
|
||||||
|
``` |
||||||
|
┌──────────┐ |
||||||
|
│ Черновик │ ← автор может править свободно, |
||||||
|
└────┬─────┘ назначения нельзя выдать |
||||||
|
│ |
||||||
|
«Опубликовать как v2» |
||||||
|
│ |
||||||
|
▼ |
||||||
|
┌──────────┐ |
||||||
|
│ Активная │ ← новые назначения идут на эту версию; |
||||||
|
└────┬─────┘ уже идущие прохождения остаются на старой |
||||||
|
│ |
||||||
|
«Опубликовать как v3» |
||||||
|
│ |
||||||
|
▼ |
||||||
|
┌──────────┐ |
||||||
|
│ Архив │ ← новые назначения нельзя; старые |
||||||
|
└──────────┘ прохождения видны в отчётах |
||||||
|
``` |
||||||
|
|
||||||
|
Что должно быть видно в UI: |
||||||
|
|
||||||
|
- **Бейдж версии** рядом с названием теста: `Введение про LLM · v2 (активна)`. |
||||||
|
- **На странице редактирования** — явно: «Редактируется черновик v3 на основе активной v2». |
||||||
|
- **При публикации новой версии** — диалог: «Сейчас тест проходят 12 сотрудников на v2. Они закончат на v2; новые назначения пойдут на v3. Опубликовать v3?» |
||||||
|
- **В отчётах** — фильтр по версии теста. |
||||||
|
- **В назначении** — версия зафиксирована: «Назначен на тесте Введение про LLM v2». |
||||||
|
|
||||||
|
## 2.6. Состояние «черновик» страницы теста |
||||||
|
|
||||||
|
Сейчас единственная кнопка — «Сохранить черновик». Лучше добавить два глагола: |
||||||
|
|
||||||
|
- **«Сохранить черновик»** — сохранить промежуточно, не публиковать. Не создаёт новой версии. |
||||||
|
- **«Опубликовать как новую версию»** — фиксирует версию, делает её активной, открывает диалог из 2.5. |
||||||
|
|
||||||
|
Тогда жёлтый баннер из C-1 превращается в осмысленную подсказку: он показывается **только при наличии несохранённых изменений** и говорит «Чтобы изменения попали в назначения — опубликуйте новую версию». |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
# Часть 3. Чеклист изменений |
||||||
|
|
||||||
|
Разбит на три волны по приоритету и независимости работ. |
||||||
|
|
||||||
|
## Волна 1 — быстрые правки на текущей странице (1–2 спринта) |
||||||
|
|
||||||
|
Не требуют структурных изменений, можно делать параллельно с разработкой новой IA: |
||||||
|
|
||||||
|
- [ ] **C-1** Скрыть баннер версионирования при отсутствии изменений (dirty state). |
||||||
|
- [ ] **C-2** Confirm-диалог + прогресс для ИИ-генерации, undo последнего результата. |
||||||
|
- [ ] **C-3** Sticky-панель «Сохранить» внизу + предупреждение `beforeunload` при unsaved changes. |
||||||
|
- [ ] **M-1** Аккордеон «Вопросы» открыт по умолчанию. |
||||||
|
- [ ] **M-2** Валидация порога зачёта (1–100, целое число). |
||||||
|
- [ ] **M-3** Поясняющий текст для «Несколько верных ответов». |
||||||
|
- [ ] **M-5** Confirm + undo для «Удалить вопрос». |
||||||
|
- [ ] **M-6** Длинные варианты — `textarea` с автовысотой. |
||||||
|
- [ ] **M-7** Перевод ENUM-значений статусов прохождения. |
||||||
|
- [ ] **M-9** Toggle-switch для «Видимость» вместо одной кнопки. |
||||||
|
- [ ] **m-1, m-2, m-3** Косметика: логотип логина, роль, опечатка title. |
||||||
|
|
||||||
|
## Волна 2 — выделение разделов (новая IA) |
||||||
|
|
||||||
|
- [ ] Выделить раздел «Назначения» с собственной таблицей и фильтрами. |
||||||
|
- [ ] Перенести «Кому выдать» со страницы теста в модалку «Назначить» из 2.3. |
||||||
|
- [ ] Выделить раздел «Отчёты» из секции «История», расширить фильтрами и агрегатами. |
||||||
|
- [ ] Реализовать ролевую модель из 2.4 (RBAC): меню, разделы и действия зависят от роли. |
||||||
|
- [ ] Реализовать жизненный цикл версии (2.5) и явную публикацию. |
||||||
|
|
||||||
|
## Волна 3 — масштабирование и качество |
||||||
|
|
||||||
|
- [ ] Виртуализация списков сотрудников (поддержка 5 000+). |
||||||
|
- [ ] Drag-and-drop для перестановки вопросов (M-4). |
||||||
|
- [ ] Drag-and-drop загрузка файла с превью результата (m-5). |
||||||
|
- [ ] Аудит доступности (WCAG 2.2 AA): клавиатурная навигация, focus-ring, контрастность. |
||||||
|
- [ ] Адаптивная вёрстка для мобильных и планшетов. |
||||||
|
- [ ] Уведомления (e-mail, в системе) для назначений, дедлайнов, результатов. |
||||||
|
- [ ] Связка с курсами/треками (когда появятся). |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
# Что дальше |
||||||
|
|
||||||
|
После согласования этого документа имеет смысл: |
||||||
|
|
||||||
|
1. Сделать кликабельный прототип в Figma на 2 ключевых сценария: «автор создаёт и сразу назначает тест», «HR назначает существующий тест 200 сотрудникам». Это покажет, как именно ложится новая IA на реальные действия и где остались дыры. |
||||||
|
2. Прогнать прототип на 2–3 пользователях каждой роли (автор, HR, руководитель) — модерируемое юзабилити-тестирование на 30–40 минут. По итогам — финальные правки до старта разработки. |
||||||
|
3. Параллельно запустить Волну 1 — она независима от IA и сразу снимает большую часть пользовательской боли. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
*Если по какому-то пункту нужно больше деталей или хочется, чтобы я подготовил визард-сценарий «Назначить» в виде последовательных макетов — скажите.* |
||||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 21 KiB |
@ -0,0 +1,72 @@ |
|||||||
|
# Рекомендации UX по экранам редактирования теста |
||||||
|
|
||||||
|
*Основание: скриншоты в `docs/screens`, словарь `docs/Словарь UX-UI-IA терминов.md`. Дата фиксации: 29.04.2026.* |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Навигация и IA |
||||||
|
|
||||||
|
- **Хлебные крошки.** Сейчас только «← к списку». Имеет смысл добавить полную цепочку вроде «Тесты → Введение про LLM → Редактирование», чтобы снизить когнитивную нагрузку и отразить иерархию сущностей (Тест → Версия). |
||||||
|
- **Якоря по длинной странице.** Блоки «О тесте», «Вопросы», «История», «Показ в каталоге» образуют длинную вертикаль. Полезны боковое оглавление или «прыжки» по разделам / закреплённая поднавигация внутри страницы теста, чтобы не терять контекст при работе с нижними вопросами и назначением. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Состояния интерфейса и обратная связь |
||||||
|
|
||||||
|
- **ИИ-кнопки.** Для «Сгенерировать тест (ИИ)» и «Сгенерировать вопрос (ИИ)» нужны явные состояния: загрузка (спиннер, disabled), успех/ошибка, при необходимости — отмена длительной операции (видимость статуса системы). |
||||||
|
- **Черновик и риск потери данных.** Уже есть заметный «Сохранить черновик» и жёлтый баннер про новую версию — хорошо. Дополнительно: предупреждение при уходе со страницы с несохранёнными изменениями; для длинной формы — **закреплённая панель** с сохранением (или дублирование primary-действия после блока вопросов), чтобы не скроллить вниз каждый раз. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Редактор вопросов (UI и логика) |
||||||
|
|
||||||
|
- **Один vs несколько верных ответов.** При включённом «Несколько верных ответов» визуально должны быть **чекбоксы**, а не радиокнопки — соответствие метафоре, ожиданиям пользователя и доступности (скринридер, множественный выбор). |
||||||
|
- **Разделение действий.** «+ вариант» и «Удалить вопрос» сейчас визуально близки по весу — риск ошибочного клика. Деструктивное действие: вторичный стиль, отступ, по желанию подтверждение или «Удалить» в меню «⋯». |
||||||
|
- **Иерархия ИИ vs ручное редактирование.** Блок «Генерация сетки (ИИ)» логично оформить как сворачиваемый «продвинутый» блок или визуально отделить (заголовок, граница), чтобы отличать массовую генерацию от точечной «Сгенерировать вопрос» у карточки. |
||||||
|
- **Длинные варианты ответа.** Обрезка текста в однострочном поле мешает автору. Варианты: многострочное поле с авто-ростом по высоте или предпросмотр полной строки при фокусе/hover. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Локализация и терминология |
||||||
|
|
||||||
|
- В истории статус **`in_progress` на английском** при русском интерфейсе — заменить на «В процессе» или единый глоссарий статусов прохождения. |
||||||
|
- В шапке роль **`employee`** — унифицировать с русскими названиями ролей из словаря проекта (сотрудник, HR и т.д.). |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## «Показ в каталоге» и список сотрудников |
||||||
|
|
||||||
|
- **Кнопка «Назначить выбранных».** Сейчас выглядит как вторичная; это главное действие сценария выдачи теста. Имеет смысл сделать её **заполненной primary** при наличии выбора и **disabled с подсказкой**, если никто не выбран. |
||||||
|
- **Повтор строки «нет учётки (создадим при назначении)».** На каждой строке создаётся шум. Лучше: один информационный блок над списком; в строке — компактный бейдж/иконка только где уместно. |
||||||
|
- **Крайний случай: много сотрудников.** При сотнях/тысячах записей — виртуализация, пагинация или «выбрать всех по фильтру» с явным числом «будет назначено N человек». |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## История и версии |
||||||
|
|
||||||
|
- При росте списка карточки версий и прохождений превращаются в длинную простыню — предусмотреть **свёрнутый список**, пагинацию или табы «Версии» / «Прохождения» с фильтром по версии и статусу. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Доступность и плотность |
||||||
|
|
||||||
|
- Мелкий серый текст в списке сотрудников — проверить контраст (WCAG). |
||||||
|
- Чекбоксы и переключатели: достаточная зона клика, связь подписи с полем, логичный порядок табуляции. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Пустые состояния |
||||||
|
|
||||||
|
- Пустая история, нет вопросов, поиск «никого не нашёл» — короткий текст **почему пусто** и **следующий шаг** («Добавьте вопрос», «Измените фильтр»). |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Приоритизация внедрения |
||||||
|
|
||||||
|
1. **Высокий эффект / низкий риск:** локализация статусов и ролей; визуальное различие «Удалить вопрос» vs «+ вариант»; primary для «Назначить выбранных»; убрать повтор длинного текста про учётку в каждой строке. |
||||||
|
2. **Средний:** чекбоксы при нескольких верных ответах; многострочные варианты; состояния загрузки для ИИ; закреплённое сохранение. |
||||||
|
3. **Стратегический:** хлебные крошки и внутренняя навигация по разделу; масштабирование списка назначений; пустые состояния; продуктовая аналитика (например, доходят ли авторы до «Показ в каталоге»). |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
*Документ можно дополнять по мере внедрения и новых скринов.* |
||||||
@ -0,0 +1,298 @@ |
|||||||
|
# Словарь терминов проектирования |
||||||
|
|
||||||
|
**UX · UI · IA и смежные понятия** |
||||||
|
|
||||||
|
*Контекст: HR system / Платформа Цифровых Сервисов клиники им. Е. Н. Оленевой* |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
Короткий справочник, чтобы команда говорила на одном языке. Для каждого термина: русское название, английский эквивалент, короткое определение и пример из вашего продукта, чтобы было понятно, как термин применяется в реальной работе. |
||||||
|
|
||||||
|
Файл живой: добавляйте сюда термины, которые регулярно всплывают в обсуждениях. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 1. Три основных слоя проектирования |
||||||
|
|
||||||
|
Три понятия, которые часто путают друг с другом. Это не синонимы и не одно и то же — это три разных профессиональных взгляда на один и тот же продукт. |
||||||
|
|
||||||
|
### UX (User Experience) — *опыт взаимодействия / пользовательский опыт* |
||||||
|
|
||||||
|
Совокупность ощущений пользователя от взаимодействия с продуктом: насколько просто понять, как достичь цели, насколько быстро это получается, насколько мало раздражения по дороге. UX — это про задачу пользователя, а не про конкретный экран. |
||||||
|
|
||||||
|
> *Пример из HR system:* HR-менеджер хочет назначить тест 50 сотрудникам отделения. Хороший UX — он делает это в три клика через фильтр по отделу. Плохой UX — он скроллит список из 147 человек и отмечает чекбоксами вручную. |
||||||
|
|
||||||
|
### UI (User Interface) — *пользовательский интерфейс* |
||||||
|
|
||||||
|
Видимая и кликабельная часть продукта: кнопки, поля, цвета, иконки, типографика, состояния (наведение, фокус, ошибка). UI — это про то, как продукт выглядит и как откликается на действия. |
||||||
|
|
||||||
|
> *Пример из HR system:* Кнопка «Сохранить черновик» на странице теста — её цвет, размер, скруглённые углы, текст внутри, реакция на наведение курсора — это всё UI. |
||||||
|
|
||||||
|
### IA (Information Architecture) — *информационная архитектура* |
||||||
|
|
||||||
|
Структура продукта на уровне «что где лежит и как связано»: какие есть разделы, какие сущности живут внутри, по какой логике пользователь переходит с одной страницы на другую. IA — это скелет, на который потом натягиваются UX и UI. |
||||||
|
|
||||||
|
> *Пример из HR system:* Решение «авторская работа над тестом, назначение и отчётность — это три разных раздела меню, а не один длинный аккордеон» — это IA-решение. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 2. Исследования и работа с пользователем |
||||||
|
|
||||||
|
### Целевая аудитория — *target audience* |
||||||
|
|
||||||
|
Группы людей, для которых проектируется продукт. У каждой группы своя задача и контекст использования. |
||||||
|
|
||||||
|
> *Пример из HR system:* В вашей системе четыре аудитории: сотрудник, руководитель подразделения, HR-менеджер, директор. У них разные потребности и разные роли в системе. |
||||||
|
|
||||||
|
### Персона — *persona* |
||||||
|
|
||||||
|
Собирательный образ типичного представителя аудитории: имя, должность, цели, ограничения, частые сценарии. Помогает команде договориться, для кого мы решаем задачу. |
||||||
|
|
||||||
|
> *Пример из HR system:* «Ольга, HR-менеджер, 35 лет. Раз в квартал назначает массовое обучение 200 сотрудникам. Не любит интерфейсы, где надо кликать каждого по отдельности. Открывает систему с рабочего ноутбука и иногда с телефона на ходу.» |
||||||
|
|
||||||
|
### Сценарий использования — *user scenario / use case* |
||||||
|
|
||||||
|
История: пользователь приходит с какой-то задачей и проходит шаги, чтобы её решить. Сценарий описывает, что он делает и какие ожидания у него есть. |
||||||
|
|
||||||
|
> *Пример из HR system:* «Руководитель отделения хочет, чтобы все его подчинённые прошли тест по пожарной безопасности до конца квартала. Он входит в систему, выбирает свой отдел, выбирает тест, ставит дедлайн, отправляет.» |
||||||
|
|
||||||
|
### Пользовательский путь / CJM — *Customer Journey Map* |
||||||
|
|
||||||
|
Развёрнутая визуализация пути пользователя: шаги, точки контакта, эмоции на каждом этапе, где возникают проблемы (pain points) и где можно улучшить. |
||||||
|
|
||||||
|
> *Пример из HR system:* CJM сотрудника: получил уведомление → открыл письмо → перешёл по ссылке → ввёл логин → увидел список назначенных тестов → выбрал → прошёл → получил результат. На каждом шаге — что ему легко, а что мешает. |
||||||
|
|
||||||
|
### JTBD (Jobs To Be Done) — *работы, которые нужно выполнить* |
||||||
|
|
||||||
|
Подход: люди не «пользуются продуктом», они «нанимают» его, чтобы сделать конкретную работу. Помогает увидеть истинную мотивацию, а не поверхностный запрос. |
||||||
|
|
||||||
|
> *Пример из HR system:* HR не «нанимает» вашу систему, чтобы кликать по чекбоксам. Он нанимает её, чтобы доказать аудиту, что 100% персонала прошли инструктаж в срок. |
||||||
|
|
||||||
|
### Pain point — *болевая точка* |
||||||
|
|
||||||
|
Конкретное место, где пользователю плохо: непонятно, медленно, страшно, обидно. Pain points — главные кандидаты на улучшение. |
||||||
|
|
||||||
|
> *Пример из HR system:* На странице теста пользователь не понимает, где кнопка «Сохранить», и боится потерять изменения — это pain point. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 3. Информационная архитектура и навигация |
||||||
|
|
||||||
|
### Карта сайта / структура продукта — *sitemap* |
||||||
|
|
||||||
|
Иерархическое описание всех экранов и разделов продукта. Показывает, что есть в продукте и в каких отношениях разделы стоят друг к другу. |
||||||
|
|
||||||
|
> *Пример из HR system:* Главная → Тесты → [страница теста] → Назначения → Отчёты → Сотрудники → Настройки. |
||||||
|
|
||||||
|
### Навигация — *navigation* |
||||||
|
|
||||||
|
Способ перемещаться по продукту: главное меню, хлебные крошки, ссылки, табы, кнопки «назад». Навигация бывает первичной (основной), вторичной и контекстной. |
||||||
|
|
||||||
|
> *Пример из HR system:* Шапка с логотипом «Тестирование», меню справа («Тесты», «Назначения», «Отчёты»), ссылка «← к списку» наверху страницы — всё это элементы навигации. |
||||||
|
|
||||||
|
### Хлебные крошки — *breadcrumbs* |
||||||
|
|
||||||
|
Цепочка ссылок, показывающая, где пользователь находится в иерархии и куда можно вернуться: «Тесты / Введение про LLM / Редактирование». |
||||||
|
|
||||||
|
> *Пример из HR system:* Сейчас на странице теста есть только «← к списку». Полные крошки помогли бы быстрее ориентироваться. |
||||||
|
|
||||||
|
### Таксономия — *taxonomy* |
||||||
|
|
||||||
|
Набор категорий и тегов, по которым классифицируются объекты. Хорошая таксономия позволяет быстро находить нужное и не плодит дубликаты. |
||||||
|
|
||||||
|
> *Пример из HR system:* Тест может иметь категории: «обязательные», «рекомендованные», «по специальности», «обучающие». Это таксономия. |
||||||
|
|
||||||
|
### Сущность / объект предметной области — *entity / domain object* |
||||||
|
|
||||||
|
Главные «существительные» вашей системы: Тест, Версия теста, Вопрос, Вариант, Сотрудник, Назначение, Прохождение, Отчёт. Дизайн начинается с понимания, какие сущности есть и как они связаны. |
||||||
|
|
||||||
|
> *Пример из HR system:* Связь «Тест → Версия → Прохождение» позволяет фиксировать результаты конкретной версии, даже если автор потом изменил вопросы. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 4. Проектирование интерфейса |
||||||
|
|
||||||
|
### Вайрфрейм — *wireframe* |
||||||
|
|
||||||
|
Скелетный набросок экрана без цвета и стилей: просто блоки, поля, кнопки, чтобы показать структуру и иерархию. Используется на ранних этапах для быстрого обсуждения. |
||||||
|
|
||||||
|
> *Пример из HR system:* Перед прорисовкой страницы создания теста — простой набросок: «слева 70% — форма, справа 30% — превью теста». |
||||||
|
|
||||||
|
### Макет — *mockup* |
||||||
|
|
||||||
|
Визуально проработанный вариант экрана: с реальными цветами, шрифтами, иконками, но обычно статичный (не кликается). |
||||||
|
|
||||||
|
> *Пример из HR system:* Готовый Figma-макет страницы теста, согласованный с вашим зелёным брендом и шрифтом. |
||||||
|
|
||||||
|
### Прототип — *prototype* |
||||||
|
|
||||||
|
Кликабельная модель продукта: можно жать на кнопки, переходить между экранами, увидеть переходы. Прототип бывает разной степени проработанности — от карандашных набросков до почти-настоящего продукта. |
||||||
|
|
||||||
|
> *Пример из HR system:* Кликабельный прототип в Figma, на котором можно «пройти» сценарий «создал тест → назначил отделению → получил уведомление о результате». |
||||||
|
|
||||||
|
### Состояния интерфейса — *states* |
||||||
|
|
||||||
|
Один и тот же элемент или экран в разных ситуациях: пустой, загрузка, ошибка, успех, наведение, фокус, отключённый. Хорошие проекты прорисовывают все состояния, а не только «всё хорошо». |
||||||
|
|
||||||
|
> *Пример из HR system:* Кнопка «Назначить выбранных» имеет состояния: disabled (никто не выбран), normal, hover, loading (отправка идёт), success (готово). |
||||||
|
|
||||||
|
### Empty state — *пустое состояние* |
||||||
|
|
||||||
|
Что пользователь видит, когда данных нет: список пуст, поиск ничего не нашёл, ещё ничего не назначено. Хороший empty state объясняет, почему пусто, и предлагает следующий шаг. |
||||||
|
|
||||||
|
> *Пример из HR system:* Сотрудник заходит и видит пустой список «Мои тесты». Empty state: «Сейчас вам ничего не назначено. Когда руководитель добавит тест — он появится здесь.» |
||||||
|
|
||||||
|
### Edge case — *крайний случай* |
||||||
|
|
||||||
|
Редкая, но возможная ситуация: ноль элементов, тысяча элементов, очень длинный текст, обрыв сети. Игнорирование edge cases ломает интерфейс именно тогда, когда пользователь меньше всего этого ожидает. |
||||||
|
|
||||||
|
> *Пример из HR system:* Что если в клинике 5000 сотрудников, а не 147? Список «Кому выдать» сегодня этого не выдержит — это edge case, который нужно учесть. |
||||||
|
|
||||||
|
### Happy path — *счастливый сценарий* |
||||||
|
|
||||||
|
Идеальное прохождение сценария без ошибок и непредвиденных ситуаций. Полезно как стартовая точка, но проектирование только под happy path — частая ошибка. |
||||||
|
|
||||||
|
> *Пример из HR system:* «Автор создаёт тест, заполняет 7 вопросов, сохраняет, назначает отделу, все проходят» — это happy path. А что если у автора оборвался интернет на полпути? |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 5. Дизайн-система и компоненты |
||||||
|
|
||||||
|
### Дизайн-система — *design system* |
||||||
|
|
||||||
|
Набор готовых правил, компонентов и токенов (цветов, отступов, шрифтов), которыми пользуется вся команда. Цель — единообразие и скорость: не изобретать каждый раз кнопку с нуля. |
||||||
|
|
||||||
|
> *Пример из HR system:* Внутри Платформы Цифровых Сервисов клиники должна быть единая дизайн-система: HR system, регистратура, эндовидеоплатформа выглядят как продукты одной семьи. |
||||||
|
|
||||||
|
### UI-кит — *UI kit* |
||||||
|
|
||||||
|
Библиотека готовых интерфейсных элементов (кнопки, поля, модалки, таблицы) в Figma или коде, которой пользуются дизайнеры и разработчики. |
||||||
|
|
||||||
|
> *Пример из HR system:* Если у вас есть UI-кит, новая страница «Назначения» собирается из готовых компонентов за день, а не за неделю. |
||||||
|
|
||||||
|
### Компонент — *component* |
||||||
|
|
||||||
|
Самостоятельный кусочек интерфейса с понятным API: входные параметры, состояния, поведение. Кнопка, поле ввода, аккордеон, модалка — всё это компоненты. |
||||||
|
|
||||||
|
> *Пример из HR system:* Аккордеон «О тесте» / «Вопросы» / «История» / «Показ в каталоге» — четыре экземпляра одного и того же компонента «аккордеон». |
||||||
|
|
||||||
|
### Токен дизайна — *design token* |
||||||
|
|
||||||
|
Атомарная переменная стиля: цвет, отступ, размер шрифта, радиус скругления. Токены позволяют менять оформление всего продукта централизованно. |
||||||
|
|
||||||
|
> *Пример из HR system:* Цвет `primary-green = #2E7D5B` — токен. Если решите перейти на другой оттенок зелёного, меняете в одном месте, и все кнопки обновляются. |
||||||
|
|
||||||
|
### Паттерн — *pattern* |
||||||
|
|
||||||
|
Типовое решение типовой задачи: «как реализовать поиск с фильтрами», «как показать длинный список». Паттерны — это коллективная мудрость комьюнити. |
||||||
|
|
||||||
|
> *Пример из HR system:* Паттерн «master-detail»: слева список тестов, справа детали выбранного. Хорошо ложится на ваш будущий раздел «Назначения». |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 6. Качество и проверка дизайна |
||||||
|
|
||||||
|
### Юзабилити — *usability* |
||||||
|
|
||||||
|
Свойство интерфейса быть простым и эффективным в использовании. Измеряется через эффективность (получилось ли), скорость и количество ошибок. |
||||||
|
|
||||||
|
> *Пример из HR system:* Если сотрудник не может с первого раза найти, как пройти тест — у интерфейса проблема с юзабилити. |
||||||
|
|
||||||
|
### Доступность — *accessibility / a11y* |
||||||
|
|
||||||
|
Возможность использовать продукт людям с особенностями: слабовидящим, незрячим (через скринридеры), людям с моторными ограничениями (только клавиатура), дальтоникам. Стандарт — WCAG. |
||||||
|
|
||||||
|
> *Пример из HR system:* Радиокнопки выбора правильного варианта должны быть доступны с клавиатуры (Tab + Space) и понятны скринридеру («Вариант 1 из 3, выбран»). |
||||||
|
|
||||||
|
### Юзабилити-тестирование — *usability testing* |
||||||
|
|
||||||
|
Метод исследования: реальный пользователь выполняет задание, исследователь наблюдает, где он спотыкается. Дешёвый способ найти большую часть проблем. |
||||||
|
|
||||||
|
> *Пример из HR system:* Дать HR-менеджеру задание «назначь этот тест всему отделению хирургии до 1 мая» и записать, где он зависнет. |
||||||
|
|
||||||
|
### Эвристическая оценка — *heuristic evaluation* |
||||||
|
|
||||||
|
Эксперт сверяет интерфейс с набором эвристик (правил хорошего дизайна, например, эвристиками Нильсена) и фиксирует нарушения. Быстрее теста с пользователями, но менее точно. |
||||||
|
|
||||||
|
> *Пример из HR system:* Анализ страницы теста, который мы делаем сейчас — это, по сути, эвристическая оценка. |
||||||
|
|
||||||
|
### A/B-тест — *A/B testing* |
||||||
|
|
||||||
|
Сравнение двух вариантов интерфейса на реальной аудитории: половина видит вариант A, половина — B; измеряем, какой работает лучше. |
||||||
|
|
||||||
|
> *Пример из HR system:* Сравнить две формулировки кнопки: «Сохранить черновик» vs «Сохранить и назначить» — что чаще ведёт к завершению задачи. |
||||||
|
|
||||||
|
### Аналитика продукта — *product analytics* |
||||||
|
|
||||||
|
Сбор и анализ данных о том, как пользователи реально пользуются продуктом: где кликают, где бросают, сколько времени проводят. Подсказывает, где искать проблемы. |
||||||
|
|
||||||
|
> *Пример из HR system:* Если в аналитике видно, что 40% авторов не доходят до раздела «Показ в каталоге» — это сигнал, что его упускают. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 7. Технические понятия, нужные дизайнеру |
||||||
|
|
||||||
|
### Респонсив / адаптивность — *responsive design* |
||||||
|
|
||||||
|
Способность интерфейса корректно работать на разных размерах экрана: от телефона до большого монитора. Не путать с «мобильной версией». |
||||||
|
|
||||||
|
> *Пример из HR system:* Список «Кому выдать» должен оставаться удобным на 13-дюймовом ноутбуке руководителя и на телефоне HR-менеджера в дороге. |
||||||
|
|
||||||
|
### Брейкпойнт — *breakpoint* |
||||||
|
|
||||||
|
Ширина экрана, на которой меняется раскладка интерфейса. Типовые: 360, 768, 1024, 1440 px. |
||||||
|
|
||||||
|
> *Пример из HR system:* На брейкпойнте 768 px (планшет) две колонки на странице теста схлопываются в одну. |
||||||
|
|
||||||
|
### RBAC — *Role-Based Access Control / ролевая модель доступа* |
||||||
|
|
||||||
|
Правила, что какая роль видит и может делать в системе. Дизайн интерфейса должен учитывать роль: один и тот же экран показывается по-разному сотруднику, руководителю, HR и директору. |
||||||
|
|
||||||
|
> *Пример из HR system:* Сотрудник видит только «Мои тесты». Руководитель — ещё «Мой отдел». HR — все назначения. Директор — сводный отчёт. |
||||||
|
|
||||||
|
### Версионирование — *versioning* |
||||||
|
|
||||||
|
Подход, при котором у объекта (теста, документа) есть несколько версий, и история фиксируется. Полезно для аудита и неизменности результатов. |
||||||
|
|
||||||
|
> *Пример из HR system:* В вашей системе тест имеет версии v1, v2 и т.д. Прохождение всегда привязано к конкретной версии — изменения автора не «переписывают» прошлые результаты. |
||||||
|
|
||||||
|
### Состояние черновика — *draft state* |
||||||
|
|
||||||
|
Промежуточное состояние объекта: ещё не опубликован/не активирован, можно безопасно править. |
||||||
|
|
||||||
|
> *Пример из HR system:* Кнопка «Сохранить черновик» означает: тест сохранён, но пока не выдан сотрудникам. Можно ещё дорабатывать. |
||||||
|
|
||||||
|
### Уведомление — *notification* |
||||||
|
|
||||||
|
Сообщение системы пользователю: всплывающее (toast), баннер на странице, push, e-mail. Каждый канал имеет свои правила использования. |
||||||
|
|
||||||
|
> *Пример из HR system:* Тост «Тест сохранён» после нажатия кнопки. E-mail сотруднику с дедлайном по назначенному тесту. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 8. Терминология этого проекта |
||||||
|
|
||||||
|
Чтобы команда не путалась, фиксируем основные сущности HR system явно. |
||||||
|
|
||||||
|
- **Тест** — учебный материал, состоящий из вопросов с вариантами ответов. Один тест может иметь несколько версий. |
||||||
|
- **Версия теста** — снимок содержимого теста на момент сохранения. Прохождение всегда привязано к конкретной версии. |
||||||
|
- **Вопрос** — отдельный пункт теста с формулировкой и набором вариантов. Может допускать один или несколько верных ответов. |
||||||
|
- **Вариант ответа** — один из предложенных ответов на вопрос. Помечается как верный или нет. |
||||||
|
- **Назначение** — связь «тест × сотрудник × срок». Формирует у сотрудника обязательство пройти этот тест. |
||||||
|
- **Прохождение** — попытка сотрудника пройти конкретную версию теста. Имеет статус (в процессе, пройдено, не пройдено) и результат (X из Y). |
||||||
|
- **Порог зачёта** — процент правильных ответов, начиная с которого прохождение засчитывается. |
||||||
|
- **Каталог** — общий список тестов, видимый сотрудникам с правами. |
||||||
|
- **Роль** — профиль доступа: сотрудник, руководитель подразделения, HR-менеджер, директор. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Полезные ссылки и стандарты |
||||||
|
|
||||||
|
- **Эвристики Якоба Нильсена** — 10 базовых правил юзабилити: [nngroup.com](https://www.nngroup.com/articles/ten-usability-heuristics/) |
||||||
|
- **WCAG 2.2** — стандарт доступности: [w3.org/TR/WCAG22](https://www.w3.org/TR/WCAG22/) |
||||||
|
- **Material Design** — [m3.material.io](https://m3.material.io/) и **Apple HIG** — [developer.apple.com/design](https://developer.apple.com/design/human-interface-guidelines/) — два больших источника готовых паттернов и принципов. |
||||||
|
- **Refactoring UI** (Adam Wathan, Steve Schoger) — настольная книга по практическому UI-дизайну. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
*— Справочник можно дополнять по мере появления новых терминов —* |
||||||
@ -0,0 +1,354 @@ |
|||||||
|
from __future__ import annotations |
||||||
|
|
||||||
|
from sqlalchemy import text |
||||||
|
|
||||||
|
from ..services.test_access import is_test_author, user_has_test_access |
||||||
|
|
||||||
|
|
||||||
|
class HttpError(Exception): |
||||||
|
def __init__(self, status: int, message: str): |
||||||
|
super().__init__(message) |
||||||
|
self.status = status |
||||||
|
self.message = message |
||||||
|
|
||||||
|
|
||||||
|
def _sort_uuid_strings(items) -> list[str]: |
||||||
|
return sorted({str(x) for x in (items or []) if x is not None}) |
||||||
|
|
||||||
|
|
||||||
|
def _same_selection(selected, correct_ids) -> bool: |
||||||
|
a = _sort_uuid_strings(selected) |
||||||
|
b = _sort_uuid_strings(correct_ids) |
||||||
|
return a == b |
||||||
|
|
||||||
|
|
||||||
|
def load_questions_for_version(conn, test_version_id, *, include_correct: bool) -> list[dict]: |
||||||
|
qrows = conn.execute( |
||||||
|
text( |
||||||
|
'SELECT id, text, question_order, has_multiple_answers ' |
||||||
|
'FROM questions WHERE test_version_id = :v ORDER BY question_order' |
||||||
|
), |
||||||
|
{'v': test_version_id}, |
||||||
|
).mappings().all() |
||||||
|
out = [] |
||||||
|
for q in qrows: |
||||||
|
orows = conn.execute( |
||||||
|
text( |
||||||
|
'SELECT id, text, is_correct, option_order ' |
||||||
|
'FROM answer_options WHERE question_id = :q ORDER BY option_order' |
||||||
|
), |
||||||
|
{'q': q['id']}, |
||||||
|
).mappings().all() |
||||||
|
opts = [] |
||||||
|
for o in orows: |
||||||
|
base = { |
||||||
|
'id': str(o['id']), |
||||||
|
'text': o['text'], |
||||||
|
'optionOrder': o['option_order'], |
||||||
|
} |
||||||
|
if include_correct: |
||||||
|
base['isCorrect'] = bool(o['is_correct']) |
||||||
|
opts.append(base) |
||||||
|
out.append( |
||||||
|
{ |
||||||
|
'id': str(q['id']), |
||||||
|
'text': q['text'], |
||||||
|
'questionOrder': q['question_order'], |
||||||
|
'hasMultipleAnswers': bool(q['has_multiple_answers']), |
||||||
|
'options': opts, |
||||||
|
} |
||||||
|
) |
||||||
|
return out |
||||||
|
|
||||||
|
|
||||||
|
def start_attempt(eng, user_id: str, test_id: str) -> dict: |
||||||
|
acc = user_has_test_access(user_id, test_id) |
||||||
|
if not acc.ok: |
||||||
|
raise HttpError(404, 'Тест не найден.') |
||||||
|
with eng.begin() as conn: |
||||||
|
tv = conn.execute( |
||||||
|
text( |
||||||
|
'SELECT id AS test_version_id FROM test_versions ' |
||||||
|
'WHERE test_id = :id AND is_active = true LIMIT 1' |
||||||
|
), |
||||||
|
{'id': test_id}, |
||||||
|
).mappings().first() |
||||||
|
if not tv: |
||||||
|
raise HttpError(404, 'Нет активной версии теста.') |
||||||
|
version_id = tv['test_version_id'] |
||||||
|
mx = conn.execute( |
||||||
|
text( |
||||||
|
'SELECT COALESCE(MAX(attempt_number), 0) AS n FROM test_attempts ' |
||||||
|
'WHERE test_version_id = :v AND user_id = :u' |
||||||
|
), |
||||||
|
{'v': version_id, 'u': user_id}, |
||||||
|
).mappings().first() |
||||||
|
next_n = int(mx['n'] or 0) + 1 |
||||||
|
a = conn.execute( |
||||||
|
text( |
||||||
|
"INSERT INTO test_attempts (test_version_id, user_id, attempt_number, status) " |
||||||
|
"VALUES (:v, :u, :n, 'in_progress') " |
||||||
|
'RETURNING id, test_version_id, user_id, attempt_number, status, started_at' |
||||||
|
), |
||||||
|
{'v': version_id, 'u': user_id, 'n': next_n}, |
||||||
|
).mappings().first() |
||||||
|
return {'attempt': dict(a)} |
||||||
|
|
||||||
|
|
||||||
|
def get_play_content(eng, user_id: str, test_id: str, attempt_id: str) -> dict: |
||||||
|
with eng.connect() as conn: |
||||||
|
a = conn.execute( |
||||||
|
text( |
||||||
|
'SELECT ta.id, ta.user_id, ta.status, ta.test_version_id, tv.test_id, ' |
||||||
|
't.title, t.passing_threshold ' |
||||||
|
'FROM test_attempts ta ' |
||||||
|
'INNER JOIN test_versions tv ON tv.id = ta.test_version_id ' |
||||||
|
'INNER JOIN tests t ON t.id = tv.test_id ' |
||||||
|
'WHERE ta.id = :a' |
||||||
|
), |
||||||
|
{'a': attempt_id}, |
||||||
|
).mappings().first() |
||||||
|
if not a: |
||||||
|
raise HttpError(404, 'Попытка не найдена.') |
||||||
|
if str(a['test_id']) != str(test_id): |
||||||
|
raise HttpError(404, 'Попытка не найдена.') |
||||||
|
if str(a['user_id']) != str(user_id): |
||||||
|
raise HttpError(403, 'Доступ запрещён.') |
||||||
|
if a['status'] != 'in_progress': |
||||||
|
raise HttpError(400, 'Попытка уже завершена.') |
||||||
|
qs = load_questions_for_version(conn, a['test_version_id'], include_correct=False) |
||||||
|
return { |
||||||
|
'testTitle': a['title'], |
||||||
|
'passingThreshold': a['passing_threshold'], |
||||||
|
'attemptId': str(a['id']), |
||||||
|
'questions': qs, |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
def submit_attempt(eng, user_id: str, test_id: str, attempt_id: str, raw_answers: dict | None) -> dict: |
||||||
|
answers = raw_answers if isinstance(raw_answers, dict) else {} |
||||||
|
with eng.begin() as conn: |
||||||
|
a = conn.execute( |
||||||
|
text( |
||||||
|
'SELECT id, user_id, status, test_version_id ' |
||||||
|
'FROM test_attempts WHERE id = :a FOR UPDATE' |
||||||
|
), |
||||||
|
{'a': attempt_id}, |
||||||
|
).mappings().first() |
||||||
|
if not a: |
||||||
|
raise HttpError(404, 'Попытка не найдена.') |
||||||
|
link = conn.execute( |
||||||
|
text( |
||||||
|
'SELECT t.passing_threshold, tv.test_id ' |
||||||
|
'FROM test_versions tv ' |
||||||
|
'INNER JOIN tests t ON t.id = tv.test_id ' |
||||||
|
'WHERE tv.id = :v' |
||||||
|
), |
||||||
|
{'v': a['test_version_id']}, |
||||||
|
).mappings().first() |
||||||
|
if not link: |
||||||
|
raise HttpError(404, 'Тест не найден.') |
||||||
|
if str(link['test_id']) != str(test_id): |
||||||
|
raise HttpError(404, 'Попытка не найдена.') |
||||||
|
if str(a['user_id']) != str(user_id): |
||||||
|
raise HttpError(403, 'Доступ запрещён.') |
||||||
|
if a['status'] != 'in_progress': |
||||||
|
raise HttpError(400, 'Попытка уже завершена.') |
||||||
|
|
||||||
|
qrows = conn.execute( |
||||||
|
text('SELECT id FROM questions WHERE test_version_id = :v'), |
||||||
|
{'v': a['test_version_id']}, |
||||||
|
).mappings().all() |
||||||
|
if not qrows: |
||||||
|
raise HttpError(400, 'В тесте нет вопросов.') |
||||||
|
|
||||||
|
opts = conn.execute( |
||||||
|
text( |
||||||
|
'SELECT a.id, a.question_id, a.is_correct ' |
||||||
|
'FROM answer_options a ' |
||||||
|
'INNER JOIN questions q ON q.id = a.question_id ' |
||||||
|
'WHERE q.test_version_id = :v' |
||||||
|
), |
||||||
|
{'v': a['test_version_id']}, |
||||||
|
).mappings().all() |
||||||
|
|
||||||
|
by_q = {} |
||||||
|
for o in opts: |
||||||
|
qid = str(o['question_id']) |
||||||
|
if qid not in by_q: |
||||||
|
by_q[qid] = {'all': set(), 'correct': []} |
||||||
|
by_q[qid]['all'].add(str(o['id'])) |
||||||
|
if o['is_correct']: |
||||||
|
by_q[qid]['correct'].append(str(o['id'])) |
||||||
|
|
||||||
|
correct_count = 0 |
||||||
|
for q in qrows: |
||||||
|
qid = str(q['id']) |
||||||
|
selected = answers.get(qid, []) |
||||||
|
if not isinstance(selected, list): |
||||||
|
selected = [str(selected)] |
||||||
|
selected = [str(x) for x in selected] |
||||||
|
g = by_q.get(qid, {'all': set(), 'correct': []}) |
||||||
|
for sid in selected: |
||||||
|
if sid not in g['all']: |
||||||
|
raise HttpError(400, 'Некорректный вариант ответа.') |
||||||
|
if _same_selection(selected, g['correct']): |
||||||
|
correct_count += 1 |
||||||
|
|
||||||
|
total = len(qrows) |
||||||
|
percent = (correct_count / total) * 100 if total else 0 |
||||||
|
threshold = int(link['passing_threshold'] or 0) |
||||||
|
passed = percent + 1e-9 >= threshold |
||||||
|
|
||||||
|
conn.execute(text('DELETE FROM user_answers WHERE attempt_id = :a'), {'a': attempt_id}) |
||||||
|
for q in qrows: |
||||||
|
qid = str(q['id']) |
||||||
|
selected = answers.get(qid, []) |
||||||
|
if not isinstance(selected, list): |
||||||
|
selected = [str(selected)] |
||||||
|
selected = [str(x) for x in selected] |
||||||
|
conn.execute( |
||||||
|
text( |
||||||
|
'INSERT INTO user_answers (attempt_id, question_id, selected_options) ' |
||||||
|
'VALUES (:a, :q, :s::uuid[])' |
||||||
|
), |
||||||
|
{'a': attempt_id, 'q': q['id'], 's': selected}, |
||||||
|
) |
||||||
|
conn.execute( |
||||||
|
text( |
||||||
|
"UPDATE test_attempts SET status = 'completed', completed_at = CURRENT_TIMESTAMP, " |
||||||
|
'correct_count = :c, total_questions = :t, passed = :p WHERE id = :a' |
||||||
|
), |
||||||
|
{'a': attempt_id, 'c': correct_count, 't': total, 'p': passed}, |
||||||
|
) |
||||||
|
|
||||||
|
review = build_review_from_db(eng, attempt_id) |
||||||
|
return { |
||||||
|
'attemptId': attempt_id, |
||||||
|
'correctCount': correct_count, |
||||||
|
'totalQuestions': total, |
||||||
|
'percent': round(percent, 1), |
||||||
|
'passed': passed, |
||||||
|
'passingThreshold': threshold, |
||||||
|
'review': review, |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
def build_review_from_db(eng, attempt_id: str) -> dict: |
||||||
|
with eng.connect() as conn: |
||||||
|
a = conn.execute( |
||||||
|
text( |
||||||
|
'SELECT ta.id, ta.status, ta.test_version_id, ta.user_id, ta.correct_count, ta.total_questions, ' |
||||||
|
'ta.passed, ta.started_at, ta.completed_at, ' |
||||||
|
't.id AS test_id, t.title, t.passing_threshold, ' |
||||||
|
'u.full_name AS attempter_name, u.login AS attempter_login ' |
||||||
|
'FROM test_attempts ta ' |
||||||
|
'INNER JOIN test_versions tv ON tv.id = ta.test_version_id ' |
||||||
|
'INNER JOIN tests t ON t.id = tv.test_id ' |
||||||
|
'INNER JOIN users u ON u.id = ta.user_id ' |
||||||
|
'WHERE ta.id = :a' |
||||||
|
), |
||||||
|
{'a': attempt_id}, |
||||||
|
).mappings().first() |
||||||
|
if not a: |
||||||
|
raise HttpError(404, 'Попытка не найдена.') |
||||||
|
if a['status'] != 'completed': |
||||||
|
raise HttpError(400, 'Попытка не завершена.') |
||||||
|
questions = load_questions_for_version(conn, a['test_version_id'], include_correct=True) |
||||||
|
uans = conn.execute( |
||||||
|
text('SELECT question_id, selected_options FROM user_answers WHERE attempt_id = :a'), |
||||||
|
{'a': attempt_id}, |
||||||
|
).mappings().all() |
||||||
|
|
||||||
|
sel_by_q = {str(r['question_id']): [str(x) for x in (r['selected_options'] or [])] for r in uans} |
||||||
|
total = int(a['total_questions'] or len(questions)) |
||||||
|
percent = round(((a['correct_count'] or 0) / total) * 100, 1) if total else 0 |
||||||
|
|
||||||
|
q_out = [] |
||||||
|
for q in questions: |
||||||
|
selected = _sort_uuid_strings(sel_by_q.get(str(q['id']), [])) |
||||||
|
correct = _sort_uuid_strings([o['id'] for o in q['options'] if o.get('isCorrect')]) |
||||||
|
selected_set = set(selected) |
||||||
|
q_out.append( |
||||||
|
{ |
||||||
|
'id': q['id'], |
||||||
|
'text': q['text'], |
||||||
|
'hasMultipleAnswers': q['hasMultipleAnswers'], |
||||||
|
'isUserCorrect': _same_selection(selected, correct), |
||||||
|
'options': [ |
||||||
|
{ |
||||||
|
'id': o['id'], |
||||||
|
'text': o['text'], |
||||||
|
'isCorrect': o.get('isCorrect', False), |
||||||
|
'selected': o['id'] in selected_set, |
||||||
|
} |
||||||
|
for o in q['options'] |
||||||
|
], |
||||||
|
} |
||||||
|
) |
||||||
|
|
||||||
|
return { |
||||||
|
'attemptId': str(a['id']), |
||||||
|
'testId': str(a['test_id']), |
||||||
|
'testTitle': a['title'], |
||||||
|
'passingThreshold': int(a['passing_threshold'] or 0), |
||||||
|
'correctCount': int(a['correct_count'] or 0), |
||||||
|
'totalQuestions': total, |
||||||
|
'percent': percent, |
||||||
|
'passed': bool(a['passed']), |
||||||
|
'startedAt': a['started_at'].isoformat() if a['started_at'] else None, |
||||||
|
'completedAt': a['completed_at'].isoformat() if a['completed_at'] else None, |
||||||
|
'attempterUserId': str(a['user_id']), |
||||||
|
'attempterName': a['attempter_name'], |
||||||
|
'attempterLogin': a['attempter_login'], |
||||||
|
'questions': q_out, |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
def get_attempt_review_for_user(eng, current_user_id: str, test_id: str, attempt_id: str) -> dict: |
||||||
|
with eng.connect() as conn: |
||||||
|
row = conn.execute( |
||||||
|
text( |
||||||
|
'SELECT ta.user_id, t.created_by, tv.test_id ' |
||||||
|
'FROM test_attempts ta ' |
||||||
|
'INNER JOIN test_versions tv ON tv.id = ta.test_version_id ' |
||||||
|
'INNER JOIN tests t ON t.id = tv.test_id ' |
||||||
|
'WHERE ta.id = :a' |
||||||
|
), |
||||||
|
{'a': attempt_id}, |
||||||
|
).mappings().first() |
||||||
|
if not row: |
||||||
|
raise HttpError(404, 'Попытка не найдена.') |
||||||
|
if str(row['test_id']) != str(test_id): |
||||||
|
raise HttpError(404, 'Попытка не найдена.') |
||||||
|
is_owner = str(row['user_id']) == str(current_user_id) |
||||||
|
is_author = is_test_author(row['created_by'], current_user_id) |
||||||
|
if not is_owner and not is_author: |
||||||
|
raise HttpError(403, 'Доступ запрещён.') |
||||||
|
return build_review_from_db(eng, attempt_id) |
||||||
|
|
||||||
|
|
||||||
|
def list_test_attempts_for_author(eng, author_id: str, test_id: str) -> list[dict]: |
||||||
|
with eng.connect() as conn: |
||||||
|
t = conn.execute( |
||||||
|
text('SELECT id, created_by FROM tests WHERE id = :id'), |
||||||
|
{'id': test_id}, |
||||||
|
).mappings().first() |
||||||
|
if not t: |
||||||
|
raise HttpError(404, 'Тест не найден.') |
||||||
|
if not is_test_author(t['created_by'], author_id): |
||||||
|
raise HttpError(403, 'Доступ запрещён.') |
||||||
|
rows = conn.execute( |
||||||
|
text( |
||||||
|
'SELECT ta.id, ta.user_id, ta.status, ta.attempt_number, ta.started_at, ta.completed_at, ' |
||||||
|
'ta.correct_count, ta.total_questions, ta.passed, tv.version AS test_version, ' |
||||||
|
'u.full_name AS attempter_name, u.login AS attempter_login ' |
||||||
|
'FROM test_attempts ta ' |
||||||
|
'INNER JOIN test_versions tv ON tv.id = ta.test_version_id ' |
||||||
|
'INNER JOIN users u ON u.id = ta.user_id ' |
||||||
|
'WHERE tv.test_id = :id ' |
||||||
|
'ORDER BY ta.started_at DESC NULLS LAST LIMIT 200' |
||||||
|
), |
||||||
|
{'id': test_id}, |
||||||
|
).mappings().all() |
||||||
|
return [dict(r) for r in rows] |
||||||
@ -1,17 +1,668 @@ |
|||||||
/* Точечные стили поверх Tailwind CDN. |
/* Базовые токены и точечные стили в духе webapp-nginx/cabinet-theme. */ |
||||||
В E1.0 файл почти пустой — задаёт только сглаживание иконок и базовый focus-ring, |
|
||||||
чтобы кнопки/ссылки были консистентны со стилем кабинета HR. */ |
:root { |
||||||
|
--surface: #ffffff; |
||||||
|
--surface-container-low: #f3f8f9; |
||||||
|
--surface-container: #eaf3f5; |
||||||
|
--on-surface: #0d1b1d; |
||||||
|
--on-surface-variant: #3d5357; |
||||||
|
--primary: #007168; |
||||||
|
--primary-hover: #00645b; |
||||||
|
--outline-variant: #b9bc94; |
||||||
|
--shadow-card: 0 8px 40px rgba(0, 0, 0, 0.08); |
||||||
|
--radius-card: 2rem; |
||||||
|
--max-content: 42rem; |
||||||
|
} |
||||||
|
|
||||||
|
*, |
||||||
|
*::before, |
||||||
|
*::after { |
||||||
|
box-sizing: border-box; |
||||||
|
} |
||||||
|
|
||||||
|
body { |
||||||
|
margin: 0; |
||||||
|
min-height: 100dvh; |
||||||
|
font-family: 'Inter', system-ui, -apple-system, sans-serif; |
||||||
|
background: var(--surface-container-low); |
||||||
|
color: var(--on-surface); |
||||||
|
-webkit-tap-highlight-color: transparent; |
||||||
|
line-height: 1.45; |
||||||
|
} |
||||||
|
|
||||||
|
h1, |
||||||
|
h2, |
||||||
|
h3 { |
||||||
|
letter-spacing: -0.02em; |
||||||
|
} |
||||||
|
|
||||||
.material-symbols-outlined { |
.material-symbols-outlined { |
||||||
|
font-family: 'Material Symbols Outlined', sans-serif; |
||||||
|
font-weight: normal; |
||||||
|
font-style: normal; |
||||||
|
line-height: 1; |
||||||
|
letter-spacing: normal; |
||||||
|
text-transform: none; |
||||||
|
display: inline-block; |
||||||
|
white-space: nowrap; |
||||||
|
word-wrap: normal; |
||||||
font-variation-settings: |
font-variation-settings: |
||||||
'FILL' 0, |
'FILL' 0, |
||||||
'wght' 400, |
'wght' 400, |
||||||
'GRAD' 0, |
'GRAD' 0, |
||||||
'opsz' 20; |
'opsz' 24; |
||||||
|
direction: ltr; |
||||||
|
-webkit-font-feature-settings: 'liga'; |
||||||
|
font-feature-settings: 'liga'; |
||||||
|
-webkit-font-smoothing: antialiased; |
||||||
} |
} |
||||||
|
|
||||||
:focus-visible { |
:focus-visible { |
||||||
outline: 2px solid #6366f1; /* brand-500 */ |
outline: 2px solid var(--primary); |
||||||
outline-offset: 2px; |
outline-offset: 2px; |
||||||
border-radius: 6px; |
border-radius: 6px; |
||||||
} |
} |
||||||
|
|
||||||
|
/* Небольшой "cabinet" акцент карточек/кнопок без переписывания шаблонов. */ |
||||||
|
.rounded-2xl.bg-white, |
||||||
|
.rounded-xl.bg-white { |
||||||
|
border-color: color-mix(in srgb, var(--outline-variant) 38%, transparent); |
||||||
|
} |
||||||
|
|
||||||
|
.bg-brand-600 { |
||||||
|
background-color: var(--primary) !important; |
||||||
|
} |
||||||
|
|
||||||
|
.hover\:bg-brand-700:hover { |
||||||
|
background-color: var(--primary-hover) !important; |
||||||
|
} |
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */ |
||||||
|
/* UI variants (оба режима на Flask, отличие только в компоновке UI). */ |
||||||
|
/* ------------------------------------------------------------------ */ |
||||||
|
|
||||||
|
/* Modern: плотная колонка и акцент на карточный контент. */ |
||||||
|
body.ui-modern .max-w-2xl { |
||||||
|
max-width: 42rem !important; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-modern main { |
||||||
|
padding-top: 1.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
/* Legacy: идентичный cabinet layout. */ |
||||||
|
body.ui-legacy .max-w-2xl { |
||||||
|
max-width: 42rem !important; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .cabinet-app { |
||||||
|
min-height: 100dvh; |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
background: var(--surface); |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .cabinet-header { |
||||||
|
position: sticky; |
||||||
|
top: 0; |
||||||
|
z-index: 20; |
||||||
|
background: color-mix(in srgb, var(--surface) 88%, transparent); |
||||||
|
backdrop-filter: blur(10px); |
||||||
|
-webkit-backdrop-filter: blur(10px); |
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent); |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .cabinet-header__inner { |
||||||
|
max-width: var(--max-content); |
||||||
|
margin: 0 auto; |
||||||
|
padding-top: max(0.75rem, env(safe-area-inset-top, 0px)); |
||||||
|
padding-bottom: 0.75rem; |
||||||
|
padding-left: max(1.25rem, env(safe-area-inset-left, 0px) + 0.5rem); |
||||||
|
padding-right: max(1.25rem, env(safe-area-inset-right, 0px) + 0.5rem); |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: space-between; |
||||||
|
gap: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .cabinet-brand { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
gap: 0.65rem; |
||||||
|
color: var(--on-surface); |
||||||
|
text-decoration: none; |
||||||
|
min-width: 0; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .cabinet-brand:hover { |
||||||
|
text-decoration: none; |
||||||
|
color: var(--on-surface); |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .cabinet-brand__logo { |
||||||
|
width: 2rem; |
||||||
|
height: 2rem; |
||||||
|
object-fit: contain; |
||||||
|
display: block; |
||||||
|
} |
||||||
|
body.ui-legacy .login-logo__img { |
||||||
|
width: 96px; |
||||||
|
height: 96px; |
||||||
|
object-fit: contain; |
||||||
|
display: block; |
||||||
|
margin: 0 auto 0.5rem; |
||||||
|
} |
||||||
|
body.ui-legacy .cabinet-brand__icon { |
||||||
|
font-size: 1.75rem; |
||||||
|
color: var(--primary); |
||||||
|
background: var(--surface-container-low); |
||||||
|
border-radius: 0.75rem; |
||||||
|
padding: 0.35rem; |
||||||
|
border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent); |
||||||
|
flex-shrink: 0; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .cabinet-brand__title { |
||||||
|
font-family: 'Manrope', 'Inter', sans-serif; |
||||||
|
font-weight: 800; |
||||||
|
font-size: 1rem; |
||||||
|
line-height: 1.2; |
||||||
|
letter-spacing: -0.02em; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .cabinet-header__actions { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
gap: 0.75rem; |
||||||
|
flex-shrink: 0; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .cabinet-user { |
||||||
|
font-size: 0.8rem; |
||||||
|
color: var(--on-surface-variant); |
||||||
|
text-align: right; |
||||||
|
max-width: 12rem; |
||||||
|
overflow: hidden; |
||||||
|
text-overflow: ellipsis; |
||||||
|
white-space: nowrap; |
||||||
|
display: none; |
||||||
|
} |
||||||
|
|
||||||
|
@media (min-width: 480px) { |
||||||
|
body.ui-legacy .cabinet-user { |
||||||
|
display: inline; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .cabinet-user__role { |
||||||
|
color: var(--secondary, #506965); |
||||||
|
font-weight: 500; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .cabinet-main { |
||||||
|
flex: 1; |
||||||
|
max-width: var(--max-content); |
||||||
|
width: 100%; |
||||||
|
margin: 0 auto; |
||||||
|
padding: 1.25rem 1.25rem calc(2.5rem + env(safe-area-inset-bottom, 0px)); |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy main { |
||||||
|
padding-top: 0; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .rounded-2xl.bg-white, |
||||||
|
body.ui-legacy .rounded-xl.bg-white { |
||||||
|
border-radius: 0.85rem; |
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); |
||||||
|
} |
||||||
|
|
||||||
|
/* Legacy catalog (портировано из старого webapp) */ |
||||||
|
body.ui-legacy .legacy-list-shell { |
||||||
|
max-width: 42rem; |
||||||
|
margin: 0 auto; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .legacy-list-title { |
||||||
|
font-size: 1.5rem; |
||||||
|
margin: 0 0 0.75rem; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .legacy-list-toolbar { |
||||||
|
display: flex; |
||||||
|
flex-wrap: wrap; |
||||||
|
gap: 0.5rem; |
||||||
|
margin: 0 0 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .legacy-list-subtitle { |
||||||
|
font-size: 1.1rem; |
||||||
|
margin: 1.5rem 0 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .btn { |
||||||
|
font-family: inherit; |
||||||
|
font-size: 0.9375rem; |
||||||
|
font-weight: 600; |
||||||
|
padding: 0.55rem 1.1rem; |
||||||
|
border-radius: 0.75rem; |
||||||
|
border: 1.5px solid transparent; |
||||||
|
cursor: pointer; |
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s, box-shadow 0.15s; |
||||||
|
text-decoration: none; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .btn-ghost { |
||||||
|
background: transparent; |
||||||
|
color: var(--primary); |
||||||
|
border-color: color-mix(in srgb, var(--outline-variant) 70%, transparent); |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .btn-ghost:hover { |
||||||
|
background: var(--surface-container); |
||||||
|
border-color: var(--primary); |
||||||
|
text-decoration: none; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .text-muted { |
||||||
|
color: var(--on-surface-variant); |
||||||
|
font-size: 0.875rem; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .list-stack { |
||||||
|
list-style: none; |
||||||
|
margin: 0; |
||||||
|
padding: 0; |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .list-row { |
||||||
|
display: block; |
||||||
|
border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent); |
||||||
|
border-radius: 1rem; |
||||||
|
padding: 0.9rem 1rem; |
||||||
|
background: var(--surface); |
||||||
|
transition: border-color 0.15s, box-shadow 0.15s; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .list-row--split { |
||||||
|
display: flex; |
||||||
|
flex-direction: row; |
||||||
|
align-items: stretch; |
||||||
|
padding: 0; |
||||||
|
overflow: hidden; |
||||||
|
gap: 0; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .list-row__main { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
flex: 1; |
||||||
|
min-width: 0; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .list-row__link { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
flex: 1 1 auto; |
||||||
|
padding: 0.9rem 1rem; |
||||||
|
text-decoration: none; |
||||||
|
color: inherit; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .list-row__title { |
||||||
|
display: block; |
||||||
|
color: var(--on-surface); |
||||||
|
font-weight: 600; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .list-row__meta { |
||||||
|
color: var(--on-surface-variant); |
||||||
|
font-size: 0.8rem; |
||||||
|
display: block; |
||||||
|
margin-top: 0.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .list-row__meta-tail { |
||||||
|
white-space: nowrap; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .list-row__side { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
padding: 0.5rem 0.9rem 0.5rem 0; |
||||||
|
flex-shrink: 0; |
||||||
|
border-left: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent); |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .list-row--hidden { |
||||||
|
border-style: dashed; |
||||||
|
opacity: 0.95; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .link-back { |
||||||
|
display: inline-flex; |
||||||
|
align-items: center; |
||||||
|
gap: 0.25rem; |
||||||
|
font-size: 0.9rem; |
||||||
|
font-weight: 500; |
||||||
|
margin: 0 0 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .callout { |
||||||
|
border-radius: 1rem; |
||||||
|
padding: 0.75rem 1rem; |
||||||
|
font-size: 0.9rem; |
||||||
|
font-weight: 500; |
||||||
|
margin: 0 0 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .callout--warning { |
||||||
|
background: #fffbeb; |
||||||
|
border: 1px solid #fde68a; |
||||||
|
color: #92400e; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .muted, |
||||||
|
body.ui-legacy .text-muted, |
||||||
|
body.ui-legacy .text-secondary { |
||||||
|
color: #506965; |
||||||
|
font-size: 0.875rem; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .mono { |
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .form-label { |
||||||
|
display: block; |
||||||
|
font-size: 0.9rem; |
||||||
|
font-weight: 500; |
||||||
|
color: var(--on-surface); |
||||||
|
margin-bottom: 0.35rem; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .form-input { |
||||||
|
width: 100%; |
||||||
|
padding: 11px 13px; |
||||||
|
border: 1.5px solid var(--outline-variant); |
||||||
|
border-radius: 0.75rem; |
||||||
|
font-size: 15px; |
||||||
|
font-family: inherit; |
||||||
|
outline: none; |
||||||
|
background: var(--surface-container-low); |
||||||
|
color: var(--on-surface); |
||||||
|
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .form-input:focus { |
||||||
|
border-color: var(--primary); |
||||||
|
box-shadow: 0 0 0 3px rgba(0, 113, 104, 0.12); |
||||||
|
background: #fff; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .surface-card { |
||||||
|
background: var(--surface); |
||||||
|
border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent); |
||||||
|
border-radius: 1rem; |
||||||
|
padding: 1rem 1.1rem; |
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .cabinet-brick { |
||||||
|
margin-bottom: 1.1rem; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .cabinet-brick--hero { |
||||||
|
padding: 0.1rem 0 0.6rem; |
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 45%, transparent); |
||||||
|
margin-bottom: 1.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
.hero-brick__nav { |
||||||
|
display: flex; |
||||||
|
justify-content: space-between; |
||||||
|
align-items: baseline; |
||||||
|
flex-wrap: wrap; |
||||||
|
gap: 0.5rem; |
||||||
|
font-size: 0.85rem; |
||||||
|
color: var(--ink-500, #6b7280); |
||||||
|
} |
||||||
|
.hero-brick__meta { |
||||||
|
display: inline-flex; |
||||||
|
flex-wrap: wrap; |
||||||
|
gap: 0.4rem; |
||||||
|
align-items: baseline; |
||||||
|
color: var(--ink-500, #6b7280); |
||||||
|
} |
||||||
|
.hero-brick__sep { opacity: 0.55; } |
||||||
|
|
||||||
|
.hero-brick__title { |
||||||
|
display: block; |
||||||
|
width: 100%; |
||||||
|
margin-top: 0.5rem; |
||||||
|
border: 1px solid transparent; |
||||||
|
background: transparent; |
||||||
|
font-size: 1.65rem; |
||||||
|
line-height: 1.2; |
||||||
|
font-weight: 700; |
||||||
|
padding: 0.3rem 0.4rem; |
||||||
|
border-radius: 0.5rem; |
||||||
|
outline: none; |
||||||
|
resize: none; |
||||||
|
overflow: hidden; |
||||||
|
white-space: pre-wrap; |
||||||
|
word-break: break-word; |
||||||
|
font-family: inherit; |
||||||
|
min-height: 2.4rem; |
||||||
|
} |
||||||
|
.hero-brick__title:hover { border-color: color-mix(in srgb, var(--outline-variant) 50%, transparent); } |
||||||
|
.hero-brick__title:focus { border-color: var(--primary, #0d9488); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary, #0d9488) 18%, transparent); background: #fff; } |
||||||
|
|
||||||
|
.hero-brick__desc { |
||||||
|
display: block; |
||||||
|
width: 100%; |
||||||
|
margin-top: 0.35rem; |
||||||
|
border: 1px solid transparent; |
||||||
|
background: transparent; |
||||||
|
font-size: 0.95rem; |
||||||
|
color: var(--ink-700, #374151); |
||||||
|
padding: 0.3rem 0.4rem; |
||||||
|
border-radius: 0.5rem; |
||||||
|
resize: none; |
||||||
|
overflow: hidden; |
||||||
|
outline: none; |
||||||
|
font-family: inherit; |
||||||
|
} |
||||||
|
.hero-brick__desc:hover { border-color: color-mix(in srgb, var(--outline-variant) 50%, transparent); } |
||||||
|
.hero-brick__desc:focus { border-color: var(--primary, #0d9488); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary, #0d9488) 18%, transparent); background: #fff; } |
||||||
|
|
||||||
|
.hero-brick__chips { |
||||||
|
margin-top: 0.65rem; |
||||||
|
display: flex; |
||||||
|
flex-wrap: wrap; |
||||||
|
gap: 0.5rem; |
||||||
|
align-items: center; |
||||||
|
} |
||||||
|
.hero-brick__chip { |
||||||
|
display: inline-flex; |
||||||
|
align-items: center; |
||||||
|
gap: 0.35rem; |
||||||
|
padding: 0.25rem 0.55rem; |
||||||
|
background: color-mix(in srgb, var(--surface, #fff) 80%, var(--outline-variant, #e5e7eb)); |
||||||
|
border: 1px solid color-mix(in srgb, var(--outline-variant, #e5e7eb) 70%, transparent); |
||||||
|
border-radius: 999px; |
||||||
|
font-size: 0.85rem; |
||||||
|
color: var(--ink-700, #374151); |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
.hero-brick__chip--readonly { cursor: default; } |
||||||
|
.hero-brick__chip input[type="number"] { |
||||||
|
width: 3.2rem; |
||||||
|
border: none; |
||||||
|
background: transparent; |
||||||
|
text-align: right; |
||||||
|
font: inherit; |
||||||
|
outline: none; |
||||||
|
padding: 0; |
||||||
|
} |
||||||
|
.hero-brick__chip input[type="checkbox"] { accent-color: var(--primary, #0d9488); } |
||||||
|
|
||||||
|
body.ui-legacy .cabinet-disclosure { |
||||||
|
border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent); |
||||||
|
border-radius: 1rem; |
||||||
|
background: var(--surface); |
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .cabinet-disclosure__summary { |
||||||
|
cursor: pointer; |
||||||
|
list-style: none; |
||||||
|
user-select: none; |
||||||
|
padding: 0.85rem 1rem 0.75rem; |
||||||
|
font-size: 1.05rem; |
||||||
|
border-radius: 1rem 1rem 0 0; |
||||||
|
min-height: 2.75rem; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
gap: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .cabinet-disclosure__summary::-webkit-details-marker { display: none; } |
||||||
|
|
||||||
|
body.ui-legacy .cabinet-disclosure__summary::after { |
||||||
|
content: 'expand_more'; |
||||||
|
font-family: 'Material Symbols Outlined', sans-serif; |
||||||
|
margin-left: auto; |
||||||
|
font-size: 1.25rem; |
||||||
|
opacity: 0.55; |
||||||
|
transition: transform 0.2s ease; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .cabinet-disclosure[open] .cabinet-disclosure__summary::after { |
||||||
|
transform: rotate(180deg); |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .cabinet-disclosure__summary-text { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
align-items: flex-start; |
||||||
|
gap: 0.15rem; |
||||||
|
min-width: 0; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .cabinet-disclosure__summary-title { |
||||||
|
font-size: 1.05rem; |
||||||
|
line-height: 1.25; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .cabinet-disclosure__summary-sub { |
||||||
|
display: block; |
||||||
|
font-size: 0.8rem; |
||||||
|
font-weight: 400; |
||||||
|
line-height: 1.3; |
||||||
|
color: #506965; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .cabinet-disclosure__body { |
||||||
|
padding: 0.7rem 1rem 1.05rem; |
||||||
|
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent); |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .test-detail-subsection { |
||||||
|
margin-top: 1.25rem; |
||||||
|
padding-top: 1.15rem; |
||||||
|
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 32%, transparent); |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .test-detail-subsection--tight { |
||||||
|
margin-top: 0; |
||||||
|
padding-top: 0; |
||||||
|
border-top: none; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .test-detail-subsection__title { |
||||||
|
margin: 0 0 0.35rem; |
||||||
|
font-size: 0.95rem; |
||||||
|
font-weight: 600; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .test-detail-hint { |
||||||
|
margin: 0 0 0.6rem; |
||||||
|
font-size: 0.8rem; |
||||||
|
line-height: 1.4; |
||||||
|
color: #506965; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .test-detail-ai-panel { |
||||||
|
padding: 0.9rem 1rem; |
||||||
|
margin-bottom: 1.15rem; |
||||||
|
background: var(--surface-container-low); |
||||||
|
border: 1px solid color-mix(in srgb, var(--outline-variant) 32%, transparent); |
||||||
|
border-radius: 0.85rem; |
||||||
|
box-shadow: none; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .assign-toolbar { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 0.5rem; |
||||||
|
margin-bottom: 0.65rem; |
||||||
|
} |
||||||
|
|
||||||
|
@media (min-width: 520px) { |
||||||
|
body.ui-legacy .assign-toolbar { |
||||||
|
flex-direction: row; |
||||||
|
flex-wrap: wrap; |
||||||
|
align-items: center; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .assign-toolbar__search { |
||||||
|
flex: 1 1 200px; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .assign-list { |
||||||
|
max-height: min(40vh, 18rem); |
||||||
|
overflow: auto; |
||||||
|
border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent); |
||||||
|
border-radius: 0.75rem; |
||||||
|
background: var(--surface-container-low); |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .assign-row { |
||||||
|
display: flex; |
||||||
|
gap: 0.5rem; |
||||||
|
padding: 0.65rem 0.75rem; |
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 40%, transparent); |
||||||
|
cursor: pointer; |
||||||
|
align-items: center; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .assign-row:last-child { border-bottom: none; } |
||||||
|
body.ui-legacy .assign-row--selected, |
||||||
|
body.ui-legacy .assign-row:hover { background: color-mix(in srgb, var(--primary) 8%, transparent); } |
||||||
|
|
||||||
|
body.ui-legacy .assign-row__text { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 0.2rem; |
||||||
|
min-width: 0; |
||||||
|
flex: 1; |
||||||
|
} |
||||||
|
|
||||||
|
body.ui-legacy .assign-row__fio { font-weight: 600; font-size: 0.95rem; } |
||||||
|
body.ui-legacy .assign-row__login { font-size: 0.8rem; color: #506965; font-family: ui-monospace, Menlo, monospace; } |
||||||
|
body.ui-legacy .assign-row__meta { font-size: 0.8rem; color: #506965; line-height: 1.35; } |
||||||
|
|
||||||
|
body.ui-legacy .version-card-list, |
||||||
|
body.ui-legacy .attempts-card-list { |
||||||
|
list-style: none; |
||||||
|
margin: 0; |
||||||
|
padding: 0; |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 0.5rem; |
||||||
|
} |
||||||
|
|||||||
|
After Width: | Height: | Size: 52 KiB |
@ -0,0 +1,136 @@ |
|||||||
|
{% extends "base.html" %} |
||||||
|
{% block title %}Прохождение теста{% endblock %} |
||||||
|
|
||||||
|
{% block content %} |
||||||
|
<div class="test-detail-page" id="attempt-root" data-test-id="{{ test_id }}" data-attempt-id="{{ attempt_id }}"> |
||||||
|
<p class="link-back"><a href="/tests">← к списку тестов</a></p> |
||||||
|
<h1 class="font-headline" style="font-size:1.35rem;margin-top:0;" id="attempt-title">Загрузка…</h1> |
||||||
|
<p class="text-muted" style="margin-top:0;" id="attempt-subtitle"></p> |
||||||
|
<p class="error-text" id="attempt-error" style="display:none;"></p> |
||||||
|
|
||||||
|
<ol id="questions-list" style="padding-left:1.25rem;"></ol> |
||||||
|
|
||||||
|
<div class="inline-actions" style="margin-top:1rem;"> |
||||||
|
<button type="button" class="btn btn-primary" id="submit-attempt-btn">Завершить тест</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div id="attempt-result" class="surface-card" style="display:none;margin-top:1rem;padding:1rem;"></div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<script> |
||||||
|
(() => { |
||||||
|
const root = document.getElementById('attempt-root'); |
||||||
|
const testId = root.dataset.testId; |
||||||
|
const attemptId = root.dataset.attemptId; |
||||||
|
const titleEl = document.getElementById('attempt-title'); |
||||||
|
const subEl = document.getElementById('attempt-subtitle'); |
||||||
|
const errEl = document.getElementById('attempt-error'); |
||||||
|
const listEl = document.getElementById('questions-list'); |
||||||
|
const resultEl = document.getElementById('attempt-result'); |
||||||
|
const submitBtn = document.getElementById('submit-attempt-btn'); |
||||||
|
let playData = null; |
||||||
|
const selections = {}; |
||||||
|
|
||||||
|
function esc(s) { |
||||||
|
return String(s ?? '').replace(/[&<>"']/g, (m) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); |
||||||
|
} |
||||||
|
function setErr(msg) { |
||||||
|
errEl.textContent = msg || 'Ошибка.'; |
||||||
|
errEl.style.display = ''; |
||||||
|
} |
||||||
|
function isSelected(qid, oid) { |
||||||
|
return (selections[String(qid)] || []).includes(String(oid)); |
||||||
|
} |
||||||
|
function toggle(qid, oid, multi) { |
||||||
|
const k = String(qid); |
||||||
|
const cur = selections[k] || []; |
||||||
|
const id = String(oid); |
||||||
|
if (multi) { |
||||||
|
selections[k] = cur.includes(id) ? cur.filter(x => x !== id) : [...cur, id]; |
||||||
|
return; |
||||||
|
} |
||||||
|
selections[k] = [id]; |
||||||
|
} |
||||||
|
function renderQuestions() { |
||||||
|
listEl.innerHTML = ''; |
||||||
|
for (const q of (playData.questions || [])) { |
||||||
|
const li = document.createElement('li'); |
||||||
|
li.style.marginBottom = '1.5rem'; |
||||||
|
li.innerHTML = '<p style="margin-top:0;margin-bottom:0.5rem;">' + esc(q.text) + '</p>'; |
||||||
|
const ul = document.createElement('ul'); |
||||||
|
ul.style.listStyle = 'none'; |
||||||
|
ul.style.padding = '0'; |
||||||
|
ul.style.margin = '0'; |
||||||
|
for (const o of (q.options || [])) { |
||||||
|
const row = document.createElement('li'); |
||||||
|
row.style.marginBottom = '6px'; |
||||||
|
const type = q.hasMultipleAnswers ? 'checkbox' : 'radio'; |
||||||
|
const name = 'q-' + q.id; |
||||||
|
row.innerHTML = |
||||||
|
'<label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer;">' + |
||||||
|
'<input type="' + type + '" ' + (q.hasMultipleAnswers ? '' : ('name="' + name + '"')) + ' ' + (isSelected(q.id, o.id) ? 'checked' : '') + ' />' + |
||||||
|
'<span>' + esc(o.text) + '</span>' + |
||||||
|
'</label>'; |
||||||
|
const input = row.querySelector('input'); |
||||||
|
input.addEventListener('change', () => { |
||||||
|
toggle(q.id, o.id, q.hasMultipleAnswers); |
||||||
|
renderQuestions(); |
||||||
|
}); |
||||||
|
ul.appendChild(row); |
||||||
|
} |
||||||
|
li.appendChild(ul); |
||||||
|
listEl.appendChild(li); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function load() { |
||||||
|
try { |
||||||
|
const r = await fetch('/api/tests/' + testId + '/attempts/' + attemptId + '/play'); |
||||||
|
const data = await r.json().catch(() => ({})); |
||||||
|
if (!r.ok) throw new Error(data.error || 'Не удалось открыть попытку.'); |
||||||
|
playData = data; |
||||||
|
titleEl.textContent = data.testTitle || 'Прохождение теста'; |
||||||
|
subEl.textContent = 'Порог зачёта: ' + (data.passingThreshold ?? 0) + '%.'; |
||||||
|
if (!Array.isArray(data.questions) || !data.questions.length) { |
||||||
|
setErr('В активной версии нет вопросов.'); |
||||||
|
submitBtn.disabled = true; |
||||||
|
return; |
||||||
|
} |
||||||
|
renderQuestions(); |
||||||
|
} catch (e) { |
||||||
|
setErr(e.message); |
||||||
|
submitBtn.disabled = true; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function submit() { |
||||||
|
submitBtn.disabled = true; |
||||||
|
submitBtn.textContent = 'Отправка…'; |
||||||
|
try { |
||||||
|
const r = await fetch('/api/tests/' + testId + '/attempts/' + attemptId + '/submit', { |
||||||
|
method: 'POST', |
||||||
|
headers: { 'Content-Type': 'application/json' }, |
||||||
|
body: JSON.stringify({ answers: selections }), |
||||||
|
}); |
||||||
|
const data = await r.json().catch(() => ({})); |
||||||
|
if (!r.ok) throw new Error(data.error || 'Не удалось завершить попытку.'); |
||||||
|
resultEl.style.display = ''; |
||||||
|
resultEl.innerHTML = |
||||||
|
'<h3 style="margin-top:0;">Результат</h3>' + |
||||||
|
'<p>Правильно: <strong>' + data.correctCount + '</strong> из ' + data.totalQuestions + |
||||||
|
' (' + data.percent + '%). Порог: ' + data.passingThreshold + '%.</p>' + |
||||||
|
'<p class="' + (data.passed ? 'text-muted' : 'error-text') + '">' + (data.passed ? 'Зачёт.' : 'Незачёт.') + '</p>' + |
||||||
|
'<p><a href="/tests/' + testId + '/attempts/' + data.attemptId + '/review">Разбор попытки</a></p>'; |
||||||
|
submitBtn.style.display = 'none'; |
||||||
|
} catch (e) { |
||||||
|
setErr(e.message); |
||||||
|
submitBtn.disabled = false; |
||||||
|
submitBtn.textContent = 'Завершить тест'; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
submitBtn.addEventListener('click', submit); |
||||||
|
load(); |
||||||
|
})(); |
||||||
|
</script> |
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,40 @@ |
|||||||
|
{% extends "base.html" %} |
||||||
|
{% block title %}Разбор попытки{% endblock %} |
||||||
|
|
||||||
|
{% block content %} |
||||||
|
<div class="test-detail-page"> |
||||||
|
<p class="link-back"><a href="/tests">← к списку тестов</a></p> |
||||||
|
<h1 class="font-headline" style="font-size:1.35rem;margin-top:0;">Разбор: {{ review.testTitle }}</h1> |
||||||
|
<p> |
||||||
|
Правильно: <strong>{{ review.correctCount }}</strong> из {{ review.totalQuestions }} |
||||||
|
({{ review.percent }}%). Порог: {{ review.passingThreshold }}%. |
||||||
|
{% if review.passed %} |
||||||
|
<span class="text-muted">Зачёт.</span> |
||||||
|
{% else %} |
||||||
|
<span class="error-text">Незачёт.</span> |
||||||
|
{% endif %} |
||||||
|
</p> |
||||||
|
|
||||||
|
<div class="attempts-card-list"> |
||||||
|
{% for q in review.questions %} |
||||||
|
<article class="attempt-card"> |
||||||
|
<div class="attempt-card__meta"> |
||||||
|
<span>{{ 'Верно' if q.isUserCorrect else 'Ошибка' }}</span> |
||||||
|
</div> |
||||||
|
<p style="margin-top:.25rem;"><strong>{{ loop.index }}.</strong> {{ q.text }}</p> |
||||||
|
<ul style="list-style:none;padding-left:0;margin:0;"> |
||||||
|
{% for o in q.options %} |
||||||
|
<li style="margin:.25rem 0;"> |
||||||
|
<span> |
||||||
|
{% if o.selected %}☑{% else %}☐{% endif %} |
||||||
|
{{ o.text }} |
||||||
|
{% if o.isCorrect %}<strong> (правильный)</strong>{% endif %} |
||||||
|
</span> |
||||||
|
</li> |
||||||
|
{% endfor %} |
||||||
|
</ul> |
||||||
|
</article> |
||||||
|
{% endfor %} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
|
After Width: | Height: | Size: 52 KiB |