Дорабоки интерфейса системы тестирования. Раздел 1 Шапка+Верхний brick
This commit is contained in:
@@ -2,57 +2,99 @@
|
||||
{% 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>
|
||||
{% if ui_variant == 'legacy' %}
|
||||
<div class="login-page">
|
||||
<div class="login-shell">
|
||||
<div class="login-logo">
|
||||
<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>
|
||||
<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>
|
||||
{% 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 %}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
{# Tailwind CDN — на E1.0 этого достаточно. В Этапе 2/CI можно заменить на сборку. #}
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
// Минимальная палитра в стиле кабинета HR. Без зависимостей от HR-репо.
|
||||
// Палитра/типографика в стиле webapp-nginx (cabinet-theme).
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
@@ -17,18 +17,19 @@
|
||||
},
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#eef2ff',
|
||||
100: '#e0e7ff',
|
||||
500: '#6366f1',
|
||||
600: '#4f46e5',
|
||||
700: '#4338ca',
|
||||
50: '#ecf7f6',
|
||||
100: '#d9efec',
|
||||
300: '#9bd7d0',
|
||||
500: '#007168',
|
||||
600: '#00645b',
|
||||
700: '#00574f',
|
||||
},
|
||||
ink: {
|
||||
900: '#0f172a',
|
||||
700: '#334155',
|
||||
500: '#64748b',
|
||||
300: '#cbd5e1',
|
||||
100: '#f1f5f9',
|
||||
900: '#0d1b1d',
|
||||
700: '#3d5357',
|
||||
500: '#506965',
|
||||
300: '#b9bc94',
|
||||
100: '#f3f8f9',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -39,7 +40,7 @@
|
||||
<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"
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Manrope:wght@600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
@@ -50,65 +51,97 @@
|
||||
<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-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>
|
||||
<body data-ui-variant="{{ ui_variant }}"
|
||||
class="min-h-screen bg-ink-100 text-ink-900 font-sans antialiased ui-{{ ui_variant }}">
|
||||
{% if ui_variant == 'legacy' %}
|
||||
<div class="cabinet-app">
|
||||
<header class="cabinet-header">
|
||||
<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>
|
||||
<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">{{ 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 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>
|
||||
</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>
|
||||
{% 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') }}"
|
||||
alt="Логотип клиники" class="h-7 w-7 object-contain" />
|
||||
<span>Тестирование</span>
|
||||
</a>
|
||||
<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 %}
|
||||
</body>
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
{% block title %}Настройки — LLM{% endblock %}
|
||||
|
||||
{% 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">
|
||||
<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>
|
||||
<h2 class="mt-5 font-semibold {% if ui_variant == 'legacy' %}font-headline{% endif %}">Подключение к 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.
|
||||
@@ -53,8 +53,7 @@ OPENAI_API_KEY=sk-...
|
||||
|
||||
<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">
|
||||
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 %}">
|
||||
<span class="material-symbols-outlined text-base">cable</span>
|
||||
Проверить подключение
|
||||
</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 %}
|
||||
<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-initial='{{ content | tojson | safe }}'>
|
||||
|
||||
{# ── 1. Шапка теста ─────────────────────────────────────────── #}
|
||||
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-4 sm:p-5">
|
||||
<h2 class="text-xs font-medium text-ink-500 uppercase tracking-wide">Тест</h2>
|
||||
<section class="cabinet-brick cabinet-brick--hero hero-brick">
|
||||
<div class="hero-brick__nav">
|
||||
<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">
|
||||
<span class="sr-only">Название</span>
|
||||
<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>
|
||||
<textarea id="test-title" maxlength="200" rows="1" placeholder="Название теста"
|
||||
class="hero-brick__title font-headline"></textarea>
|
||||
|
||||
<label class="mt-3 block">
|
||||
<span class="text-xs font-medium text-ink-500">Описание</span>
|
||||
<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>
|
||||
<textarea id="test-description" rows="2" placeholder="Краткое описание (необязательно)"
|
||||
class="hero-brick__desc"></textarea>
|
||||
|
||||
<label class="mt-3 flex items-center justify-between gap-3">
|
||||
<span class="text-xs font-medium text-ink-500">Проходной балл, %</span>
|
||||
<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
|
||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
|
||||
</label>
|
||||
<div class="hero-brick__chips">
|
||||
<label class="hero-brick__chip">
|
||||
<span>Порог зачёта</span>
|
||||
<input id="test-threshold" type="number" min="0" max="100" step="1" inputmode="numeric" />
|
||||
<span>%</span>
|
||||
</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>
|
||||
|
||||
{# ── 2. AI-помощник ─────────────────────────────────────────── #}
|
||||
<section class="rounded-2xl bg-brand-50/60 border border-brand-100 p-4 sm:p-5">
|
||||
<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>
|
||||
|
||||
{# Группа A — генерация. Главные действия. На sm+ — в одну строку. #}
|
||||
<div class="mt-3">
|
||||
<p class="text-xs font-medium text-ink-500 uppercase tracking-wide">Создать вопросы</p>
|
||||
<div class="mt-2 grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button id="ai-generate-by-title"
|
||||
class="inline-flex items-center justify-center gap-2 px-3 py-3 rounded-lg
|
||||
bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium min-h-11">
|
||||
<span class="material-symbols-outlined text-base">edit_note</span>
|
||||
По названию
|
||||
</button>
|
||||
<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">
|
||||
<section class="rounded-2xl bg-brand-50/60 border border-brand-100 p-4 sm:p-5 test-detail-ai-panel">
|
||||
<div class="question-editor-block question-editor-block--first">
|
||||
<h3 class="test-detail-subsection__title" style="margin-top:0;">Генерация сетки вопросов (ИИ)</h3>
|
||||
<label class="block">
|
||||
<span class="form-label">Тема</span>
|
||||
<input id="ai-topic" type="text" class="form-input" placeholder="Например: Введение про LLM" />
|
||||
</label>
|
||||
<div class="mt-3 flex flex-wrap items-end gap-3">
|
||||
<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"
|
||||
class="inline-flex items-center justify-center gap-2 px-3 py-3 rounded-lg
|
||||
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>
|
||||
По текущей сетке
|
||||
class="btn btn-ghost" type="button" style="min-height:43px;">
|
||||
Сгенерировать тест (ИИ)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Группа B — анализ существующего. #}
|
||||
<div class="mt-4">
|
||||
<p class="text-xs font-medium text-ink-500 uppercase tracking-wide">Улучшить существующее</p>
|
||||
<div class="mt-2 grid grid-cols-2 gap-2">
|
||||
<button id="ai-check"
|
||||
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>
|
||||
<div class="question-editor-block test-detail-subsection test-detail-subsection--import">
|
||||
<h3 class="test-detail-subsection__title">Документ в вопросы</h3>
|
||||
<p class="muted test-detail-hint" style="margin-top:0;">
|
||||
PDF, Word или текст — вставьте в черновик вопросов.
|
||||
</p>
|
||||
<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
|
||||
text-sm cursor-pointer min-h-11">
|
||||
@@ -103,10 +101,11 @@
|
||||
{# ── 3. Вопросы ─────────────────────────────────────────────── #}
|
||||
<section>
|
||||
<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"
|
||||
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="hidden sm:inline">Добавить вопрос</span>
|
||||
<span class="sm:hidden">Добавить</span>
|
||||
@@ -114,37 +113,91 @@
|
||||
</div>
|
||||
<ol id="questions" class="mt-3 space-y-3"></ol>
|
||||
</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>
|
||||
|
||||
{# ── Sticky-footer: «Цепочка активна» + «Сохранить» ────────────── #}
|
||||
<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)]">
|
||||
<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">
|
||||
<label class="inline-flex items-center gap-2 text-sm min-w-0">
|
||||
<input id="chain-active" type="checkbox"
|
||||
class="rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
|
||||
<span class="truncate">Цепочка активна</span>
|
||||
</label>
|
||||
<span class="text-xs text-ink-500 truncate">Активность цепочки и поля теста — в шапке.</span>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<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>
|
||||
<button id="save-draft"
|
||||
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>
|
||||
Сохранить
|
||||
</button>
|
||||
</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>
|
||||
|
||||
{# ── Шаблон вопроса ─────────────────────────────────────────────── #}
|
||||
<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">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-md
|
||||
@@ -165,27 +218,28 @@
|
||||
</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"
|
||||
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">
|
||||
<label class="inline-flex items-center gap-2 min-h-9">
|
||||
<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 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>
|
||||
|
||||
<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
|
||||
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>
|
||||
Добавить вариант
|
||||
</button>
|
||||
@@ -194,19 +248,19 @@
|
||||
|
||||
{# ── Шаблон варианта ────────────────────────────────────────────── #}
|
||||
<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. #}
|
||||
<label class="inline-flex items-center justify-center w-10 h-10 shrink-0 cursor-pointer
|
||||
rounded hover:bg-ink-100" title="Правильный ответ">
|
||||
<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>
|
||||
<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"
|
||||
placeholder="Вариант ответа" />
|
||||
<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="Удалить вариант">
|
||||
<span class="material-symbols-outlined text-base">close</span>
|
||||
</button>
|
||||
|
||||
@@ -2,66 +2,122 @@
|
||||
{% block title %}Тесты — каталог{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<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>
|
||||
{% if ui_variant == 'legacy' %}
|
||||
<section class="legacy-list-shell">
|
||||
<h1 class="font-headline legacy-list-title">Тесты</h1>
|
||||
<div class="legacy-list-toolbar">
|
||||
<button id="btn-create-test" class="btn btn-ghost" type="button">
|
||||
Создать
|
||||
</button>
|
||||
</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>
|
||||
{% if visible %}
|
||||
<ul class="list-stack" aria-label="Тесты в общем списке">
|
||||
{% for t in visible %}
|
||||
<li class="list-row list-row--split">
|
||||
<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>
|
||||
{% 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 class="list-row__side">
|
||||
<button type="button" class="btn btn-ghost btn-start-pass" data-test-id="{{ t.id }}">Пройти</button>
|
||||
</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>
|
||||
{% else %}
|
||||
<p class="text-muted">Нет тестов</p>
|
||||
{% 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"
|
||||
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 || 'Не удалось создать тест.');
|
||||
}
|
||||
});
|
||||
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user