remove UI split (legacy/modern)
This commit is contained in:
@@ -14,11 +14,12 @@
|
|||||||
|
|
||||||
**Целевой и единственный рабочий стек** — Python 3.11 + Flask 3 +
|
**Целевой и единственный рабочий стек** — Python 3.11 + Flask 3 +
|
||||||
Jinja2 + Tailwind CDN + SQLAlchemy / psycopg2, код в
|
Jinja2 + Tailwind CDN + SQLAlchemy / psycopg2, код в
|
||||||
[`flask_app/`](flask_app/). На нём работает и прод, и dev (`:3108`).
|
[`flask_app/`](flask_app/). На нём работает и прод, и dev (кабинетный UI,
|
||||||
|
порт **:3107** в Docker, см. ниже).
|
||||||
|
|
||||||
Старые каталоги `backend/` (Node.js / Express) и `frontend/`
|
Старые каталоги `backend/` (Node.js / Express) и `frontend/`
|
||||||
(React + Vite) — **архив**: не разворачиваются, в `docker-compose.dev.yml`
|
(React + Vite) — **архив**: не разворачиваются, в `docker-compose.dev.yml`
|
||||||
поднимается только сервис `testing-flask`, удаление папок запланировано
|
поднимается сервис **`testing-flask`**, удаление папок запланировано
|
||||||
в спринте **E1.6**. Использовать их не надо, миграции и SQL-схема
|
в спринте **E1.6**. Использовать их не надо, миграции и SQL-схема
|
||||||
сохранены в `backend/src/db/migrations/` исключительно как источник
|
сохранены в `backend/src/db/migrations/` исключительно как источник
|
||||||
структуры БД.
|
структуры БД.
|
||||||
@@ -33,6 +34,20 @@ Jinja2 + Tailwind CDN + SQLAlchemy / psycopg2, код в
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Интерфейс (кабинет)
|
||||||
|
|
||||||
|
Единственный вариант UI — **как у основного HR-веба**: в
|
||||||
|
[`base.html`](flask_app/app/templates/base.html) корень
|
||||||
|
`cabinet-app` → шапка `cabinet-header` → контент `cabinet-main`; на
|
||||||
|
`<body>` всегда класс **`ui-legacy`**, стили в [`app.css`](flask_app/app/static/css/app.css)
|
||||||
|
с префиксом **`body.ui-legacy`** (primary/teal, `.btn`, `.surface-card`,
|
||||||
|
`legacy-list-shell`, `test-detail-page` и т.д.).
|
||||||
|
|
||||||
|
В [`docker-compose.dev.yml`](docker-compose.dev.yml) один сервис
|
||||||
|
**`testing-flask`** (`container_name: testing_webapp_flask`), порт **3107**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Что уже работает на новом (Flask) контуре
|
## Что уже работает на новом (Flask) контуре
|
||||||
|
|
||||||
E1.0–E1.3 и E1.8 закрыты. Чек-лист и журнал —
|
E1.0–E1.3 и E1.8 закрыты. Чек-лист и журнал —
|
||||||
@@ -69,7 +84,7 @@ E1.0–E1.3 и E1.8 закрыты. Чек-лист и журнал —
|
|||||||
|---|---|
|
|---|---|
|
||||||
| **E1.4** — Назначение и прохождение | Назначить тест сотруднику, экран прохождения, экран результата с разбором ошибок. |
|
| **E1.4** — Назначение и прохождение | Назначить тест сотруднику, экран прохождения, экран результата с разбором ошибок. |
|
||||||
| **E1.5** — Трекер и настройки модуля | Единый список попыток с фильтрами, страница настроек цепочки. |
|
| **E1.5** — Трекер и настройки модуля | Единый список попыток с фильтрами, страница настроек цепочки. |
|
||||||
| **E1.6** — Cutover внутри репозитория | Удаление `backend/` и `frontend/`, чистка `docker-compose.dev.yml` от legacy-сервисов. |
|
| **E1.6** — Cutover внутри репозитория | Удаление `backend/` и `frontend/`, чистка `docker-compose.dev.yml` от архивных Node/React-сервисов. |
|
||||||
| **E1.7** — UX-полировка редактора | 4 аккордеона (Шапка / AI / Вопросы / Действия) и drag-n-drop из [Спринта 3](docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md). |
|
| **E1.7** — UX-полировка редактора | 4 аккордеона (Шапка / AI / Вопросы / Действия) и drag-n-drop из [Спринта 3](docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md). |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -93,26 +108,16 @@ docker network create hr_postgres_dev_net || true
|
|||||||
|
|
||||||
### Dev-стенд
|
### Dev-стенд
|
||||||
|
|
||||||
Выбор интерфейса задаётся через env-переменную `COMPOSE_PROFILES`:
|
|
||||||
|
|
||||||
- `modern` — основной интерфейс на Flask/Jinja;
|
|
||||||
- `legacy` — legacy-раскладка интерфейса на том же Flask-стеке.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Новый стек (рекомендуется)
|
docker compose -f docker-compose.dev.yml up -d --build
|
||||||
COMPOSE_PROFILES=modern docker compose -f docker-compose.dev.yml up -d --build
|
|
||||||
|
|
||||||
# Legacy-раскладка (тот же Flask)
|
|
||||||
COMPOSE_PROFILES=legacy docker compose -f docker-compose.dev.yml up -d --build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| Что | URL |
|
| Что | URL |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Приложение (Flask modern) | <http://localhost:3108> |
|
| Приложение (Flask) | <http://localhost:3107> |
|
||||||
| Health-check | <http://localhost:3108/health> |
|
| Health-check | <http://localhost:3107/health> |
|
||||||
| Приложение (Flask legacy) | <http://localhost:3107> |
|
|
||||||
|
|
||||||
`docker-compose.dev.yml` пробрасывает в `testing-flask`:
|
`docker-compose.dev.yml` пробрасывает в контейнер **`testing-flask`**:
|
||||||
- `DATABASE_URL` (по умолчанию на контейнерный Postgres `clinic_tests`);
|
- `DATABASE_URL` (по умолчанию на контейнерный Postgres `clinic_tests`);
|
||||||
- `HR_AUTH=1` / `HR_DATABASE_URL` по умолчанию — вход через HR-кабинет;
|
- `HR_AUTH=1` / `HR_DATABASE_URL` по умолчанию — вход через HR-кабинет;
|
||||||
- `DEEPSEEK_API_KEY` / `OPENAI_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` —
|
- `DEEPSEEK_API_KEY` / `OPENAI_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` —
|
||||||
|
|||||||
+1
-41
@@ -4,53 +4,14 @@
|
|||||||
# База 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;"
|
||||||
#
|
#
|
||||||
# Flask-only режим. Выбор варианта интерфейса через profile:
|
# Flask UI (кабинетный стиль): http://localhost:3107
|
||||||
# COMPOSE_PROFILES=modern docker compose -f docker-compose.dev.yml up -d --build
|
|
||||||
# COMPOSE_PROFILES=legacy docker compose -f docker-compose.dev.yml up -d --build
|
|
||||||
# Оба варианта работают на одном Flask-стеке, отличаются только UI-раскладкой.
|
|
||||||
# UI (Flask modern): http://localhost:3108
|
|
||||||
# UI (Flask legacy): http://localhost:3107
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# Flask modern UI
|
|
||||||
testing-flask:
|
testing-flask:
|
||||||
profiles: ["modern"]
|
|
||||||
build:
|
build:
|
||||||
context: ./flask_app
|
context: ./flask_app
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: testing_webapp_flask
|
container_name: testing_webapp_flask
|
||||||
environment:
|
|
||||||
PORT: "3108"
|
|
||||||
WEB_USE_WAITRESS: "1"
|
|
||||||
FLASK_DEBUG: "0"
|
|
||||||
SECRET_KEY: ${FLASK_SECRET_KEY:-testing_flask_dev_change_me}
|
|
||||||
# БД (clinic_tests) в общей сети hr_postgres_dev_net.
|
|
||||||
# По умолчанию используем те же dev-учётки, что и в backend-сервисе.
|
|
||||||
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg2://hr_bot_user:hrbot123@hr_postgres_dev:5432/clinic_tests}
|
|
||||||
# HR-аутентификация включена по умолчанию:
|
|
||||||
# пароль проверяется в 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}
|
|
||||||
DEV_FIO_PASSWORD: ${DEV_FIO_PASSWORD:-}
|
|
||||||
# LLM (E1.2/E1.3/E1.8): один общий ключ, читается из .env проекта.
|
|
||||||
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}
|
|
||||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
|
||||||
LLM_BASE_URL: ${LLM_BASE_URL:-}
|
|
||||||
LLM_MODEL: ${LLM_MODEL:-}
|
|
||||||
ports:
|
|
||||||
- "3108:3108"
|
|
||||||
networks:
|
|
||||||
- app
|
|
||||||
- postgres
|
|
||||||
|
|
||||||
# Flask legacy UI (старое расположение элементов на новом стеке)
|
|
||||||
testing-flask-legacy:
|
|
||||||
profiles: ["legacy"]
|
|
||||||
build:
|
|
||||||
context: ./flask_app
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: testing_webapp_flask_legacy
|
|
||||||
environment:
|
environment:
|
||||||
PORT: "3107"
|
PORT: "3107"
|
||||||
WEB_USE_WAITRESS: "1"
|
WEB_USE_WAITRESS: "1"
|
||||||
@@ -59,7 +20,6 @@ services:
|
|||||||
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg2://hr_bot_user:hrbot123@hr_postgres_dev:5432/clinic_tests}
|
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg2://hr_bot_user:hrbot123@hr_postgres_dev:5432/clinic_tests}
|
||||||
HR_AUTH: ${HR_AUTH:-1}
|
HR_AUTH: ${HR_AUTH:-1}
|
||||||
HR_DATABASE_URL: ${HR_DATABASE_URL:-postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/hr_bot_test}
|
HR_DATABASE_URL: ${HR_DATABASE_URL:-postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/hr_bot_test}
|
||||||
UI_VARIANT: ${UI_VARIANT_LEGACY:-legacy}
|
|
||||||
DEV_FIO_PASSWORD: ${DEV_FIO_PASSWORD:-}
|
DEV_FIO_PASSWORD: ${DEV_FIO_PASSWORD:-}
|
||||||
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}
|
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}
|
||||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
|
|||||||
@@ -151,7 +151,7 @@
|
|||||||
Используется один тонкий клиент `frontend/src/api.js`: `fetch` с `credentials: 'include'`, базовый путь — пустой (т.е. **`/api/...` относительно текущего origin**, что в dev резолвит Vite-proxy, а в prod — Nginx). Это значит:
|
Используется один тонкий клиент `frontend/src/api.js`: `fetch` с `credentials: 'include'`, базовый путь — пустой (т.е. **`/api/...` относительно текущего origin**, что в dev резолвит Vite-proxy, а в prod — Nginx). Это значит:
|
||||||
|
|
||||||
- **Менять фронтенд при смене бэкенда не нужно**, если новый сервис отвечает по тем же путям.
|
- **Менять фронтенд при смене бэкенда не нужно**, если новый сервис отвечает по тем же путям.
|
||||||
- В dev сейчас `vite.config.js` проксирует `/api` на Express (`localhost:3001`). После переноса — заменить адрес/порт на Flask (см. `flask_app/run.py`, по умолчанию `3108`).
|
- В dev сейчас `vite.config.js` проксирует `/api` на Express (`localhost:3001`). После переноса — заменить адрес/порт на Flask (см. `flask_app/run.py`, по умолчанию `3107`).
|
||||||
|
|
||||||
Список путей (отсортирован по убыванию использований):
|
Список путей (отсортирован по убыванию использований):
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
| `/health` | `flask_app/app/__init__.py` |
|
| `/health` | `flask_app/app/__init__.py` |
|
||||||
| Пустой `index.html` | `flask_app/app/templates/index.html` |
|
| Пустой `index.html` | `flask_app/app/templates/index.html` |
|
||||||
| Зависимости | `flask_app/requirements.txt` (Flask, python-dotenv, waitress) |
|
| Зависимости | `flask_app/requirements.txt` (Flask, python-dotenv, waitress) |
|
||||||
| Docker-сервис на порту 3108 | `flask_app/Dockerfile`, корневой `docker-compose.dev.yml` (сервис `testing-flask`) |
|
| Docker Flask (порт 3107) | `flask_app/Dockerfile`, `docker-compose.dev.yml` (сервис `testing-flask`) |
|
||||||
|
|
||||||
Всё остальное — **писать**.
|
Всё остальное — **писать**.
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
| Контур | Путь в репозитории | Назначение |
|
| Контур | Путь в репозитории | Назначение |
|
||||||
|--------|-------------------|------------|
|
|--------|-------------------|------------|
|
||||||
| **Основной веб-кабинет HR** | `HR_TG_Bot/tgFlaskForm/` | Flask-приложение: авторизация, кабинет, разделы в т.ч. **тестирование сотрудников**. Именно сюда чаще всего относят жалобы «мини-приложение / кабинет на Flask». |
|
| **Основной веб-кабинет HR** | `HR_TG_Bot/tgFlaskForm/` | Flask-приложение: авторизация, кабинет, разделы в т.ч. **тестирование сотрудников**. Именно сюда чаще всего относят жалобы «мини-приложение / кабинет на Flask». |
|
||||||
| **Отдельный Flask-скелет под тестирование** | `TestingWebApp/flask_app/` | Упрощённое приложение того же стека (переходный контур, Docker-сервис `testing-flask`, порт **3108** в `TestingWebApp/docker-compose.dev.yml`). Может быть медленным по тем же причинам (БД, шаблоны, отсутствие кэша статики), но **это не обязательно тот же инстанс**, что видят пользователи в проде. |
|
| **Отдельный Flask-скелет под тестирование** | `TestingWebApp/flask_app/` | Упрощённое приложение того же стека (переходный контур, Docker `testing-flask`, порт **3107**). Может быть медленным по тем же причинам (БД, шаблоны, отсутствие кэша статики), но **это не обязательно тот же инстанс**, что видят пользователи в проде. |
|
||||||
|
|
||||||
Связанные по смыслу документы (миграция данных, две БД):
|
Связанные по смыслу документы (миграция данных, две БД):
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
1. **Веб-кабинет в браузере** (`tgFlaskForm`, типичный порт локально **3104** в `web_run.py`).
|
1. **Веб-кабинет в браузере** (`tgFlaskForm`, типичный порт локально **3104** в `web_run.py`).
|
||||||
2. **Встроенный WebView в мини-приложении** (Telegram MAX и т.п.) — тот же HTML с того же хоста, но **другая сеть, кэш, DNS, TLS**; воспроизведение обязательно на целевом клиенте.
|
2. **Встроенный WebView в мини-приложении** (Telegram MAX и т.п.) — тот же HTML с того же хоста, но **другая сеть, кэш, DNS, TLS**; воспроизведение обязательно на целевом клиенте.
|
||||||
3. **Переходный контур** `TestingWebApp` на **3108** — проверять отдельно, если пользователи реально ходят туда.
|
3. **Переходный контур** `TestingWebApp` на **3107** — проверять отдельно, если пользователи реально ходят туда.
|
||||||
|
|
||||||
Перед оптимизацией **уточнить URL/контур** у тех, кто жалуется.
|
Перед оптимизацией **уточнить URL/контур** у тех, кто жалуется.
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
|
|
||||||
### Фаза 0 — уточнение (полдня максимум)
|
### Фаза 0 — уточнение (полдня максимум)
|
||||||
|
|
||||||
- [ ] Точный **URL/продукт** (кабинет HR vs TestingWebApp:3108 vs мини-app WebView).
|
- [ ] Точный **URL/продукт** (кабинет HR vs TestingWebApp:3107 vs мини-app WebView).
|
||||||
- [ ] **Роль пользователя** и сценарий (первый заход, каждый клик, только раздел тестирования).
|
- [ ] **Роль пользователя** и сценарий (первый заход, каждый клик, только раздел тестирования).
|
||||||
- [ ] Есть ли **nginx / CDN** перед приложением.
|
- [ ] Есть ли **nginx / CDN** перед приложением.
|
||||||
|
|
||||||
@@ -175,7 +175,7 @@
|
|||||||
| Авторизация и gate | `HR_TG_Bot/tgFlaskForm/webApp/auth.py` |
|
| Авторизация и gate | `HR_TG_Bot/tgFlaskForm/webApp/auth.py` |
|
||||||
| Шаблоны | `HR_TG_Bot/tgFlaskForm/webApp/templates/` |
|
| Шаблоны | `HR_TG_Bot/tgFlaskForm/webApp/templates/` |
|
||||||
| Переходный Flask + waitress | `TestingWebApp/flask_app/run.py`, `TestingWebApp/flask_app/app/` |
|
| Переходный Flask + waitress | `TestingWebApp/flask_app/run.py`, `TestingWebApp/flask_app/app/` |
|
||||||
| Docker dev (пример порта 3108) | `TestingWebApp/docker-compose.dev.yml` |
|
| Docker dev (по умолчанию 3107 legacy) | `TestingWebApp/docker-compose.dev.yml` |
|
||||||
| Docker dev кабинета | `HR_TG_Bot/docker-compose.dev.yml` |
|
| Docker dev кабинета | `HR_TG_Bot/docker-compose.dev.yml` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ─── HTTP сервер ─────────────────────────────────────────────────
|
# ─── HTTP сервер ─────────────────────────────────────────────────
|
||||||
# Порт (не пересекать с :3107 текущего docker-compose.dev.yml)
|
# Порт как в docker-compose (:3107).
|
||||||
PORT=3108
|
PORT=3107
|
||||||
FLASK_DEBUG=1
|
FLASK_DEBUG=1
|
||||||
# В Docker задайте WEB_USE_WAITRESS=1 (см. docker-compose.dev.yml)
|
# В Docker задайте WEB_USE_WAITRESS=1 (см. docker-compose.dev.yml)
|
||||||
# WEB_USE_WAITRESS=1
|
# WEB_USE_WAITRESS=1
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ FROM python:3.11-slim
|
|||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||||
PORT=3108 \
|
PORT=3107 \
|
||||||
WEB_USE_WAITRESS=1 \
|
WEB_USE_WAITRESS=1 \
|
||||||
FLASK_DEBUG=0
|
FLASK_DEBUG=0
|
||||||
|
|
||||||
@@ -18,6 +18,6 @@ COPY app ./app
|
|||||||
COPY alembic.ini .
|
COPY alembic.ini .
|
||||||
COPY alembic ./alembic
|
COPY alembic ./alembic
|
||||||
|
|
||||||
EXPOSE 3108
|
EXPOSE 3107
|
||||||
|
|
||||||
CMD ["python", "run.py"]
|
CMD ["python", "run.py"]
|
||||||
|
|||||||
+3
-11
@@ -15,20 +15,12 @@
|
|||||||
`DATABASE_URL`, `HR_AUTH`, `HR_DATABASE_URL`, `DEEPSEEK_API_KEY` /
|
`DATABASE_URL`, `HR_AUTH`, `HR_DATABASE_URL`, `DEEPSEEK_API_KEY` /
|
||||||
`OPENAI_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` из корневого `.env`.
|
`OPENAI_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` из корневого `.env`.
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.dev.yml up -d --build testing-flask
|
|
||||||
```
|
|
||||||
|
|
||||||
- **URL:** http://localhost:3108
|
|
||||||
- **Проверка:** http://localhost:3108/health
|
|
||||||
|
|
||||||
Вместе со старым UI и API:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.dev.yml up -d --build
|
docker compose -f docker-compose.dev.yml up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Тогда Node-интерфейс остаётся на **http://localhost:3107**, Flask — на **3108**.
|
- **URL:** http://localhost:3107
|
||||||
|
- **Проверка:** http://localhost:3107/health
|
||||||
|
|
||||||
## Запуск локально (без Docker)
|
## Запуск локально (без Docker)
|
||||||
|
|
||||||
@@ -41,7 +33,7 @@ cp .env.example .env # при необходимости поправьте
|
|||||||
python run.py
|
python run.py
|
||||||
```
|
```
|
||||||
|
|
||||||
По умолчанию: **http://127.0.0.1:3108** , проверка: **http://127.0.0.1:3108/health**
|
По умолчанию (без `PORT` в `.env`): **http://127.0.0.1:3107** , проверка: **http://127.0.0.1:3107/health**
|
||||||
Для режима как в Docker (waitress): в `.env` задайте `WEB_USE_WAITRESS=1`.
|
Для режима как в Docker (waitress): в `.env` задайте `WEB_USE_WAITRESS=1`.
|
||||||
|
|
||||||
## Зачем отдельная папка
|
## Зачем отдельная папка
|
||||||
|
|||||||
@@ -80,13 +80,11 @@ 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_name_short': _format_surname_with_initials,
|
||||||
'format_role': _format_role,
|
'format_role': _format_role,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,19 +78,9 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* UI variants (оба режима на Flask, отличие только в компоновке UI). */
|
/* Кабинетный UI (класс body.ui-legacy на корне). */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
/* 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 {
|
body.ui-legacy .max-w-2xl {
|
||||||
max-width: 42rem !important;
|
max-width: 42rem !important;
|
||||||
}
|
}
|
||||||
@@ -1133,17 +1123,6 @@ body.ui-legacy .attempts-card-list {
|
|||||||
border-color: color-mix(in srgb, var(--primary, #007168) 25%, transparent);
|
border-color: color-mix(in srgb, var(--primary, #007168) 25%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
body.ui-modern .version-item {
|
|
||||||
background: #fff;
|
|
||||||
border-color: rgba(15, 23, 42, 0.08);
|
|
||||||
}
|
|
||||||
body.ui-modern .version-item[data-active="1"] {
|
|
||||||
background: color-mix(in srgb, var(--brand-600, #6366f1) 6%, #fff);
|
|
||||||
border-color: color-mix(in srgb, var(--brand-600, #6366f1) 28%, transparent);
|
|
||||||
}
|
|
||||||
body.ui-modern .version-item__badge {
|
|
||||||
background: var(--brand-600, #6366f1);
|
|
||||||
}
|
|
||||||
body.ui-legacy #versions-section {
|
body.ui-legacy #versions-section {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
}
|
}
|
||||||
@@ -1176,7 +1155,7 @@ body.ui-legacy .attempts-card-list__action {
|
|||||||
.attempt-review-page {
|
.attempt-review-page {
|
||||||
max-width: 42rem;
|
max-width: 42rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 0.25rem 2rem;
|
padding: 0 0.25rem max(2rem, env(safe-area-inset-bottom, 0px));
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1442,14 +1421,6 @@ body.ui-legacy .attempts-card-list__action {
|
|||||||
color: #14532d;
|
color: #14532d;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.ui-modern .attempt-review-page {
|
|
||||||
padding-bottom: max(2rem, env(safe-area-inset-bottom, 0px));
|
|
||||||
}
|
|
||||||
|
|
||||||
body.ui-modern .attempt-review-card {
|
|
||||||
border-color: rgba(15, 23, 42, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.ui-legacy .attempt-review-page {
|
body.ui-legacy .attempt-review-page {
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
@@ -1934,21 +1905,6 @@ body.ui-legacy .attempt-review-page {
|
|||||||
color: #b42318;
|
color: #b42318;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.ui-modern .attempt-result-card {
|
|
||||||
--attempt-result-fail-score: var(--brand-600, #6366f1);
|
|
||||||
}
|
|
||||||
body.ui-modern .attempt-result-verdict[data-passed="1"] .attempt-result-verdict__label {
|
|
||||||
color: var(--attempt-result-pass);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.ui-modern .attempt-flow {
|
|
||||||
min-height: min(75dvh, 880px);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.ui-modern .attempt-title {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.ui-legacy .attempt-flow {
|
body.ui-legacy .attempt-flow {
|
||||||
min-height: min(72dvh, 820px);
|
min-height: min(72dvh, 820px);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,117 +3,55 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% set fio_as_login = dev_fio_enabled or hr_auth_enabled %}
|
{% set fio_as_login = dev_fio_enabled or hr_auth_enabled %}
|
||||||
{% if ui_variant == 'legacy' %}
|
<div class="login-page">
|
||||||
<div class="login-page">
|
<div class="login-shell">
|
||||||
<div class="login-shell">
|
<div class="login-logo">
|
||||||
<div class="login-logo">
|
<img src="{{ url_for('static', filename='img/clinic-logo.png') }}"
|
||||||
<img src="{{ url_for('static', filename='img/clinic-logo.png') }}"
|
alt="Логотип клиники" class="login-logo__img" />
|
||||||
alt="Логотип клиники" class="login-logo__img" />
|
<h1 class="font-headline">Тестирование</h1>
|
||||||
<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">
|
|
||||||
{% if dev_fio_enabled %}
|
|
||||||
<p style="font-size:0.8rem; color:#4b7b78; margin-bottom:0.75rem; line-height:1.4;">
|
|
||||||
Введите <b>ФИО</b> из кадровой системы и общий dev-пароль — или обычный логин/пароль.
|
|
||||||
</p>
|
|
||||||
{% elif hr_auth_enabled %}
|
|
||||||
<p style="font-size:0.8rem; color:#4b7b78; margin-bottom:0.75rem; line-height:1.4;">
|
|
||||||
Можно ввести <b>логин</b> из HR или <b>ФИО</b> (как в кадровой системе), если совпадение одно, и пароль учётной записи HR.
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
<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">
|
|
||||||
{% if fio_as_login %}ФИО или логин{% else %}Логин{% endif %}
|
|
||||||
</label>
|
|
||||||
<input id="login-username" class="form-input" type="text" name="login"
|
|
||||||
value="{{ login or '' }}" required autofocus autocomplete="username"
|
|
||||||
placeholder="{% if fio_as_login %}Иванов Иван Иванович{% endif %}" />
|
|
||||||
</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>
|
||||||
</div>
|
|
||||||
{% else %}
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
<section class="mx-auto max-w-md mt-8">
|
{% if messages %}
|
||||||
<div class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-6">
|
<div class="callout callout--error" style="margin-bottom: 1rem;">
|
||||||
<div class="flex items-center gap-2">
|
{% for category, msg in messages %}
|
||||||
<span class="material-symbols-outlined text-brand-600">login</span>
|
{% if category == 'error' %}{{ msg }}{% endif %}
|
||||||
<h1 class="text-xl font-semibold">Вход в систему</h1>
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-sm text-ink-500">
|
{% endif %}
|
||||||
{% if dev_fio_enabled %}
|
{% endwith %}
|
||||||
|
|
||||||
|
<div class="login-card">
|
||||||
|
{% if dev_fio_enabled %}
|
||||||
|
<p style="font-size:0.8rem; color:#4b7b78; margin-bottom:0.75rem; line-height:1.4;">
|
||||||
Введите <b>ФИО</b> из кадровой системы и общий dev-пароль — или обычный логин/пароль.
|
Введите <b>ФИО</b> из кадровой системы и общий dev-пароль — или обычный логин/пароль.
|
||||||
{% elif hr_auth_enabled %}
|
</p>
|
||||||
Учётная запись HR: можно ввести <b>логин</b> или <b>ФИО</b> (если в базе только один такой сотрудник), и пароль.
|
{% elif hr_auth_enabled %}
|
||||||
{% else %}
|
<p style="font-size:0.8rem; color:#4b7b78; margin-bottom:0.75rem; line-height:1.4;">
|
||||||
Используйте логин и пароль.
|
Можно ввести <b>логин</b> из HR или <b>ФИО</b> (как в кадровой системе), если совпадение одно, и пароль учётной записи HR.
|
||||||
{% endif %}
|
</p>
|
||||||
</p>
|
{% endif %}
|
||||||
|
<form method="post" action="{{ url_for('auth.login_submit') }}" novalidate>
|
||||||
{% 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 '/' }}">
|
<input type="hidden" name="next" value="{{ next or '/' }}">
|
||||||
|
|
||||||
<label class="block">
|
<div class="form-field">
|
||||||
<span class="text-sm font-medium text-ink-700">
|
<label class="form-label" for="login-username">
|
||||||
{% if fio_as_login %}ФИО или логин{% else %}Логин{% endif %}
|
{% if fio_as_login %}ФИО или логин{% else %}Логин{% endif %}
|
||||||
</span>
|
</label>
|
||||||
<input type="text" name="login" value="{{ login or '' }}" required autofocus autocomplete="username"
|
<input id="login-username" class="form-input" type="text" name="login"
|
||||||
placeholder="{% if fio_as_login %}Иванов Иван Иванович{% endif %}"
|
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
|
placeholder="{% if fio_as_login %}Иванов Иван Иванович{% endif %}" />
|
||||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
|
</div>
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="block">
|
<div class="form-field">
|
||||||
<span class="text-sm font-medium text-ink-700">Пароль</span>
|
<label class="form-label" for="login-password">Пароль</label>
|
||||||
<input type="password" name="password" required autocomplete="current-password"
|
<input id="login-password" class="form-input" type="password" name="password"
|
||||||
class="mt-1 w-full rounded-lg border border-ink-300 bg-white px-3 py-2 text-ink-900
|
required autocomplete="current-password" />
|
||||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
|
</div>
|
||||||
</label>
|
|
||||||
|
|
||||||
<button type="submit"
|
<button type="submit" class="btn btn-primary">Войти</button>
|
||||||
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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
{% endif %}
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -51,96 +51,36 @@
|
|||||||
<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 data-ui-variant="{{ ui_variant }}"
|
<body class="min-h-screen bg-ink-100 text-ink-900 font-sans antialiased ui-legacy">
|
||||||
class="min-h-screen bg-ink-100 text-ink-900 font-sans antialiased ui-{{ ui_variant }}">
|
<div class="cabinet-app">
|
||||||
{% if ui_variant == 'legacy' %}
|
<header class="cabinet-header">
|
||||||
<div class="cabinet-app">
|
<div class="cabinet-header__inner">
|
||||||
<header class="cabinet-header">
|
<a href="{{ url_for('tests.tests_list_page') }}" class="cabinet-brand">
|
||||||
<div class="cabinet-header__inner">
|
|
||||||
<a href="{{ url_for('tests.tests_list_page') }}" class="cabinet-brand">
|
|
||||||
<img src="{{ url_for('static', filename='img/clinic-logo.png') }}"
|
|
||||||
alt="Логотип клиники" class="cabinet-brand__logo" />
|
|
||||||
<div>
|
|
||||||
<div class="cabinet-brand__title">Тестирование</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<div class="cabinet-header__actions">
|
|
||||||
{% if current_user %}
|
|
||||||
<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 '') }}">
|
|
||||||
{{ format_name_short(current_user.full_name, current_user.login) }}
|
|
||||||
{% if format_role(current_user.role) %}<span class="cabinet-user__role"> · {{ format_role(current_user.role) }}</span>{% endif %}
|
|
||||||
</span>
|
|
||||||
<form method="post" action="{{ url_for('auth.logout') }}" class="inline">
|
|
||||||
<button type="submit" class="btn btn-ghost">Выйти</button>
|
|
||||||
</form>
|
|
||||||
{% else %}
|
|
||||||
<a href="{{ url_for('auth.login_page') }}" class="btn btn-ghost">Войти</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main class="cabinet-main">
|
|
||||||
{% block content scoped %}{% endblock %}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<header class="sticky top-0 z-30 bg-white/90 backdrop-blur border-b border-ink-300/50">
|
|
||||||
<div class="mx-auto max-w-2xl px-4 h-14 flex items-center justify-between">
|
|
||||||
<a href="{{ url_for('main.index') }}" class="flex items-center gap-2 font-semibold text-ink-900">
|
|
||||||
<img src="{{ url_for('static', filename='img/clinic-logo.png') }}"
|
<img src="{{ url_for('static', filename='img/clinic-logo.png') }}"
|
||||||
alt="Логотип клиники" class="h-7 w-7 object-contain" />
|
alt="Логотип клиники" class="cabinet-brand__logo" />
|
||||||
<span>Тестирование</span>
|
<div>
|
||||||
|
<div class="cabinet-brand__title">Тестирование</div>
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<nav class="flex items-center gap-1 sm:gap-2 text-sm">
|
<div class="cabinet-header__actions">
|
||||||
{% if current_user %}
|
{% if current_user %}
|
||||||
<a href="{{ url_for('tests.tests_list_page') }}"
|
<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 '') }}">
|
||||||
class="inline-flex items-center justify-center gap-1
|
{{ format_name_short(current_user.full_name, current_user.login) }}
|
||||||
min-w-10 min-h-10 px-2 sm:px-3 rounded-lg
|
{% if format_role(current_user.role) %}<span class="cabinet-user__role"> · {{ format_role(current_user.role) }}</span>{% endif %}
|
||||||
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>
|
|
||||||
</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>
|
</span>
|
||||||
<form method="post" action="{{ url_for('auth.logout') }}" class="inline">
|
<form method="post" action="{{ url_for('auth.logout') }}" class="inline">
|
||||||
<button type="submit"
|
<button type="submit" class="btn btn-ghost">Выйти</button>
|
||||||
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>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ url_for('auth.login_page') }}"
|
<a href="{{ url_for('auth.login_page') }}" class="btn btn-ghost">Войти</a>
|
||||||
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 %}
|
{% endif %}
|
||||||
</nav>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="mx-auto max-w-2xl px-4 py-6">
|
<main class="cabinet-main">
|
||||||
{{ self.content() }}
|
{% block content scoped %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
<footer class="mx-auto max-w-2xl px-4 py-8 text-xs text-ink-500">
|
</div>
|
||||||
{% 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="{% if ui_variant == 'legacy' %}surface-card{% else %}rounded-2xl bg-white shadow-sm border border-ink-300/60{% endif %} p-6 max-w-2xl">
|
<section class="surface-card 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 {% if ui_variant == 'legacy' %}font-headline{% endif %}">Подключение к LLM</h2>
|
<h2 class="mt-5 font-semibold font-headline">Подключение к 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,7 +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="{% 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 %}">
|
class="btn btn-primary">
|
||||||
<span class="material-symbols-outlined text-base">cable</span>
|
<span class="material-symbols-outlined text-base">cable</span>
|
||||||
Проверить подключение
|
Проверить подключение
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div id="editor-root"
|
<div id="editor-root"
|
||||||
class="space-y-4 sm:space-y-5 pb-24 {% if ui_variant == 'legacy' %}test-detail-page test-detail-page--with-fixed-actions{% endif %}"
|
class="space-y-4 sm:space-y-5 pb-24 test-detail-page test-detail-page--with-fixed-actions"
|
||||||
data-test-id="{{ test_id }}"
|
data-test-id="{{ test_id }}"
|
||||||
data-initial='{{ content | tojson | safe }}'>
|
data-initial='{{ content | tojson | safe }}'>
|
||||||
|
|
||||||
@@ -277,7 +277,7 @@
|
|||||||
{# ── 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 {% if ui_variant == 'legacy' %}max-w-2xl{% else %}max-w-6xl{% endif %} px-4 py-3
|
<div class="mx-auto max-w-2xl px-4 py-3
|
||||||
flex items-center justify-between gap-3">
|
flex items-center justify-between gap-3">
|
||||||
<div id="intro-fork-banner" class="callout callout--warning text-xs sm:text-sm"
|
<div id="intro-fork-banner" class="callout callout--warning text-xs sm:text-sm"
|
||||||
data-fork-risk="{{ '1' if content.test.hasForkRisk else '0' }}"
|
data-fork-risk="{{ '1' if content.test.hasForkRisk else '0' }}"
|
||||||
@@ -297,7 +297,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p id="save-status" class="mx-auto max-w-2xl px-4 pb-2 text-xs text-ink-500"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ── Шаблон вопроса ─────────────────────────────────────────────── #}
|
{# ── Шаблон вопроса ─────────────────────────────────────────────── #}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if ui_variant == 'legacy' %}
|
|
||||||
<section class="legacy-list-shell">
|
<section class="legacy-list-shell">
|
||||||
<h1 class="font-headline legacy-list-title">Тесты</h1>
|
<h1 class="font-headline legacy-list-title">Тесты</h1>
|
||||||
<div class="legacy-list-toolbar">
|
<div class="legacy-list-toolbar">
|
||||||
@@ -77,71 +76,6 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</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 whitespace-nowrap">Версия {{ 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>
|
|
||||||
<p class="mt-1.5 text-xs text-ink-500 leading-snug">{{ catalog_test_params_line(t) }}</p>
|
|
||||||
</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 flex-col gap-1 sm:flex-row sm:items-start sm:justify-between text-sm">
|
|
||||||
<span class="min-w-0">{{ t.title }} <span class="text-ink-500">· v{{ t.version }}</span>
|
|
||||||
<span class="block text-xs text-ink-500 mt-0.5 leading-snug">{{ catalog_test_params_line(t) }}</span>
|
|
||||||
</span>
|
|
||||||
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}"
|
|
||||||
class="text-brand-700 hover:underline shrink-0">Открыть</a>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<dialog id="dlg-create"
|
<dialog id="dlg-create"
|
||||||
class="m-0 p-0 w-full sm:w-full sm:max-w-md
|
class="m-0 p-0 w-full sm:w-full sm:max-w-md
|
||||||
@@ -235,7 +169,7 @@ class="m-0 p-0 w-full sm:w-full sm:max-w-md
|
|||||||
let data = {};
|
let data = {};
|
||||||
try { data = await r.json(); } catch (_) {}
|
try { data = await r.json(); } catch (_) {}
|
||||||
if (!r.ok || !data.attempt || !data.attempt.id) {
|
if (!r.ok || !data.attempt || !data.attempt.id) {
|
||||||
// В Flask legacy контуре пока может отсутствовать отдельная UI-страница попытки.
|
// Если нет страницы попытки, уводим в редактор.
|
||||||
// Тогда ведём в карточку теста, чтобы пользователь не попадал на not_found.
|
// Тогда ведём в карточку теста, чтобы пользователь не попадал на not_found.
|
||||||
window.location.href = `/tests/${testId}/edit`;
|
window.location.href = `/tests/${testId}/edit`;
|
||||||
return;
|
return;
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ def _use_waitress() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
port = int(os.environ.get('PORT', '3108'))
|
port = int(os.environ.get('PORT', '3107'))
|
||||||
if _use_waitress():
|
if _use_waitress():
|
||||||
from waitress import serve
|
from waitress import serve
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user