блоки 2 и 3 доработки интерфейса системы тестирования

This commit is contained in:
Константин Лебединский
2026-04-29 21:06:17 +05:00
parent eff3fda5b0
commit bba96f8f9f
37 changed files with 4440 additions and 1292 deletions
+169
View File
@@ -0,0 +1,169 @@
{% extends "base.html" %}
{% block title %}Назначения — Тестирование персонала{% endblock %}
{% block content %}
<div class="space-y-4 sm:space-y-5 pb-10">
<div class="flex items-center justify-between gap-3">
<h1 class="text-xl font-semibold text-ink-900">Назначения</h1>
<a href="{{ url_for('main.index') }}" class="link-back text-sm">← Главная</a>
</div>
{# Выбор теста #}
<div class="cabinet-brick">
<h2 class="font-semibold text-sm text-ink-700 mb-3">Тест</h2>
<select id="assign-test-select" class="form-input">
<option value="">— Выберите тест —</option>
{% for t in tests %}
<option value="{{ t.id }}">{{ t.title }}</option>
{% endfor %}
</select>
</div>
{# Панель назначения (скрыта пока не выбран тест) #}
<div id="assign-panel" class="cabinet-brick hidden">
<h2 class="font-semibold text-sm text-ink-700 mb-3">Кому выдать</h2>
<p class="test-detail-hint">Список с учётом поиска и фильтров; можно отметить всех на экране.</p>
<div class="assign-toolbar mt-3">
<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 mt-3"></div>
<div class="inline-actions mt-3">
<button id="assign-submit" class="btn btn-primary" type="button">Назначить выбранным</button>
<span id="assign-status" class="muted"></span>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
(function () {
const $ = (s) => document.querySelector(s);
const testSelect = $('#assign-test-select');
const assignPanel = $('#assign-panel');
const assignSearchEl = $('#assign-search');
const assignDeptEl = $('#assign-dept');
const assignClinicEl = $('#assign-clinic');
const assignListEl = $('#assign-list');
const assignSelectAllBtn = $('#assign-select-all');
const assignSubmitBtn = $('#assign-submit');
const assignStatusEl = $('#assign-status');
let currentTestId = null;
let assignPeople = [];
let assignSelected = new Set();
function escHtml(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function renderAssignList() {
assignListEl.innerHTML = '';
assignPeople.forEach((p) => {
const row = document.createElement('label');
row.className = `assign-row${assignSelected.has(String(p.staffId)) ? ' assign-row--selected' : ''}`;
row.innerHTML = `
<input type="checkbox" ${assignSelected.has(String(p.staffId)) ? 'checked' : ''} />
<span class="assign-row__text">
<span class="assign-row__fio">${escHtml(p.fio || '—')}</span>
${p.webLogin ? `<span class="assign-row__login">${escHtml(p.webLogin)}</span>` : ''}
<span class="assign-row__meta">${escHtml(p.department || '—')}</span>
</span>`;
const cb = row.querySelector('input');
cb.addEventListener('change', () => {
const k = String(p.staffId);
if (cb.checked) assignSelected.add(k); else assignSelected.delete(k);
row.classList.toggle('assign-row--selected', cb.checked);
});
assignListEl.appendChild(row);
});
if (!assignPeople.length) assignListEl.innerHTML = '<p class="muted" style="padding:.75rem;">Никого не найдено.</p>';
}
async function loadDirectory() {
if (!currentTestId) return;
assignStatusEl.textContent = 'Загружаем…';
try {
const params = new URLSearchParams();
if (assignSearchEl.value.trim()) params.set('q', assignSearchEl.value.trim());
if (assignDeptEl.value && assignDeptEl.value !== '__all__') params.set('department', assignDeptEl.value);
params.set('clinic', assignClinicEl.value || 'all');
const r = await fetch(`/api/auth/dev/assignment-directory?${params.toString()}`);
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Не удалось загрузить сотрудников');
assignPeople = data.people || [];
const depts = data.departments || [];
if (assignDeptEl.options.length <= 1) {
depts.forEach((d) => {
const o = document.createElement('option');
o.value = d; o.textContent = d;
assignDeptEl.appendChild(o);
});
}
assignSelected = new Set();
renderAssignList();
assignStatusEl.textContent = '';
} catch (e) {
assignStatusEl.textContent = e.message || 'Ошибка загрузки';
}
}
testSelect.addEventListener('change', () => {
currentTestId = testSelect.value || null;
if (currentTestId) {
assignPanel.classList.remove('hidden');
// Сброс фильтров
assignDeptEl.innerHTML = '<option value="__all__">Все отделы</option>';
assignSearchEl.value = '';
loadDirectory();
} else {
assignPanel.classList.add('hidden');
}
});
let t = null;
assignSearchEl.addEventListener('input', () => { clearTimeout(t); t = setTimeout(loadDirectory, 350); });
assignDeptEl.addEventListener('change', loadDirectory);
assignClinicEl.addEventListener('change', loadDirectory);
assignSelectAllBtn.addEventListener('click', () => {
assignPeople.forEach((p) => assignSelected.add(String(p.staffId)));
renderAssignList();
});
assignSubmitBtn.addEventListener('click', async () => {
if (!currentTestId) return;
const selectedRows = assignPeople.filter((p) => assignSelected.has(String(p.staffId)));
if (!selectedRows.length) { assignStatusEl.textContent = 'Никто не выбран'; return; }
assignStatusEl.textContent = 'Назначаем…';
try {
const payload = selectedRows.map((p) => ({
staffId: p.staffId,
webLogin: p.webLogin || null,
fio: p.fio || null,
department: p.department || null,
}));
const r = await fetch(`/api/tests/${currentTestId}/assign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targets: payload }),
});
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Ошибка назначения');
assignStatusEl.textContent = `Назначено: ${data.count ?? selectedRows.length}`;
} catch (e) {
assignStatusEl.textContent = e.message || 'Ошибка назначения';
}
});
})();
</script>
{% endblock %}
+19 -5
View File
@@ -22,13 +22,21 @@
{% 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>
{% 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">Логин</label>
<label class="form-label" for="login-username">
{% if dev_fio_enabled %}ФИО или логин{% else %}Логин{% endif %}
</label>
<input id="login-username" class="form-input" type="text" name="login"
value="{{ login or '' }}" required autofocus autocomplete="username" />
value="{{ login or '' }}" required autofocus autocomplete="username"
placeholder="{% if dev_fio_enabled %}Иванов Иван Иванович{% endif %}" />
</div>
<div class="form-field">
@@ -50,9 +58,12 @@
<h1 class="text-xl font-semibold">Вход в систему</h1>
</div>
<p class="mt-1 text-sm text-ink-500">
Используйте логин и пароль.
{% if hr_auth_enabled %}
{% if dev_fio_enabled %}
Введите <b>ФИО</b> из кадровой системы и общий dev-пароль — или обычный логин/пароль.
{% elif hr_auth_enabled %}
Учётка кадровой системы (HR).
{% else %}
Используйте логин и пароль.
{% endif %}
</p>
@@ -74,8 +85,11 @@
<input type="hidden" name="next" value="{{ next or '/' }}">
<label class="block">
<span class="text-sm font-medium text-ink-700">Логин</span>
<span class="text-sm font-medium text-ink-700">
{% if dev_fio_enabled %}ФИО или логин{% else %}Логин{% endif %}
</span>
<input type="text" name="login" value="{{ login or '' }}" required autofocus autocomplete="username"
placeholder="{% if dev_fio_enabled %}Иванов Иван Иванович{% endif %}"
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>
-1
View File
@@ -107,7 +107,6 @@
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 }}
+59 -38
View File
@@ -1,45 +1,66 @@
{% extends "base.html" %}
{% block title %}Тестирование — главная{% endblock %}
{% 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>
<h1 class="text-2xl font-semibold text-ink-900 mb-5">Тестирование персонала</h1>
<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 class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{# Статистика #}
<a href="{{ url_for('main.stats_page') }}"
class="rounded-2xl bg-white border border-ink-300/50 shadow-sm p-5 flex flex-col gap-3
hover:shadow-md hover:border-brand-300 transition-all group">
<div class="flex items-center gap-3">
<span class="material-symbols-outlined text-brand-500 text-2xl">bar_chart</span>
<h2 class="font-semibold text-ink-900">Статистика</h2>
</div>
</section>
<p class="text-sm text-ink-500 flex-1">Прохождения по отделам, общая динамика и последняя активность.</p>
<span class="inline-flex items-center gap-1 text-xs font-medium text-brand-600 group-hover:underline">
Открыть <span class="material-symbols-outlined text-sm">arrow_forward</span>
</span>
</a>
<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>
{# Тесты #}
<a href="{{ url_for('tests.tests_list_page') }}"
class="rounded-2xl bg-white border border-ink-300/50 shadow-sm p-5 flex flex-col gap-3
hover:shadow-md hover:border-brand-300 transition-all group">
<div class="flex items-center gap-3">
<span class="material-symbols-outlined text-brand-500 text-2xl">list_alt</span>
<h2 class="font-semibold text-ink-900">Тесты</h2>
</div>
<p class="text-sm text-ink-500 flex-1">Каталог тестов, создание, редактирование и прохождение.</p>
<span class="inline-flex items-center gap-1 text-xs font-medium text-brand-600 group-hover:underline">
Открыть <span class="material-symbols-outlined text-sm">arrow_forward</span>
</span>
</a>
{# Назначения #}
<a href="{{ url_for('main.assignments_page') }}"
class="rounded-2xl bg-white border border-ink-300/50 shadow-sm p-5 flex flex-col gap-3
hover:shadow-md hover:border-brand-300 transition-all group">
<div class="flex items-center gap-3">
<span class="material-symbols-outlined text-brand-500 text-2xl">assignment_ind</span>
<h2 class="font-semibold text-ink-900">Назначения</h2>
</div>
<p class="text-sm text-ink-500 flex-1">Выдача тестов сотрудникам и отделам.</p>
<span class="inline-flex items-center gap-1 text-xs font-medium text-brand-600 group-hover:underline">
Открыть <span class="material-symbols-outlined text-sm">arrow_forward</span>
</span>
</a>
{# Настройки ИИ #}
<a href="{{ url_for('settings.prompts_page') }}"
class="rounded-2xl bg-white border border-ink-300/50 shadow-sm p-5 flex flex-col gap-3
hover:shadow-md hover:border-brand-300 transition-all group">
<div class="flex items-center gap-3">
<span class="material-symbols-outlined text-brand-500 text-2xl">psychology</span>
<h2 class="font-semibold text-ink-900">Настройки ИИ</h2>
</div>
<p class="text-sm text-ink-500 flex-1">Редактор промптов — генерация вопросов, дистракторы, улучшение теста.</p>
<span class="inline-flex items-center gap-1 text-xs font-medium text-brand-600 group-hover:underline">
Открыть <span class="material-symbols-outlined text-sm">arrow_forward</span>
</span>
</a>
</div>
{% endblock %}
@@ -0,0 +1,433 @@
{% extends "base.html" %}
{% block title %}Настройки ИИ — промпты{% endblock %}
{% block head %}
<style>
.pe-wrap {
border: 1px solid #d1d5db;
border-radius: 0.625rem;
background: #fff;
transition: border-color 0.15s, box-shadow 0.15s;
}
.pe-wrap:focus-within {
border-color: #00645b;
box-shadow: 0 0 0 3px rgba(0,100,91,0.12);
}
.pe-field {
min-height: 72px;
padding: 10px 12px;
font-family: 'Inter', system-ui, sans-serif;
font-size: 13.5px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
outline: none;
cursor: text;
}
.pe-chip {
display: inline-flex;
align-items: center;
padding: 1px 9px 1px 7px;
height: 20px;
border-radius: 99px;
background: #d9efec;
color: #00574f;
font-size: 12px;
font-weight: 600;
border: 1px solid #9bd7d0;
cursor: grab;
user-select: none;
vertical-align: middle;
line-height: 1;
white-space: nowrap;
transition: background 0.12s, opacity 0.12s;
}
.pe-chip:hover { background: #bfe8e3; }
.pe-chip.is-dragging { opacity: 0.3; cursor: grabbing; }
.pe-caret {
display: inline-block;
width: 2px;
height: 1.1em;
background: #00645b;
border-radius: 1px;
vertical-align: middle;
pointer-events: none;
animation: pe-blink 0.7s steps(1) infinite;
}
@keyframes pe-blink { 50% { opacity: 0; } }
.pc {
border: 1px solid #e5e7eb;
border-radius: 1rem;
background: #fff;
overflow: hidden;
}
.pc-head {
display: flex; align-items: center; gap: 10px;
padding: 13px 16px;
cursor: pointer;
user-select: none;
background: #f9fafb;
border-bottom: 1px solid transparent;
}
.pc.open .pc-head { border-bottom-color: #e5e7eb; }
.pc-chevron { font-size: 18px; color: #6b7280; transition: transform 0.2s; }
.pc.open .pc-chevron { transform: rotate(90deg); }
.pc-body { display: none; padding: 16px; gap: 14px; flex-direction: column; }
.pc.open .pc-body { display: flex; }
.pe-label {
display: block;
font-size: 11px; font-weight: 700; letter-spacing: .06em;
text-transform: uppercase; color: #9ca3af;
margin-bottom: 5px;
}
.pe-palette {
display: flex; flex-wrap: wrap; gap: 5px;
padding: 7px 10px;
background: #f3f8f9;
border-top: 1px solid #e5e7eb;
border-radius: 0 0 0.625rem 0.625rem;
}
.pe-palette-chip {
display: inline-flex; align-items: center; gap: 3px;
padding: 2px 10px;
border-radius: 99px;
background: #ecfdf5;
color: #065f46;
font-size: 12px;
border: 1px dashed #6ee7b7;
cursor: pointer;
transition: background 0.1s;
}
.pe-palette-chip:hover { background: #d1fae5; }
.pc-badge { font-size: 11px; padding: 2px 8px; border-radius: 99px; }
.pc-badge--ok { background: #dcfce7; color: #166534; }
.pc-badge--err { background: #fee2e2; color: #991b1b; }
</style>
{% endblock %}
{% block content %}
{# Inject prompt data safely as JSON into JS scope #}
<script>
const PROMPTS_DATA = {{ prompts | tojson }};
</script>
<div class="flex items-center justify-between mb-5 gap-3 flex-wrap">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-brand-500 text-2xl">psychology</span>
<h1 class="text-2xl font-semibold text-ink-900">Настройки ИИ</h1>
</div>
<a href="{{ url_for('main.index') }}"
class="inline-flex items-center gap-1 text-sm text-ink-500 hover:text-ink-800 transition">
<span class="material-symbols-outlined text-base">arrow_back</span>
Главная
</a>
</div>
<p class="text-sm text-ink-500 mb-5">
Переменные отображаются как
<span class="pe-chip" style="cursor:default; pointer-events:none;">Название теста</span>
— перетащите их в нужное место или нажмите «+&nbsp;переменная» внизу редактора для вставки.
</p>
<div id="pc-list" class="flex flex-col gap-3">
{% for pid, p in prompts.items() %}
<div class="pc" data-pid="{{ pid }}">
<div class="pc-head" onclick="this.closest('.pc').classList.toggle('open')">
<span class="material-symbols-outlined pc-chevron">chevron_right</span>
<div class="flex-1 min-w-0">
<div class="text-sm font-semibold text-ink-900">{{ p.label }}</div>
{% if p.description %}
<div class="text-xs text-ink-400 mt-0.5">{{ p.description }}</div>
{% endif %}
</div>
<span class="pc-save-status pc-badge"></span>
</div>
<div class="pc-body">
{# System #}
<div>
<span class="pe-label">System</span>
<div class="pe-wrap">
<div class="pe-field" contenteditable="true" spellcheck="false"
data-pid="{{ pid }}" data-field="system"></div>
<div class="pe-palette" data-pid="{{ pid }}" data-field="system"></div>
</div>
</div>
{# User #}
<div>
<span class="pe-label">User</span>
<div class="pe-wrap">
<div class="pe-field" contenteditable="true" spellcheck="false"
data-pid="{{ pid }}" data-field="user"></div>
<div class="pe-palette" data-pid="{{ pid }}" data-field="user"></div>
</div>
</div>
<div class="flex items-center gap-3 pt-1">
<button type="button"
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium transition"
onclick="savePrompt(this.closest('.pc'))">
<span class="material-symbols-outlined text-base">save</span>Сохранить
</button>
<button type="button"
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-white border border-ink-300 hover:bg-ink-100 text-ink-700 text-sm transition"
onclick="resetPrompt(this.closest('.pc'))">
<span class="material-symbols-outlined text-base">restart_alt</span>Сбросить
</button>
</div>
</div>
</div>
{% endfor %}
</div>
{% endblock %}
{% block scripts %}
<script>
(() => {
'use strict';
// ── DnD state ─────────────────────────────────────────────────────────
let _drag = null;
let _caret = null;
// ── Chip ──────────────────────────────────────────────────────────────
function makeChip(varName, label) {
const s = document.createElement('span');
s.className = 'pe-chip';
s.setAttribute('contenteditable', 'false');
s.setAttribute('draggable', 'true');
s.dataset.var = varName;
s.textContent = label;
bindChipDnD(s);
return s;
}
function bindChipDnD(chip) {
chip.addEventListener('dragstart', e => {
_drag = chip;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', '');
requestAnimationFrame(() => chip.classList.add('is-dragging'));
});
chip.addEventListener('dragend', () => {
chip.classList.remove('is-dragging');
removeCaret();
_drag = null;
});
}
// ── Render / serialize ────────────────────────────────────────────────
function renderText(el, text, vars) {
el.innerHTML = '';
const parts = text.split(/(\{[a-zA-Z_]+\})/g);
parts.forEach(part => {
const m = part.match(/^\{([a-zA-Z_]+)\}$/);
if (m && vars[m[1]] !== undefined) {
el.appendChild(makeChip(m[1], vars[m[1]]));
} else {
el.appendChild(document.createTextNode(part));
}
});
}
function serialize(el) {
let out = '';
el.childNodes.forEach(n => {
if (n.nodeType === Node.TEXT_NODE) out += n.textContent;
else if (n.dataset && n.dataset.var) out += '{' + n.dataset.var + '}';
});
return out;
}
// ── Editor DnD ────────────────────────────────────────────────────────
function removeCaret() {
if (_caret && _caret.parentNode) _caret.parentNode.removeChild(_caret);
_caret = null;
}
function caretAt(x, y) {
if (document.caretRangeFromPoint) {
const r = document.caretRangeFromPoint(x, y);
if (r) return [r.startContainer, r.startOffset];
}
if (document.caretPositionFromPoint) {
const p = document.caretPositionFromPoint(x, y);
if (p) return [p.offsetNode, p.offset];
}
return [null, 0];
}
function bindEditorDrop(el) {
el.addEventListener('dragover', e => {
if (!_drag) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
removeCaret();
const [node, off] = caretAt(e.clientX, e.clientY);
if (!node) return;
const c = document.createElement('span');
c.className = 'pe-caret';
_caret = c;
try {
const r = document.createRange();
r.setStart(node, Math.min(off, node.nodeType === Node.TEXT_NODE ? node.length : node.childNodes.length));
r.collapse(true);
r.insertNode(c);
} catch {}
});
el.addEventListener('dragleave', e => {
if (!el.contains(e.relatedTarget)) removeCaret();
});
el.addEventListener('drop', e => {
e.preventDefault();
removeCaret();
if (!_drag) return;
const [node, off] = caretAt(e.clientX, e.clientY);
if (_drag.parentNode) _drag.parentNode.removeChild(_drag);
if (node && el.contains(node)) {
try {
const r = document.createRange();
r.setStart(node, Math.min(off, node.nodeType === Node.TEXT_NODE ? node.length : node.childNodes.length));
r.collapse(true);
r.insertNode(_drag);
} catch { el.appendChild(_drag); }
} else {
el.appendChild(_drag);
}
bindChipDnD(_drag);
});
}
// ── Block deletion of chips ───────────────────────────────────────────
function bindEditorKeys(el) {
el.addEventListener('keydown', e => {
if (e.key !== 'Backspace' && e.key !== 'Delete') return;
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return;
const range = sel.getRangeAt(0);
if (!range.collapsed && range.cloneContents().querySelector('.pe-chip')) {
e.preventDefault(); return;
}
if (range.collapsed) {
const siblings = (container, offset, dir) => {
if (container.nodeType === Node.TEXT_NODE) {
if (dir === 'prev' && offset > 0) return null;
if (dir === 'next' && offset < container.length) return null;
const arr = Array.from(container.parentNode.childNodes);
const i = arr.indexOf(container);
return dir === 'prev' ? arr[i - 1] : arr[i + 1];
}
return dir === 'prev' ? container.childNodes[offset - 1] : container.childNodes[offset];
};
const check = e.key === 'Backspace'
? siblings(range.startContainer, range.startOffset, 'prev')
: siblings(range.startContainer, range.startOffset, 'next');
if (check && check.classList && check.classList.contains('pe-chip')) {
e.preventDefault();
}
}
});
}
// ── Init from PROMPTS_DATA ────────────────────────────────────────────
function initEditor(el) {
const pid = el.dataset.pid;
const field = el.dataset.field;
const prompt = PROMPTS_DATA[pid];
if (!prompt) return;
const text = prompt[field] || '';
const vars = prompt.vars || {};
renderText(el, text, vars);
bindEditorDrop(el);
bindEditorKeys(el);
}
function initPalette(palette) {
const pid = palette.dataset.pid;
const field = palette.dataset.field;
const vars = (PROMPTS_DATA[pid] || {}).vars || {};
palette.innerHTML = '';
Object.entries(vars).forEach(([varName, label]) => {
const chip = document.createElement('span');
chip.className = 'pe-palette-chip';
chip.textContent = '+ ' + label;
chip.title = 'вставить переменную «' + label + '»';
chip.addEventListener('click', () => insertVar(pid, field, varName, label));
palette.appendChild(chip);
});
}
function insertVar(pid, field, varName, label) {
const el = document.querySelector(`.pe-field[data-pid="${pid}"][data-field="${field}"]`);
if (!el) return;
el.focus();
const sel = window.getSelection();
let range;
if (sel && sel.rangeCount && el.contains(sel.getRangeAt(0).commonAncestorContainer)) {
range = sel.getRangeAt(0);
range.deleteContents();
} else {
range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
}
const chip = makeChip(varName, label);
range.insertNode(chip);
range.setStartAfter(chip);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
document.querySelectorAll('.pe-field').forEach(initEditor);
document.querySelectorAll('.pe-palette').forEach(initPalette);
// ── Save ──────────────────────────────────────────────────────────────
window.savePrompt = async function(card) {
const pid = card.dataset.pid;
const sysEl = card.querySelector('.pe-field[data-field="system"]');
const usrEl = card.querySelector('.pe-field[data-field="user"]');
const badge = card.querySelector('.pc-save-status');
badge.textContent = '';
badge.className = 'pc-save-status pc-badge';
try {
const r = await fetch('/api/ai/prompts/' + pid, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ system: serialize(sysEl), user: serialize(usrEl) }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error || 'Ошибка');
// Update in-memory data
PROMPTS_DATA[pid].system = serialize(sysEl);
PROMPTS_DATA[pid].user = serialize(usrEl);
badge.textContent = '✓ Сохранено';
badge.className = 'pc-save-status pc-badge pc-badge--ok';
setTimeout(() => { badge.textContent = ''; badge.className = 'pc-save-status pc-badge'; }, 3000);
} catch (e) {
badge.textContent = e.message;
badge.className = 'pc-save-status pc-badge pc-badge--err';
}
};
// ── Reset ─────────────────────────────────────────────────────────────
window.resetPrompt = async function(card) {
if (!confirm('Сбросить к последней сохранённой версии?')) return;
const pid = card.dataset.pid;
try {
const r = await fetch('/api/ai/prompts');
const d = await r.json();
const p = d.prompts?.[pid];
if (!p) return;
PROMPTS_DATA[pid] = p;
const sysEl = card.querySelector('.pe-field[data-field="system"]');
const usrEl = card.querySelector('.pe-field[data-field="user"]');
renderText(sysEl, p.system || '', p.vars || {});
renderText(usrEl, p.user || '', p.vars || {});
} catch (e) { alert(e.message); }
};
})();
</script>
{% endblock %}
+103
View File
@@ -0,0 +1,103 @@
{% extends "base.html" %}
{% block title %}Статистика прохождений{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6 gap-3 flex-wrap">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-brand-500 text-2xl">bar_chart</span>
<h1 class="text-2xl font-semibold text-ink-900">Статистика</h1>
</div>
<a href="{{ url_for('main.index') }}"
class="inline-flex items-center gap-1 text-sm text-ink-500 hover:text-ink-800 transition">
<span class="material-symbols-outlined text-base">arrow_back</span>
Главная
</a>
</div>
{# Сводные метрики #}
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 mb-6">
{% for icon, label, value in [
('quiz', 'Тестов всего', stats.total_tests),
('people', 'Пользователей', stats.total_users),
('fact_check', 'Прохождений', stats.total_completed),
('check_circle', 'Сдали', stats.total_passed),
('percent', 'Успешность', stats.pass_rate | string + '\u2009%'),
] %}
<div class="rounded-2xl bg-white border border-ink-300/50 shadow-sm p-4 flex flex-col gap-1">
<span class="material-symbols-outlined text-brand-500 text-xl">{{ icon }}</span>
<div class="text-2xl font-bold text-ink-900 leading-tight">{{ value }}</div>
<div class="text-xs text-ink-500">{{ label }}</div>
</div>
{% endfor %}
</div>
{# По отделам + последние прохождения #}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<section class="rounded-2xl bg-white border border-ink-300/50 shadow-sm overflow-hidden">
<div class="px-4 py-3 border-b border-ink-100 flex items-center gap-2">
<span class="material-symbols-outlined text-brand-500 text-base">corporate_fare</span>
<h2 class="font-semibold text-sm text-ink-900">По отделам</h2>
</div>
{% if stats.dept_stats %}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-ink-100 text-xs text-ink-500 uppercase tracking-wide">
<th class="px-4 py-2 text-left font-medium">Отдел</th>
<th class="px-4 py-2 text-right font-medium">Прошли</th>
<th class="px-4 py-2 text-right font-medium">Сдали</th>
<th class="px-4 py-2 text-right font-medium">%</th>
</tr>
</thead>
<tbody class="divide-y divide-ink-100/60">
{% for d in stats.dept_stats %}
<tr class="hover:bg-ink-100/40 transition-colors">
<td class="px-4 py-2.5 text-ink-800 max-w-[180px] truncate">{{ d.name }}</td>
<td class="px-4 py-2.5 text-right text-ink-700">{{ d.total }}</td>
<td class="px-4 py-2.5 text-right text-ink-700">{{ d.passed }}</td>
<td class="px-4 py-2.5 text-right">
<span class="inline-block min-w-[38px] text-center rounded-full px-2 py-0.5 text-xs font-semibold
{% if d.rate >= 80 %}bg-green-50 text-green-700
{% elif d.rate >= 50 %}bg-yellow-50 text-yellow-700
{% else %}bg-red-50 text-red-700{% endif %}">
{{ d.rate }}%
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="px-4 py-8 text-sm text-ink-500 text-center">Прохождений ещё нет.</p>
{% endif %}
</section>
<section class="rounded-2xl bg-white border border-ink-300/50 shadow-sm overflow-hidden">
<div class="px-4 py-3 border-b border-ink-100 flex items-center gap-2">
<span class="material-symbols-outlined text-brand-500 text-base">history</span>
<h2 class="font-semibold text-sm text-ink-900">Последние прохождения</h2>
</div>
{% if stats.recent %}
<ul class="divide-y divide-ink-100/60">
{% for r in stats.recent %}
<li class="px-4 py-2.5 flex items-start gap-3 hover:bg-ink-100/30 transition-colors">
<span class="material-symbols-outlined text-base mt-0.5
{% if r.passed == true %}text-green-500{% elif r.passed is none %}text-ink-300{% else %}text-red-400{% endif %}">
{% if r.passed == true %}check_circle{% elif r.passed is none %}radio_button_unchecked{% else %}cancel{% endif %}
</span>
<div class="flex-1 min-w-0">
<div class="text-sm text-ink-800 truncate font-medium">{{ r.test }}</div>
<div class="text-xs text-ink-500 truncate">{{ r.user }} · {{ r.score }} · {{ r.at }}</div>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="px-4 py-8 text-sm text-ink-500 text-center">Прохождений ещё нет.</p>
{% endif %}
</section>
</div>
{% endblock %}
+127 -8
View File
@@ -6,6 +6,7 @@
<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="text-muted" id="attempt-timer" style="margin-top:0;display:none;font-weight:600;"></p>
<p class="error-text" id="attempt-error" style="display:none;"></p>
<ol id="questions-list" style="padding-left:1.25rem;"></ol>
@@ -15,6 +16,18 @@
</div>
<div id="attempt-result" class="surface-card" style="display:none;margin-top:1rem;padding:1rem;"></div>
<dialog id="hint-modal" style="border:none;border-radius:14px;padding:0;max-width:480px;width:90%;box-shadow:0 12px 40px rgba(0,0,0,0.18);">
<div style="padding:1rem 1.25rem;">
<h3 id="hint-title" style="margin:0 0 0.5rem 0;font-size:1.05rem;">Подсказка</h3>
<p id="hint-verdict" style="margin:0 0 0.5rem 0;font-weight:600;"></p>
<p id="hint-correct" style="margin:0 0 0.5rem 0;font-size:0.9rem;color:#506965;"></p>
<p id="hint-explanation" style="margin:0;font-size:0.95rem;line-height:1.4;"></p>
<div class="inline-actions" style="margin-top:0.85rem;justify-content:flex-end;">
<button type="button" class="btn btn-primary btn--sm" id="hint-close-btn">Понятно</button>
</div>
</div>
</dialog>
</div>
<script>
@@ -24,12 +37,23 @@
const attemptId = root.dataset.attemptId;
const titleEl = document.getElementById('attempt-title');
const subEl = document.getElementById('attempt-subtitle');
const timerEl = document.getElementById('attempt-timer');
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');
const hintModal = document.getElementById('hint-modal');
const hintTitle = document.getElementById('hint-title');
const hintVerdict = document.getElementById('hint-verdict');
const hintCorrect = document.getElementById('hint-correct');
const hintExplanation = document.getElementById('hint-explanation');
const hintCloseBtn = document.getElementById('hint-close-btn');
let playData = null;
const selections = {};
const checked = {};
let timerHandle = null;
let deadlineMs = null;
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (m) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
@@ -51,38 +75,127 @@
}
selections[k] = [id];
}
function isImmediate() {
return playData && playData.resultMode === 'immediate';
}
function renderQuestions() {
listEl.innerHTML = '';
for (const q of (playData.questions || [])) {
const qid = String(q.id);
const isChecked = !!checked[qid];
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>';
li.dataset.qid = qid;
let badge = '';
if (isChecked) {
const ok = checked[qid].isCorrect;
badge = '<span style="margin-left:8px;font-size:0.8rem;font-weight:600;color:' +
(ok ? '#1a7a4a' : '#b32d2d') + ';">' + (ok ? '✓ верно' : '✗ неверно') + '</span>';
}
li.innerHTML = '<p style="margin-top:0;margin-bottom:0.5rem;">' + esc(q.text) + badge + '</p>';
const ul = document.createElement('ul');
ul.style.listStyle = 'none';
ul.style.padding = '0';
ul.style.margin = '0';
const correctSet = new Set((checked[qid] && checked[qid].correctOptionIds) || []);
for (const o of (q.options || [])) {
const oid = String(o.id);
const row = document.createElement('li');
row.style.marginBottom = '6px';
const type = q.hasMultipleAnswers ? 'checkbox' : 'radio';
const name = 'q-' + q.id;
let mark = '';
if (isChecked) {
if (correctSet.has(oid)) mark = ' <span style="color:#1a7a4a;font-weight:600;">(верно)</span>';
else if (isSelected(q.id, o.id)) mark = ' <span style="color:#b32d2d;">(ваш ответ)</span>';
}
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 style="display:flex;align-items:flex-start;gap:8px;cursor:' + (isChecked ? 'default' : 'pointer') + ';' +
(isChecked ? 'opacity:0.85;' : '') + '">' +
'<input type="' + type + '" ' + (q.hasMultipleAnswers ? '' : ('name="' + name + '"')) + ' ' +
(isSelected(q.id, o.id) ? 'checked' : '') + ' ' + (isChecked ? 'disabled' : '') + ' />' +
'<span>' + esc(o.text) + mark + '</span>' +
'</label>';
const input = row.querySelector('input');
input.addEventListener('change', () => {
if (checked[qid]) return;
toggle(q.id, o.id, q.hasMultipleAnswers);
renderQuestions();
});
ul.appendChild(row);
}
li.appendChild(ul);
if (isImmediate() && !isChecked) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-ghost btn--sm';
btn.textContent = 'Ответить';
btn.style.marginTop = '0.4rem';
const sel = selections[qid] || [];
btn.disabled = sel.length === 0;
btn.addEventListener('click', () => checkOne(q.id));
li.appendChild(btn);
}
listEl.appendChild(li);
}
}
async function checkOne(qid) {
const k = String(qid);
const sel = selections[k] || [];
if (!sel.length) return;
try {
const r = await fetch('/api/tests/' + testId + '/attempts/' + attemptId + '/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ questionId: qid, selectedOptionIds: sel }),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || 'Не удалось проверить ответ.');
checked[k] = data;
renderQuestions();
if (playData.hintsEnabled) {
showHint(data);
}
} catch (e) {
setErr(e.message);
}
}
function showHint(data) {
hintVerdict.textContent = data.isCorrect ? 'Верно!' : 'Неверно.';
hintVerdict.style.color = data.isCorrect ? '#1a7a4a' : '#b32d2d';
const correct = (data.correctOptionTexts || []).join('; ');
hintCorrect.textContent = correct ? ('Правильный ответ: ' + correct) : '';
hintExplanation.textContent = data.explanation || 'Объяснение недоступно.';
if (typeof hintModal.showModal === 'function') hintModal.showModal();
else hintModal.setAttribute('open', '');
}
hintCloseBtn.addEventListener('click', () => {
if (typeof hintModal.close === 'function') hintModal.close();
else hintModal.removeAttribute('open');
});
function startTimer(minutes) {
if (!minutes || minutes <= 0) return;
deadlineMs = Date.now() + minutes * 60 * 1000;
timerEl.style.display = '';
const tick = () => {
const left = Math.max(0, deadlineMs - Date.now());
const m = Math.floor(left / 60000);
const s = Math.floor((left % 60000) / 1000);
timerEl.textContent = 'Осталось: ' + m + ':' + String(s).padStart(2, '0');
if (left <= 0) {
clearInterval(timerHandle);
submit(true);
}
};
tick();
timerHandle = setInterval(tick, 500);
}
async function load() {
try {
const r = await fetch('/api/tests/' + testId + '/attempts/' + attemptId + '/play');
@@ -90,22 +203,26 @@
if (!r.ok) throw new Error(data.error || 'Не удалось открыть попытку.');
playData = data;
titleEl.textContent = data.testTitle || 'Прохождение теста';
subEl.textContent = 'Порог зачёта: ' + (data.passingThreshold ?? 0) + '%.';
const parts = ['Порог зачёта: ' + (data.passingThreshold ?? 0) + '%'];
if (data.resultMode === 'immediate') parts.push('Результат сразу после ответа');
if (data.hintsEnabled) parts.push('С ИИ-подсказками');
subEl.textContent = parts.join(' · ') + '.';
if (!Array.isArray(data.questions) || !data.questions.length) {
setErr('В активной версии нет вопросов.');
submitBtn.disabled = true;
return;
}
renderQuestions();
if (data.timeLimit) startTimer(Number(data.timeLimit));
} catch (e) {
setErr(e.message);
submitBtn.disabled = true;
}
}
async function submit() {
async function submit(auto) {
submitBtn.disabled = true;
submitBtn.textContent = 'Отправка…';
submitBtn.textContent = auto ? 'Время вышло, отправка…' : 'Отправка…';
try {
const r = await fetch('/api/tests/' + testId + '/attempts/' + attemptId + '/submit', {
method: 'POST',
@@ -114,6 +231,8 @@
});
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || 'Не удалось завершить попытку.');
if (timerHandle) clearInterval(timerHandle);
timerEl.style.display = 'none';
resultEl.style.display = '';
resultEl.innerHTML =
'<h3 style="margin-top:0;">Результат</h3>' +
@@ -129,7 +248,7 @@
}
}
submitBtn.addEventListener('click', submit);
submitBtn.addEventListener('click', () => submit(false));
load();
})();
</script>
+265 -142
View File
@@ -8,16 +8,9 @@
data-initial='{{ content | tojson | safe }}'>
<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>
<a href="{{ url_for('tests.tests_list_page') }}" class="link-back">К тестам</a>
<textarea id="test-title" maxlength="200" rows="1" placeholder="Название теста"
class="hero-brick__title font-headline"></textarea>
<textarea id="test-title" maxlength="200" rows="1" placeholder="Название теста"
class="hero-brick__title font-headline"></textarea>
@@ -25,26 +18,92 @@
<textarea id="test-description" rows="2" placeholder="Краткое описание (необязательно)"
class="hero-brick__desc"></textarea>
<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 class="hero-brick__meta-row">
<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>
</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 class="hero-brick__divider"></div>
<div class="hero-brick__meta-row">
<span>Порог зачёта: <b id="threshold-mirror"></b>%</span>
<span class="hero-brick__sep">·</span>
<span>Вопросов: <b id="q-count">0</b></span>
<span class="hero-brick__sep">·</span>
<span id="chain-active-display">Активна в каталоге</span>
</div>
</section>
{# ── Версии ───────────────────────────────────────────────────── #}
<details class="cabinet-disclosure cabinet-brick">
<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">
<ul id="versions-list" class="version-card-list"></ul>
</div>
</details>
<details class="cabinet-disclosure cabinet-brick">
<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="settings-grid">
<label class="settings-row">
<span class="settings-row__label">Проходной балл, %</span>
<input id="test-threshold" type="number" min="0" max="100" step="1" inputmode="numeric"
class="settings-row__input" />
</label>
<label class="settings-row">
<span class="settings-row__label">
Таймер, минут
<span class="settings-row__hint">0 или пусто — без ограничения</span>
</span>
<input id="test-time-limit" type="number" min="0" max="600" step="1" inputmode="numeric"
class="settings-row__input" placeholder="—" />
</label>
<fieldset class="settings-row settings-row--block">
<legend class="settings-row__label">Когда показывать результат</legend>
<label class="settings-radio">
<input type="radio" name="result-mode" value="end" />
<span>В конце теста <span class="settings-row__hint">(подсказок не будет)</span></span>
</label>
<label class="settings-radio">
<input type="radio" name="result-mode" value="immediate" />
<span>Сразу после ответа <span class="settings-row__hint">(с ИИ-подсказкой)</span></span>
</label>
</fieldset>
<label class="settings-row settings-row--toggle" id="test-hints-row" style="display:none;">
<span class="settings-row__label">
Показывать подсказку после ответа
<span class="settings-row__hint">Краткое объяснение во всплывающем окне</span>
</span>
<input id="test-hints-enabled" type="checkbox" />
</label>
<div class="settings-row settings-row--block" style="padding-top:0.75rem; border-top:1px solid var(--outline-variant); margin-top:0.25rem;">
<span class="settings-row__label">Видимость в каталоге</span>
<p class="settings-row__hint" style="margin-bottom:0.5rem;">Скрытые тесты не показываются в общем списке; ссылку по-прежнему можно открыть.</p>
<button id="btn-toggle-visibility" class="btn btn-ghost btn--sm" type="button">Скрыть из списка</button>
</div>
</div>
</div>
</details>
<details class="cabinet-disclosure cabinet-brick" open>
<summary class="cabinet-disclosure__summary">
<span class="cabinet-disclosure__summary-text">
@@ -54,13 +113,14 @@
</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">
<h3 class="test-detail-subsection__title">Структура теста</h3>
<p class="muted text-xs mb-3">
Укажите количество вопросов и вариантов — создайте шаблон и заполните его вручную или через ИИ.
</p>
<div class="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"
@@ -71,106 +131,121 @@
<input id="ai-o-count" type="number" min="2" max="8" step="1" value="3"
class="form-input" style="width:90px;" />
</label>
<button id="ai-generate-test"
<button id="create-template"
class="btn btn-ghost" type="button" style="min-height:43px;">
Сгенерировать тест (ИИ)
<span class="material-symbols-outlined text-base" style="vertical-align:-3px;">grid_view</span>
Создать шаблон
</button>
</div>
</div>
{# ── Заполнить через ИИ по теме ──────────────────────────── #}
<div class="question-editor-block">
<h3 class="test-detail-subsection__title">Заполнить через ИИ</h3>
<label class="block">
<span class="form-label">Тема / промпт</span>
<textarea id="ai-topic" rows="1" class="form-input"
placeholder="Например: охрана труда на производстве"
style="resize:none; overflow:hidden; font-family:inherit;"></textarea>
</label>
<div class="mt-2">
<button id="ai-generate-test"
class="btn btn-ghost" type="button" style="min-height:43px;">
Сгенерировать вопросы (ИИ)
</button>
</div>
</div>
{# ── Проверить и улучшить ─────────────────────────────────── #}
<div class="question-editor-block">
<h3 class="test-detail-subsection__title">Проверить и улучшить</h3>
<div class="flex flex-wrap gap-2">
<button id="ai-check"
class="btn btn-ghost" type="button" style="min-height:43px;">
<span class="material-symbols-outlined text-base" style="vertical-align:-3px;">fact_check</span>
Проверить тест
</button>
<button id="ai-improve"
class="btn btn-ghost" type="button" style="min-height:43px;">
<span class="material-symbols-outlined text-base" style="vertical-align:-3px;">auto_fix_high</span>
Предложить улучшение
</button>
</div>
</div>
{# ── Документ в вопросы ──────────────────────────────────── #}
<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">
<span class="material-symbols-outlined text-base text-brand-600">upload_file</span>
<span>Загрузить документ (PDF, DOCX, TXT, MD)</span>
<label id="ai-import-dropzone"
class="import-dropzone mt-2 flex flex-col w-full items-center justify-center gap-1
px-4 py-5 rounded-xl bg-white border-2 border-dashed border-ink-300/70
hover:border-brand-400 hover:bg-brand-50/40 cursor-pointer transition-colors">
<span class="material-symbols-outlined text-2xl text-brand-400">upload_file</span>
<span id="ai-import-dropzone-label" class="text-sm font-medium text-ink-700">Перетащите файл сюда или нажмите</span>
<span class="text-xs text-ink-400">PDF, DOCX, TXT, MD · до 16 МБ</span>
<input id="ai-import-file" type="file" accept=".pdf,.docx,.txt,.md" class="hidden" />
</label>
<p class="mt-1.5 text-xs text-ink-500">
До 16 МБ. AI извлечёт текст и предложит черновик теста.
</p>
<label class="block mt-3">
<span class="form-label">Пожелания по содержанию <span class="text-ink-400 font-normal">(необязательно)</span></span>
<textarea id="doc-user-hint" rows="1"
class="form-input mt-1"
placeholder="Например: акцент на разделе 3, не делать вопросы про даты"
style="resize:none; overflow:hidden; font-family:inherit;"></textarea>
</label>
<button id="doc-generate-btn"
class="btn btn-ghost mt-2" type="button" style="min-height:43px;">
<span class="material-symbols-outlined text-base" style="vertical-align:-3px;">auto_awesome</span>
Сгенерировать из документа
</button>
</div>
{# ── Модалка результата импорта документа ─────────────────── #}
<dialog id="import-modal" class="save-modal">
<div class="save-modal__inner" style="max-width:480px; width:100%;">
<h3 id="import-modal-title" class="font-headline text-base font-semibold mb-2"></h3>
<div id="import-modal-body" class="text-sm text-ink-600 mb-4 max-h-64 overflow-y-auto"></div>
<div id="import-modal-actions" class="flex gap-2 justify-end flex-wrap"></div>
</div>
</dialog>
<p id="ai-status" class="mt-3 text-sm text-ink-500 min-h-[1.25rem]"></p>
</section>
{# ── 3. Вопросы ─────────────────────────────────────────────── #}
<section>
<div class="flex items-center justify-between gap-2 px-1">
<h2 class="font-semibold">Вопросы (<span id="q-count-mirror">0</span>)</h2>
<section class="mt-1">
<div class="flex items-center gap-2">
<h2 class="font-semibold text-ink-900">Вопросы (<span id="q-count-mirror">0</span>)</h2>
</div>
<ol id="questions" class="mt-3 space-y-4"></ol>
<div class="mt-3 flex justify-center">
<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
btn btn-ghost btn--sm question-editor__add-question">
btn btn-ghost question-editor__add-question">
<span class="material-symbols-outlined text-base">add</span>
<span class="hidden sm:inline">Добавить вопрос</span>
<span class="sm:hidden">Добавить</span>
<span>Добавить вопрос</span>
</button>
</div>
<ol id="questions" class="mt-3 space-y-3"></ol>
{# Кнопка «Сохранить» под вопросами #}
<div class="mt-5 flex items-center gap-3">
<button id="save-draft-inline"
class="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg
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>
<button id="btn-cancel-inline"
class="inline-flex px-3 py-2 rounded-lg text-ink-700 hover:bg-ink-100 text-sm btn btn-ghost">
Отмена
</button>
<p id="save-status-inline" class="text-xs text-ink-500"></p>
</div>
</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>
{# Прохождения перенесены на /stats. Назначения перенесены на /assignments #}
</div>
{# ── Sticky-footer: «Цепочка активна» + «Сохранить» ────────────── #}
@@ -178,12 +253,16 @@
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
flex items-center justify-between gap-3">
<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 btn btn-ghost">
К каталогу
</a>
<div id="intro-fork-banner" class="callout callout--warning text-xs sm:text-sm"
data-fork-risk="{{ '1' if content.test.hasForkRisk else '0' }}"
style="display:none; margin:0; padding:0.4rem 0.6rem; flex:1 1 0; min-width:0; white-space:normal; word-break:break-word; line-height:1.25;">
При сохранении будет создана новая версия теста.
</div>
<div class="flex items-center gap-2 ml-auto shrink-0">
<button id="btn-cancel"
class="inline-flex px-3 py-2 rounded-lg text-ink-700 hover:bg-ink-100 text-sm btn btn-ghost">
Отмена
</button>
<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 btn btn-primary">
@@ -197,12 +276,31 @@
{# ── Шаблон вопроса ─────────────────────────────────────────────── #}
<template id="tpl-question">
<li class="rounded-xl bg-white border border-ink-300/60 p-3 sm:p-4 q-item question-editor-block">
{# Шапка карточки вопроса: номер слева, кнопки справа. #}
<li class="relative rounded-xl bg-white border border-ink-300/60 p-4 sm:p-5 q-item" draggable="true">
{# Оверлей загрузки AI #}
<div class="q-ai-overlay hidden absolute inset-0 rounded-xl z-10
bg-white/80 backdrop-blur-[2px] flex flex-col items-center justify-center gap-2">
<span class="q-ai-spinner inline-block w-7 h-7 rounded-full
border-[3px] border-brand-200 border-t-brand-600 animate-spin"></span>
<span class="text-xs text-ink-500 font-medium">Генерирую…</span>
</div>
{# Шапка карточки вопроса #}
<div class="flex items-center justify-between gap-2">
<span class="inline-flex items-center px-2 py-0.5 rounded-md
bg-brand-50 text-brand-700 text-xs font-medium q-num">Вопрос #</span>
<span class="inline-flex items-center gap-1">
<button class="q-drag p-2 rounded hover:bg-ink-100 min-w-10 min-h-10 cursor-grab"
title="Перетащить" aria-label="Перетащить" type="button">
<span class="material-symbols-outlined text-base">drag_indicator</span>
</button>
<span class="inline-flex items-center px-2 py-0.5 rounded-md
bg-brand-50 text-brand-700 text-xs font-medium q-num">Вопрос #</span>
</span>
<div class="flex items-center gap-0.5">
<button class="q-clear p-2 rounded hover:bg-ink-100 min-w-10 min-h-10 text-ink-400"
title="Очистить вопрос" aria-label="Очистить вопрос" type="button">
<span class="material-symbols-outlined text-base">backspace</span>
</button>
<button class="q-up p-2 rounded hover:bg-ink-100 min-w-10 min-h-10"
title="Выше" aria-label="Поднять выше">
<span class="material-symbols-outlined text-base">arrow_upward</span>
@@ -218,55 +316,80 @@
</div>
</div>
<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 class="mt-2 relative">
<textarea class="q-text 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="1" placeholder="Формулировка вопроса" maxlength="500"
style="resize:none; overflow:hidden; font-family:inherit;"></textarea>
<span class="q-text-counter absolute bottom-1.5 right-2 text-xs text-ink-400 pointer-events-none select-none"></span>
</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-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 btn btn-ghost btn--sm q-ai-btn" style="font-size:0.75rem; padding:0.3rem 0.7rem;">
<span class="material-symbols-outlined q-ai-icon" style="font-size:0.9rem; vertical-align:-2px;">auto_fix_high</span>
<span class="q-ai-label">Сгенерировать</span>
</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 btn btn-ghost btn--sm">
<span class="material-symbols-outlined text-base">add</span>
Добавить вариант
</button>
<p class="mt-4 mb-2 text-xs text-ink-400 font-medium">Отметьте правильные варианты</p>
<ul class="q-options space-y-2"></ul>
<div class="mt-3 flex items-center gap-3">
<button class="q-add-option inline-flex items-center gap-1 px-2 py-2 rounded
text-sm text-brand-700 hover:bg-brand-50 min-h-10 btn btn-ghost btn--sm">
<span class="material-symbols-outlined text-base">add</span>
<span class="q-add-option-label">Добавить вариант</span>
</button>
<span class="q-options-count text-xs text-ink-400"></span>
</div>
</li>
</template>
{# ── Шаблон варианта ────────────────────────────────────────────── #}
<template id="tpl-option">
<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="Правильный ответ">
<li class="flex items-start gap-2 opt-item">
{# Чекбокс «Правильный» — выровнен по первой строке textarea #}
<label class="shrink-0 w-10 inline-flex items-center justify-center cursor-pointer
rounded hover:bg-ink-100 pt-1.5" style="min-height:2.5rem;" title="Правильный ответ">
<input type="checkbox"
class="opt-correct w-5 h-5 rounded border-ink-300 text-brand-600 focus:ring-brand-500 question-option-row__mark" />
class="opt-correct w-5 h-5 rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
</label>
<input type="text"
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 question-option-remove"
<textarea rows="1"
class="opt-text flex-1 min-w-0 rounded-lg border border-ink-300 px-3 py-2
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
placeholder="Вариант ответа"
style="resize:none; overflow:hidden; font-family:inherit; line-height:1.55;"></textarea>
<button class="opt-delete shrink-0 w-10 inline-flex items-center justify-center
rounded hover:bg-red-50 text-red-600 pt-1.5"
style="min-height:2.5rem;"
title="Удалить" aria-label="Удалить вариант">
<span class="material-symbols-outlined text-base">close</span>
</button>
</li>
</template>
{# ── Модалка успешного сохранения (компактная, сверху) ──────────── #}
<dialog id="save-modal" class="save-modal">
<div class="save-modal__inner">
<h3 class="text-base font-semibold mb-1" id="save-modal-title">Сохранено</h3>
<p id="save-modal-msg" class="text-sm text-ink-700">Изменения сохранены.</p>
<div class="mt-4 flex items-center justify-end gap-2">
<button id="save-modal-stay" type="button"
class="px-3 py-2 rounded-lg bg-ink-100 hover:bg-ink-200 text-sm btn btn-ghost">
К редактору
</button>
<button id="save-modal-go" type="button"
class="px-3 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm btn btn-primary">
К каталогу
</button>
</div>
</div>
</dialog>
{# ── Модалка результата AI-проверки/улучшения (fullscreen на мобиле) ── #}
<dialog id="ai-modal"
class="m-0 p-0 w-full h-full sm:h-auto sm:max-w-3xl sm:w-full sm:max-h-[90vh]
+6 -6
View File
@@ -20,7 +20,7 @@
<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 class="list-row__meta-tail"> · Версия {{ t.version }}</span>
</span>
</a>
</div>
@@ -44,7 +44,7 @@
<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 class="list-row__meta-tail"> · Версия {{ t.version }} · скрыт</span>
</span>
</a>
</div>
@@ -61,7 +61,7 @@
<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>
<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
@@ -80,13 +80,13 @@
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>
<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="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>
Открыть
@@ -103,7 +103,7 @@
{% 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 }})
Скрытые из каталога ({{ hidden|length }})
</summary>
<ul class="mt-3 space-y-2">
{% for t in hidden %}