Дорабоки интерфейса системы тестирования. Раздел 1 Шапка+Верхний brick
@@ -0,0 +1 @@
|
|||||||
|
тестирования
|
||||||
@@ -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)
|
**Целевой и единственный рабочий стек** — Python 3.11 + Flask 3 +
|
||||||
|
Jinja2 + Tailwind CDN + SQLAlchemy / psycopg2, код в
|
||||||
|
[`flask_app/`](flask_app/). На нём работает и прод, и dev (`:3108`).
|
||||||
|
|
||||||
| Слой | Технологии |
|
Старые каталоги `backend/` (Node.js / Express) и `frontend/`
|
||||||
|------|------------|
|
(React + Vite) — **архив**: не разворачиваются, в `docker-compose.dev.yml`
|
||||||
| **Backend** | Node.js (ESM), **Express** 4, **pg**, миграции SQL; аутентификация — cookie + **JWT** (**jsonwebtoken**), пароли **bcryptjs**; опционально вход через HR (`HR_AUTH`, отдельное подключение к БД HR). |
|
поднимается только сервис `testing-flask`, удаление папок запланировано
|
||||||
| **Frontend** | **React** 18, **React Router** 6, сборка **Vite** 5; статика в проде через Nginx (см. `docker-compose.dev.yml`). |
|
в спринте **E1.6**. Использовать их не надо, миграции и SQL-схема
|
||||||
| **Данные** | **PostgreSQL**, отдельная БД **`clinic_tests`**: UUID-ключи, таблицы `tests`, `test_versions`, `questions`, `answer_options`, назначения, попытки (см. `backend/src/db/migrations/`). |
|
сохранены в `backend/src/db/migrations/` исключительно как источник
|
||||||
| **Прочее** | Извлечение текста из PDF/DOCX (**pdf-parse**, **mammoth**), опционально LLM для черновиков тестов; **dotenv**, **cors**, **multer**. |
|
структуры БД.
|
||||||
|
|
||||||
### Целевой стек (Flask, как в кабинете / мини-приложении)
|
БД — **`clinic_tests`** (PostgreSQL, UUID-ключи). В Этапе 1 схема
|
||||||
|
не меняется.
|
||||||
|
|
||||||
Тот же класс технологий, что в **`HR_TG_Bot/tgFlaskForm`**: Python, Flask, шаблоны, Postgres. Сейчас допускается **отдельный деплой** нового контура из каталога [`flask_app/`](flask_app/README.md); позже — слияние с полным кабинетом при необходимости.
|
**Этап 2** — слияние с общим HR-кабинетом `HR_TG_Bot/tgFlaskForm` —
|
||||||
|
запланирован на будущее, сейчас не делается. План:
|
||||||
Эталон реализации модуля в монорепозитории HR — общий веб-кабинет **`HR_TG_Bot/tgFlaskForm`**:
|
[`docs/migration-to-tgflaskform.md`](docs/migration-to-tgflaskform.md)
|
||||||
|
([простыми словами](docs/migration-to-tgflaskform-plain.md)).
|
||||||
| Слой | Технологии |
|
|
||||||
|------|------------|
|
|
||||||
| **Приложение** | **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).
|
|
||||||
Скрипт 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`).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Содержание
|
## Что уже работает на новом (Flask) контуре
|
||||||
|
|
||||||
- [Стек технологий](#стек-технологий) · [flask_app/ — новый контур](flask_app/README.md)
|
E1.0–E1.3 и E1.8 закрыты. Чек-лист и журнал —
|
||||||
- [Состояние реализации (сводка)](#состояние-реализации-сводка)
|
[`docs/migration-final.md`](docs/migration-final.md).
|
||||||
- [Функциональные возможности](#функциональные-возможности)
|
|
||||||
- [Роли и права доступа](#роли-и-права-доступа)
|
|
||||||
- [Установка и запуск](#установка-и-запуск)
|
|
||||||
- [Данные, сотрудники, интеграция с HR](#данные-сотрудники-интеграция-с-hr)
|
|
||||||
- [Нефункциональные требования](#нефункциональные-требования)
|
|
||||||
- [Вне scope](#вне-scope-не-реализуется-в-данной-версии)
|
|
||||||
|
|
||||||
---
|
- **Авторизация** через куки-сессию Flask: bcrypt (локальные пользователи
|
||||||
|
`clinic_tests.users`) или Werkzeug-хеши при включённом `HR_AUTH=1`
|
||||||
|
(UPSERT в `clinic_tests.users` по `staff_id` из `hr_bot_test`).
|
||||||
|
UI: `/login`, JSON: `/api/auth/{login,logout,me}`.
|
||||||
|
- **Каталог тестов** `/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).
|
||||||
|
|
||||||
Коротко и по-человечески: [docs/PROJECT_STATUS.md](docs/PROJECT_STATUS.md) (черновики и версии, разбор попыток, список тестов, dev-стенд).
|
## Что ещё не реализовано
|
||||||
Как пользоваться локальным **dev** без чтения кода: [docs/DEV_CONTOUR_USER_GUIDE.md](docs/DEV_CONTOUR_USER_GUIDE.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). |
|
||||||
- Создание/редактирование/деактивация учётных записей сотрудников
|
|
||||||
- Каждый сотрудник принадлежит одному подразделению
|
|
||||||
- Создание/редактирование справочника подразделений
|
|
||||||
- Назначение роли сотруднику: HR-менеджер / Руководитель подразделения / Сотрудник
|
|
||||||
|
|
||||||
### Создание и редактирование тестов
|
|
||||||
|
|
||||||
**Тест содержит:**
|
|
||||||
- Название теста
|
|
||||||
- Описание (опционально)
|
|
||||||
- Список вопросов (минимум 7)
|
|
||||||
- Порог зачёта — минимальный % правильных ответов
|
|
||||||
- Таймер прохождения — лимит в минутах (опционально)
|
|
||||||
|
|
||||||
**Вопрос содержит:**
|
|
||||||
- Текст вопроса
|
|
||||||
- Минимум 3 варианта ответа
|
|
||||||
- Один или несколько правильных ответов
|
|
||||||
|
|
||||||
**Настройки теста:**
|
|
||||||
- Разрешить возврат к предыдущему вопросу: да / нет
|
|
||||||
|
|
||||||
**Версионирование:**
|
|
||||||
- Автор может редактировать тест пока никто его не проходил
|
|
||||||
- Если тест уже проходили — создаётся новая версия (`version + 1`), старая сохраняется
|
|
||||||
- Все версии теста хранятся; результаты привязаны к конкретной версии
|
|
||||||
- Активная версия — та, которую видят сотрудники; автор может вручную переключить активную версию
|
|
||||||
- Тест можно деактивировать (скрыть из списка, не удалять)
|
|
||||||
|
|
||||||
### Назначение теста
|
|
||||||
|
|
||||||
- Список получателей (отдел или конкретные сотрудники)
|
|
||||||
- Срок сдачи — дата дедлайна
|
|
||||||
- Допустимое количество попыток (1 или более)
|
|
||||||
|
|
||||||
### Прохождение теста
|
|
||||||
|
|
||||||
- На главной странице сотрудник видит список назначенных тестов со статусами:
|
|
||||||
- `Не начат` — ещё не открывал
|
|
||||||
- `В процессе` — начал, не завершил
|
|
||||||
- `Завершён` — сдал/не сдал
|
|
||||||
- `Просрочен` — дедлайн прошёл, не сдан
|
|
||||||
- Если задан таймер — отображается обратный отсчёт, по истечении тест завершается автоматически
|
|
||||||
- Порядок вопросов **случайный** при каждом прохождении
|
|
||||||
- Возможность вернуться к предыдущему вопросу — определяется настройкой теста
|
|
||||||
|
|
||||||
### Результаты после завершения теста
|
|
||||||
|
|
||||||
- Итоговый балл и процент правильных ответов
|
|
||||||
- Факт зачёта: **сдал / не сдал**
|
|
||||||
- Разбор ошибок: по каждому вопросу — его ответ и правильный ответ
|
|
||||||
|
|
||||||
### Трекер попыток
|
|
||||||
|
|
||||||
Единый интерфейс просмотра всех попыток прохождения тестов:
|
|
||||||
- Фильтрация по подразделению, сотруднику, тесту, статусу, результату
|
|
||||||
- Пагинация и сортировка
|
|
||||||
|
|
||||||
### AI-помощник
|
|
||||||
|
|
||||||
Интеграция с LLM для помощи при создании тестов:
|
|
||||||
|
|
||||||
| Функция | Описание |
|
|
||||||
|---------|----------|
|
|
||||||
| Генерация теста | AI генерирует готовый набор вопросов с вариантами ответов по теме |
|
|
||||||
| Улучшение формулировки | AI переформулирует выбранный вопрос более чётко |
|
|
||||||
| Добавление дистракторов | AI генерирует правдоподобные неправильные варианты ответов |
|
|
||||||
| Проверка качества | AI анализирует весь тест и выдаёт рекомендации |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Роли и права доступа
|
|
||||||
|
|
||||||
| Роль | Кто | Создаёт тесты | Назначает тесты | Видит результаты |
|
|
||||||
|------|-----|:---:|:---:|:---:|
|
|
||||||
| **HR-менеджер** | Руководитель службы HR, Директор клиники | ✅ | Всем сотрудникам клиники | Всех сотрудников |
|
|
||||||
| **Руководитель подразделения** | Главный врач, рук. службы администраторов | ✅ | Только своему подразделению | Только своего подразделения |
|
|
||||||
| **Сотрудник** | Все остальные работники | ❌ | ❌ | Только свои |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Установка и запуск
|
## Установка и запуск
|
||||||
|
|
||||||
### База данных (как в HR_TG_Bot / Postgres_TG_Bots)
|
### Предпосылка: общий Postgres
|
||||||
|
|
||||||
Используется **тот же** экземпляр 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)).
|
Используется **тот же** PostgreSQL, что и в
|
||||||
|
[Postgres_TG_Bots](../Postgres_TG_Bots) (контейнер `hr_postgres_dev`,
|
||||||
|
сеть `hr_postgres_dev_net`, учётка `hr_bot_user`).
|
||||||
|
|
||||||
Схема приложения (таблицы `users`, `tests`, `departments`, …) **не** совмещается с БД `hr_bot_test` — для TestingWebApp заведена отдельная база **`clinic_tests`**.
|
```bash
|
||||||
|
# (один раз) создать базу
|
||||||
|
psql "postgresql://hr_bot_user:hrbot123@localhost:5432/postgres" \
|
||||||
|
-c "CREATE DATABASE clinic_tests;"
|
||||||
|
|
||||||
1. Поднять Postgres из `Postgres_TG_Bots` (и при необходимости внешнюю сеть: `docker network create hr_postgres_dev_net` — как в compose этих репозиториев).
|
# (один раз) внешняя сеть, если ещё не создана соседом
|
||||||
2. Один раз создать базу:
|
docker network create hr_postgres_dev_net || true
|
||||||
`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`:
|
### Dev-стенд
|
||||||
`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` — остановка.
|
Выбор интерфейса задаётся через env-переменную `COMPOSE_PROFILES`:
|
||||||
|
|
||||||
**Без общего кластера** (только отладка): `docker compose --profile standalone up -d` в TestingWebApp — Postgres на **5433**, тогда в `.env` укажите `DATABASE_URL=...localhost:5433/clinic_tests` или `DB_PORT=5433` с `DB_NAME`/`DB_USER` из compose.
|
- `modern` — основной интерфейс на Flask/Jinja;
|
||||||
|
- `legacy` — legacy-раскладка интерфейса на том же Flask-стеке.
|
||||||
|
|
||||||
**Если `npm run migrate` пишет `ECONNREFUSED ...:5433`:** в `backend/.env` нет (или кривой) `DATABASE_URL` на **5432**, и сработал старый `DB_PORT=5433`. Задайте `DATABASE_URL` как в `backend/.env.example` для общего Postgres.
|
```bash
|
||||||
|
# Новый стек (рекомендуется)
|
||||||
|
COMPOSE_PROFILES=modern docker compose -f docker-compose.dev.yml up -d --build
|
||||||
|
|
||||||
### Данные, сотрудники, интеграция с HR
|
# Legacy-раскладка (тот же Flask)
|
||||||
|
COMPOSE_PROFILES=legacy docker compose -f docker-compose.dev.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
- **Две роли кластера Postgres:** в **`clinic_tests`** — только сущности модуля тестирования (тесты, версии, назначения, попытки, локальные технические учётки при необходимости). В **`hr_bot_test`** (Postgres_TG_Bots / hr_web_viewer) — штат, справочники, существующий **RBAC** и веб-логины. Так мы не смешиваем схемы и не дублируем «источник правды» по людям.
|
| Что | URL |
|
||||||
- **Сотрудник в процессах** (назначения, дашборды, доступ к результатам) — везде по **`staff_members.id`**. Ссылки в `clinic_tests` храним как **тот же идентификатор** (логическая связь с `staff_members` в `hr_bot_test`); **ФИО, отдел, роли** подтягиваем из HR при отображении или кэшируем по согласованной политике, а не ведём второй кадровый учёт.
|
|---|---|
|
||||||
- **`telegram_id`** в данных сотрудника **не участвует** в бизнес-логике модуля: ни вход, ни проверка прав, ни выбор сотрудника в сценариях, ни фильтрация — только **справочная** информация при необходимости (отображение, история).
|
| Приложение (Flask modern) | <http://localhost:3108> |
|
||||||
- **RBAC в перспективе:** единая система разрешений — та, что уже в HR (роли, `staff_role_assignments`, permissions). Модуль тестирования **не** развивает отдельную полную копию матрицы; проверка действий в целевом виде — через **HR** (внутренний API / токен / согласованные запросы к БД). Пока договор и API не готовы — допустимы временные флаги в `clinic_tests`, явно помечаемые как MVP.
|
| Health-check | <http://localhost:3108/health> |
|
||||||
|
| Приложение (Flask legacy) | <http://localhost:3107> |
|
||||||
|
|
||||||
Детализация задач и варианты A.x: [docs/revision_task/card1.md](docs/revision_task/card1.md).
|
`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` репозитория.
|
||||||
|
|
||||||
|
### Локально без Docker
|
||||||
|
|
||||||
|
См. [`flask_app/README.md`](flask_app/README.md) — `venv` +
|
||||||
|
`pip install -r requirements.txt` + `python run.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Данные и интеграция с HR
|
||||||
|
|
||||||
|
- **Две роли кластера Postgres.** В **`clinic_tests`** — только сущности
|
||||||
|
модуля тестирования (тесты, версии, назначения, попытки, локальные
|
||||||
|
технические учётки). В **`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-менеджер** | Руководитель HR, директор | ✅ | Всем | Всех |
|
||||||
|
| **Руководитель подразделения** | Главврач, рук. отделения | ✅ | Только своему подразделению | Только своего подразделения |
|
||||||
|
| **Сотрудник** | Все остальные | ❌ | ❌ | Только свои |
|
||||||
|
|
||||||
|
> На текущем Flask-контуре (E1.0–E1.3, E1.8) проверяется только
|
||||||
|
> `@login_required`; разделение по ролям задействуется на E1.4–E1.5.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Нефункциональные требования
|
## Нефункциональные требования
|
||||||
|
|
||||||
| Параметр | Значение |
|
| Параметр | Значение |
|
||||||
|----------|----------|
|
|---|---|
|
||||||
| Количество пользователей | 50–200 человек |
|
| Количество пользователей | 50–200 человек |
|
||||||
| Платформа | Веб-приложение, браузер (desktop-first) |
|
| Платформа | Веб, браузер; mobile-friendly |
|
||||||
| Доступность | Внутренняя сеть клиники |
|
| Доступность | Внутренняя сеть клиники |
|
||||||
| Язык интерфейса | Русский |
|
| Язык интерфейса | Русский |
|
||||||
| Время отклика | < 2 секунды |
|
| Время отклика | < 2 секунды |
|
||||||
|
|
||||||
|
## Вне scope (в текущей версии не делаем)
|
||||||
|
|
||||||
|
- Интеграция с AD / LDAP.
|
||||||
|
- Нативное мобильное приложение.
|
||||||
|
- Вопросы с вложениями (картинки, видео).
|
||||||
|
- Экспорт отчётов в Excel / PDF.
|
||||||
|
- Уведомления в MAX (отдельный спринт).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Вне scope (не реализуется в данной версии)
|
## Документация
|
||||||
|
|
||||||
- Интеграция с AD/LDAP
|
| Файл | О чём |
|
||||||
- Мобильное приложение
|
|---|---|
|
||||||
- Вопросы с вложениями (изображения, видео)
|
| [`docs/PROJECT_STATUS.md`](docs/PROJECT_STATUS.md) | Что работает «прямо сейчас», что в работе, что в бэклоге. |
|
||||||
- Экспорт отчётов в Excel / PDF
|
| [`docs/migration-final.md`](docs/migration-final.md) | Главный трекер миграции: спринты Этапа 1, журнал, критерии готовности. |
|
||||||
- Уведомления в MAX (отдельный спринт)
|
| [`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) | Исходное ТЗ заказчика. |
|
||||||
|
|||||||
@@ -4,52 +4,17 @@
|
|||||||
# База clinic_tests: один раз
|
# База clinic_tests: один раз
|
||||||
# psql "postgresql://hr_bot_user:hrbot123@localhost:5432/postgres" -c "CREATE DATABASE clinic_tests;"
|
# psql "postgresql://hr_bot_user:hrbot123@localhost:5432/postgres" -c "CREATE DATABASE clinic_tests;"
|
||||||
#
|
#
|
||||||
# Запуск: из каталога TestingWebApp
|
# Flask-only режим. Выбор варианта интерфейса через profile:
|
||||||
# docker compose -f docker-compose.dev.yml up --build
|
# COMPOSE_PROFILES=modern docker compose -f docker-compose.dev.yml up -d --build
|
||||||
# UI (Node): http://localhost:3107 (Nginx: /api → backend:3001), API: http://localhost:3001
|
# COMPOSE_PROFILES=legacy docker compose -f docker-compose.dev.yml up -d --build
|
||||||
# UI (Flask, новый контур): http://localhost:3108
|
# Оба варианта работают на одном Flask-стеке, отличаются только UI-раскладкой.
|
||||||
|
# UI (Flask modern): http://localhost:3108
|
||||||
|
# UI (Flask legacy): http://localhost:3107
|
||||||
|
|
||||||
services:
|
services:
|
||||||
testing-backend:
|
# Flask modern UI
|
||||||
build:
|
|
||||||
context: ./backend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: testing_webapp_backend
|
|
||||||
# LLM и прочие секреты из хоста (не копируются в образ — см. .dockerignore)
|
|
||||||
env_file:
|
|
||||||
- ./backend/.env
|
|
||||||
environment:
|
|
||||||
DATABASE_URL: postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/clinic_tests
|
|
||||||
JWT_SECRET: ${JWT_SECRET:-testing_webapp_jwt_dev}
|
|
||||||
# development: httpOnly-cookie без Secure (иначе на http://localhost:3107 логин не сработает)
|
|
||||||
NODE_ENV: development
|
|
||||||
FRONTEND_URL: http://localhost:3107
|
|
||||||
PORT: "3001"
|
|
||||||
# Вход теми же учётками, что в HR: проверка пароля в hr_bot_test + привязка сотрудника по web_login.
|
|
||||||
# Без HR_AUTH / HR_DATABASE_URL логин ищется только в clinic_tests.users (локальные dev-учётки).
|
|
||||||
HR_AUTH: ${HR_AUTH:-1}
|
|
||||||
HR_DATABASE_URL: postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/hr_bot_test
|
|
||||||
# Прямой доступ к API с хоста (Vite proxy в dev: см. frontend/vite.config.js)
|
|
||||||
ports:
|
|
||||||
- "3001:3001"
|
|
||||||
networks:
|
|
||||||
- app
|
|
||||||
- postgres
|
|
||||||
|
|
||||||
testing-web:
|
|
||||||
build:
|
|
||||||
context: ./frontend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: testing_webapp_nginx
|
|
||||||
depends_on:
|
|
||||||
- testing-backend
|
|
||||||
ports:
|
|
||||||
- "3107:80"
|
|
||||||
networks:
|
|
||||||
- app
|
|
||||||
|
|
||||||
# Новый контур: Flask (тот же стек, что кабинет HR), отдельный порт
|
|
||||||
testing-flask:
|
testing-flask:
|
||||||
|
profiles: ["modern"]
|
||||||
build:
|
build:
|
||||||
context: ./flask_app
|
context: ./flask_app
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -59,11 +24,14 @@ services:
|
|||||||
WEB_USE_WAITRESS: "1"
|
WEB_USE_WAITRESS: "1"
|
||||||
FLASK_DEBUG: "0"
|
FLASK_DEBUG: "0"
|
||||||
SECRET_KEY: ${FLASK_SECRET_KEY:-testing_flask_dev_change_me}
|
SECRET_KEY: ${FLASK_SECRET_KEY:-testing_flask_dev_change_me}
|
||||||
# БД (clinic_tests). Хост postgres — в общей сети hr_postgres_dev_net.
|
# БД (clinic_tests) в общей сети hr_postgres_dev_net.
|
||||||
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg2://app:app@postgres:5432/clinic_tests}
|
# По умолчанию используем те же dev-учётки, что и в backend-сервисе.
|
||||||
# Опц. HR-кабинет (E1.1): включается флагом + URL базы hr_bot_test.
|
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg2://hr_bot_user:hrbot123@hr_postgres_dev:5432/clinic_tests}
|
||||||
HR_AUTH: ${HR_AUTH:-0}
|
# HR-аутентификация включена по умолчанию:
|
||||||
HR_DATABASE_URL: ${HR_DATABASE_URL:-}
|
# пароль проверяется в hr_bot_test.users + staff по web_login.
|
||||||
|
HR_AUTH: ${HR_AUTH:-1}
|
||||||
|
HR_DATABASE_URL: ${HR_DATABASE_URL:-postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/hr_bot_test}
|
||||||
|
UI_VARIANT: ${UI_VARIANT_MODERN:-modern}
|
||||||
# LLM (E1.2/E1.3/E1.8): один общий ключ, читается из .env проекта.
|
# LLM (E1.2/E1.3/E1.8): один общий ключ, читается из .env проекта.
|
||||||
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}
|
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}
|
||||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
@@ -75,6 +43,32 @@ services:
|
|||||||
- app
|
- app
|
||||||
- postgres
|
- postgres
|
||||||
|
|
||||||
|
# Flask legacy UI (старое расположение элементов на новом стеке)
|
||||||
|
testing-flask-legacy:
|
||||||
|
profiles: ["legacy"]
|
||||||
|
build:
|
||||||
|
context: ./flask_app
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: testing_webapp_flask_legacy
|
||||||
|
environment:
|
||||||
|
PORT: "3107"
|
||||||
|
WEB_USE_WAITRESS: "1"
|
||||||
|
FLASK_DEBUG: "0"
|
||||||
|
SECRET_KEY: ${FLASK_SECRET_KEY:-testing_flask_dev_change_me}
|
||||||
|
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg2://hr_bot_user:hrbot123@hr_postgres_dev:5432/clinic_tests}
|
||||||
|
HR_AUTH: ${HR_AUTH:-1}
|
||||||
|
HR_DATABASE_URL: ${HR_DATABASE_URL:-postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/hr_bot_test}
|
||||||
|
UI_VARIANT: ${UI_VARIANT_LEGACY:-legacy}
|
||||||
|
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}
|
||||||
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
|
LLM_BASE_URL: ${LLM_BASE_URL:-}
|
||||||
|
LLM_MODEL: ${LLM_MODEL:-}
|
||||||
|
ports:
|
||||||
|
- "3107:3107"
|
||||||
|
networks:
|
||||||
|
- app
|
||||||
|
- postgres
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
app:
|
app:
|
||||||
postgres:
|
postgres:
|
||||||
|
|||||||
@@ -1,78 +1,148 @@
|
|||||||
# Состояние проекта (человеческий обзор)
|
# Состояние проекта
|
||||||
|
|
||||||
**Репозиторий:** [TestingWebApp](https://git.pirogov.ai/l_konstantin/TestingWebApp) · ветка разработки: **`dev`**
|
**Репозиторий:** [TestingWebApp](https://git.pirogov.ai/l_konstantin/TestingWebApp) · ветка разработки **`dev`**
|
||||||
**Дата среза:** 2026-04-24
|
**Прод:** **[https://edullm.pirogov.ai/](https://edullm.pirogov.ai/)**
|
||||||
|
**Дата среза:** 2026-04-28
|
||||||
|
|
||||||
Этот документ — не дублирование ТЗ, а **короткое объяснение**, что уже работает в коде и что логично делать дальше. Подробные задачи: [revision_task/card1.md](revision_task/card1.md), [revision_task/BACKLOG.md](revision_task/BACKLOG.md).
|
Не дубль ТЗ, а карта «что реально работает в коде, на каком контуре,
|
||||||
|
и что логично сделать дальше».
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Что уже сделано (как это устроено)
|
## TL;DR
|
||||||
|
|
||||||
### Вход и роли
|
- Прод и dev работают **только на Flask-контуре** (`flask_app/`,
|
||||||
|
Python 3.11 + Flask 3 + Jinja2 + Tailwind CDN + SQLAlchemy).
|
||||||
|
- Каталоги `backend/` (Express) и `frontend/` (React) — архив, не
|
||||||
|
разворачиваются и не используются; удаление запланировано в
|
||||||
|
спринте **E1.6**.
|
||||||
|
- БД — **`clinic_tests`** (PostgreSQL). Схема в Этапе 1 не меняется.
|
||||||
|
- Этап 2 (слияние с `HR_TG_Bot/tgFlaskForm`) пока не делаем —
|
||||||
|
[`migration-to-tgflaskform.md`](migration-to-tgflaskform.md).
|
||||||
|
|
||||||
- Сотрудник входит по **логину и паролю** (сессия через cookie + JWT).
|
Главный трекер по спринтам — [`migration-final.md`](migration-final.md).
|
||||||
- В шапке показываются **роль** и **Фамилия с инициалами** (например, *Иванов И. О.*), полное ФИО — во всплывающей подсказке.
|
|
||||||
- В **режиме разработки** (`NODE_ENV=development`) у удобного тестирования могут быть дополнительные кнопки (например, создание теста сотрудником — `devUi` в ответе `/api/auth/me`).
|
|
||||||
|
|
||||||
### «Цепочка» теста и черновики
|
|
||||||
|
|
||||||
- У каждого теста есть **одна логическая цепочка** в базе: все правки вопросов относятся к ней, но **версия контента** (`v1`, `v2`, …) может расти.
|
|
||||||
- **Пока никто не проходил** этот тест — автор правит **на месте**: сохраняет черновик, и меняется текущая активная версия **без** лишнего дублирования строк в истории.
|
|
||||||
- **Как только по цепочке появилась хотя бы одна завершённая попытка** — каждое **содержательное** сохранение с изменениями создаёт **новую версию** (новый номер, старая остаётся в истории). Старые результаты остаются привязаны к **той** версии, с которой человек реально отвечал.
|
|
||||||
- **Активная версия** — та, с которой сейчас стартуют новые попытки. Автор может **вручную** переключить активную версию в таблице истории (с подтверждением), если бизнесу так нужно.
|
|
||||||
- **Публикация / видимость:** в кабинете (аккордеон **«Показ в каталоге»**, подсекция **«Видимость»**) тест можно **скрыть из общего списка** (цепочка остаётся в базе) или **снова показать**; **назначения** (подсекция **«Кому выдать»**) — при включённой фиче, см. раздел «Назначения» ниже.
|
|
||||||
- **Мобильный UI** кабинета (колонка списка на узком экране, фикс-футер, группировка разделов, копи): [СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md) · [РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md](РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md) (тезисы для врачей/кураторов).
|
|
||||||
- **Унификация стека (Этап 1, текущий)**: Express → Flask + React → Jinja **внутри TestingWebApp** (`flask_app/`). БД остаётся `clinic_tests`, схема не меняется. План и журнал — [migration-final.md](migration-final.md).
|
|
||||||
- **Слияние с HR-кабинетом (Этап 2, на будущее, без сроков)**: перенос в `HR_TG_Bot/tgFlaskForm` как blueprint `cabinet/testing`, ETL `clinic_tests → hr_bot_test`. План — [migration-to-tgflaskform.md](migration-to-tgflaskform.md). Карта Express-функционала и справочный gap-analysis с уже существующим модулем HR-кабинета — [migration-final-inventory.md](migration-final-inventory.md).
|
|
||||||
|
|
||||||
### Список тестов и доступ
|
|
||||||
|
|
||||||
- В каталоге **«Тесты»** видны цепочки, где вы **автор**, и тесты, **назначенные вам** (через назначение на пользователя; в dev назначения обычно **включены**).
|
|
||||||
- Под названием показывается **«Автор: Вы»** для своих тестов и **«Автор: Фамилия И. О.»** для чужих (назначенных).
|
|
||||||
- **Пройти** тест — кнопка **справа** в строке; **карточка** теста — клик по названию **слева** (попытка с карточки не стартует сама).
|
|
||||||
|
|
||||||
### Прохождение и результат
|
|
||||||
|
|
||||||
- Открывается экран вопросов (один или несколько верных вариантов); после **«Завершить тест»** — итог: сколько верно, процент, **зачёт** по порогу.
|
|
||||||
- **Разбор:** после сдачи показывается, по **каждому вопросу**, что выбрал пользователь и какие варианты верны. Отдельная страница разбора доступна по ссылке; **автор** в аккордеоне **«История»** (подсекция **«Прохождения»**) видит **завершённые** попытки и кнопку **«Разбор»** (раньше секция называлась **«Прогоны и разбор»**).
|
|
||||||
|
|
||||||
### Импорт и ИИ (MVP)
|
|
||||||
|
|
||||||
- Можно загрузить **файл** (PDF, DOCX, текст): сервер **извлекает текст** и при настроенном ключе **LLM** (например, `DEEPSEEK_API_KEY` / `OPENAI_API_KEY` в окружении) предлагает **черновик** вопросов. В UI: подсекция **«Документ в вопросы»** внутри **«Вопросы»** (раньше — отдельный блок «Импорт из файла»). Дальше тот же поток, что и при ручном редактировании: правки → **сохранить черновик** (с учётом правил версий выше).
|
|
||||||
- **Полный** набор сценариев из ТЗ (отдельная страница настроек ключа, «проверить тест целиком», модалки с чекбоксами и т.д.) — в [sprint-02](revision_task/sprint-02.md); часть уже заложена в сервисах, UI доводится.
|
|
||||||
|
|
||||||
### Назначения (MVP)
|
|
||||||
|
|
||||||
- **Автор** в **«Показ в каталоге»** → **«Кому выдать»** может **назначить** сотрудников из справочника (поиск, фильтры, **«Выбрать всех»** в текущем списке; в dev — при включённой фиче в `docker-compose` / `.env`). Назначение **не** перепривязывается автоматически к каждой новой версии контента: **старт попытки** всегда берёт **текущую активную** версию на момент нажатия **«Пройти»**.
|
|
||||||
|
|
||||||
### Интеграция с HR (в зачатке)
|
|
||||||
|
|
||||||
- Поддержан сценарий **входа через учётки HR** (`HR_AUTH` + `HR_DATABASE_URL`) для проверок на одном кластере Postgres с экосистемой `Postgres_TG_Bots` (см. [README — установка](../README.md)).
|
|
||||||
- Целевой **RBAC** из HR-таблиц — [card1, часть A](revision_task/card1.md#часть-a--авторизация-по-паролю-бд-postgres_tg_bots); сейчас — упрощённое сопоставление ролей.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Что в планах (логичный следующий слой)
|
## Что уже работает на новом контуре (E1.0–E1.3, E1.8)
|
||||||
|
|
||||||
|
### Вход
|
||||||
|
- `/login` (форма) и `/api/auth/login` (JSON), `/api/auth/logout`,
|
||||||
|
`/api/auth/me`.
|
||||||
|
- По умолчанию — bcrypt-хеши из `clinic_tests.users`.
|
||||||
|
- `HR_AUTH=1` + `HR_DATABASE_URL` — вход через `hr_bot_test.users`
|
||||||
|
(Werkzeug); запись синхронизируется в `clinic_tests.users` UPSERT-ом по
|
||||||
|
`staff_id`. Сценарий «пользователь без `staff_id`» — пропускается с
|
||||||
|
предупреждением в логах.
|
||||||
|
|
||||||
|
### Каталог тестов (`/tests`)
|
||||||
|
- Видны цепочки, где вы автор, и активные публичные.
|
||||||
|
- Создание теста через модалку («Название» + «Описание»).
|
||||||
|
- Кнопка «Скрыть» / «Вернуть» работает на цепочку целиком.
|
||||||
|
|
||||||
|
### Редактор теста (`/tests/<id>/edit`)
|
||||||
|
- Поля шапки: название, описание, проходной балл, переключатель
|
||||||
|
«Цепочка активна».
|
||||||
|
- Вопросы и варианты: добавить / удалить / переместить, отметить верные.
|
||||||
|
- **Версионирование.** Пока по цепочке нет завершённых попыток —
|
||||||
|
правки идут «на месте». После первой попытки любое содержательное
|
||||||
|
сохранение делает форк (`version + 1`, `parent_id` = прежняя),
|
||||||
|
старая версия остаётся в БД и не видна в каталоге.
|
||||||
|
- Подробная модель поведения и проверочные сценарии —
|
||||||
|
[`QA-versioning-and-ai.md`](QA-versioning-and-ai.md).
|
||||||
|
|
||||||
|
### AI-помощник в редакторе
|
||||||
|
| Кнопка | Что делает |
|
||||||
|
|---|---|
|
||||||
|
| По названию | Генерирует весь набор вопросов по теме. Параметры — кол-во вопросов и вариантов. |
|
||||||
|
| По текущей сетке | Дописывает варианты для уже расставленных карточек. |
|
||||||
|
| Проверить | Рецензирует тест: вердикт + блоки рекомендаций. |
|
||||||
|
| Улучшить | «Было → стало» по каждому вопросу/варианту с чекбоксами. |
|
||||||
|
| AI: вопрос | На карточке вопроса — переформулировка / генерация дистракторов. |
|
||||||
|
|
||||||
|
При отсутствии ключа — единая ошибка с ссылкой на `/settings`.
|
||||||
|
|
||||||
|
### Импорт документа
|
||||||
|
- PDF / DOCX / TXT / MD до 16 МБ.
|
||||||
|
- `pypdf` для PDF, `python-docx` для DOCX, плоский текст — как есть.
|
||||||
|
- Извлечённый текст идёт в LLM, на выходе — черновик теста, который
|
||||||
|
открывается в редакторе.
|
||||||
|
|
||||||
|
### Настройки (`/settings`)
|
||||||
|
- Статус общего LLM-ключа (берётся из ENV: `DEEPSEEK_API_KEY` →
|
||||||
|
`OPENAI_API_KEY`).
|
||||||
|
- Провайдер, модель, base URL.
|
||||||
|
- Кнопка «Проверить подключение» — пинг `/v1/chat/completions` через
|
||||||
|
`ping_llm()`.
|
||||||
|
- Ключ на клиента не уходит и в БД не пишется.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Чего на Flask пока нет
|
||||||
|
|
||||||
|
Эти сценарии будут реализованы в E1.4–E1.5. До этого в приложении они
|
||||||
|
просто отсутствуют (старый Express-контур не используется и не
|
||||||
|
поднимается):
|
||||||
|
|
||||||
|
- **Назначение теста сотруднику** — поиск по справочнику, «Выбрать
|
||||||
|
всех», фильтры по подразделениям.
|
||||||
|
- **Прохождение** — экран вопросов, таймер, сохранение попытки.
|
||||||
|
- **Результат и разбор ошибок** — отдельная страница с ответами
|
||||||
|
пользователя и правильными вариантами.
|
||||||
|
- **Трекер попыток** — единый список завершённых попыток с фильтрами
|
||||||
|
(подразделение / сотрудник / тест / статус / результат).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что в работе и в планах
|
||||||
|
|
||||||
|
### Этап 1 — паритет внутри TestingWebApp
|
||||||
|
|
||||||
|
| Спринт | Содержание | Статус |
|
||||||
|
|---|---|---|
|
||||||
|
| E1.0 | База Flask-приложения (БД-пул, сессии, `base.html`). | ✅ |
|
||||||
|
| E1.1 | Auth + `/api/me` (bcrypt + Werkzeug, опц. `HR_AUTH`). | ✅ |
|
||||||
|
| E1.2 | Каталог тестов и редактор (функциональный минимум). | ✅ |
|
||||||
|
| E1.3 | Импорт документов (PDF / DOCX / TXT / MD). | ✅ |
|
||||||
|
| E1.4 | Назначения и прохождение тестов. | ⬜ Следующий. |
|
||||||
|
| E1.5 | Трекер попыток + страница настроек цепочки. | ⬜ |
|
||||||
|
| E1.6 | Cutover внутри репозитория (удаление `backend/` + `frontend/`). | ⬜ |
|
||||||
|
| E1.7 | UX-полировка редактора: 4 аккордеона + drag-n-drop. | ⬜ |
|
||||||
|
| E1.8 | AI-функции v2 (`/settings`, generate-by-title, check, improve). | ✅ |
|
||||||
|
|
||||||
|
Подробности — [`migration-final.md`](migration-final.md).
|
||||||
|
|
||||||
|
### Этап 2 — слияние с HR-кабинетом (на будущее)
|
||||||
|
|
||||||
|
- Перенос blueprint'ом в `HR_TG_Bot/tgFlaskForm` под путь
|
||||||
|
`/cabinet/testing`.
|
||||||
|
- ETL `clinic_tests → hr_bot_test`. Скрипт-заготовка:
|
||||||
|
[`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`).
|
||||||
|
- Авторизация — через сессию HR-кабинета.
|
||||||
|
- Подробности и риски — [`migration-to-tgflaskform.md`](migration-to-tgflaskform.md)
|
||||||
|
(и [простыми словами](migration-to-tgflaskform-plain.md)).
|
||||||
|
|
||||||
|
### Долгий бэклог
|
||||||
|
|
||||||
| Направление | Суть |
|
| Направление | Суть |
|
||||||
|-------------|------|
|
|---|---|
|
||||||
| **AI по ТЗ §4.2** | Ключ в настройках (не на клиенте), кнопки «сгенерировать/проверить/улучшить» с превью и подтверждением, регресс с версиями. |
|
| Дашборды (ТЗ этап 2) | Единая картина по отделу / клинике, фильтры, история. |
|
||||||
| **Дашборды (ТЗ этап 2)** | Единая картина по отделу / клинике, фильтры, история. |
|
| MAX / мини-приложение | Встраивание в общий HR-контур клиники. |
|
||||||
| **MAX / мини-приложение** | Встраивание в общий HR-контур клиники. |
|
| Таймер, подсказки, медиа в вопросах | Режимы прохождения и вложения — отдельные этапы ТЗ. |
|
||||||
| **Таймер, подсказки, медиа в вопросах** | Режимы прохождения и вложения — отдельные этапы ТЗ. |
|
| E2E и интеграционные тесты | Расширение `V.9`, стабильный CI. |
|
||||||
| **E2E и интеграционные тесты** | Расширение `V.9`, стабильный CI. |
|
| Назначения по отделу | Сроки, лимит попыток, групповые назначения. |
|
||||||
| **Назначения** | Сроки, лимит попыток, назначения «по отделу» (частично в бэклоге [BACKLOG_IDEAS](revision_task/BACKLOG_IDEAS.md)). |
|
|
||||||
|
|
||||||
Журнал приёмок и чек-листы: [TESTING_JOURNAL.md](revision_task/TESTING_JOURNAL.md).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Связанные файлы
|
## Связанные документы
|
||||||
|
|
||||||
- [Руководство пользователя dev-контура](DEV_CONTOUR_USER_GUIDE.md)
|
- [README репозитория](../README.md)
|
||||||
- [Руководство кабинета (простыми словами)](РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md)
|
- [Главный трекер миграции — `migration-final.md`](migration-final.md)
|
||||||
- [Спринты: мобильный UI](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md)
|
- [Карта Express + gap-analysis с `tgFlaskForm` — `migration-final-inventory.md`](migration-final-inventory.md)
|
||||||
- [Предложение по дизайну (ист. + актуализация)](ПРЕДЛОЖЕНИЕ_ДИЗАЙН_СОЗДАНИЕ_ТЕСТА.md)
|
- [План Этапа 2 — `migration-to-tgflaskform.md`](migration-to-tgflaskform.md)
|
||||||
- [README с установкой](../README.md)
|
- [Инструкция тестировщику — `QA-versioning-and-ai.md`](QA-versioning-and-ai.md)
|
||||||
- [Карта задач card1](revision_task/card1.md)
|
- [Спринты мобильного UX редактора](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md)
|
||||||
|
- [Кратко для врачей-кураторов](РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md)
|
||||||
|
- [Руководство по dev-контуру](DEV_CONTOUR_USER_GUIDE.md)
|
||||||
|
- [ТЗ заказчика](ТЗ.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-дизайну.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*— Справочник можно дополнять по мере появления новых терминов —*
|
||||||
@@ -1,17 +1,24 @@
|
|||||||
# Flask-контур тестирования (тот же стек, отдельный деплой)
|
# Flask-контур тестирования (целевой стек)
|
||||||
|
|
||||||
Здесь — **новое** приложение на **Python / Flask** в духе `HR_TG_Bot/tgFlaskForm` (шаблоны + серверный рендер, без React). Старый стек (`backend/` + `frontend/`) пока не удаляется: оба контура могут существовать параллельно, пока не зафиксирована политика «один источник записи» и cutover.
|
Приложение на **Python 3.11 / Flask 3 / Jinja2** в духе
|
||||||
|
`HR_TG_Bot/tgFlaskForm` (серверный рендер, без React). На этом контуре
|
||||||
|
работает прод **[edullm.pirogov.ai](https://edullm.pirogov.ai/)**.
|
||||||
|
|
||||||
|
Старый стек (`backend/` + `frontend/`) пока не удаляется: оба контура
|
||||||
|
существуют параллельно до закрытия паритета (спринты **E1.4–E1.6** в
|
||||||
|
[`docs/migration-final.md`](../docs/migration-final.md)).
|
||||||
|
|
||||||
## Запуск в Docker (рекомендуется)
|
## Запуск в Docker (рекомендуется)
|
||||||
|
|
||||||
Из **корня** репозитория TestingWebApp. Сервис **не** зависит от `testing-backend` и **не** требует внешней сети Postgres для старта (только внутренняя сеть compose).
|
Из **корня** репозитория TestingWebApp. `docker-compose.dev.yml` уже
|
||||||
|
подключён к сети `postgres` (`hr_postgres_dev_net`) и пробрасывает
|
||||||
|
`DATABASE_URL`, `HR_AUTH`, `HR_DATABASE_URL`, `DEEPSEEK_API_KEY` /
|
||||||
|
`OPENAI_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` из корневого `.env`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.dev.yml up -d --build testing-flask
|
docker compose -f docker-compose.dev.yml up -d --build testing-flask
|
||||||
```
|
```
|
||||||
|
|
||||||
Когда подключите БД из контейнера к `hr_postgres_dev` / `clinic_tests`, в `docker-compose.dev.yml` у сервиса `testing-flask` добавьте сеть `postgres` (как у `testing-backend`).
|
|
||||||
|
|
||||||
- **URL:** http://localhost:3108
|
- **URL:** http://localhost:3108
|
||||||
- **Проверка:** http://localhost:3108/health
|
- **Проверка:** http://localhost:3108/health
|
||||||
|
|
||||||
@@ -42,16 +49,28 @@ python run.py
|
|||||||
- Один **стек** с кабинетом / мини-приложением — проще переносить экраны и запросы из `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/`.
|
- Один **стек** с кабинетом / мини-приложением — проще переносить экраны и запросы из `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/`.
|
||||||
- **Отдельный** процесс и порт — без риска сломать текущий `docker-compose.dev.yml` с Node до готовности.
|
- **Отдельный** процесс и порт — без риска сломать текущий `docker-compose.dev.yml` с Node до готовности.
|
||||||
|
|
||||||
## Дальнейшие шаги (код)
|
## Состояние спринтов
|
||||||
|
|
||||||
Этот каталог — место разработки **Этапа 1** ([migration-final.md](../docs/migration-final.md)).
|
Этот каталог — место разработки **Этапа 1**
|
||||||
|
([migration-final.md](../docs/migration-final.md)).
|
||||||
|
|
||||||
1. Подключить БД **`clinic_tests`** (схема не меняется), psycopg2-пул в стиле `tgFlaskForm/db/`.
|
| Спринт | Что входит | Статус |
|
||||||
2. Перенести 22 эндпоинта Express из `backend/` в blueprint'ы Flask, ориентируясь на чек-лист в [migration-final-inventory.md](../docs/migration-final-inventory.md).
|
|---|---|---|
|
||||||
3. Перенести экраны React (`frontend/src/pages/*`) в Jinja-шаблоны `app/templates/`, повторяя мобильный UX [Спринта 3](../docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md).
|
| E1.0 | База Flask-приложения, БД-пул, сессии, `base.html`. | ✅ |
|
||||||
4. Когда паритет закрыт — `docker-compose.dev.yml` указывает на этот сервис как основной, `backend/` и `frontend/` уходят.
|
| E1.1 | Auth + `/api/me` (bcrypt + Werkzeug, опц. `HR_AUTH`). | ✅ |
|
||||||
|
| E1.2 | Каталог тестов и редактор (функциональный минимум). | ✅ |
|
||||||
|
| E1.3 | Импорт документов (PDF / DOCX / TXT / MD). | ✅ |
|
||||||
|
| E1.4 | Назначения и прохождение тестов. | ⬜ |
|
||||||
|
| E1.5 | Трекер попыток + страница настроек цепочки. | ⬜ |
|
||||||
|
| E1.6 | Cutover: удаление `backend/` и `frontend/`. | ⬜ |
|
||||||
|
| E1.7 | UX-полировка: 4 аккордеона + drag-n-drop. | ⬜ |
|
||||||
|
| E1.8 | AI-функции v2 (`/settings`, generate / check / improve). | ✅ |
|
||||||
|
|
||||||
ETL `clinic_tests → hr_bot_test` и слияние с `tgFlaskForm` — это **Этап 2**, на будущее ([migration-to-tgflaskform.md](../docs/migration-to-tgflaskform.md)).
|
Чек-лист эндпоинтов и gap-analysis с `tgFlaskForm` —
|
||||||
|
[`migration-final-inventory.md`](../docs/migration-final-inventory.md).
|
||||||
|
ETL `clinic_tests → hr_bot_test` и слияние с `tgFlaskForm` — это
|
||||||
|
**Этап 2**, на будущее
|
||||||
|
([migration-to-tgflaskform.md](../docs/migration-to-tgflaskform.md)).
|
||||||
|
|
||||||
## Связанные документы
|
## Связанные документы
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,31 @@ from datetime import timedelta
|
|||||||
from flask import Flask, jsonify, render_template, request
|
from flask import Flask, jsonify, render_template, request
|
||||||
|
|
||||||
|
|
||||||
|
_ROLE_LABELS = {
|
||||||
|
'employee': 'Сотрудник',
|
||||||
|
'manager': 'Руководитель',
|
||||||
|
'hr': 'HR',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_role(role: str | None) -> str:
|
||||||
|
return _ROLE_LABELS.get((role or '').strip().lower(), '')
|
||||||
|
|
||||||
|
|
||||||
|
def _format_surname_with_initials(full_name: str | None, fallback: str | None = None) -> str:
|
||||||
|
name = (full_name or '').strip()
|
||||||
|
if not name:
|
||||||
|
return (fallback or '—').strip() or '—'
|
||||||
|
parts = [p for p in name.replace('\xa0', ' ').split(' ') if p]
|
||||||
|
if len(parts) < 2:
|
||||||
|
return name
|
||||||
|
surname = parts[0]
|
||||||
|
initials = []
|
||||||
|
for p in parts[1:3]:
|
||||||
|
initials.append(f'{p[0].upper()}.')
|
||||||
|
return f"{surname} {' '.join(initials)}".strip()
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> Flask:
|
def create_app() -> Flask:
|
||||||
app = Flask(
|
app = Flask(
|
||||||
__name__,
|
__name__,
|
||||||
@@ -49,11 +74,15 @@ def create_app() -> Flask:
|
|||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
def _inject_globals():
|
def _inject_globals():
|
||||||
|
ui_variant = (os.environ.get('UI_VARIANT') or 'modern').strip().lower() or 'modern'
|
||||||
return {
|
return {
|
||||||
'current_user': _current_user(),
|
'current_user': _current_user(),
|
||||||
'hr_auth_enabled': is_hr_auth_enabled(),
|
'hr_auth_enabled': is_hr_auth_enabled(),
|
||||||
'dev_ui': is_dev_ui(),
|
'dev_ui': is_dev_ui(),
|
||||||
'assignment_ui': is_assignment_feature_enabled(),
|
'assignment_ui': is_assignment_feature_enabled(),
|
||||||
|
'ui_variant': ui_variant,
|
||||||
|
'format_name_short': _format_surname_with_initials,
|
||||||
|
'format_role': _format_role,
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from sqlalchemy import text
|
|||||||
from ..db import get_engine
|
from ..db import get_engine
|
||||||
from ..messages import RU
|
from ..messages import RU
|
||||||
from .test_access import is_test_author
|
from .test_access import is_test_author
|
||||||
|
from .test_chain import has_any_attempt_for_test
|
||||||
|
|
||||||
|
|
||||||
class HttpError(Exception):
|
class HttpError(Exception):
|
||||||
@@ -81,7 +82,13 @@ def get_editor_content(user_id: str, test_id: str) -> dict:
|
|||||||
if not tv:
|
if not tv:
|
||||||
raise HttpError(400, 'Нет активной версии теста.')
|
raise HttpError(400, 'Нет активной версии теста.')
|
||||||
version_id = tv['id']
|
version_id = tv['id']
|
||||||
|
version_count_row = conn.execute(
|
||||||
|
text('SELECT COUNT(*) AS n FROM test_versions WHERE test_id = :id'),
|
||||||
|
{'id': test_id},
|
||||||
|
).mappings().first()
|
||||||
|
version_count = int(version_count_row['n'] or 0)
|
||||||
questions = load_questions_for_version(conn, version_id, include_correct=True)
|
questions = load_questions_for_version(conn, version_id, include_correct=True)
|
||||||
|
has_attempts = has_any_attempt_for_test(conn, test_id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'test': {
|
'test': {
|
||||||
@@ -89,6 +96,9 @@ def get_editor_content(user_id: str, test_id: str) -> dict:
|
|||||||
'title': tr['title'],
|
'title': tr['title'],
|
||||||
'description': tr['description'],
|
'description': tr['description'],
|
||||||
'passingThreshold': tr['passing_threshold'],
|
'passingThreshold': tr['passing_threshold'],
|
||||||
|
'hasAttempts': bool(has_attempts),
|
||||||
|
'versionCount': version_count,
|
||||||
|
'hasForkRisk': bool(has_attempts) or version_count > 1,
|
||||||
},
|
},
|
||||||
'activeVersionId': str(version_id),
|
'activeVersionId': str(version_id),
|
||||||
'questions': questions,
|
'questions': questions,
|
||||||
|
|||||||
@@ -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 |
@@ -25,14 +25,74 @@
|
|||||||
const saveStatusEl = $('#save-status');
|
const saveStatusEl = $('#save-status');
|
||||||
const aiStatusEl = $('#ai-status');
|
const aiStatusEl = $('#ai-status');
|
||||||
const chainActiveEl = $('#chain-active');
|
const chainActiveEl = $('#chain-active');
|
||||||
|
const aiTopicEl = $('#ai-topic');
|
||||||
|
const aiQCountEl = $('#ai-q-count');
|
||||||
|
const aiOCountEl = $('#ai-o-count');
|
||||||
|
const introUpdatedEl = $('#intro-updated');
|
||||||
|
const introForkBannerEl = $('#intro-fork-banner');
|
||||||
|
const versionsListEl = $('#versions-list');
|
||||||
|
const attemptsListEl = $('#attempts-list');
|
||||||
|
const visibilityBtn = $('#btn-toggle-visibility');
|
||||||
|
const assignSearchEl = $('#assign-search');
|
||||||
|
const assignDeptEl = $('#assign-dept');
|
||||||
|
const assignClinicEl = $('#assign-clinic');
|
||||||
|
const assignListEl = $('#assign-list');
|
||||||
|
const assignSelectAllBtn = $('#assign-select-all');
|
||||||
|
const assignSubmitBtn = $('#assign-submit');
|
||||||
|
const assignStatusEl = $('#assign-status');
|
||||||
|
|
||||||
const tplQ = $('#tpl-question');
|
const tplQ = $('#tpl-question');
|
||||||
const tplO = $('#tpl-option');
|
const tplO = $('#tpl-option');
|
||||||
|
|
||||||
let chainActive = true;
|
let chainActive = true;
|
||||||
|
let assignPeople = [];
|
||||||
|
let assignSelected = new Set();
|
||||||
|
let hasAnyAttempts = false;
|
||||||
|
let hasForkRisk = Boolean(initial?.test?.hasForkRisk)
|
||||||
|
|| (introForkBannerEl && introForkBannerEl.dataset.forkRisk === '1');
|
||||||
|
let baselineDraftKey = '';
|
||||||
|
let dirtyCheckQueued = false;
|
||||||
|
|
||||||
|
function currentDraftKey() {
|
||||||
|
return JSON.stringify(collectPayload());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDirty() {
|
||||||
|
return baselineDraftKey !== '' && baselineDraftKey !== currentDraftKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateForkBanner() {
|
||||||
|
if (!introForkBannerEl) return;
|
||||||
|
introForkBannerEl.style.display = (hasForkRisk && isDirty()) ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleDirtyCheck() {
|
||||||
|
if (dirtyCheckQueued) return;
|
||||||
|
dirtyCheckQueued = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
dirtyCheckQueued = false;
|
||||||
|
updateForkBanner();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetBaselineDraft() {
|
||||||
|
baselineDraftKey = currentDraftKey();
|
||||||
|
updateForkBanner();
|
||||||
|
}
|
||||||
|
|
||||||
// ─── render ─────────────────────────────────────────────────────────
|
// ─── render ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function syncOptionInputTypes(qNode) {
|
||||||
|
const isMulti = $('.q-multi', qNode).checked;
|
||||||
|
const qName = `q-correct-${Math.random().toString(36).slice(2)}`;
|
||||||
|
$$('.opt-correct', qNode).forEach((input) => {
|
||||||
|
input.type = isMulti ? 'checkbox' : 'radio';
|
||||||
|
if (isMulti) input.removeAttribute('name');
|
||||||
|
else input.setAttribute('name', qName);
|
||||||
|
input.classList.add('question-option-row__mark');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderQuestion(q) {
|
function renderQuestion(q) {
|
||||||
const node = tplQ.content.firstElementChild.cloneNode(true);
|
const node = tplQ.content.firstElementChild.cloneNode(true);
|
||||||
node._q = { id: q.id || null };
|
node._q = { id: q.id || null };
|
||||||
@@ -43,6 +103,7 @@
|
|||||||
(q.options || []).forEach((o) => optsEl.appendChild(renderOption(o)));
|
(q.options || []).forEach((o) => optsEl.appendChild(renderOption(o)));
|
||||||
|
|
||||||
bindQuestionEvents(node);
|
bindQuestionEvents(node);
|
||||||
|
syncOptionInputTypes(node);
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,41 +122,86 @@
|
|||||||
if (!confirm('Удалить вопрос?')) return;
|
if (!confirm('Удалить вопрос?')) return;
|
||||||
node.remove();
|
node.remove();
|
||||||
renumber();
|
renumber();
|
||||||
|
scheduleDirtyCheck();
|
||||||
});
|
});
|
||||||
$('.q-up', node).addEventListener('click', () => {
|
$('.q-up', node).addEventListener('click', () => {
|
||||||
if (node.previousElementSibling) {
|
if (node.previousElementSibling) {
|
||||||
node.parentNode.insertBefore(node, node.previousElementSibling);
|
node.parentNode.insertBefore(node, node.previousElementSibling);
|
||||||
renumber();
|
renumber();
|
||||||
|
scheduleDirtyCheck();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
$('.q-down', node).addEventListener('click', () => {
|
$('.q-down', node).addEventListener('click', () => {
|
||||||
if (node.nextElementSibling) {
|
if (node.nextElementSibling) {
|
||||||
node.parentNode.insertBefore(node.nextElementSibling, node);
|
node.parentNode.insertBefore(node.nextElementSibling, node);
|
||||||
renumber();
|
renumber();
|
||||||
|
scheduleDirtyCheck();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
$('.q-add-option', node).addEventListener('click', () => {
|
$('.q-add-option', node).addEventListener('click', () => {
|
||||||
$('.q-options', node).appendChild(renderOption({ text: '', isCorrect: false }));
|
$('.q-options', node).appendChild(renderOption({ text: '', isCorrect: false }));
|
||||||
|
syncOptionInputTypes(node);
|
||||||
|
scheduleDirtyCheck();
|
||||||
});
|
});
|
||||||
$('.q-ai', node).addEventListener('click', () => aiGenerateQuestion(node));
|
$('.q-ai', node).addEventListener('click', () => aiGenerateQuestion(node));
|
||||||
|
$('.q-multi', node).addEventListener('change', () => {
|
||||||
|
syncOptionInputTypes(node);
|
||||||
|
scheduleDirtyCheck();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renumber() {
|
function renumber() {
|
||||||
$$('#questions .q-item').forEach((li, i) => {
|
$$('#questions .q-item').forEach((li, i) => {
|
||||||
$('.q-num', li).textContent = `Вопрос #${i + 1}`;
|
$('.q-num', li).textContent = `Вопрос #${i + 1}`;
|
||||||
});
|
});
|
||||||
qCountEl.textContent = $$('#questions .q-item').length;
|
const n = $$('#questions .q-item').length;
|
||||||
|
if (qCountEl) qCountEl.textContent = n;
|
||||||
|
const mirror = document.getElementById('q-count-mirror');
|
||||||
|
if (mirror) mirror.textContent = n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoResize(el) {
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = 'auto';
|
||||||
|
el.style.height = el.scrollHeight + 'px';
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadInitial() {
|
function loadInitial() {
|
||||||
titleEl.value = initial.test.title || '';
|
titleEl.value = initial.test.title || '';
|
||||||
descEl.value = initial.test.description || '';
|
descEl.value = initial.test.description || '';
|
||||||
|
autoResize(titleEl);
|
||||||
|
autoResize(descEl);
|
||||||
|
if (titleEl && titleEl.tagName === 'TEXTAREA') {
|
||||||
|
titleEl.addEventListener('input', () => autoResize(titleEl));
|
||||||
|
titleEl.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') e.preventDefault();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (descEl) descEl.addEventListener('input', () => autoResize(descEl));
|
||||||
thresholdEl.value =
|
thresholdEl.value =
|
||||||
initial.test.passingThreshold == null ? '' : Number(initial.test.passingThreshold);
|
initial.test.passingThreshold == null ? '' : Number(initial.test.passingThreshold);
|
||||||
|
|
||||||
questionsEl.innerHTML = '';
|
questionsEl.innerHTML = '';
|
||||||
(initial.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
(initial.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
||||||
renumber();
|
renumber();
|
||||||
|
if (aiTopicEl && !aiTopicEl.value.trim()) {
|
||||||
|
aiTopicEl.value = initial.test.title || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDt(iso) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' });
|
||||||
|
} catch {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return String(s == null ? '' : s)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── collect ───────────────────────────────────────────────────────
|
// ─── collect ───────────────────────────────────────────────────────
|
||||||
@@ -144,6 +250,7 @@
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
renumber();
|
renumber();
|
||||||
|
scheduleDirtyCheck();
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#save-draft').addEventListener('click', async () => {
|
$('#save-draft').addEventListener('click', async () => {
|
||||||
@@ -167,6 +274,7 @@
|
|||||||
saveStatusEl.textContent = data.forked
|
saveStatusEl.textContent = data.forked
|
||||||
? 'Сохранено (создана новая версия — есть попытки прохождения).'
|
? 'Сохранено (создана новая версия — есть попытки прохождения).'
|
||||||
: 'Сохранено.';
|
: 'Сохранено.';
|
||||||
|
resetBaselineDraft();
|
||||||
setTimeout(() => (saveStatusEl.textContent = ''), 4000);
|
setTimeout(() => (saveStatusEl.textContent = ''), 4000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
saveStatusEl.textContent = '';
|
saveStatusEl.textContent = '';
|
||||||
@@ -175,18 +283,24 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$('#ai-generate-test').addEventListener('click', async () => {
|
$('#ai-generate-test').addEventListener('click', async () => {
|
||||||
const shape = collectShape();
|
const topic = (aiTopicEl?.value || titleEl.value || '').trim();
|
||||||
if (!shape.length) {
|
if (!topic) {
|
||||||
alert('Сначала добавьте хотя бы один вопрос (его настройки определяют сетку).');
|
alert('Укажите тему.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const nQ = Math.min(30, Math.max(1, Number(aiQCountEl?.value || 7) || 7));
|
||||||
|
const nO = Math.min(8, Math.max(2, Number(aiOCountEl?.value || 3) || 3));
|
||||||
|
const shape = Array.from({ length: nQ }, () => ({
|
||||||
|
optionsCount: nO,
|
||||||
|
hasMultipleAnswers: false,
|
||||||
|
}));
|
||||||
aiStatusEl.textContent = 'Генерируем…';
|
aiStatusEl.textContent = 'Генерируем…';
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-test`, {
|
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-test`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
testTitle: titleEl.value,
|
testTitle: topic,
|
||||||
testDescription: descEl.value,
|
testDescription: descEl.value,
|
||||||
shape,
|
shape,
|
||||||
}),
|
}),
|
||||||
@@ -194,11 +308,15 @@
|
|||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
if (!r.ok) throw new Error(data.error || 'AI: ошибка.');
|
if (!r.ok) throw new Error(data.error || 'AI: ошибка.');
|
||||||
const draft = data.draft;
|
const draft = data.draft;
|
||||||
if (draft.title) titleEl.value = draft.title;
|
if (draft.title) {
|
||||||
|
titleEl.value = draft.title;
|
||||||
|
if (aiTopicEl) aiTopicEl.value = draft.title;
|
||||||
|
}
|
||||||
if (draft.description) descEl.value = draft.description;
|
if (draft.description) descEl.value = draft.description;
|
||||||
questionsEl.innerHTML = '';
|
questionsEl.innerHTML = '';
|
||||||
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
||||||
renumber();
|
renumber();
|
||||||
|
scheduleDirtyCheck();
|
||||||
aiStatusEl.textContent = `Готово: ${draft.questions?.length || 0} вопросов.`;
|
aiStatusEl.textContent = `Готово: ${draft.questions?.length || 0} вопросов.`;
|
||||||
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -242,6 +360,7 @@
|
|||||||
questionsEl.innerHTML = '';
|
questionsEl.innerHTML = '';
|
||||||
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
||||||
renumber();
|
renumber();
|
||||||
|
scheduleDirtyCheck();
|
||||||
aiStatusEl.textContent = `Применено: ${draft.questions?.length || 0} вопросов.`;
|
aiStatusEl.textContent = `Применено: ${draft.questions?.length || 0} вопросов.`;
|
||||||
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -263,12 +382,6 @@
|
|||||||
alert(msg);
|
alert(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
function escHtml(s) {
|
|
||||||
return String(s == null ? '' : s)
|
|
||||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
const modal = $('#ai-modal');
|
const modal = $('#ai-modal');
|
||||||
const modalTitle = $('#ai-modal-title');
|
const modalTitle = $('#ai-modal-title');
|
||||||
const modalBody = $('#ai-modal-body');
|
const modalBody = $('#ai-modal-body');
|
||||||
@@ -290,7 +403,8 @@
|
|||||||
modal.showModal();
|
modal.showModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
$('#ai-generate-by-title').addEventListener('click', async () => {
|
const aiGenerateByTitleBtn = $('#ai-generate-by-title');
|
||||||
|
if (aiGenerateByTitleBtn) aiGenerateByTitleBtn.addEventListener('click', async () => {
|
||||||
const title = titleEl.value.trim();
|
const title = titleEl.value.trim();
|
||||||
if (!title) {
|
if (!title) {
|
||||||
alert('Сначала заполните название теста.');
|
alert('Сначала заполните название теста.');
|
||||||
@@ -334,6 +448,7 @@
|
|||||||
questionsEl.innerHTML = '';
|
questionsEl.innerHTML = '';
|
||||||
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
||||||
renumber();
|
renumber();
|
||||||
|
scheduleDirtyCheck();
|
||||||
aiStatusEl.textContent = `Применено: ${draft.questions.length} вопросов.`;
|
aiStatusEl.textContent = `Применено: ${draft.questions.length} вопросов.`;
|
||||||
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -342,7 +457,8 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#ai-check').addEventListener('click', async () => {
|
const aiCheckBtn = $('#ai-check');
|
||||||
|
if (aiCheckBtn) aiCheckBtn.addEventListener('click', async () => {
|
||||||
const payload = collectPayload();
|
const payload = collectPayload();
|
||||||
if (!payload.questions.length) {
|
if (!payload.questions.length) {
|
||||||
alert('В тесте нет вопросов — нечего проверять.');
|
alert('В тесте нет вопросов — нечего проверять.');
|
||||||
@@ -394,7 +510,8 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#ai-improve').addEventListener('click', async () => {
|
const aiImproveBtn = $('#ai-improve');
|
||||||
|
if (aiImproveBtn) aiImproveBtn.addEventListener('click', async () => {
|
||||||
const payload = collectPayload();
|
const payload = collectPayload();
|
||||||
if (!payload.questions.length) {
|
if (!payload.questions.length) {
|
||||||
alert('В тесте нет вопросов — нечего улучшать.');
|
alert('В тесте нет вопросов — нечего улучшать.');
|
||||||
@@ -480,6 +597,7 @@
|
|||||||
it.suggested.options.forEach((o) => optsEl.appendChild(renderOption(o)));
|
it.suggested.options.forEach((o) => optsEl.appendChild(renderOption(o)));
|
||||||
});
|
});
|
||||||
modal.close();
|
modal.close();
|
||||||
|
scheduleDirtyCheck();
|
||||||
aiStatusEl.textContent = 'Изменения применены. Не забудьте сохранить.';
|
aiStatusEl.textContent = 'Изменения применены. Не забудьте сохранить.';
|
||||||
setTimeout(() => (aiStatusEl.textContent = ''), 5000);
|
setTimeout(() => (aiStatusEl.textContent = ''), 5000);
|
||||||
},
|
},
|
||||||
@@ -517,6 +635,7 @@
|
|||||||
data.options.forEach((o) => optsEl.appendChild(renderOption(o)));
|
data.options.forEach((o) => optsEl.appendChild(renderOption(o)));
|
||||||
$('.q-multi', node).checked = !!data.hasMultipleAnswers;
|
$('.q-multi', node).checked = !!data.hasMultipleAnswers;
|
||||||
}
|
}
|
||||||
|
scheduleDirtyCheck();
|
||||||
aiStatusEl.textContent = data.mode === 'full' ? 'AI: вопрос сгенерирован.' : 'AI: формулировка обновлена.';
|
aiStatusEl.textContent = data.mode === 'full' ? 'AI: вопрос сгенерирован.' : 'AI: формулировка обновлена.';
|
||||||
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -542,5 +661,195 @@
|
|||||||
chainActiveEl.checked = true;
|
chainActiveEl.checked = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function renderVersions(rows) {
|
||||||
|
if (!versionsListEl) return;
|
||||||
|
versionsListEl.innerHTML = '';
|
||||||
|
(rows || []).forEach((r) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'surface-card version-card-list__item';
|
||||||
|
li.innerHTML = `
|
||||||
|
<div class="version-card-list__row">
|
||||||
|
<div class="version-card-list__main">
|
||||||
|
<div class="version-card-list__title-line">
|
||||||
|
<span class="font-headline" style="font-size:1rem;">v${r.version}</span>
|
||||||
|
${r.is_active ? '<span class="code-inline" style="font-size:0.7rem;">текущая</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<p class="muted mono" style="margin:.4rem 0 0; font-size:.8rem;">${fmtDt(r.created_at)}</p>
|
||||||
|
<p class="muted" style="margin:.2rem 0 0; font-size:.8rem;">Активна: ${r.is_active ? 'да' : 'нет'}</p>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
versionsListEl.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAttempts(rows) {
|
||||||
|
if (!attemptsListEl) return;
|
||||||
|
attemptsListEl.innerHTML = '';
|
||||||
|
(rows || []).forEach((a) => {
|
||||||
|
const when = a.completedAt ? fmtDt(a.completedAt) : (a.startedAt ? fmtDt(a.startedAt) : '—');
|
||||||
|
const result = a.status === 'completed' && a.totalQuestions != null
|
||||||
|
? `${a.correctCount}/${a.totalQuestions}${a.passed ? ' · зачёт' : ' · незачёт'}`
|
||||||
|
: a.status;
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'surface-card attempts-card-list__item';
|
||||||
|
li.innerHTML = `
|
||||||
|
<div class="attempts-card-list__row">
|
||||||
|
<div class="attempts-card-list__main">
|
||||||
|
<p class="muted mono" style="margin:0; font-size:.8rem;">${when}</p>
|
||||||
|
<p style="margin:.35rem 0 0; font-weight:600;">${escHtml(a.attempterName || '—')}
|
||||||
|
${a.attempterLogin ? `<span class="code-inline" style="font-size:11px; margin-left:6px;">${escHtml(a.attempterLogin)}</span>` : ''}
|
||||||
|
</p>
|
||||||
|
<p class="muted" style="margin:.25rem 0 0; font-size:.85rem;">v${a.testVersion} · ${escHtml(result)}</p>
|
||||||
|
</div>
|
||||||
|
${a.status === 'completed'
|
||||||
|
? `<a class="btn btn-ghost btn--sm attempts-card-list__action" href="/tests/${TEST_ID}/attempts/${a.id}/review">Разбор</a>`
|
||||||
|
: ''}
|
||||||
|
</div>`;
|
||||||
|
attemptsListEl.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAssignList() {
|
||||||
|
if (!assignListEl) return;
|
||||||
|
assignListEl.innerHTML = '';
|
||||||
|
assignPeople.forEach((p) => {
|
||||||
|
const row = document.createElement('label');
|
||||||
|
row.className = `assign-row${assignSelected.has(String(p.staffId)) ? ' assign-row--selected' : ''}`;
|
||||||
|
row.innerHTML = `
|
||||||
|
<input type="checkbox" ${assignSelected.has(String(p.staffId)) ? 'checked' : ''} />
|
||||||
|
<span class="assign-row__text">
|
||||||
|
<span class="assign-row__fio">${escHtml(p.fio || '—')}</span>
|
||||||
|
${p.webLogin ? `<span class="assign-row__login">${escHtml(p.webLogin)}</span>` : ''}
|
||||||
|
<span class="assign-row__meta">${escHtml(p.department || '—')}${p.clinicUserId ? ' · есть учётка' : ' · нет учётки (создадим при назначении)'}</span>
|
||||||
|
</span>`;
|
||||||
|
const cb = row.querySelector('input');
|
||||||
|
cb.addEventListener('change', () => {
|
||||||
|
const k = String(p.staffId);
|
||||||
|
if (cb.checked) assignSelected.add(k); else assignSelected.delete(k);
|
||||||
|
row.classList.toggle('assign-row--selected', cb.checked);
|
||||||
|
});
|
||||||
|
assignListEl.appendChild(row);
|
||||||
|
});
|
||||||
|
if (!assignPeople.length) assignListEl.innerHTML = '<p class="muted" style="padding:.75rem;">Никого не найдено.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDirectory() {
|
||||||
|
if (!assignListEl) return;
|
||||||
|
assignStatusEl.textContent = 'Загружаем…';
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (assignSearchEl.value.trim()) params.set('q', assignSearchEl.value.trim());
|
||||||
|
if (assignDeptEl.value && assignDeptEl.value !== '__all__') params.set('department', assignDeptEl.value);
|
||||||
|
params.set('clinic', assignClinicEl.value || 'all');
|
||||||
|
const r = await fetch(`/api/auth/dev/assignment-directory?${params.toString()}`);
|
||||||
|
const data = await r.json();
|
||||||
|
if (!r.ok) throw new Error(data.error || 'Не удалось загрузить сотрудников');
|
||||||
|
assignPeople = data.people || [];
|
||||||
|
const depts = data.departments || [];
|
||||||
|
if (assignDeptEl.options.length <= 1) {
|
||||||
|
depts.forEach((d) => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = d;
|
||||||
|
o.textContent = d;
|
||||||
|
assignDeptEl.appendChild(o);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assignSelected = new Set();
|
||||||
|
renderAssignList();
|
||||||
|
assignStatusEl.textContent = '';
|
||||||
|
} catch (e) {
|
||||||
|
assignStatusEl.textContent = e.message || 'Ошибка загрузки';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assignSearchEl) {
|
||||||
|
let t = null;
|
||||||
|
assignSearchEl.addEventListener('input', () => {
|
||||||
|
clearTimeout(t);
|
||||||
|
t = setTimeout(loadDirectory, 350);
|
||||||
|
});
|
||||||
|
assignDeptEl.addEventListener('change', loadDirectory);
|
||||||
|
assignClinicEl.addEventListener('change', loadDirectory);
|
||||||
|
assignSelectAllBtn.addEventListener('click', () => {
|
||||||
|
assignPeople.forEach((p) => assignSelected.add(String(p.staffId)));
|
||||||
|
renderAssignList();
|
||||||
|
});
|
||||||
|
assignSubmitBtn.addEventListener('click', async () => {
|
||||||
|
const selectedRows = assignPeople.filter((p) => assignSelected.has(String(p.staffId)));
|
||||||
|
const userIds = selectedRows.filter((p) => p.clinicUserId).map((p) => p.clinicUserId);
|
||||||
|
const staffIds = selectedRows.filter((p) => !p.clinicUserId && p.staffId != null).map((p) => p.staffId);
|
||||||
|
if (!userIds.length && !staffIds.length) return;
|
||||||
|
assignStatusEl.textContent = 'Назначаем…';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/tests/${TEST_ID}/assign`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userIds, staffIds }),
|
||||||
|
});
|
||||||
|
const data = await r.json();
|
||||||
|
if (!r.ok) throw new Error(data.error || 'Ошибка назначения');
|
||||||
|
assignStatusEl.textContent = `Назначено: ${data.count ?? selectedRows.length}`;
|
||||||
|
} catch (e) {
|
||||||
|
assignStatusEl.textContent = e.message || 'Ошибка назначения';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
loadDirectory();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visibilityBtn) {
|
||||||
|
visibilityBtn.addEventListener('click', async () => {
|
||||||
|
const next = !chainActiveEl.checked;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/tests/${TEST_ID}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ chainActive: next }),
|
||||||
|
});
|
||||||
|
const data = await r.json();
|
||||||
|
if (!r.ok) throw new Error(data.error || 'Ошибка изменения видимости');
|
||||||
|
chainActiveEl.checked = !!next;
|
||||||
|
chainActive = !!next;
|
||||||
|
visibilityBtn.textContent = chainActive ? 'Скрыть из списка' : 'Снова показать в списке';
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message || 'Ошибка изменения видимости');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
fetch(`/api/tests/${TEST_ID}/versions`).then((r) => r.json()).catch(() => null),
|
||||||
|
fetch(`/api/tests/${TEST_ID}/attempts`).then((r) => r.json()).catch(() => null),
|
||||||
|
fetch(`/api/tests/${TEST_ID}/summary`).then((r) => r.json()).catch(() => null),
|
||||||
|
]).then(([v, a, s]) => {
|
||||||
|
if (v && Array.isArray(v.versions)) {
|
||||||
|
renderVersions(v.versions);
|
||||||
|
hasForkRisk = hasForkRisk || (v.versions.length > 1);
|
||||||
|
if (typeof v.hasAttempts === 'boolean') {
|
||||||
|
hasAnyAttempts = hasAnyAttempts || v.hasAttempts;
|
||||||
|
hasForkRisk = hasForkRisk || v.hasAttempts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (a && Array.isArray(a.attempts)) {
|
||||||
|
renderAttempts(a.attempts);
|
||||||
|
hasAnyAttempts = a.attempts.length > 0;
|
||||||
|
}
|
||||||
|
if (s && s.test) {
|
||||||
|
if (introUpdatedEl) introUpdatedEl.textContent = fmtDt(s.test.updated_at || s.test.updatedAt);
|
||||||
|
const versionEl = document.getElementById('intro-version');
|
||||||
|
if (versionEl && s.test.version != null) versionEl.textContent = s.test.version;
|
||||||
|
if (typeof s.test.hasAttempts === 'boolean') {
|
||||||
|
hasAnyAttempts = hasAnyAttempts || s.test.hasAttempts;
|
||||||
|
hasForkRisk = hasForkRisk || s.test.hasAttempts;
|
||||||
|
}
|
||||||
|
if (typeof s.test.versionCount === 'number') {
|
||||||
|
hasForkRisk = hasForkRisk || s.test.versionCount > 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateForkBanner();
|
||||||
|
});
|
||||||
|
|
||||||
loadInitial();
|
loadInitial();
|
||||||
|
resetBaselineDraft();
|
||||||
|
root.addEventListener('input', scheduleDirtyCheck);
|
||||||
|
root.addEventListener('change', scheduleDirtyCheck);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -2,57 +2,99 @@
|
|||||||
{% block title %}Вход — Тестирование{% endblock %}
|
{% block title %}Вход — Тестирование{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="mx-auto max-w-md mt-8">
|
{% if ui_variant == 'legacy' %}
|
||||||
<div class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-6">
|
<div class="login-page">
|
||||||
<div class="flex items-center gap-2">
|
<div class="login-shell">
|
||||||
<span class="material-symbols-outlined text-brand-600">login</span>
|
<div class="login-logo">
|
||||||
<h1 class="text-xl font-semibold">Вход в систему</h1>
|
<img src="{{ url_for('static', filename='img/clinic-logo.png') }}"
|
||||||
|
alt="Логотип клиники" class="login-logo__img" />
|
||||||
|
<h1 class="font-headline">Тестирование</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="callout callout--error" style="margin-bottom: 1rem;">
|
||||||
|
{% for category, msg in messages %}
|
||||||
|
{% if category == 'error' %}{{ msg }}{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<div class="login-card">
|
||||||
|
<form method="post" action="{{ url_for('auth.login_submit') }}" novalidate>
|
||||||
|
<input type="hidden" name="next" value="{{ next or '/' }}">
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label class="form-label" for="login-username">Логин</label>
|
||||||
|
<input id="login-username" class="form-input" type="text" name="login"
|
||||||
|
value="{{ login or '' }}" required autofocus autocomplete="username" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label class="form-label" for="login-password">Пароль</label>
|
||||||
|
<input id="login-password" class="form-input" type="password" name="password"
|
||||||
|
required autocomplete="current-password" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Войти</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-sm text-ink-500">
|
|
||||||
Используйте логин и пароль.
|
|
||||||
{% if hr_auth_enabled %}
|
|
||||||
Учётка кадровой системы (HR).
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
||||||
{% if messages %}
|
|
||||||
<div class="mt-4 space-y-2">
|
|
||||||
{% for category, msg in messages %}
|
|
||||||
<div class="px-3 py-2 rounded-lg text-sm
|
|
||||||
{% if category == 'error' %}bg-red-50 text-red-700 border border-red-200
|
|
||||||
{% else %}bg-brand-50 text-brand-700 border border-brand-100{% endif %}">
|
|
||||||
{{ msg }}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
<form method="post" action="{{ url_for('auth.login_submit') }}" class="mt-5 space-y-4" novalidate>
|
|
||||||
<input type="hidden" name="next" value="{{ next or '/' }}">
|
|
||||||
|
|
||||||
<label class="block">
|
|
||||||
<span class="text-sm font-medium text-ink-700">Логин</span>
|
|
||||||
<input type="text" name="login" value="{{ login or '' }}" required autofocus autocomplete="username"
|
|
||||||
class="mt-1 w-full rounded-lg border border-ink-300 bg-white px-3 py-2 text-ink-900
|
|
||||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="block">
|
|
||||||
<span class="text-sm font-medium text-ink-700">Пароль</span>
|
|
||||||
<input type="password" name="password" required autocomplete="current-password"
|
|
||||||
class="mt-1 w-full rounded-lg border border-ink-300 bg-white px-3 py-2 text-ink-900
|
|
||||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<button type="submit"
|
|
||||||
class="w-full inline-flex items-center justify-center gap-2 rounded-lg
|
|
||||||
bg-brand-600 hover:bg-brand-700 text-white font-medium px-4 py-2 transition">
|
|
||||||
<span class="material-symbols-outlined text-base">login</span>
|
|
||||||
Войти
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
{% else %}
|
||||||
|
<section class="mx-auto max-w-md mt-8">
|
||||||
|
<div class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-6">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="material-symbols-outlined text-brand-600">login</span>
|
||||||
|
<h1 class="text-xl font-semibold">Вход в систему</h1>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-ink-500">
|
||||||
|
Используйте логин и пароль.
|
||||||
|
{% if hr_auth_enabled %}
|
||||||
|
Учётка кадровой системы (HR).
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="mt-4 space-y-2">
|
||||||
|
{% for category, msg in messages %}
|
||||||
|
<div class="px-3 py-2 rounded-lg text-sm
|
||||||
|
{% if category == 'error' %}bg-red-50 text-red-700 border border-red-200
|
||||||
|
{% else %}bg-brand-50 text-brand-700 border border-brand-100{% endif %}">
|
||||||
|
{{ msg }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('auth.login_submit') }}" class="mt-5 space-y-4" novalidate>
|
||||||
|
<input type="hidden" name="next" value="{{ next or '/' }}">
|
||||||
|
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium text-ink-700">Логин</span>
|
||||||
|
<input type="text" name="login" value="{{ login or '' }}" required autofocus autocomplete="username"
|
||||||
|
class="mt-1 w-full rounded-lg border border-ink-300 bg-white px-3 py-2 text-ink-900
|
||||||
|
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium text-ink-700">Пароль</span>
|
||||||
|
<input type="password" name="password" required autocomplete="current-password"
|
||||||
|
class="mt-1 w-full rounded-lg border border-ink-300 bg-white px-3 py-2 text-ink-900
|
||||||
|
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full inline-flex items-center justify-center gap-2 rounded-lg
|
||||||
|
bg-brand-600 hover:bg-brand-700 text-white font-medium px-4 py-2 transition">
|
||||||
|
<span class="material-symbols-outlined text-base">login</span>
|
||||||
|
Войти
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
{# Tailwind CDN — на E1.0 этого достаточно. В Этапе 2/CI можно заменить на сборку. #}
|
{# Tailwind CDN — на E1.0 этого достаточно. В Этапе 2/CI можно заменить на сборку. #}
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script>
|
<script>
|
||||||
// Минимальная палитра в стиле кабинета HR. Без зависимостей от HR-репо.
|
// Палитра/типографика в стиле webapp-nginx (cabinet-theme).
|
||||||
tailwind.config = {
|
tailwind.config = {
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
@@ -17,18 +17,19 @@
|
|||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
brand: {
|
brand: {
|
||||||
50: '#eef2ff',
|
50: '#ecf7f6',
|
||||||
100: '#e0e7ff',
|
100: '#d9efec',
|
||||||
500: '#6366f1',
|
300: '#9bd7d0',
|
||||||
600: '#4f46e5',
|
500: '#007168',
|
||||||
700: '#4338ca',
|
600: '#00645b',
|
||||||
|
700: '#00574f',
|
||||||
},
|
},
|
||||||
ink: {
|
ink: {
|
||||||
900: '#0f172a',
|
900: '#0d1b1d',
|
||||||
700: '#334155',
|
700: '#3d5357',
|
||||||
500: '#64748b',
|
500: '#506965',
|
||||||
300: '#cbd5e1',
|
300: '#b9bc94',
|
||||||
100: '#f1f5f9',
|
100: '#f3f8f9',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -39,7 +40,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Manrope:wght@600;700;800&display=swap"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
<link
|
<link
|
||||||
@@ -50,65 +51,97 @@
|
|||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}" />
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}" />
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-ink-100 text-ink-900 font-sans antialiased">
|
<body data-ui-variant="{{ ui_variant }}"
|
||||||
<header class="sticky top-0 z-30 bg-white/90 backdrop-blur border-b border-ink-300/60">
|
class="min-h-screen bg-ink-100 text-ink-900 font-sans antialiased ui-{{ ui_variant }}">
|
||||||
<div class="mx-auto max-w-6xl px-4 h-14 flex items-center justify-between">
|
{% if ui_variant == 'legacy' %}
|
||||||
<a href="{{ url_for('main.index') }}" class="flex items-center gap-2 font-semibold text-ink-900">
|
<div class="cabinet-app">
|
||||||
<span class="material-symbols-outlined text-brand-600">quiz</span>
|
<header class="cabinet-header">
|
||||||
<span>Тестирование</span>
|
<div class="cabinet-header__inner">
|
||||||
</a>
|
<a href="{{ url_for('tests.tests_list_page') }}" class="cabinet-brand">
|
||||||
<nav class="flex items-center gap-1 sm:gap-2 text-sm">
|
<img src="{{ url_for('static', filename='img/clinic-logo.png') }}"
|
||||||
{% if current_user %}
|
alt="Логотип клиники" class="cabinet-brand__logo" />
|
||||||
<a href="{{ url_for('tests.tests_list_page') }}"
|
<div>
|
||||||
class="inline-flex items-center justify-center gap-1
|
<div class="cabinet-brand__title">Тестирование</div>
|
||||||
min-w-10 min-h-10 px-2 sm:px-3 rounded-lg
|
</div>
|
||||||
text-ink-700 hover:bg-ink-100"
|
|
||||||
title="Каталог тестов" aria-label="Каталог тестов">
|
|
||||||
<span class="material-symbols-outlined text-base">list_alt</span>
|
|
||||||
<span class="hidden sm:inline">Тесты</span>
|
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('settings.settings_page') }}"
|
<div class="cabinet-header__actions">
|
||||||
class="inline-flex items-center justify-center gap-1
|
{% if current_user %}
|
||||||
min-w-10 min-h-10 px-2 sm:px-3 rounded-lg
|
<span class="cabinet-user" title="{{ (current_user.full_name or current_user.login) ~ (' · ' ~ format_role(current_user.role) if format_role(current_user.role) else '') }}">
|
||||||
text-ink-700 hover:bg-ink-100"
|
{{ format_name_short(current_user.full_name, current_user.login) }}
|
||||||
title="Настройки" aria-label="Настройки">
|
{% if format_role(current_user.role) %}<span class="cabinet-user__role"> · {{ format_role(current_user.role) }}</span>{% endif %}
|
||||||
<span class="material-symbols-outlined text-base">settings</span>
|
</span>
|
||||||
<span class="hidden sm:inline">Настройки</span>
|
<form method="post" action="{{ url_for('auth.logout') }}" class="inline">
|
||||||
</a>
|
<button type="submit" class="btn btn-ghost">Выйти</button>
|
||||||
<span class="hidden md:inline text-ink-500">
|
</form>
|
||||||
{{ current_user.full_name or current_user.login }}
|
{% else %}
|
||||||
<span class="text-ink-300">·</span>
|
<a href="{{ url_for('auth.login_page') }}" class="btn btn-ghost">Войти</a>
|
||||||
<span class="text-brand-700">{{ current_user.role }}</span>
|
{% endif %}
|
||||||
</span>
|
</div>
|
||||||
<form method="post" action="{{ url_for('auth.logout') }}" class="inline">
|
</div>
|
||||||
<button type="submit"
|
</header>
|
||||||
class="inline-flex items-center justify-center gap-1
|
<main class="cabinet-main">
|
||||||
min-w-10 min-h-10 px-2 sm:px-3 rounded-lg
|
{% block content scoped %}{% endblock %}
|
||||||
text-ink-700 hover:bg-ink-100 transition"
|
</main>
|
||||||
title="Выйти" aria-label="Выйти">
|
|
||||||
<span class="material-symbols-outlined text-base">logout</span>
|
|
||||||
<span class="hidden sm:inline">Выйти</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{% else %}
|
|
||||||
<a href="{{ url_for('auth.login_page') }}"
|
|
||||||
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg
|
|
||||||
text-brand-700 hover:bg-brand-50 transition min-h-10">
|
|
||||||
<span class="material-symbols-outlined text-base">login</span>
|
|
||||||
Войти
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
{% else %}
|
||||||
|
<header class="sticky top-0 z-30 bg-white/90 backdrop-blur border-b border-ink-300/50">
|
||||||
<main class="mx-auto max-w-6xl px-4 py-6">
|
<div class="mx-auto max-w-2xl px-4 h-14 flex items-center justify-between">
|
||||||
{% block content %}{% endblock %}
|
<a href="{{ url_for('main.index') }}" class="flex items-center gap-2 font-semibold text-ink-900">
|
||||||
</main>
|
<img src="{{ url_for('static', filename='img/clinic-logo.png') }}"
|
||||||
|
alt="Логотип клиники" class="h-7 w-7 object-contain" />
|
||||||
<footer class="mx-auto max-w-6xl px-4 py-8 text-xs text-ink-500">
|
<span>Тестирование</span>
|
||||||
{% block footer %}testing-flask-app · Этап 1{% endblock %}
|
</a>
|
||||||
</footer>
|
<nav class="flex items-center gap-1 sm:gap-2 text-sm">
|
||||||
|
{% if current_user %}
|
||||||
|
<a href="{{ url_for('tests.tests_list_page') }}"
|
||||||
|
class="inline-flex items-center justify-center gap-1
|
||||||
|
min-w-10 min-h-10 px-2 sm:px-3 rounded-lg
|
||||||
|
text-ink-700 hover:bg-ink-100"
|
||||||
|
title="Каталог тестов" aria-label="Каталог тестов">
|
||||||
|
<span class="material-symbols-outlined text-base">list_alt</span>
|
||||||
|
<span class="hidden sm:inline">Тесты</span>
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('settings.settings_page') }}"
|
||||||
|
class="inline-flex items-center justify-center gap-1
|
||||||
|
min-w-10 min-h-10 px-2 sm:px-3 rounded-lg
|
||||||
|
text-ink-700 hover:bg-ink-100"
|
||||||
|
title="Настройки" aria-label="Настройки">
|
||||||
|
<span class="material-symbols-outlined text-base">settings</span>
|
||||||
|
<span class="hidden sm:inline">Настройки</span>
|
||||||
|
</a>
|
||||||
|
<span class="hidden md:inline text-ink-500">
|
||||||
|
{{ current_user.full_name or current_user.login }}
|
||||||
|
<span class="text-ink-300">·</span>
|
||||||
|
<span class="text-brand-700">{{ format_role(current_user.role) }}</span>
|
||||||
|
</span>
|
||||||
|
<form method="post" action="{{ url_for('auth.logout') }}" class="inline">
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center justify-center gap-1
|
||||||
|
min-w-10 min-h-10 px-2 sm:px-3 rounded-lg
|
||||||
|
text-ink-700 hover:bg-ink-100 transition"
|
||||||
|
title="Выйти" aria-label="Выйти">
|
||||||
|
<span class="material-symbols-outlined text-base">logout</span>
|
||||||
|
<span class="hidden sm:inline">Выйти</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('auth.login_page') }}"
|
||||||
|
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg
|
||||||
|
text-brand-700 hover:bg-brand-50 transition min-h-10">
|
||||||
|
<span class="material-symbols-outlined text-base">login</span>
|
||||||
|
Войти
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="mx-auto max-w-2xl px-4 py-6">
|
||||||
|
{{ self.content() }}
|
||||||
|
</main>
|
||||||
|
<footer class="mx-auto max-w-2xl px-4 py-8 text-xs text-ink-500">
|
||||||
|
{% block footer %}testing-flask-app · Этап 1{% endblock %}
|
||||||
|
</footer>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
{% block title %}Настройки — LLM{% endblock %}
|
{% block title %}Настройки — LLM{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-6 max-w-2xl">
|
<section class="{% if ui_variant == 'legacy' %}surface-card{% else %}rounded-2xl bg-white shadow-sm border border-ink-300/60{% endif %} p-6 max-w-2xl">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="material-symbols-outlined text-brand-600">settings</span>
|
<span class="material-symbols-outlined text-brand-600">settings</span>
|
||||||
<h1 class="text-2xl font-semibold">Настройки</h1>
|
<h1 class="text-2xl font-semibold">Настройки</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="mt-5 font-semibold">Подключение к LLM</h2>
|
<h2 class="mt-5 font-semibold {% if ui_variant == 'legacy' %}font-headline{% endif %}">Подключение к LLM</h2>
|
||||||
<p class="mt-1 text-sm text-ink-500">
|
<p class="mt-1 text-sm text-ink-500">
|
||||||
Ключ задаётся в <code class="px-1 py-0.5 rounded bg-ink-100">.env</code> сервера
|
Ключ задаётся в <code class="px-1 py-0.5 rounded bg-ink-100">.env</code> сервера
|
||||||
(общий, не на пользователя). Поддерживаются DeepSeek и OpenAI-совместимые API.
|
(общий, не на пользователя). Поддерживаются DeepSeek и OpenAI-совместимые API.
|
||||||
@@ -53,8 +53,7 @@ OPENAI_API_KEY=sk-...
|
|||||||
|
|
||||||
<div class="mt-5 flex items-center gap-3">
|
<div class="mt-5 flex items-center gap-3">
|
||||||
<button id="btn-ping"
|
<button id="btn-ping"
|
||||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg
|
class="{% if ui_variant == 'legacy' %}btn btn-primary{% else %}inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm{% endif %}">
|
||||||
bg-brand-600 hover:bg-brand-700 text-white text-sm">
|
|
||||||
<span class="material-symbols-outlined text-base">cable</span>
|
<span class="material-symbols-outlined text-base">cable</span>
|
||||||
Проверить подключение
|
Проверить подключение
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -3,88 +3,86 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div id="editor-root"
|
<div id="editor-root"
|
||||||
class="space-y-4 sm:space-y-5 pb-24"
|
class="space-y-4 sm:space-y-5 pb-24 {% if ui_variant == 'legacy' %}test-detail-page test-detail-page--with-fixed-actions{% endif %}"
|
||||||
data-test-id="{{ test_id }}"
|
data-test-id="{{ test_id }}"
|
||||||
data-initial='{{ content | tojson | safe }}'>
|
data-initial='{{ content | tojson | safe }}'>
|
||||||
|
|
||||||
{# ── 1. Шапка теста ─────────────────────────────────────────── #}
|
<section class="cabinet-brick cabinet-brick--hero hero-brick">
|
||||||
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-4 sm:p-5">
|
<div class="hero-brick__nav">
|
||||||
<h2 class="text-xs font-medium text-ink-500 uppercase tracking-wide">Тест</h2>
|
<a href="{{ url_for('tests.tests_list_page') }}" class="link-back">← к списку</a>
|
||||||
|
<span class="hero-brick__meta">
|
||||||
|
<span>Автор: <b id="intro-author">Вы</b></span>
|
||||||
|
<span class="hero-brick__sep">·</span>
|
||||||
|
<span>Обновлён: <span id="intro-updated">—</span></span>
|
||||||
|
<span class="hero-brick__sep">·</span>
|
||||||
|
<span>Версия <span id="intro-version">—</span></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label class="mt-2 block">
|
<textarea id="test-title" maxlength="200" rows="1" placeholder="Название теста"
|
||||||
<span class="sr-only">Название</span>
|
class="hero-brick__title font-headline"></textarea>
|
||||||
<input id="test-title" type="text" maxlength="200" placeholder="Название теста"
|
|
||||||
class="w-full rounded-lg border border-ink-300 px-3 py-3 text-lg font-semibold
|
|
||||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="mt-3 block">
|
<textarea id="test-description" rows="2" placeholder="Краткое описание (необязательно)"
|
||||||
<span class="text-xs font-medium text-ink-500">Описание</span>
|
class="hero-brick__desc"></textarea>
|
||||||
<textarea id="test-description" rows="2" placeholder="Краткое описание (необязательно)"
|
|
||||||
class="mt-1 w-full rounded-lg border border-ink-300 px-3 py-2
|
|
||||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"></textarea>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="mt-3 flex items-center justify-between gap-3">
|
<div class="hero-brick__chips">
|
||||||
<span class="text-xs font-medium text-ink-500">Проходной балл, %</span>
|
<label class="hero-brick__chip">
|
||||||
<input id="test-threshold" type="number" min="0" max="100" step="1"
|
<span>Порог зачёта</span>
|
||||||
inputmode="numeric"
|
<input id="test-threshold" type="number" min="0" max="100" step="1" inputmode="numeric" />
|
||||||
class="w-24 text-right rounded-lg border border-ink-300 px-3 py-2
|
<span>%</span>
|
||||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
|
</label>
|
||||||
</label>
|
<span class="hero-brick__chip hero-brick__chip--readonly">
|
||||||
|
Вопросов: <b id="q-count">0</b>
|
||||||
|
</span>
|
||||||
|
<label class="hero-brick__chip">
|
||||||
|
<input id="chain-active" type="checkbox" />
|
||||||
|
<span>Активна в каталоге</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="intro-fork-banner" class="callout callout--warning" data-fork-risk="{{ '1' if content.test.hasForkRisk else '0' }}" style="margin-top:0.85rem; display:none;">
|
||||||
|
При сохранении будет создана новая версия теста.
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{# ── 2. AI-помощник ─────────────────────────────────────────── #}
|
<details class="cabinet-disclosure cabinet-brick" open>
|
||||||
<section class="rounded-2xl bg-brand-50/60 border border-brand-100 p-4 sm:p-5">
|
<summary class="cabinet-disclosure__summary">
|
||||||
<div class="flex items-center gap-2">
|
<span class="cabinet-disclosure__summary-text">
|
||||||
<span class="material-symbols-outlined text-brand-600">auto_awesome</span>
|
<span class="cabinet-disclosure__summary-title font-headline">Вопросы</span>
|
||||||
<h2 class="font-semibold text-brand-700">AI-помощник</h2>
|
<span class="cabinet-disclosure__summary-sub">Тексты, варианты и при необходимости загрузка из файла</span>
|
||||||
</div>
|
</span>
|
||||||
|
</summary>
|
||||||
{# Группа A — генерация. Главные действия. На sm+ — в одну строку. #}
|
<div class="cabinet-disclosure__body">
|
||||||
<div class="mt-3">
|
<section class="rounded-2xl bg-brand-50/60 border border-brand-100 p-4 sm:p-5 test-detail-ai-panel">
|
||||||
<p class="text-xs font-medium text-ink-500 uppercase tracking-wide">Создать вопросы</p>
|
<div class="question-editor-block question-editor-block--first">
|
||||||
<div class="mt-2 grid grid-cols-1 sm:grid-cols-2 gap-2">
|
<h3 class="test-detail-subsection__title" style="margin-top:0;">Генерация сетки вопросов (ИИ)</h3>
|
||||||
<button id="ai-generate-by-title"
|
<label class="block">
|
||||||
class="inline-flex items-center justify-center gap-2 px-3 py-3 rounded-lg
|
<span class="form-label">Тема</span>
|
||||||
bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium min-h-11">
|
<input id="ai-topic" type="text" class="form-input" placeholder="Например: Введение про LLM" />
|
||||||
<span class="material-symbols-outlined text-base">edit_note</span>
|
</label>
|
||||||
По названию
|
<div class="mt-3 flex flex-wrap items-end gap-3">
|
||||||
</button>
|
<label class="block">
|
||||||
|
<span class="form-label">Вопросов</span>
|
||||||
|
<input id="ai-q-count" type="number" min="1" max="30" step="1" value="7"
|
||||||
|
class="form-input" style="width:90px;" />
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="form-label">Вариантов</span>
|
||||||
|
<input id="ai-o-count" type="number" min="2" max="8" step="1" value="3"
|
||||||
|
class="form-input" style="width:90px;" />
|
||||||
|
</label>
|
||||||
<button id="ai-generate-test"
|
<button id="ai-generate-test"
|
||||||
class="inline-flex items-center justify-center gap-2 px-3 py-3 rounded-lg
|
class="btn btn-ghost" type="button" style="min-height:43px;">
|
||||||
bg-white border border-brand-300/60 text-brand-700 hover:bg-brand-50
|
Сгенерировать тест (ИИ)
|
||||||
text-sm font-medium min-h-11">
|
|
||||||
<span class="material-symbols-outlined text-base">stars</span>
|
|
||||||
По текущей сетке
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Группа B — анализ существующего. #}
|
<div class="question-editor-block test-detail-subsection test-detail-subsection--import">
|
||||||
<div class="mt-4">
|
<h3 class="test-detail-subsection__title">Документ в вопросы</h3>
|
||||||
<p class="text-xs font-medium text-ink-500 uppercase tracking-wide">Улучшить существующее</p>
|
<p class="muted test-detail-hint" style="margin-top:0;">
|
||||||
<div class="mt-2 grid grid-cols-2 gap-2">
|
PDF, Word или текст — вставьте в черновик вопросов.
|
||||||
<button id="ai-check"
|
</p>
|
||||||
class="inline-flex items-center justify-center gap-2 px-3 py-3 rounded-lg
|
|
||||||
bg-white border border-ink-300/60 hover:border-brand-300
|
|
||||||
text-sm min-h-11">
|
|
||||||
<span class="material-symbols-outlined text-base">fact_check</span>
|
|
||||||
Проверить
|
|
||||||
</button>
|
|
||||||
<button id="ai-improve"
|
|
||||||
class="inline-flex items-center justify-center gap-2 px-3 py-3 rounded-lg
|
|
||||||
bg-white border border-ink-300/60 hover:border-brand-300
|
|
||||||
text-sm min-h-11">
|
|
||||||
<span class="material-symbols-outlined text-base">tune</span>
|
|
||||||
Улучшить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Группа C — импорт. #}
|
|
||||||
<div class="mt-4">
|
|
||||||
<p class="text-xs font-medium text-ink-500 uppercase tracking-wide">Импортировать</p>
|
|
||||||
<label class="mt-2 inline-flex w-full items-center justify-center gap-2 px-3 py-3
|
<label class="mt-2 inline-flex w-full items-center justify-center gap-2 px-3 py-3
|
||||||
rounded-lg bg-white border border-ink-300/60 hover:border-brand-300
|
rounded-lg bg-white border border-ink-300/60 hover:border-brand-300
|
||||||
text-sm cursor-pointer min-h-11">
|
text-sm cursor-pointer min-h-11">
|
||||||
@@ -103,10 +101,11 @@
|
|||||||
{# ── 3. Вопросы ─────────────────────────────────────────────── #}
|
{# ── 3. Вопросы ─────────────────────────────────────────────── #}
|
||||||
<section>
|
<section>
|
||||||
<div class="flex items-center justify-between gap-2 px-1">
|
<div class="flex items-center justify-between gap-2 px-1">
|
||||||
<h2 class="font-semibold">Вопросы (<span id="q-count">0</span>)</h2>
|
<h2 class="font-semibold">Вопросы (<span id="q-count-mirror">0</span>)</h2>
|
||||||
<button id="add-question"
|
<button id="add-question"
|
||||||
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg
|
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg
|
||||||
bg-white border border-ink-300/60 hover:border-brand-300 text-sm min-h-10">
|
bg-white border border-ink-300/60 hover:border-brand-300 text-sm min-h-10
|
||||||
|
btn btn-ghost btn--sm question-editor__add-question">
|
||||||
<span class="material-symbols-outlined text-base">add</span>
|
<span class="material-symbols-outlined text-base">add</span>
|
||||||
<span class="hidden sm:inline">Добавить вопрос</span>
|
<span class="hidden sm:inline">Добавить вопрос</span>
|
||||||
<span class="sm:hidden">Добавить</span>
|
<span class="sm:hidden">Добавить</span>
|
||||||
@@ -114,37 +113,91 @@
|
|||||||
</div>
|
</div>
|
||||||
<ol id="questions" class="mt-3 space-y-3"></ol>
|
<ol id="questions" class="mt-3 space-y-3"></ol>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="cabinet-disclosure cabinet-brick" open>
|
||||||
|
<summary class="cabinet-disclosure__summary">
|
||||||
|
<span class="cabinet-disclosure__summary-text">
|
||||||
|
<span class="cabinet-disclosure__summary-title font-headline">История</span>
|
||||||
|
<span class="cabinet-disclosure__summary-sub">Версии теста и кто проходил</span>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div class="cabinet-disclosure__body">
|
||||||
|
<div class="test-detail-subsection test-detail-subsection--tight">
|
||||||
|
<h3 class="test-detail-subsection__title">Версии</h3>
|
||||||
|
<ul id="versions-list" class="version-card-list"></ul>
|
||||||
|
</div>
|
||||||
|
<div class="test-detail-subsection">
|
||||||
|
<h3 class="test-detail-subsection__title">Прохождения</h3>
|
||||||
|
<ul id="attempts-list" class="attempts-card-list"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="cabinet-disclosure cabinet-brick" open>
|
||||||
|
<summary class="cabinet-disclosure__summary">
|
||||||
|
<span class="cabinet-disclosure__summary-text">
|
||||||
|
<span class="cabinet-disclosure__summary-title font-headline">Показ в каталоге</span>
|
||||||
|
<span class="cabinet-disclosure__summary-sub">Видимость в списке и выдача сотрудникам</span>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div class="cabinet-disclosure__body">
|
||||||
|
<div class="test-detail-subsection test-detail-subsection--tight">
|
||||||
|
<h3 class="test-detail-subsection__title">Видимость</h3>
|
||||||
|
<p class="test-detail-hint">Скрытые тесты в общем списке не показываются; ссылку на тест по-прежнему можно открыть.</p>
|
||||||
|
<div class="publication-visibility__actions">
|
||||||
|
<button id="btn-toggle-visibility" class="btn btn-ghost" type="button">Скрыть из списка</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="test-detail-subsection">
|
||||||
|
<h3 class="test-detail-subsection__title">Кому выдать</h3>
|
||||||
|
<p class="test-detail-hint">Список с учётом поиска и фильтров; можно отметить всех на экране.</p>
|
||||||
|
<div class="assign-toolbar">
|
||||||
|
<input id="assign-search" class="form-input assign-toolbar__search" type="text" placeholder="Поиск: ФИО, логин" />
|
||||||
|
<select id="assign-dept" class="form-input"><option value="__all__">Все отделы</option></select>
|
||||||
|
<select id="assign-clinic" class="form-input">
|
||||||
|
<option value="all">Все</option>
|
||||||
|
<option value="with">С учёткой в модуле</option>
|
||||||
|
<option value="without">Без учётки (создадим при назначении)</option>
|
||||||
|
</select>
|
||||||
|
<button id="assign-select-all" class="btn btn-ghost btn--sm" type="button">Выбрать всех</button>
|
||||||
|
</div>
|
||||||
|
<div id="assign-list" class="assign-list"></div>
|
||||||
|
<div class="inline-actions" style="margin-top:0.75rem;">
|
||||||
|
<button id="assign-submit" class="btn btn-primary" type="button">Назначить выбранных</button>
|
||||||
|
<span id="assign-status" class="muted"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ── Sticky-footer: «Цепочка активна» + «Сохранить» ────────────── #}
|
{# ── Sticky-footer: «Цепочка активна» + «Сохранить» ────────────── #}
|
||||||
<div class="fixed bottom-0 inset-x-0 z-30 bg-white/95 backdrop-blur border-t border-ink-300/60
|
<div class="fixed bottom-0 inset-x-0 z-30 bg-white/95 backdrop-blur border-t border-ink-300/60
|
||||||
pb-[env(safe-area-inset-bottom)]">
|
pb-[env(safe-area-inset-bottom)]">
|
||||||
<div class="mx-auto max-w-6xl px-4 py-3
|
<div class="mx-auto {% if ui_variant == 'legacy' %}max-w-2xl{% else %}max-w-6xl{% endif %} px-4 py-3
|
||||||
flex items-center justify-between gap-3">
|
flex items-center justify-between gap-3">
|
||||||
<label class="inline-flex items-center gap-2 text-sm min-w-0">
|
<span class="text-xs text-ink-500 truncate">Активность цепочки и поля теста — в шапке.</span>
|
||||||
<input id="chain-active" type="checkbox"
|
|
||||||
class="rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
|
|
||||||
<span class="truncate">Цепочка активна</span>
|
|
||||||
</label>
|
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
<a href="{{ url_for('tests.tests_list_page') }}"
|
<a href="{{ url_for('tests.tests_list_page') }}"
|
||||||
class="hidden sm:inline-flex px-3 py-2 rounded-lg text-ink-700 hover:bg-ink-100 text-sm">
|
class="hidden sm:inline-flex px-3 py-2 rounded-lg text-ink-700 hover:bg-ink-100 text-sm btn btn-ghost">
|
||||||
К каталогу
|
К каталогу
|
||||||
</a>
|
</a>
|
||||||
<button id="save-draft"
|
<button id="save-draft"
|
||||||
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg
|
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg
|
||||||
bg-brand-600 hover:bg-brand-700 text-white font-medium min-h-11">
|
bg-brand-600 hover:bg-brand-700 text-white font-medium min-h-11 btn btn-primary">
|
||||||
<span class="material-symbols-outlined text-base">save</span>
|
<span class="material-symbols-outlined text-base">save</span>
|
||||||
Сохранить
|
Сохранить
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p id="save-status" class="mx-auto max-w-6xl px-4 pb-2 text-xs text-ink-500"></p>
|
<p id="save-status" class="mx-auto {% if ui_variant == 'legacy' %}max-w-2xl{% else %}max-w-6xl{% endif %} px-4 pb-2 text-xs text-ink-500"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ── Шаблон вопроса ─────────────────────────────────────────────── #}
|
{# ── Шаблон вопроса ─────────────────────────────────────────────── #}
|
||||||
<template id="tpl-question">
|
<template id="tpl-question">
|
||||||
<li class="rounded-xl bg-white border border-ink-300/60 p-3 sm:p-4 q-item">
|
<li class="rounded-xl bg-white border border-ink-300/60 p-3 sm:p-4 q-item question-editor-block">
|
||||||
{# Шапка карточки вопроса: номер слева, кнопки справа. #}
|
{# Шапка карточки вопроса: номер слева, кнопки справа. #}
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<span class="inline-flex items-center px-2 py-0.5 rounded-md
|
<span class="inline-flex items-center px-2 py-0.5 rounded-md
|
||||||
@@ -165,27 +218,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<textarea class="q-text mt-3 w-full rounded-lg border border-ink-300 px-3 py-2
|
<div class="question-editor-block__header">
|
||||||
|
<h4 class="question-editor-block__title q-num">Вопрос #</h4>
|
||||||
|
<button class="q-ai btn btn-ghost btn--sm question-editor-block__ai-btn">
|
||||||
|
Сгенерировать вопрос (ИИ)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea class="q-text mt-2 w-full rounded-lg border border-ink-300 px-3 py-2
|
||||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
||||||
rows="2" placeholder="Формулировка вопроса"></textarea>
|
rows="2" placeholder="Формулировка вопроса"></textarea>
|
||||||
|
|
||||||
{# Тип ответа + AI — две полные строки на мобиле, в строку на sm+. #}
|
|
||||||
<div class="mt-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 text-sm">
|
<div class="mt-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 text-sm">
|
||||||
<label class="inline-flex items-center gap-2 min-h-9">
|
<label class="inline-flex items-center gap-2 min-h-9">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
class="q-multi rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
|
class="q-multi rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
|
||||||
<span>Несколько правильных ответов</span>
|
<span>Несколько правильных ответов</span>
|
||||||
</label>
|
</label>
|
||||||
<button class="q-ai inline-flex items-center justify-center gap-1 px-2.5 py-2 rounded-lg
|
|
||||||
bg-brand-50 hover:bg-brand-100 text-brand-700 text-sm min-h-10">
|
|
||||||
<span class="material-symbols-outlined text-base">auto_awesome</span>
|
|
||||||
AI: вопрос/переформулировать
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="q-options mt-3 space-y-2"></ul>
|
<ul class="q-options mt-3 space-y-2"></ul>
|
||||||
<button class="q-add-option mt-2 inline-flex items-center gap-1 px-2 py-2 rounded
|
<button class="q-add-option mt-2 inline-flex items-center gap-1 px-2 py-2 rounded
|
||||||
text-sm text-brand-700 hover:bg-brand-50 min-h-10">
|
text-sm text-brand-700 hover:bg-brand-50 min-h-10 btn btn-ghost btn--sm">
|
||||||
<span class="material-symbols-outlined text-base">add</span>
|
<span class="material-symbols-outlined text-base">add</span>
|
||||||
Добавить вариант
|
Добавить вариант
|
||||||
</button>
|
</button>
|
||||||
@@ -194,19 +248,19 @@
|
|||||||
|
|
||||||
{# ── Шаблон варианта ────────────────────────────────────────────── #}
|
{# ── Шаблон варианта ────────────────────────────────────────────── #}
|
||||||
<template id="tpl-option">
|
<template id="tpl-option">
|
||||||
<li class="flex items-center gap-2 opt-item">
|
<li class="flex items-center gap-2 opt-item question-option-row">
|
||||||
{# Чекбокс «Правильный» — обёрнут в большой tap-target. #}
|
{# Чекбокс «Правильный» — обёрнут в большой tap-target. #}
|
||||||
<label class="inline-flex items-center justify-center w-10 h-10 shrink-0 cursor-pointer
|
<label class="inline-flex items-center justify-center w-10 h-10 shrink-0 cursor-pointer
|
||||||
rounded hover:bg-ink-100" title="Правильный ответ">
|
rounded hover:bg-ink-100" title="Правильный ответ">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
class="opt-correct w-5 h-5 rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
|
class="opt-correct w-5 h-5 rounded border-ink-300 text-brand-600 focus:ring-brand-500 question-option-row__mark" />
|
||||||
</label>
|
</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
class="opt-text flex-1 min-w-0 rounded-lg border border-ink-300 px-3 py-2
|
class="opt-text flex-1 min-w-0 rounded-lg border border-ink-300 px-3 py-2 question-option-row__text
|
||||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
||||||
placeholder="Вариант ответа" />
|
placeholder="Вариант ответа" />
|
||||||
<button class="opt-delete shrink-0 w-10 h-10 inline-flex items-center justify-center
|
<button class="opt-delete shrink-0 w-10 h-10 inline-flex items-center justify-center
|
||||||
rounded hover:bg-red-50 text-red-600"
|
rounded hover:bg-red-50 text-red-600 question-option-remove"
|
||||||
title="Удалить" aria-label="Удалить вариант">
|
title="Удалить" aria-label="Удалить вариант">
|
||||||
<span class="material-symbols-outlined text-base">close</span>
|
<span class="material-symbols-outlined text-base">close</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,66 +2,122 @@
|
|||||||
{% block title %}Тесты — каталог{% endblock %}
|
{% block title %}Тесты — каталог{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-4 sm:p-6">
|
{% if ui_variant == 'legacy' %}
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
<section class="legacy-list-shell">
|
||||||
<div>
|
<h1 class="font-headline legacy-list-title">Тесты</h1>
|
||||||
<h1 class="text-xl sm:text-2xl font-semibold">Каталог тестов</h1>
|
<div class="legacy-list-toolbar">
|
||||||
<p class="mt-1 text-sm text-ink-500">Активные тесты, к которым у вас есть доступ.</p>
|
<button id="btn-create-test" class="btn btn-ghost" type="button">
|
||||||
|
Создать
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button id="btn-create-test"
|
|
||||||
class="inline-flex items-center justify-center gap-2 px-4 py-3 rounded-lg
|
|
||||||
bg-brand-600 hover:bg-brand-700 text-white font-medium transition
|
|
||||||
min-h-11 w-full sm:w-auto">
|
|
||||||
<span class="material-symbols-outlined text-base">add</span>
|
|
||||||
Создать тест
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if visible %}
|
{% if visible %}
|
||||||
<ul class="mt-5 grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
<ul class="list-stack" aria-label="Тесты в общем списке">
|
||||||
{% for t in visible %}
|
{% for t in visible %}
|
||||||
<li class="rounded-xl border border-ink-300/60 hover:border-brand-300 hover:shadow-sm transition bg-white">
|
<li class="list-row list-row--split">
|
||||||
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}"
|
<div class="list-row__main">
|
||||||
class="block p-4 active:bg-ink-100/40">
|
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}" class="list-row__link">
|
||||||
<div class="flex items-start justify-between gap-2">
|
<span class="list-row__title">{{ t.title }}</span>
|
||||||
<h3 class="font-semibold text-ink-900 line-clamp-2 min-w-0">{{ t.title }}</h3>
|
<span class="list-row__meta">
|
||||||
<span class="text-xs text-ink-500 shrink-0 mt-0.5">v{{ t.version }}</span>
|
{{ t.author_full_name or '—' }}
|
||||||
|
<span class="list-row__meta-tail"> · v{{ t.version }}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% if t.description %}
|
<div class="list-row__side">
|
||||||
<p class="mt-1 text-sm text-ink-500 line-clamp-3">{{ t.description }}</p>
|
<button type="button" class="btn btn-ghost btn-start-pass" data-test-id="{{ t.id }}">Пройти</button>
|
||||||
{% endif %}
|
|
||||||
<div class="mt-3 flex items-center justify-between gap-2 text-xs text-ink-500">
|
|
||||||
<span class="truncate">Автор: {{ t.author_full_name or '—' }}</span>
|
|
||||||
<span class="inline-flex items-center gap-1 text-brand-700">
|
|
||||||
<span class="material-symbols-outlined text-sm">edit_note</span>
|
|
||||||
Открыть
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% else %}
|
|
||||||
<p class="mt-5 text-ink-500 text-sm">Доступных тестов пока нет.</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if hidden %}
|
|
||||||
<details class="mt-6 rounded-xl border border-ink-300/60 bg-ink-100/50 p-4">
|
|
||||||
<summary class="cursor-pointer font-medium text-ink-700">
|
|
||||||
Скрытые вами цепочки ({{ hidden|length }})
|
|
||||||
</summary>
|
|
||||||
<ul class="mt-3 space-y-2">
|
|
||||||
{% for t in hidden %}
|
|
||||||
<li class="flex items-center justify-between gap-2 text-sm">
|
|
||||||
<span>{{ t.title }} <span class="text-ink-500">· v{{ t.version }}</span></span>
|
|
||||||
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}"
|
|
||||||
class="text-brand-700 hover:underline">Открыть</a>
|
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
{% else %}
|
||||||
{% endif %}
|
<p class="text-muted">Нет тестов</p>
|
||||||
</section>
|
{% endif %}
|
||||||
|
|
||||||
|
{% if hidden %}
|
||||||
|
<h2 class="font-headline legacy-list-subtitle">Скрытые вами из списка</h2>
|
||||||
|
<ul class="list-stack" aria-label="Скрытые тесты автора">
|
||||||
|
{% for t in hidden %}
|
||||||
|
<li class="list-row list-row--split list-row--hidden">
|
||||||
|
<div class="list-row__main">
|
||||||
|
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}" class="list-row__link">
|
||||||
|
<span class="list-row__title">{{ t.title }}</span>
|
||||||
|
<span class="list-row__meta">
|
||||||
|
{{ t.author_full_name or '—' }}
|
||||||
|
<span class="list-row__meta-tail"> · v{{ t.version }} · скрыт</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="list-row__side">
|
||||||
|
<button type="button" class="btn btn-ghost btn-start-pass" data-test-id="{{ t.id }}">Пройти</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% else %}
|
||||||
|
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-4 sm:p-6">
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl sm:text-2xl font-semibold">Каталог тестов</h1>
|
||||||
|
<p class="mt-1 text-sm text-ink-500">Активные тесты, к которым у вас есть доступ.</p>
|
||||||
|
</div>
|
||||||
|
<button id="btn-create-test"
|
||||||
|
class="inline-flex items-center justify-center gap-2 px-4 py-3 rounded-lg
|
||||||
|
bg-brand-600 hover:bg-brand-700 text-white font-medium transition
|
||||||
|
min-h-11 w-full sm:w-auto">
|
||||||
|
<span class="material-symbols-outlined text-base">add</span>
|
||||||
|
Создать тест
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if visible %}
|
||||||
|
<ul class="mt-5 grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{% for t in visible %}
|
||||||
|
<li class="rounded-xl border border-ink-300/60 hover:border-brand-300 hover:shadow-sm transition bg-white">
|
||||||
|
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}"
|
||||||
|
class="block p-4 active:bg-ink-100/40">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<h3 class="font-semibold text-ink-900 line-clamp-2 min-w-0">{{ t.title }}</h3>
|
||||||
|
<span class="text-xs text-ink-500 shrink-0 mt-0.5">v{{ t.version }}</span>
|
||||||
|
</div>
|
||||||
|
{% if t.description %}
|
||||||
|
<p class="mt-1 text-sm text-ink-500 line-clamp-3">{{ t.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="mt-3 flex items-center justify-between gap-2 text-xs text-ink-500">
|
||||||
|
<span class="truncate">Автор: {{ t.author_full_name or '—' }}</span>
|
||||||
|
<span class="inline-flex items-center gap-1 text-brand-700">
|
||||||
|
<span class="material-symbols-outlined text-sm">edit_note</span>
|
||||||
|
Открыть
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="mt-5 text-ink-500 text-sm">Доступных тестов пока нет.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if hidden %}
|
||||||
|
<details class="mt-6 rounded-xl border border-ink-300/60 bg-ink-100/50 p-4">
|
||||||
|
<summary class="cursor-pointer font-medium text-ink-700">
|
||||||
|
Скрытые вами цепочки ({{ hidden|length }})
|
||||||
|
</summary>
|
||||||
|
<ul class="mt-3 space-y-2">
|
||||||
|
{% for t in hidden %}
|
||||||
|
<li class="flex items-center justify-between gap-2 text-sm">
|
||||||
|
<span>{{ t.title }} <span class="text-ink-500">· v{{ t.version }}</span></span>
|
||||||
|
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}"
|
||||||
|
class="text-brand-700 hover:underline">Открыть</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<dialog id="dlg-create"
|
<dialog id="dlg-create"
|
||||||
class="m-0 p-0 w-full h-full sm:h-auto sm:max-w-md sm:w-full sm:m-auto
|
class="m-0 p-0 w-full h-full sm:h-auto sm:max-w-md sm:w-full sm:m-auto
|
||||||
@@ -136,6 +192,36 @@
|
|||||||
alert(e.message || 'Не удалось создать тест.');
|
alert(e.message || 'Не удалось создать тест.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const passButtons = Array.from(document.querySelectorAll('.btn-start-pass'));
|
||||||
|
passButtons.forEach((btn) => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const testId = btn.dataset.testId;
|
||||||
|
if (!testId) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
const oldText = btn.textContent;
|
||||||
|
btn.textContent = '…';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/tests/${testId}/attempts/start`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
let data = {};
|
||||||
|
try { data = await r.json(); } catch (_) {}
|
||||||
|
if (!r.ok || !data.attempt || !data.attempt.id) {
|
||||||
|
// В Flask legacy контуре пока может отсутствовать отдельная UI-страница попытки.
|
||||||
|
// Тогда ведём в карточку теста, чтобы пользователь не попадал на not_found.
|
||||||
|
window.location.href = `/tests/${testId}/edit`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = `/tests/${testId}/attempt/${data.attempt.id}`;
|
||||||
|
} catch (e) {
|
||||||
|
window.location.href = `/tests/${testId}/edit`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ from ..services.document_extract import (
|
|||||||
from ..services.document_gen import generation_for_import_document
|
from ..services.document_gen import generation_for_import_document
|
||||||
from ..services.draft_validator import LlmError
|
from ..services.draft_validator import LlmError
|
||||||
from ..services.editor_content import HttpError as EditorHttpError, get_editor_content
|
from ..services.editor_content import HttpError as EditorHttpError, get_editor_content
|
||||||
|
from ..services.test_attempt import (
|
||||||
|
HttpError as AttemptHttpError,
|
||||||
|
get_attempt_review_for_user,
|
||||||
|
get_play_content,
|
||||||
|
list_test_attempts_for_author,
|
||||||
|
start_attempt,
|
||||||
|
submit_attempt,
|
||||||
|
)
|
||||||
from ..services.test_access import is_test_author, list_hidden_by_author, list_visible_tests
|
from ..services.test_access import is_test_author, list_hidden_by_author, list_visible_tests
|
||||||
from ..services.test_chain import has_any_attempt_for_test
|
from ..services.test_chain import has_any_attempt_for_test
|
||||||
from ..services.test_draft import (
|
from ..services.test_draft import (
|
||||||
@@ -150,6 +158,10 @@ def api_test_summary(test_id):
|
|||||||
if not acc.ok:
|
if not acc.ok:
|
||||||
return jsonify(error=RU['notFound']), 404
|
return jsonify(error=RU['notFound']), 404
|
||||||
|
|
||||||
|
has_attempts = False
|
||||||
|
with eng.connect() as conn:
|
||||||
|
has_attempts = has_any_attempt_for_test(conn, test_id)
|
||||||
|
|
||||||
return jsonify(
|
return jsonify(
|
||||||
test={
|
test={
|
||||||
'id': str(row['id']),
|
'id': str(row['id']),
|
||||||
@@ -163,6 +175,7 @@ def api_test_summary(test_id):
|
|||||||
'updatedAt': row['updated_at'].isoformat() if row['updated_at'] else None,
|
'updatedAt': row['updated_at'].isoformat() if row['updated_at'] else None,
|
||||||
'createdBy': str(row['created_by']) if row['created_by'] else None,
|
'createdBy': str(row['created_by']) if row['created_by'] else None,
|
||||||
'authorFullName': row['author_full_name'],
|
'authorFullName': row['author_full_name'],
|
||||||
|
'hasAttempts': bool(has_attempts),
|
||||||
},
|
},
|
||||||
isAuthor=is_author,
|
isAuthor=is_author,
|
||||||
hasActiveVersion=row['active_version_id'] is not None,
|
hasActiveVersion=row['active_version_id'] is not None,
|
||||||
@@ -293,6 +306,85 @@ def api_patch_test(test_id):
|
|||||||
return jsonify(id=test_id, chainActive=chain)
|
return jsonify(id=test_id, chainActive=chain)
|
||||||
|
|
||||||
|
|
||||||
|
@tests_bp.route('/api/tests/<test_id>/attempts/start', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_start_attempt(test_id):
|
||||||
|
user = current_user()
|
||||||
|
eng = get_engine()
|
||||||
|
try:
|
||||||
|
out = start_attempt(eng, user.id, test_id)
|
||||||
|
except AttemptHttpError as e:
|
||||||
|
return jsonify(error=e.message), e.status
|
||||||
|
return jsonify(out), 201
|
||||||
|
|
||||||
|
|
||||||
|
@tests_bp.route('/api/tests/<test_id>/attempts/<attempt_id>/play', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
def api_attempt_play(test_id, attempt_id):
|
||||||
|
user = current_user()
|
||||||
|
eng = get_engine()
|
||||||
|
try:
|
||||||
|
out = get_play_content(eng, user.id, test_id, attempt_id)
|
||||||
|
except AttemptHttpError as e:
|
||||||
|
return jsonify(error=e.message), e.status
|
||||||
|
return jsonify(out)
|
||||||
|
|
||||||
|
|
||||||
|
@tests_bp.route('/api/tests/<test_id>/attempts/<attempt_id>/submit', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_attempt_submit(test_id, attempt_id):
|
||||||
|
user = current_user()
|
||||||
|
eng = get_engine()
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
try:
|
||||||
|
out = submit_attempt(eng, user.id, test_id, attempt_id, body.get('answers'))
|
||||||
|
except AttemptHttpError as e:
|
||||||
|
return jsonify(error=e.message), e.status
|
||||||
|
return jsonify(out)
|
||||||
|
|
||||||
|
|
||||||
|
@tests_bp.route('/api/tests/<test_id>/attempts/<attempt_id>/review', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
def api_attempt_review(test_id, attempt_id):
|
||||||
|
user = current_user()
|
||||||
|
eng = get_engine()
|
||||||
|
try:
|
||||||
|
out = get_attempt_review_for_user(eng, user.id, test_id, attempt_id)
|
||||||
|
except AttemptHttpError as e:
|
||||||
|
return jsonify(error=e.message), e.status
|
||||||
|
return jsonify(out)
|
||||||
|
|
||||||
|
|
||||||
|
@tests_bp.route('/api/tests/<test_id>/attempts', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
def api_attempts_list(test_id):
|
||||||
|
user = current_user()
|
||||||
|
eng = get_engine()
|
||||||
|
try:
|
||||||
|
rows = list_test_attempts_for_author(eng, user.id, test_id)
|
||||||
|
except AttemptHttpError as e:
|
||||||
|
return jsonify(error=e.message), e.status
|
||||||
|
return jsonify(
|
||||||
|
attempts=[
|
||||||
|
{
|
||||||
|
'id': str(r['id']),
|
||||||
|
'userId': str(r['user_id']),
|
||||||
|
'status': r['status'],
|
||||||
|
'attemptNumber': r['attempt_number'],
|
||||||
|
'startedAt': r['started_at'].isoformat() if r['started_at'] else None,
|
||||||
|
'completedAt': r['completed_at'].isoformat() if r['completed_at'] else None,
|
||||||
|
'correctCount': r['correct_count'],
|
||||||
|
'totalQuestions': r['total_questions'],
|
||||||
|
'passed': r['passed'],
|
||||||
|
'testVersion': r['test_version'],
|
||||||
|
'attempterName': r['attempter_name'],
|
||||||
|
'attempterLogin': r['attempter_login'],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ─── AI ──────────────────────────────────────────────────────────────
|
# ─── AI ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@tests_bp.route('/api/tests/<test_id>/ai/generate-test', methods=['POST'])
|
@tests_bp.route('/api/tests/<test_id>/ai/generate-test', methods=['POST'])
|
||||||
@@ -463,3 +555,23 @@ def tests_editor_page(test_id):
|
|||||||
return ('Доступ запрещён.', 403)
|
return ('Доступ запрещён.', 403)
|
||||||
return render_template('500.html'), 500
|
return render_template('500.html'), 500
|
||||||
return render_template('tests/editor.html', content=content, test_id=test_id)
|
return render_template('tests/editor.html', content=content, test_id=test_id)
|
||||||
|
|
||||||
|
|
||||||
|
@tests_bp.route('/tests/<test_id>/attempt/<attempt_id>', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
def tests_attempt_page(test_id, attempt_id):
|
||||||
|
return render_template('tests/attempt.html', test_id=test_id, attempt_id=attempt_id)
|
||||||
|
|
||||||
|
|
||||||
|
@tests_bp.route('/tests/<test_id>/attempts/<attempt_id>/review', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
def tests_attempt_review_page(test_id, attempt_id):
|
||||||
|
user = current_user()
|
||||||
|
eng = get_engine()
|
||||||
|
try:
|
||||||
|
review = get_attempt_review_for_user(eng, user.id, test_id, attempt_id)
|
||||||
|
except AttemptHttpError as e:
|
||||||
|
if e.status == 404:
|
||||||
|
return render_template('404.html'), 404
|
||||||
|
return (e.message, e.status)
|
||||||
|
return render_template('tests/attempt_review.html', test_id=test_id, review=review)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
<title>Система тестрования</title>
|
<title>Система тестирования</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 52 KiB |
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate, useParams, Link, useOutletContext } from 'react-router-dom';
|
import { useNavigate, useParams, Link, useOutletContext } from 'react-router-dom';
|
||||||
import { api } from '../api';
|
import { api } from '../api';
|
||||||
import { formatTestAuthorLabel } from '../utils/formatUserName';
|
import { formatTestAuthorLabel } from '../utils/formatUserName';
|
||||||
@@ -70,6 +70,41 @@ function mapEditorToDraftQuestions(ed) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildDraftSnapshot({ title, description, passing, questions }) {
|
||||||
|
return JSON.stringify({
|
||||||
|
title: title ?? '',
|
||||||
|
description: description ?? '',
|
||||||
|
passing: String(passing ?? ''),
|
||||||
|
questions: (questions || []).map((q) => ({
|
||||||
|
text: q.text ?? '',
|
||||||
|
hasMultipleAnswers: !!q.hasMultipleAnswers,
|
||||||
|
options: (q.options || []).map((o) => ({
|
||||||
|
text: o.text ?? '',
|
||||||
|
isCorrect: !!o.isCorrect,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function postDebugLog({ runId, hypothesisId, location, message, data }) {
|
||||||
|
fetch('http://127.0.0.1:7419/ingest/a86fc408-7178-4abe-8dd9-f3e6bfb05d76', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Debug-Session-Id': '034e19',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
sessionId: '034e19',
|
||||||
|
runId,
|
||||||
|
hypothesisId,
|
||||||
|
location,
|
||||||
|
message,
|
||||||
|
data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
export default function TestDetail() {
|
export default function TestDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
@@ -83,6 +118,7 @@ export default function TestDetail() {
|
|||||||
const [draftDescription, setDraftDescription] = useState('');
|
const [draftDescription, setDraftDescription] = useState('');
|
||||||
const [draftPassing, setDraftPassing] = useState('70');
|
const [draftPassing, setDraftPassing] = useState('70');
|
||||||
const [draftQuestions, setDraftQuestions] = useState(() => [createEmptyQuestion()]);
|
const [draftQuestions, setDraftQuestions] = useState(() => [createEmptyQuestion()]);
|
||||||
|
const [draftSnapshotOnLoad, setDraftSnapshotOnLoad] = useState(null);
|
||||||
const [draftStatus, setDraftStatus] = useState('');
|
const [draftStatus, setDraftStatus] = useState('');
|
||||||
const [deactivateBusy, setDeactivateBusy] = useState(false);
|
const [deactivateBusy, setDeactivateBusy] = useState(false);
|
||||||
const [importPreview, setImportPreview] = useState(null);
|
const [importPreview, setImportPreview] = useState(null);
|
||||||
@@ -106,6 +142,16 @@ export default function TestDetail() {
|
|||||||
const [assignLoadBusy, setAssignLoadBusy] = useState(false);
|
const [assignLoadBusy, setAssignLoadBusy] = useState(false);
|
||||||
const [attemptsList, setAttemptsList] = useState(undefined);
|
const [attemptsList, setAttemptsList] = useState(undefined);
|
||||||
const [attemptsErr, setAttemptsErr] = useState(null);
|
const [attemptsErr, setAttemptsErr] = useState(null);
|
||||||
|
const debugRunId = 'pre-fix';
|
||||||
|
// #region agent log
|
||||||
|
postDebugLog({
|
||||||
|
runId: debugRunId,
|
||||||
|
hypothesisId: 'H1',
|
||||||
|
location: 'frontend/src/pages/TestDetail.jsx:component-start',
|
||||||
|
message: 'TestDetail render start',
|
||||||
|
data: { hasData: !!data, hasTaker: !!taker, hasErr: !!err, testId: id != null },
|
||||||
|
});
|
||||||
|
// #endregion
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setErr(null);
|
setErr(null);
|
||||||
@@ -127,14 +173,26 @@ export default function TestDetail() {
|
|||||||
setData(v);
|
setData(v);
|
||||||
setChain(c);
|
setChain(c);
|
||||||
if (ed?.test) {
|
if (ed?.test) {
|
||||||
setDraftTitle(ed.test.title || '');
|
const loadedTitle = ed.test.title || '';
|
||||||
setAiGenTopic((ed.test.title || '').trim());
|
const loadedDescription = ed.test.description || '';
|
||||||
setDraftDescription(ed.test.description || '');
|
|
||||||
const th = ed.test.passingThreshold;
|
const th = ed.test.passingThreshold;
|
||||||
setDraftPassing(
|
const loadedPassing =
|
||||||
th !== undefined && th !== null && String(th) !== '' ? String(th) : '70'
|
th !== undefined && th !== null && String(th) !== '' ? String(th) : '70';
|
||||||
|
const loadedQuestions = mapEditorToDraftQuestions(ed);
|
||||||
|
|
||||||
|
setDraftSnapshotOnLoad(
|
||||||
|
buildDraftSnapshot({
|
||||||
|
title: loadedTitle,
|
||||||
|
description: loadedDescription,
|
||||||
|
passing: loadedPassing,
|
||||||
|
questions: loadedQuestions,
|
||||||
|
})
|
||||||
);
|
);
|
||||||
setDraftQuestions(mapEditorToDraftQuestions(ed));
|
setDraftTitle(loadedTitle);
|
||||||
|
setAiGenTopic(loadedTitle.trim());
|
||||||
|
setDraftDescription(loadedDescription);
|
||||||
|
setDraftPassing(loadedPassing);
|
||||||
|
setDraftQuestions(loadedQuestions);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.status === 401) {
|
if (e.status === 401) {
|
||||||
@@ -590,13 +648,40 @@ export default function TestDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (err) {
|
if (err) {
|
||||||
|
// #region agent log
|
||||||
|
postDebugLog({
|
||||||
|
runId: debugRunId,
|
||||||
|
hypothesisId: 'H3',
|
||||||
|
location: 'frontend/src/pages/TestDetail.jsx:return-err',
|
||||||
|
message: 'Returning error branch before memo hook',
|
||||||
|
data: { hasErr: true },
|
||||||
|
});
|
||||||
|
// #endregion
|
||||||
return <p className="error-text">{err}</p>;
|
return <p className="error-text">{err}</p>;
|
||||||
}
|
}
|
||||||
if (!data && !taker) {
|
if (!data && !taker) {
|
||||||
|
// #region agent log
|
||||||
|
postDebugLog({
|
||||||
|
runId: debugRunId,
|
||||||
|
hypothesisId: 'H1',
|
||||||
|
location: 'frontend/src/pages/TestDetail.jsx:return-loading',
|
||||||
|
message: 'Returning loading branch before memo hook',
|
||||||
|
data: { hasData: false, hasTaker: false },
|
||||||
|
});
|
||||||
|
// #endregion
|
||||||
return <p className="text-muted">Загрузка…</p>;
|
return <p className="text-muted">Загрузка…</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (taker) {
|
if (taker) {
|
||||||
|
// #region agent log
|
||||||
|
postDebugLog({
|
||||||
|
runId: debugRunId,
|
||||||
|
hypothesisId: 'H1',
|
||||||
|
location: 'frontend/src/pages/TestDetail.jsx:return-taker',
|
||||||
|
message: 'Returning taker branch before memo hook',
|
||||||
|
data: { hasTaker: true },
|
||||||
|
});
|
||||||
|
// #endregion
|
||||||
const { test: t, hasActiveVersion } = taker.summary;
|
const { test: t, hasActiveVersion } = taker.summary;
|
||||||
const title = t?.title || 'Тест';
|
const title = t?.title || 'Тест';
|
||||||
return (
|
return (
|
||||||
@@ -635,6 +720,42 @@ export default function TestDetail() {
|
|||||||
const assignSelectedInList = assignPeople.filter((p) =>
|
const assignSelectedInList = assignPeople.filter((p) =>
|
||||||
assignSelected.has(assignPersonKey(p))
|
assignSelected.has(assignPersonKey(p))
|
||||||
);
|
);
|
||||||
|
// #region agent log
|
||||||
|
postDebugLog({
|
||||||
|
runId: debugRunId,
|
||||||
|
hypothesisId: 'H1',
|
||||||
|
location: 'frontend/src/pages/TestDetail.jsx:before-useMemo',
|
||||||
|
message: 'Reached line right before hasDraftChanges useMemo',
|
||||||
|
data: { hasData: !!data, hasTaker: !!taker },
|
||||||
|
});
|
||||||
|
// #endregion
|
||||||
|
const hasDraftChanges = useMemo(() => {
|
||||||
|
// #region agent log
|
||||||
|
postDebugLog({
|
||||||
|
runId: debugRunId,
|
||||||
|
hypothesisId: 'H2',
|
||||||
|
location: 'frontend/src/pages/TestDetail.jsx:inside-useMemo',
|
||||||
|
message: 'Computing hasDraftChanges',
|
||||||
|
data: {
|
||||||
|
hasDraftSnapshotOnLoad: !!draftSnapshotOnLoad,
|
||||||
|
titleLen: (draftTitle || '').length,
|
||||||
|
descriptionLen: (draftDescription || '').length,
|
||||||
|
passing: String(draftPassing || ''),
|
||||||
|
questionsCount: Array.isArray(draftQuestions) ? draftQuestions.length : -1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// #endregion
|
||||||
|
if (!draftSnapshotOnLoad) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const currentSnapshot = buildDraftSnapshot({
|
||||||
|
title: draftTitle,
|
||||||
|
description: draftDescription,
|
||||||
|
passing: draftPassing,
|
||||||
|
questions: draftQuestions,
|
||||||
|
});
|
||||||
|
return currentSnapshot !== draftSnapshotOnLoad;
|
||||||
|
}, [draftDescription, draftPassing, draftQuestions, draftSnapshotOnLoad, draftTitle]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="test-detail-page test-detail-page--with-fixed-actions">
|
<div className="test-detail-page test-detail-page--with-fixed-actions">
|
||||||
@@ -667,7 +788,7 @@ export default function TestDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{chain?.hasAnyAttempt && (
|
{chain?.hasAnyAttempt && hasDraftChanges && (
|
||||||
<div className="callout callout--warning" role="status" style={{ marginTop: '0.75rem' }}>
|
<div className="callout callout--warning" role="status" style={{ marginTop: '0.75rem' }}>
|
||||||
При сохранении будет создана новая версия теста.
|
При сохранении будет создана новая версия теста.
|
||||||
</div>
|
</div>
|
||||||
|
|||||||