feat(flask): E1.0–E1.3, E1.8 — миграция на Python/Flask + AI v2
Этап 1 миграции TestingWebApp на целевой стек (Python/Flask/Jinja),
БД остаётся clinic_tests.
E1.0 — База Flask-приложения: SQLAlchemy/psycopg2 пул, Flask sessions,
фабрика create_app, blueprint main с / и /health, base.html в стиле
кабинета HR (Tailwind CDN + Manrope + Material Symbols), 404/500.
E1.1 — Auth + /api/me: Flask sessions (signed cookie) вместо JWT,
bcrypt + Werkzeug, опц. HR_AUTH=1 с UPSERT в clinic_tests.users по
staff_id. UI /login, JSON /api/auth/{login,logout,me}, декораторы
@login_required / @require_role.
E1.2 — Тесты: список + редактор. 10 эндпоинтов, сервисы test_draft,
test_access, test_chain, ai_editor, llm_client, draft_validator,
editor_content. UI /tests (каталог + создание) и /tests/<id>/edit
(редактор с AI). Полный мобильный UX (аккордеоны/drag-n-drop) — в E1.7.
E1.3 — Импорт документов: pypdf + python-docx, эндпоинт
POST /api/tests/import/document, кнопка «Импорт документа» в
AI-панели редактора, лимит 16 МБ.
E1.8 — AI v2: страница /settings (статус ENV-ключа + ping),
ai/generate-by-title (без сетки), ai/check (рецензия), ai/improve
(массовое было→стало с чекбоксами). Унифицированный ответ AI-ошибок:
{ error, code, settingsUrl }.
Docker:
- docker-compose.dev.yml: добавлены DATABASE_URL, HR_AUTH/HR_DATABASE_URL,
DEEPSEEK_API_KEY/OPENAI_API_KEY/LLM_BASE_URL/LLM_MODEL и сеть postgres
для testing-flask.
Документация:
- docs/migration-final.md — двух-этапный план (Этап 1: унификация
стека внутри TestingWebApp; Этап 2: слияние с tgFlaskForm).
- docs/migration-final-inventory.md — карта 22 эндпоинтов Express.
Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}404 — страница не найдена{% endblock %}
|
||||
{% block content %}
|
||||
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-8 text-center">
|
||||
<span class="material-symbols-outlined text-5xl text-brand-600">search_off</span>
|
||||
<h1 class="mt-2 text-xl font-semibold">Страница не найдена</h1>
|
||||
<p class="mt-1 text-ink-500">Проверьте адрес или вернитесь на <a class="text-brand-600 hover:underline" href="{{ url_for('main.index') }}">главную</a>.</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}500 — внутренняя ошибка{% endblock %}
|
||||
{% block content %}
|
||||
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-8 text-center">
|
||||
<span class="material-symbols-outlined text-5xl text-red-600">error</span>
|
||||
<h1 class="mt-2 text-xl font-semibold">Что-то пошло не так</h1>
|
||||
<p class="mt-1 text-ink-500">Попробуйте обновить страницу. Если ошибка повторяется — посмотрите логи сервера.</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,58 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Вход — Тестирование{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<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>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,111 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>{% block title %}Тестирование персонала{% endblock %}</title>
|
||||
|
||||
{# Tailwind CDN — на E1.0 этого достаточно. В Этапе 2/CI можно заменить на сборку. #}
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
// Минимальная палитра в стиле кабинета HR. Без зависимостей от HR-репо.
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Manrope', 'Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#eef2ff',
|
||||
100: '#e0e7ff',
|
||||
500: '#6366f1',
|
||||
600: '#4f46e5',
|
||||
700: '#4338ca',
|
||||
},
|
||||
ink: {
|
||||
900: '#0f172a',
|
||||
700: '#334155',
|
||||
500: '#64748b',
|
||||
300: '#cbd5e1',
|
||||
100: '#f1f5f9',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined"
|
||||
/>
|
||||
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}" />
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body class="min-h-screen bg-ink-100 text-ink-900 font-sans antialiased">
|
||||
<header class="sticky top-0 z-30 bg-white/90 backdrop-blur border-b border-ink-300/60">
|
||||
<div class="mx-auto max-w-6xl 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">
|
||||
<span class="material-symbols-outlined text-brand-600">quiz</span>
|
||||
<span>Тестирование</span>
|
||||
</a>
|
||||
<nav class="flex items-center gap-2 text-sm">
|
||||
{% if current_user %}
|
||||
<a href="{{ url_for('tests.tests_list_page') }}"
|
||||
class="hidden sm:inline-flex items-center gap-1 px-2 py-1 rounded-lg
|
||||
text-ink-700 hover:bg-ink-100">
|
||||
<span class="material-symbols-outlined text-base">list_alt</span>
|
||||
Тесты
|
||||
</a>
|
||||
<a href="{{ url_for('settings.settings_page') }}"
|
||||
class="hidden sm:inline-flex items-center gap-1 px-2 py-1 rounded-lg
|
||||
text-ink-700 hover:bg-ink-100">
|
||||
<span class="material-symbols-outlined text-base">settings</span>
|
||||
Настройки
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user %}
|
||||
<span class="hidden sm:inline text-ink-500">
|
||||
{{ current_user.full_name or current_user.login }}
|
||||
<span class="text-ink-300">·</span>
|
||||
<span class="text-brand-700">{{ current_user.role }}</span>
|
||||
</span>
|
||||
<form method="post" action="{{ url_for('auth.logout') }}" class="inline">
|
||||
<button type="submit"
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg
|
||||
text-ink-700 hover:bg-ink-100 transition">
|
||||
<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-2 py-1 rounded-lg
|
||||
text-brand-700 hover:bg-brand-50 transition">
|
||||
<span class="material-symbols-outlined text-base">login</span>
|
||||
Войти
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto max-w-6xl px-4 py-6">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="mx-auto max-w-6xl px-4 py-8 text-xs text-ink-500">
|
||||
{% block footer %}testing-flask-app · Этап 1{% endblock %}
|
||||
</footer>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,9 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Тестирование</title>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Тестирование — главная{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-6">
|
||||
<h1 class="text-2xl font-semibold text-ink-900">Сервис тестирования персонала</h1>
|
||||
<p class="mt-2 text-ink-500">
|
||||
Этап 1 миграции: переход на единый стек (Flask + Jinja). Бизнес-функции
|
||||
переносятся последовательно — авторизация, каталог тестов, редактор,
|
||||
назначения, прохождение, импорт/AI.
|
||||
</p>
|
||||
|
||||
<div class="mt-5 flex flex-wrap gap-2 text-sm">
|
||||
<a href="{{ url_for('tests.tests_list_page') }}"
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-brand-600 hover:bg-brand-700 text-white transition">
|
||||
<span class="material-symbols-outlined text-base">list_alt</span>
|
||||
Каталог тестов
|
||||
</a>
|
||||
<a href="{{ url_for('main.health') }}"
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-brand-50 text-brand-700 hover:bg-brand-100 transition">
|
||||
<span class="material-symbols-outlined text-base">monitoring</span>
|
||||
Health-check
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 mt-4">
|
||||
{% for title, descr, icon in [
|
||||
('Авторизация', 'E1.1 — логин по HR/локальному пользователю.', 'login'),
|
||||
('Тесты', 'E1.2 — каталог, фильтры, карточки.', 'list_alt'),
|
||||
('Редактор', 'E1.3 — создание/правка теста, AI-помощник.', 'edit_note'),
|
||||
('Назначения', 'E1.4 — назначить сотрудникам, отслеживать.', 'assignment'),
|
||||
('Прохождение', 'E1.5 — UI прохождения теста сотрудником.', 'fact_check'),
|
||||
('Импорт/AI', 'E1.6 — генерация черновиков из документов.', 'auto_awesome'),
|
||||
] %}
|
||||
<article class="rounded-xl bg-white border border-ink-300/60 p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-brand-600">{{ icon }}</span>
|
||||
<h3 class="font-semibold">{{ title }}</h3>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-ink-500">{{ descr }}</p>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Настройки — LLM{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-6 max-w-2xl">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-brand-600">settings</span>
|
||||
<h1 class="text-2xl font-semibold">Настройки</h1>
|
||||
</div>
|
||||
|
||||
<h2 class="mt-5 font-semibold">Подключение к LLM</h2>
|
||||
<p class="mt-1 text-sm text-ink-500">
|
||||
Ключ задаётся в <code class="px-1 py-0.5 rounded bg-ink-100">.env</code> сервера
|
||||
(общий, не на пользователя). Поддерживаются DeepSeek и OpenAI-совместимые API.
|
||||
После изменения <code class="px-1 py-0.5 rounded bg-ink-100">.env</code> нужен рестарт процесса.
|
||||
</p>
|
||||
|
||||
<dl class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||
<dt class="text-ink-500">Статус ключа</dt>
|
||||
<dd>
|
||||
{% if configured %}
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-green-50 text-green-700 border border-green-200">
|
||||
<span class="material-symbols-outlined text-base">check_circle</span> Задан
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-red-50 text-red-700 border border-red-200">
|
||||
<span class="material-symbols-outlined text-base">error</span> Не задан
|
||||
</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt class="text-ink-500">Провайдер</dt>
|
||||
<dd>{{ provider or '—' }}</dd>
|
||||
<dt class="text-ink-500">Модель</dt>
|
||||
<dd>{{ model or '—' }}</dd>
|
||||
<dt class="text-ink-500">Base URL</dt>
|
||||
<dd class="break-all">{{ base_url or '—' }}</dd>
|
||||
</dl>
|
||||
|
||||
{% if not configured %}
|
||||
<div class="mt-5 rounded-lg bg-ink-100/60 border border-ink-300/60 p-4 text-sm">
|
||||
<p class="font-medium">Как задать ключ</p>
|
||||
<pre class="mt-2 text-xs whitespace-pre-wrap font-mono">DEEPSEEK_API_KEY=sk-...
|
||||
# либо
|
||||
OPENAI_API_KEY=sk-...
|
||||
# опционально:
|
||||
# LLM_BASE_URL=https://api.deepseek.com/v1
|
||||
# LLM_MODEL=deepseek-chat</pre>
|
||||
<p class="mt-2 text-ink-500">
|
||||
Файл: <code>flask_app/.env</code>. После сохранения — рестарт процесса.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-5 flex items-center gap-3">
|
||||
<button id="btn-ping"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg
|
||||
bg-brand-600 hover:bg-brand-700 text-white text-sm">
|
||||
<span class="material-symbols-outlined text-base">cable</span>
|
||||
Проверить подключение
|
||||
</button>
|
||||
<span id="ping-status" class="text-sm text-ink-500"></span>
|
||||
</div>
|
||||
|
||||
<div id="ping-result" class="mt-4 hidden text-sm rounded-lg p-3 border"></div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(() => {
|
||||
const btn = document.getElementById('btn-ping');
|
||||
const status = document.getElementById('ping-status');
|
||||
const result = document.getElementById('ping-result');
|
||||
btn.addEventListener('click', async () => {
|
||||
status.textContent = 'Запрос…';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await fetch('/api/llm/ping', { method: 'POST' });
|
||||
const d = await r.json();
|
||||
result.classList.remove('hidden', 'bg-green-50', 'border-green-200', 'text-green-800',
|
||||
'bg-red-50', 'border-red-200', 'text-red-800');
|
||||
if (d.ok) {
|
||||
result.classList.add('bg-green-50', 'border-green-200', 'text-green-800');
|
||||
result.innerHTML = `<b>OK</b> · ${d.provider} / ${d.model} · ${d.latencyMs} мс`
|
||||
+ (d.sample ? `<br><span class="text-xs opacity-80">Ответ: ${d.sample.replace(/</g,'<')}</span>` : '');
|
||||
} else {
|
||||
result.classList.add('bg-red-50', 'border-red-200', 'text-red-800');
|
||||
result.innerHTML = `<b>Ошибка</b> · ${d.code || ''}<br>${(d.error || '').replace(/</g,'<')}`;
|
||||
}
|
||||
} catch (e) {
|
||||
result.classList.remove('hidden');
|
||||
result.classList.add('bg-red-50', 'border-red-200', 'text-red-800');
|
||||
result.textContent = e.message || 'Сбой запроса.';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
status.textContent = '';
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,191 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ content.test.title }} — редактор{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="editor-root"
|
||||
data-test-id="{{ test_id }}"
|
||||
data-initial='{{ content | tojson | safe }}'>
|
||||
<!-- Шапка: название/описание/проходной балл -->
|
||||
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-5">
|
||||
<div class="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div class="flex-1 min-w-[260px]">
|
||||
<label class="block">
|
||||
<span class="text-xs font-medium text-ink-500 uppercase">Название</span>
|
||||
<input id="test-title" type="text" maxlength="200"
|
||||
class="mt-1 w-full rounded-lg border border-ink-300 px-3 py-2 text-lg font-semibold
|
||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
|
||||
</label>
|
||||
<label class="block mt-3">
|
||||
<span class="text-xs font-medium text-ink-500 uppercase">Описание</span>
|
||||
<textarea id="test-description" rows="2"
|
||||
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>
|
||||
</div>
|
||||
<div class="w-44">
|
||||
<label class="block">
|
||||
<span class="text-xs font-medium text-ink-500 uppercase">Проходной балл, %</span>
|
||||
<input id="test-threshold" type="number" min="0" max="100" step="1"
|
||||
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" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- AI-панель -->
|
||||
<section class="mt-4 rounded-2xl bg-brand-50/60 border border-brand-100 p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-brand-600">auto_awesome</span>
|
||||
<h2 class="font-semibold text-brand-700">AI-помощник</h2>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-ink-700">
|
||||
Сгенерировать вопросы по текущей сетке (число вопросов и вариантов берётся из таблицы ниже).
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2 items-center">
|
||||
<button id="ai-generate-test"
|
||||
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg
|
||||
bg-brand-600 hover:bg-brand-700 text-white text-sm">
|
||||
<span class="material-symbols-outlined text-base">stars</span>
|
||||
Сгенерировать по сетке
|
||||
</button>
|
||||
<button id="ai-generate-by-title"
|
||||
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg
|
||||
bg-white border border-brand-300/60 text-brand-700 hover:bg-brand-50 text-sm">
|
||||
<span class="material-symbols-outlined text-base">edit_note</span>
|
||||
Сгенерировать по названию
|
||||
</button>
|
||||
<button id="ai-check"
|
||||
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg
|
||||
bg-white border border-ink-300/60 hover:border-brand-300 text-sm">
|
||||
<span class="material-symbols-outlined text-base">fact_check</span>
|
||||
Проверить тест
|
||||
</button>
|
||||
<button id="ai-improve"
|
||||
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg
|
||||
bg-white border border-ink-300/60 hover:border-brand-300 text-sm">
|
||||
<span class="material-symbols-outlined text-base">tune</span>
|
||||
Улучшить тест
|
||||
</button>
|
||||
<label class="inline-flex items-center gap-2 px-3 py-2 rounded-lg
|
||||
bg-white border border-ink-300/60 hover:border-brand-300 text-sm cursor-pointer">
|
||||
<span class="material-symbols-outlined text-base text-brand-600">upload_file</span>
|
||||
<span>Импорт документа</span>
|
||||
<input id="ai-import-file" type="file" accept=".pdf,.docx,.txt,.md" class="hidden" />
|
||||
</label>
|
||||
<span id="ai-status" class="text-sm text-ink-500"></span>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-ink-500">
|
||||
Поддерживаются PDF, DOCX, TXT, MD (до 16 МБ). AI извлечёт текст и предложит черновик теста.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Список вопросов -->
|
||||
<section class="mt-4">
|
||||
<div class="flex items-center justify-between gap-2 px-1">
|
||||
<h2 class="font-semibold">Вопросы (<span id="q-count">0</span>)</h2>
|
||||
<button id="add-question"
|
||||
class="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg
|
||||
bg-white border border-ink-300/60 hover:border-brand-300 text-sm">
|
||||
<span class="material-symbols-outlined text-base">add</span>
|
||||
Добавить вопрос
|
||||
</button>
|
||||
</div>
|
||||
<ol id="questions" class="mt-3 space-y-3"></ol>
|
||||
</section>
|
||||
|
||||
<!-- Footer: сохранение / активность цепочки -->
|
||||
<section class="sticky bottom-0 z-20 mt-6 -mx-4 px-4 py-3
|
||||
bg-white/90 backdrop-blur border-t border-ink-300/60
|
||||
flex items-center justify-between gap-2 flex-wrap">
|
||||
<label class="inline-flex items-center gap-2 text-sm">
|
||||
<input id="chain-active" type="checkbox"
|
||||
class="rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
|
||||
<span>Цепочка активна (виден в каталоге)</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="save-status" class="text-sm text-ink-500"></span>
|
||||
<a href="{{ url_for('tests.tests_list_page') }}"
|
||||
class="px-3 py-2 rounded-lg text-ink-700 hover:bg-ink-100 text-sm">К каталогу</a>
|
||||
<button id="save-draft"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg
|
||||
bg-brand-600 hover:bg-brand-700 text-white">
|
||||
<span class="material-symbols-outlined text-base">save</span>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Шаблон вопроса -->
|
||||
<template id="tpl-question">
|
||||
<li class="rounded-xl bg-white border border-ink-300/60 p-4 q-item">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<span class="text-xs uppercase tracking-wide text-ink-500 q-num">Вопрос #</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<button class="q-up p-1 rounded hover:bg-ink-100" title="Выше">
|
||||
<span class="material-symbols-outlined text-base">arrow_upward</span>
|
||||
</button>
|
||||
<button class="q-down p-1 rounded hover:bg-ink-100" title="Ниже">
|
||||
<span class="material-symbols-outlined text-base">arrow_downward</span>
|
||||
</button>
|
||||
<button class="q-delete p-1 rounded hover:bg-red-50 text-red-600" title="Удалить">
|
||||
<span class="material-symbols-outlined text-base">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</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"
|
||||
rows="2" placeholder="Формулировка вопроса"></textarea>
|
||||
|
||||
<div class="mt-2 flex items-center justify-between gap-2 flex-wrap text-sm">
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input type="checkbox" class="q-multi rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
|
||||
<span>Несколько правильных ответов</span>
|
||||
</label>
|
||||
<button class="q-ai inline-flex items-center gap-1 px-2 py-1 rounded-lg
|
||||
bg-brand-50 hover:bg-brand-100 text-brand-700">
|
||||
<span class="material-symbols-outlined text-base">auto_awesome</span>
|
||||
AI: вопрос/переформулировать
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul class="q-options mt-3 space-y-2"></ul>
|
||||
<div class="mt-2">
|
||||
<button class="q-add-option inline-flex items-center gap-1 text-sm text-brand-700 hover:underline">
|
||||
<span class="material-symbols-outlined text-base">add</span> Добавить вариант
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<!-- Модалка результата AI-проверки/улучшения -->
|
||||
<dialog id="ai-modal" class="rounded-2xl p-0 max-w-3xl w-full backdrop:bg-black/40">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 id="ai-modal-title" class="text-lg font-semibold">AI</h3>
|
||||
<button id="ai-modal-close" class="p-1 rounded hover:bg-ink-100">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="ai-modal-body" class="mt-3 max-h-[70vh] overflow-y-auto"></div>
|
||||
<div id="ai-modal-actions" class="mt-4 flex items-center justify-end gap-2"></div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<template id="tpl-option">
|
||||
<li class="flex items-center gap-2 opt-item">
|
||||
<input type="checkbox" class="opt-correct rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
|
||||
<input type="text" class="opt-text flex-1 rounded-lg border border-ink-300 px-3 py-1.5
|
||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
||||
placeholder="Вариант ответа" />
|
||||
<button class="opt-delete p-1 rounded hover:bg-red-50 text-red-600" title="Удалить">
|
||||
<span class="material-symbols-outlined text-base">close</span>
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/editor.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,128 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Тесты — каталог{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-6">
|
||||
<div class="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 class="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 gap-2 px-4 py-2 rounded-lg
|
||||
bg-brand-600 hover:bg-brand-700 text-white font-medium transition">
|
||||
<span class="material-symbols-outlined text-base">add</span>
|
||||
Создать тест
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if visible %}
|
||||
<ul class="mt-5 grid gap-3 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 p-4 bg-white">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<h3 class="font-semibold text-ink-900 line-clamp-2">{{ t.title }}</h3>
|
||||
<span class="text-xs text-ink-500 shrink-0">v{{ t.version }}</span>
|
||||
</div>
|
||||
{% if t.description %}
|
||||
<p class="mt-1 text-sm text-ink-500 line-clamp-3">{{ t.description }}</p>
|
||||
{% endif %}
|
||||
<p class="mt-2 text-xs text-ink-500">Автор: {{ t.author_full_name or '—' }}</p>
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}"
|
||||
class="inline-flex items-center gap-1 text-sm text-brand-700 hover:underline">
|
||||
<span class="material-symbols-outlined text-base">edit_note</span>
|
||||
Открыть редактор
|
||||
</a>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<dialog id="dlg-create" class="rounded-2xl p-0 backdrop:bg-ink-900/40 max-w-md w-full">
|
||||
<form method="dialog" class="bg-white rounded-2xl">
|
||||
<div class="p-5 border-b border-ink-300/60">
|
||||
<h2 class="text-lg font-semibold">Новый тест</h2>
|
||||
</div>
|
||||
<div class="p-5 space-y-3">
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-ink-700">Название</span>
|
||||
<input id="new-test-title" type="text" required maxlength="200"
|
||||
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" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-ink-700">Описание (опц.)</span>
|
||||
<textarea id="new-test-desc" rows="3"
|
||||
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>
|
||||
</div>
|
||||
<div class="p-4 border-t border-ink-300/60 flex justify-end gap-2 bg-ink-100/40 rounded-b-2xl">
|
||||
<button type="button" id="dlg-cancel"
|
||||
class="px-4 py-2 rounded-lg text-ink-700 hover:bg-ink-100">Отмена</button>
|
||||
<button type="button" id="dlg-submit"
|
||||
class="px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white">
|
||||
Создать
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(() => {
|
||||
const dlg = document.getElementById('dlg-create');
|
||||
const titleEl = document.getElementById('new-test-title');
|
||||
const descEl = document.getElementById('new-test-desc');
|
||||
|
||||
document.getElementById('btn-create-test').addEventListener('click', () => {
|
||||
titleEl.value = '';
|
||||
descEl.value = '';
|
||||
if (typeof dlg.showModal === 'function') dlg.showModal();
|
||||
else dlg.setAttribute('open', 'open');
|
||||
setTimeout(() => titleEl.focus(), 50);
|
||||
});
|
||||
document.getElementById('dlg-cancel').addEventListener('click', () => dlg.close());
|
||||
|
||||
document.getElementById('dlg-submit').addEventListener('click', async () => {
|
||||
const title = titleEl.value.trim();
|
||||
if (!title) { titleEl.focus(); return; }
|
||||
try {
|
||||
const r = await fetch('/api/tests', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, description: descEl.value.trim() || null }),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'Не удалось создать тест.');
|
||||
window.location.href = `/tests/${data.testId}/edit`;
|
||||
} catch (e) {
|
||||
alert(e.message || 'Не удалось создать тест.');
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user