Дорабоки интерфейса системы тестирования. Раздел 1 Шапка+Верхний brick
This commit is contained in:
@@ -13,6 +13,31 @@ from datetime import timedelta
|
||||
from flask import Flask, jsonify, render_template, request
|
||||
|
||||
|
||||
_ROLE_LABELS = {
|
||||
'employee': 'Сотрудник',
|
||||
'manager': 'Руководитель',
|
||||
'hr': 'HR',
|
||||
}
|
||||
|
||||
|
||||
def _format_role(role: str | None) -> str:
|
||||
return _ROLE_LABELS.get((role or '').strip().lower(), '')
|
||||
|
||||
|
||||
def _format_surname_with_initials(full_name: str | None, fallback: str | None = None) -> str:
|
||||
name = (full_name or '').strip()
|
||||
if not name:
|
||||
return (fallback or '—').strip() or '—'
|
||||
parts = [p for p in name.replace('\xa0', ' ').split(' ') if p]
|
||||
if len(parts) < 2:
|
||||
return name
|
||||
surname = parts[0]
|
||||
initials = []
|
||||
for p in parts[1:3]:
|
||||
initials.append(f'{p[0].upper()}.')
|
||||
return f"{surname} {' '.join(initials)}".strip()
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
app = Flask(
|
||||
__name__,
|
||||
@@ -49,11 +74,15 @@ def create_app() -> Flask:
|
||||
|
||||
@app.context_processor
|
||||
def _inject_globals():
|
||||
ui_variant = (os.environ.get('UI_VARIANT') or 'modern').strip().lower() or 'modern'
|
||||
return {
|
||||
'current_user': _current_user(),
|
||||
'hr_auth_enabled': is_hr_auth_enabled(),
|
||||
'dev_ui': is_dev_ui(),
|
||||
'assignment_ui': is_assignment_feature_enabled(),
|
||||
'ui_variant': ui_variant,
|
||||
'format_name_short': _format_surname_with_initials,
|
||||
'format_role': _format_role,
|
||||
}
|
||||
|
||||
@app.errorhandler(404)
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy import text
|
||||
from ..db import get_engine
|
||||
from ..messages import RU
|
||||
from .test_access import is_test_author
|
||||
from .test_chain import has_any_attempt_for_test
|
||||
|
||||
|
||||
class HttpError(Exception):
|
||||
@@ -81,7 +82,13 @@ def get_editor_content(user_id: str, test_id: str) -> dict:
|
||||
if not tv:
|
||||
raise HttpError(400, 'Нет активной версии теста.')
|
||||
version_id = tv['id']
|
||||
version_count_row = conn.execute(
|
||||
text('SELECT COUNT(*) AS n FROM test_versions WHERE test_id = :id'),
|
||||
{'id': test_id},
|
||||
).mappings().first()
|
||||
version_count = int(version_count_row['n'] or 0)
|
||||
questions = load_questions_for_version(conn, version_id, include_correct=True)
|
||||
has_attempts = has_any_attempt_for_test(conn, test_id)
|
||||
|
||||
return {
|
||||
'test': {
|
||||
@@ -89,6 +96,9 @@ def get_editor_content(user_id: str, test_id: str) -> dict:
|
||||
'title': tr['title'],
|
||||
'description': tr['description'],
|
||||
'passingThreshold': tr['passing_threshold'],
|
||||
'hasAttempts': bool(has_attempts),
|
||||
'versionCount': version_count,
|
||||
'hasForkRisk': bool(has_attempts) or version_count > 1,
|
||||
},
|
||||
'activeVersionId': str(version_id),
|
||||
'questions': questions,
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from ..services.test_access import is_test_author, user_has_test_access
|
||||
|
||||
|
||||
class HttpError(Exception):
|
||||
def __init__(self, status: int, message: str):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.message = message
|
||||
|
||||
|
||||
def _sort_uuid_strings(items) -> list[str]:
|
||||
return sorted({str(x) for x in (items or []) if x is not None})
|
||||
|
||||
|
||||
def _same_selection(selected, correct_ids) -> bool:
|
||||
a = _sort_uuid_strings(selected)
|
||||
b = _sort_uuid_strings(correct_ids)
|
||||
return a == b
|
||||
|
||||
|
||||
def load_questions_for_version(conn, test_version_id, *, include_correct: bool) -> list[dict]:
|
||||
qrows = conn.execute(
|
||||
text(
|
||||
'SELECT id, text, question_order, has_multiple_answers '
|
||||
'FROM questions WHERE test_version_id = :v ORDER BY question_order'
|
||||
),
|
||||
{'v': test_version_id},
|
||||
).mappings().all()
|
||||
out = []
|
||||
for q in qrows:
|
||||
orows = conn.execute(
|
||||
text(
|
||||
'SELECT id, text, is_correct, option_order '
|
||||
'FROM answer_options WHERE question_id = :q ORDER BY option_order'
|
||||
),
|
||||
{'q': q['id']},
|
||||
).mappings().all()
|
||||
opts = []
|
||||
for o in orows:
|
||||
base = {
|
||||
'id': str(o['id']),
|
||||
'text': o['text'],
|
||||
'optionOrder': o['option_order'],
|
||||
}
|
||||
if include_correct:
|
||||
base['isCorrect'] = bool(o['is_correct'])
|
||||
opts.append(base)
|
||||
out.append(
|
||||
{
|
||||
'id': str(q['id']),
|
||||
'text': q['text'],
|
||||
'questionOrder': q['question_order'],
|
||||
'hasMultipleAnswers': bool(q['has_multiple_answers']),
|
||||
'options': opts,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def start_attempt(eng, user_id: str, test_id: str) -> dict:
|
||||
acc = user_has_test_access(user_id, test_id)
|
||||
if not acc.ok:
|
||||
raise HttpError(404, 'Тест не найден.')
|
||||
with eng.begin() as conn:
|
||||
tv = conn.execute(
|
||||
text(
|
||||
'SELECT id AS test_version_id FROM test_versions '
|
||||
'WHERE test_id = :id AND is_active = true LIMIT 1'
|
||||
),
|
||||
{'id': test_id},
|
||||
).mappings().first()
|
||||
if not tv:
|
||||
raise HttpError(404, 'Нет активной версии теста.')
|
||||
version_id = tv['test_version_id']
|
||||
mx = conn.execute(
|
||||
text(
|
||||
'SELECT COALESCE(MAX(attempt_number), 0) AS n FROM test_attempts '
|
||||
'WHERE test_version_id = :v AND user_id = :u'
|
||||
),
|
||||
{'v': version_id, 'u': user_id},
|
||||
).mappings().first()
|
||||
next_n = int(mx['n'] or 0) + 1
|
||||
a = conn.execute(
|
||||
text(
|
||||
"INSERT INTO test_attempts (test_version_id, user_id, attempt_number, status) "
|
||||
"VALUES (:v, :u, :n, 'in_progress') "
|
||||
'RETURNING id, test_version_id, user_id, attempt_number, status, started_at'
|
||||
),
|
||||
{'v': version_id, 'u': user_id, 'n': next_n},
|
||||
).mappings().first()
|
||||
return {'attempt': dict(a)}
|
||||
|
||||
|
||||
def get_play_content(eng, user_id: str, test_id: str, attempt_id: str) -> dict:
|
||||
with eng.connect() as conn:
|
||||
a = conn.execute(
|
||||
text(
|
||||
'SELECT ta.id, ta.user_id, ta.status, ta.test_version_id, tv.test_id, '
|
||||
't.title, t.passing_threshold '
|
||||
'FROM test_attempts ta '
|
||||
'INNER JOIN test_versions tv ON tv.id = ta.test_version_id '
|
||||
'INNER JOIN tests t ON t.id = tv.test_id '
|
||||
'WHERE ta.id = :a'
|
||||
),
|
||||
{'a': attempt_id},
|
||||
).mappings().first()
|
||||
if not a:
|
||||
raise HttpError(404, 'Попытка не найдена.')
|
||||
if str(a['test_id']) != str(test_id):
|
||||
raise HttpError(404, 'Попытка не найдена.')
|
||||
if str(a['user_id']) != str(user_id):
|
||||
raise HttpError(403, 'Доступ запрещён.')
|
||||
if a['status'] != 'in_progress':
|
||||
raise HttpError(400, 'Попытка уже завершена.')
|
||||
qs = load_questions_for_version(conn, a['test_version_id'], include_correct=False)
|
||||
return {
|
||||
'testTitle': a['title'],
|
||||
'passingThreshold': a['passing_threshold'],
|
||||
'attemptId': str(a['id']),
|
||||
'questions': qs,
|
||||
}
|
||||
|
||||
|
||||
def submit_attempt(eng, user_id: str, test_id: str, attempt_id: str, raw_answers: dict | None) -> dict:
|
||||
answers = raw_answers if isinstance(raw_answers, dict) else {}
|
||||
with eng.begin() as conn:
|
||||
a = conn.execute(
|
||||
text(
|
||||
'SELECT id, user_id, status, test_version_id '
|
||||
'FROM test_attempts WHERE id = :a FOR UPDATE'
|
||||
),
|
||||
{'a': attempt_id},
|
||||
).mappings().first()
|
||||
if not a:
|
||||
raise HttpError(404, 'Попытка не найдена.')
|
||||
link = conn.execute(
|
||||
text(
|
||||
'SELECT t.passing_threshold, tv.test_id '
|
||||
'FROM test_versions tv '
|
||||
'INNER JOIN tests t ON t.id = tv.test_id '
|
||||
'WHERE tv.id = :v'
|
||||
),
|
||||
{'v': a['test_version_id']},
|
||||
).mappings().first()
|
||||
if not link:
|
||||
raise HttpError(404, 'Тест не найден.')
|
||||
if str(link['test_id']) != str(test_id):
|
||||
raise HttpError(404, 'Попытка не найдена.')
|
||||
if str(a['user_id']) != str(user_id):
|
||||
raise HttpError(403, 'Доступ запрещён.')
|
||||
if a['status'] != 'in_progress':
|
||||
raise HttpError(400, 'Попытка уже завершена.')
|
||||
|
||||
qrows = conn.execute(
|
||||
text('SELECT id FROM questions WHERE test_version_id = :v'),
|
||||
{'v': a['test_version_id']},
|
||||
).mappings().all()
|
||||
if not qrows:
|
||||
raise HttpError(400, 'В тесте нет вопросов.')
|
||||
|
||||
opts = conn.execute(
|
||||
text(
|
||||
'SELECT a.id, a.question_id, a.is_correct '
|
||||
'FROM answer_options a '
|
||||
'INNER JOIN questions q ON q.id = a.question_id '
|
||||
'WHERE q.test_version_id = :v'
|
||||
),
|
||||
{'v': a['test_version_id']},
|
||||
).mappings().all()
|
||||
|
||||
by_q = {}
|
||||
for o in opts:
|
||||
qid = str(o['question_id'])
|
||||
if qid not in by_q:
|
||||
by_q[qid] = {'all': set(), 'correct': []}
|
||||
by_q[qid]['all'].add(str(o['id']))
|
||||
if o['is_correct']:
|
||||
by_q[qid]['correct'].append(str(o['id']))
|
||||
|
||||
correct_count = 0
|
||||
for q in qrows:
|
||||
qid = str(q['id'])
|
||||
selected = answers.get(qid, [])
|
||||
if not isinstance(selected, list):
|
||||
selected = [str(selected)]
|
||||
selected = [str(x) for x in selected]
|
||||
g = by_q.get(qid, {'all': set(), 'correct': []})
|
||||
for sid in selected:
|
||||
if sid not in g['all']:
|
||||
raise HttpError(400, 'Некорректный вариант ответа.')
|
||||
if _same_selection(selected, g['correct']):
|
||||
correct_count += 1
|
||||
|
||||
total = len(qrows)
|
||||
percent = (correct_count / total) * 100 if total else 0
|
||||
threshold = int(link['passing_threshold'] or 0)
|
||||
passed = percent + 1e-9 >= threshold
|
||||
|
||||
conn.execute(text('DELETE FROM user_answers WHERE attempt_id = :a'), {'a': attempt_id})
|
||||
for q in qrows:
|
||||
qid = str(q['id'])
|
||||
selected = answers.get(qid, [])
|
||||
if not isinstance(selected, list):
|
||||
selected = [str(selected)]
|
||||
selected = [str(x) for x in selected]
|
||||
conn.execute(
|
||||
text(
|
||||
'INSERT INTO user_answers (attempt_id, question_id, selected_options) '
|
||||
'VALUES (:a, :q, :s::uuid[])'
|
||||
),
|
||||
{'a': attempt_id, 'q': q['id'], 's': selected},
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE test_attempts SET status = 'completed', completed_at = CURRENT_TIMESTAMP, "
|
||||
'correct_count = :c, total_questions = :t, passed = :p WHERE id = :a'
|
||||
),
|
||||
{'a': attempt_id, 'c': correct_count, 't': total, 'p': passed},
|
||||
)
|
||||
|
||||
review = build_review_from_db(eng, attempt_id)
|
||||
return {
|
||||
'attemptId': attempt_id,
|
||||
'correctCount': correct_count,
|
||||
'totalQuestions': total,
|
||||
'percent': round(percent, 1),
|
||||
'passed': passed,
|
||||
'passingThreshold': threshold,
|
||||
'review': review,
|
||||
}
|
||||
|
||||
|
||||
def build_review_from_db(eng, attempt_id: str) -> dict:
|
||||
with eng.connect() as conn:
|
||||
a = conn.execute(
|
||||
text(
|
||||
'SELECT ta.id, ta.status, ta.test_version_id, ta.user_id, ta.correct_count, ta.total_questions, '
|
||||
'ta.passed, ta.started_at, ta.completed_at, '
|
||||
't.id AS test_id, t.title, t.passing_threshold, '
|
||||
'u.full_name AS attempter_name, u.login AS attempter_login '
|
||||
'FROM test_attempts ta '
|
||||
'INNER JOIN test_versions tv ON tv.id = ta.test_version_id '
|
||||
'INNER JOIN tests t ON t.id = tv.test_id '
|
||||
'INNER JOIN users u ON u.id = ta.user_id '
|
||||
'WHERE ta.id = :a'
|
||||
),
|
||||
{'a': attempt_id},
|
||||
).mappings().first()
|
||||
if not a:
|
||||
raise HttpError(404, 'Попытка не найдена.')
|
||||
if a['status'] != 'completed':
|
||||
raise HttpError(400, 'Попытка не завершена.')
|
||||
questions = load_questions_for_version(conn, a['test_version_id'], include_correct=True)
|
||||
uans = conn.execute(
|
||||
text('SELECT question_id, selected_options FROM user_answers WHERE attempt_id = :a'),
|
||||
{'a': attempt_id},
|
||||
).mappings().all()
|
||||
|
||||
sel_by_q = {str(r['question_id']): [str(x) for x in (r['selected_options'] or [])] for r in uans}
|
||||
total = int(a['total_questions'] or len(questions))
|
||||
percent = round(((a['correct_count'] or 0) / total) * 100, 1) if total else 0
|
||||
|
||||
q_out = []
|
||||
for q in questions:
|
||||
selected = _sort_uuid_strings(sel_by_q.get(str(q['id']), []))
|
||||
correct = _sort_uuid_strings([o['id'] for o in q['options'] if o.get('isCorrect')])
|
||||
selected_set = set(selected)
|
||||
q_out.append(
|
||||
{
|
||||
'id': q['id'],
|
||||
'text': q['text'],
|
||||
'hasMultipleAnswers': q['hasMultipleAnswers'],
|
||||
'isUserCorrect': _same_selection(selected, correct),
|
||||
'options': [
|
||||
{
|
||||
'id': o['id'],
|
||||
'text': o['text'],
|
||||
'isCorrect': o.get('isCorrect', False),
|
||||
'selected': o['id'] in selected_set,
|
||||
}
|
||||
for o in q['options']
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
'attemptId': str(a['id']),
|
||||
'testId': str(a['test_id']),
|
||||
'testTitle': a['title'],
|
||||
'passingThreshold': int(a['passing_threshold'] or 0),
|
||||
'correctCount': int(a['correct_count'] or 0),
|
||||
'totalQuestions': total,
|
||||
'percent': percent,
|
||||
'passed': bool(a['passed']),
|
||||
'startedAt': a['started_at'].isoformat() if a['started_at'] else None,
|
||||
'completedAt': a['completed_at'].isoformat() if a['completed_at'] else None,
|
||||
'attempterUserId': str(a['user_id']),
|
||||
'attempterName': a['attempter_name'],
|
||||
'attempterLogin': a['attempter_login'],
|
||||
'questions': q_out,
|
||||
}
|
||||
|
||||
|
||||
def get_attempt_review_for_user(eng, current_user_id: str, test_id: str, attempt_id: str) -> dict:
|
||||
with eng.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
'SELECT ta.user_id, t.created_by, tv.test_id '
|
||||
'FROM test_attempts ta '
|
||||
'INNER JOIN test_versions tv ON tv.id = ta.test_version_id '
|
||||
'INNER JOIN tests t ON t.id = tv.test_id '
|
||||
'WHERE ta.id = :a'
|
||||
),
|
||||
{'a': attempt_id},
|
||||
).mappings().first()
|
||||
if not row:
|
||||
raise HttpError(404, 'Попытка не найдена.')
|
||||
if str(row['test_id']) != str(test_id):
|
||||
raise HttpError(404, 'Попытка не найдена.')
|
||||
is_owner = str(row['user_id']) == str(current_user_id)
|
||||
is_author = is_test_author(row['created_by'], current_user_id)
|
||||
if not is_owner and not is_author:
|
||||
raise HttpError(403, 'Доступ запрещён.')
|
||||
return build_review_from_db(eng, attempt_id)
|
||||
|
||||
|
||||
def list_test_attempts_for_author(eng, author_id: str, test_id: str) -> list[dict]:
|
||||
with eng.connect() as conn:
|
||||
t = conn.execute(
|
||||
text('SELECT id, created_by FROM tests WHERE id = :id'),
|
||||
{'id': test_id},
|
||||
).mappings().first()
|
||||
if not t:
|
||||
raise HttpError(404, 'Тест не найден.')
|
||||
if not is_test_author(t['created_by'], author_id):
|
||||
raise HttpError(403, 'Доступ запрещён.')
|
||||
rows = conn.execute(
|
||||
text(
|
||||
'SELECT ta.id, ta.user_id, ta.status, ta.attempt_number, ta.started_at, ta.completed_at, '
|
||||
'ta.correct_count, ta.total_questions, ta.passed, tv.version AS test_version, '
|
||||
'u.full_name AS attempter_name, u.login AS attempter_login '
|
||||
'FROM test_attempts ta '
|
||||
'INNER JOIN test_versions tv ON tv.id = ta.test_version_id '
|
||||
'INNER JOIN users u ON u.id = ta.user_id '
|
||||
'WHERE tv.test_id = :id '
|
||||
'ORDER BY ta.started_at DESC NULLS LAST LIMIT 200'
|
||||
),
|
||||
{'id': test_id},
|
||||
).mappings().all()
|
||||
return [dict(r) for r in rows]
|
||||
@@ -1,17 +1,668 @@
|
||||
/* Точечные стили поверх Tailwind CDN.
|
||||
В E1.0 файл почти пустой — задаёт только сглаживание иконок и базовый focus-ring,
|
||||
чтобы кнопки/ссылки были консистентны со стилем кабинета HR. */
|
||||
/* Базовые токены и точечные стили в духе webapp-nginx/cabinet-theme. */
|
||||
|
||||
:root {
|
||||
--surface: #ffffff;
|
||||
--surface-container-low: #f3f8f9;
|
||||
--surface-container: #eaf3f5;
|
||||
--on-surface: #0d1b1d;
|
||||
--on-surface-variant: #3d5357;
|
||||
--primary: #007168;
|
||||
--primary-hover: #00645b;
|
||||
--outline-variant: #b9bc94;
|
||||
--shadow-card: 0 8px 40px rgba(0, 0, 0, 0.08);
|
||||
--radius-card: 2rem;
|
||||
--max-content: 42rem;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100dvh;
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
background: var(--surface-container-low);
|
||||
color: var(--on-surface);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-family: 'Material Symbols Outlined', sans-serif;
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
font-variation-settings:
|
||||
'FILL' 0,
|
||||
'wght' 400,
|
||||
'GRAD' 0,
|
||||
'opsz' 20;
|
||||
'opsz' 24;
|
||||
direction: ltr;
|
||||
-webkit-font-feature-settings: 'liga';
|
||||
font-feature-settings: 'liga';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid #6366f1; /* brand-500 */
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Небольшой "cabinet" акцент карточек/кнопок без переписывания шаблонов. */
|
||||
.rounded-2xl.bg-white,
|
||||
.rounded-xl.bg-white {
|
||||
border-color: color-mix(in srgb, var(--outline-variant) 38%, transparent);
|
||||
}
|
||||
|
||||
.bg-brand-600 {
|
||||
background-color: var(--primary) !important;
|
||||
}
|
||||
|
||||
.hover\:bg-brand-700:hover {
|
||||
background-color: var(--primary-hover) !important;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* UI variants (оба режима на Flask, отличие только в компоновке UI). */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Modern: плотная колонка и акцент на карточный контент. */
|
||||
body.ui-modern .max-w-2xl {
|
||||
max-width: 42rem !important;
|
||||
}
|
||||
|
||||
body.ui-modern main {
|
||||
padding-top: 1.25rem;
|
||||
}
|
||||
|
||||
/* Legacy: идентичный cabinet layout. */
|
||||
body.ui-legacy .max-w-2xl {
|
||||
max-width: 42rem !important;
|
||||
}
|
||||
|
||||
body.ui-legacy .cabinet-app {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
body.ui-legacy .cabinet-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
background: color-mix(in srgb, var(--surface) 88%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent);
|
||||
}
|
||||
|
||||
body.ui-legacy .cabinet-header__inner {
|
||||
max-width: var(--max-content);
|
||||
margin: 0 auto;
|
||||
padding-top: max(0.75rem, env(safe-area-inset-top, 0px));
|
||||
padding-bottom: 0.75rem;
|
||||
padding-left: max(1.25rem, env(safe-area-inset-left, 0px) + 0.5rem);
|
||||
padding-right: max(1.25rem, env(safe-area-inset-right, 0px) + 0.5rem);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
body.ui-legacy .cabinet-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
color: var(--on-surface);
|
||||
text-decoration: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
body.ui-legacy .cabinet-brand:hover {
|
||||
text-decoration: none;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
body.ui-legacy .cabinet-brand__logo {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
body.ui-legacy .login-logo__img {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
margin: 0 auto 0.5rem;
|
||||
}
|
||||
body.ui-legacy .cabinet-brand__icon {
|
||||
font-size: 1.75rem;
|
||||
color: var(--primary);
|
||||
background: var(--surface-container-low);
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.35rem;
|
||||
border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
body.ui-legacy .cabinet-brand__title {
|
||||
font-family: 'Manrope', 'Inter', sans-serif;
|
||||
font-weight: 800;
|
||||
font-size: 1rem;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
body.ui-legacy .cabinet-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
body.ui-legacy .cabinet-user {
|
||||
font-size: 0.8rem;
|
||||
color: var(--on-surface-variant);
|
||||
text-align: right;
|
||||
max-width: 12rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
body.ui-legacy .cabinet-user {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
body.ui-legacy .cabinet-user__role {
|
||||
color: var(--secondary, #506965);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
body.ui-legacy .cabinet-main {
|
||||
flex: 1;
|
||||
max-width: var(--max-content);
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem 1.25rem calc(2.5rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
body.ui-legacy main {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
body.ui-legacy .rounded-2xl.bg-white,
|
||||
body.ui-legacy .rounded-xl.bg-white {
|
||||
border-radius: 0.85rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* Legacy catalog (портировано из старого webapp) */
|
||||
body.ui-legacy .legacy-list-shell {
|
||||
max-width: 42rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
body.ui-legacy .legacy-list-title {
|
||||
font-size: 1.5rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
body.ui-legacy .legacy-list-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
body.ui-legacy .legacy-list-subtitle {
|
||||
font-size: 1.1rem;
|
||||
margin: 1.5rem 0 0.5rem;
|
||||
}
|
||||
|
||||
body.ui-legacy .btn {
|
||||
font-family: inherit;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
padding: 0.55rem 1.1rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1.5px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s, box-shadow 0.15s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
body.ui-legacy .btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--primary);
|
||||
border-color: color-mix(in srgb, var(--outline-variant) 70%, transparent);
|
||||
}
|
||||
|
||||
body.ui-legacy .btn-ghost:hover {
|
||||
background: var(--surface-container);
|
||||
border-color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
body.ui-legacy .text-muted {
|
||||
color: var(--on-surface-variant);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
body.ui-legacy .list-stack {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
body.ui-legacy .list-row {
|
||||
display: block;
|
||||
border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent);
|
||||
border-radius: 1rem;
|
||||
padding: 0.9rem 1rem;
|
||||
background: var(--surface);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
body.ui-legacy .list-row--split {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
body.ui-legacy .list-row__main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
body.ui-legacy .list-row__link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
padding: 0.9rem 1rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
body.ui-legacy .list-row__title {
|
||||
display: block;
|
||||
color: var(--on-surface);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
body.ui-legacy .list-row__meta {
|
||||
color: var(--on-surface-variant);
|
||||
font-size: 0.8rem;
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
body.ui-legacy .list-row__meta-tail {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
body.ui-legacy .list-row__side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.9rem 0.5rem 0;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent);
|
||||
}
|
||||
|
||||
body.ui-legacy .list-row--hidden {
|
||||
border-style: dashed;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
body.ui-legacy .link-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
body.ui-legacy .callout {
|
||||
border-radius: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
body.ui-legacy .callout--warning {
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
body.ui-legacy .muted,
|
||||
body.ui-legacy .text-muted,
|
||||
body.ui-legacy .text-secondary {
|
||||
color: #506965;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
body.ui-legacy .mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
body.ui-legacy .form-label {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--on-surface);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
body.ui-legacy .form-input {
|
||||
width: 100%;
|
||||
padding: 11px 13px;
|
||||
border: 1.5px solid var(--outline-variant);
|
||||
border-radius: 0.75rem;
|
||||
font-size: 15px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
background: var(--surface-container-low);
|
||||
color: var(--on-surface);
|
||||
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
body.ui-legacy .form-input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(0, 113, 104, 0.12);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
body.ui-legacy .surface-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent);
|
||||
border-radius: 1rem;
|
||||
padding: 1rem 1.1rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
body.ui-legacy .cabinet-brick {
|
||||
margin-bottom: 1.1rem;
|
||||
}
|
||||
|
||||
body.ui-legacy .cabinet-brick--hero {
|
||||
padding: 0.1rem 0 0.6rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 45%, transparent);
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.hero-brick__nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--ink-500, #6b7280);
|
||||
}
|
||||
.hero-brick__meta {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
align-items: baseline;
|
||||
color: var(--ink-500, #6b7280);
|
||||
}
|
||||
.hero-brick__sep { opacity: 0.55; }
|
||||
|
||||
.hero-brick__title {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
font-size: 1.65rem;
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
padding: 0.3rem 0.4rem;
|
||||
border-radius: 0.5rem;
|
||||
outline: none;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: inherit;
|
||||
min-height: 2.4rem;
|
||||
}
|
||||
.hero-brick__title:hover { border-color: color-mix(in srgb, var(--outline-variant) 50%, transparent); }
|
||||
.hero-brick__title:focus { border-color: var(--primary, #0d9488); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary, #0d9488) 18%, transparent); background: #fff; }
|
||||
|
||||
.hero-brick__desc {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.35rem;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
font-size: 0.95rem;
|
||||
color: var(--ink-700, #374151);
|
||||
padding: 0.3rem 0.4rem;
|
||||
border-radius: 0.5rem;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
.hero-brick__desc:hover { border-color: color-mix(in srgb, var(--outline-variant) 50%, transparent); }
|
||||
.hero-brick__desc:focus { border-color: var(--primary, #0d9488); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary, #0d9488) 18%, transparent); background: #fff; }
|
||||
|
||||
.hero-brick__chips {
|
||||
margin-top: 0.65rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.hero-brick__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
background: color-mix(in srgb, var(--surface, #fff) 80%, var(--outline-variant, #e5e7eb));
|
||||
border: 1px solid color-mix(in srgb, var(--outline-variant, #e5e7eb) 70%, transparent);
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--ink-700, #374151);
|
||||
cursor: pointer;
|
||||
}
|
||||
.hero-brick__chip--readonly { cursor: default; }
|
||||
.hero-brick__chip input[type="number"] {
|
||||
width: 3.2rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: right;
|
||||
font: inherit;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
}
|
||||
.hero-brick__chip input[type="checkbox"] { accent-color: var(--primary, #0d9488); }
|
||||
|
||||
body.ui-legacy .cabinet-disclosure {
|
||||
border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent);
|
||||
border-radius: 1rem;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
body.ui-legacy .cabinet-disclosure__summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
padding: 0.85rem 1rem 0.75rem;
|
||||
font-size: 1.05rem;
|
||||
border-radius: 1rem 1rem 0 0;
|
||||
min-height: 2.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
body.ui-legacy .cabinet-disclosure__summary::-webkit-details-marker { display: none; }
|
||||
|
||||
body.ui-legacy .cabinet-disclosure__summary::after {
|
||||
content: 'expand_more';
|
||||
font-family: 'Material Symbols Outlined', sans-serif;
|
||||
margin-left: auto;
|
||||
font-size: 1.25rem;
|
||||
opacity: 0.55;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
body.ui-legacy .cabinet-disclosure[open] .cabinet-disclosure__summary::after {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
body.ui-legacy .cabinet-disclosure__summary-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
body.ui-legacy .cabinet-disclosure__summary-title {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
body.ui-legacy .cabinet-disclosure__summary-sub {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.3;
|
||||
color: #506965;
|
||||
}
|
||||
|
||||
body.ui-legacy .cabinet-disclosure__body {
|
||||
padding: 0.7rem 1rem 1.05rem;
|
||||
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent);
|
||||
}
|
||||
|
||||
body.ui-legacy .test-detail-subsection {
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1.15rem;
|
||||
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 32%, transparent);
|
||||
}
|
||||
|
||||
body.ui-legacy .test-detail-subsection--tight {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
body.ui-legacy .test-detail-subsection__title {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
body.ui-legacy .test-detail-hint {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
color: #506965;
|
||||
}
|
||||
|
||||
body.ui-legacy .test-detail-ai-panel {
|
||||
padding: 0.9rem 1rem;
|
||||
margin-bottom: 1.15rem;
|
||||
background: var(--surface-container-low);
|
||||
border: 1px solid color-mix(in srgb, var(--outline-variant) 32%, transparent);
|
||||
border-radius: 0.85rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
body.ui-legacy .assign-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
@media (min-width: 520px) {
|
||||
body.ui-legacy .assign-toolbar {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
body.ui-legacy .assign-toolbar__search {
|
||||
flex: 1 1 200px;
|
||||
}
|
||||
|
||||
body.ui-legacy .assign-list {
|
||||
max-height: min(40vh, 18rem);
|
||||
overflow: auto;
|
||||
border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--surface-container-low);
|
||||
}
|
||||
|
||||
body.ui-legacy .assign-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 40%, transparent);
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
body.ui-legacy .assign-row:last-child { border-bottom: none; }
|
||||
body.ui-legacy .assign-row--selected,
|
||||
body.ui-legacy .assign-row:hover { background: color-mix(in srgb, var(--primary) 8%, transparent); }
|
||||
|
||||
body.ui-legacy .assign-row__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
body.ui-legacy .assign-row__fio { font-weight: 600; font-size: 0.95rem; }
|
||||
body.ui-legacy .assign-row__login { font-size: 0.8rem; color: #506965; font-family: ui-monospace, Menlo, monospace; }
|
||||
body.ui-legacy .assign-row__meta { font-size: 0.8rem; color: #506965; line-height: 1.35; }
|
||||
|
||||
body.ui-legacy .version-card-list,
|
||||
body.ui-legacy .attempts-card-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
@@ -25,14 +25,74 @@
|
||||
const saveStatusEl = $('#save-status');
|
||||
const aiStatusEl = $('#ai-status');
|
||||
const chainActiveEl = $('#chain-active');
|
||||
const aiTopicEl = $('#ai-topic');
|
||||
const aiQCountEl = $('#ai-q-count');
|
||||
const aiOCountEl = $('#ai-o-count');
|
||||
const introUpdatedEl = $('#intro-updated');
|
||||
const introForkBannerEl = $('#intro-fork-banner');
|
||||
const versionsListEl = $('#versions-list');
|
||||
const attemptsListEl = $('#attempts-list');
|
||||
const visibilityBtn = $('#btn-toggle-visibility');
|
||||
const assignSearchEl = $('#assign-search');
|
||||
const assignDeptEl = $('#assign-dept');
|
||||
const assignClinicEl = $('#assign-clinic');
|
||||
const assignListEl = $('#assign-list');
|
||||
const assignSelectAllBtn = $('#assign-select-all');
|
||||
const assignSubmitBtn = $('#assign-submit');
|
||||
const assignStatusEl = $('#assign-status');
|
||||
|
||||
const tplQ = $('#tpl-question');
|
||||
const tplO = $('#tpl-option');
|
||||
|
||||
let chainActive = true;
|
||||
let assignPeople = [];
|
||||
let assignSelected = new Set();
|
||||
let hasAnyAttempts = false;
|
||||
let hasForkRisk = Boolean(initial?.test?.hasForkRisk)
|
||||
|| (introForkBannerEl && introForkBannerEl.dataset.forkRisk === '1');
|
||||
let baselineDraftKey = '';
|
||||
let dirtyCheckQueued = false;
|
||||
|
||||
function currentDraftKey() {
|
||||
return JSON.stringify(collectPayload());
|
||||
}
|
||||
|
||||
function isDirty() {
|
||||
return baselineDraftKey !== '' && baselineDraftKey !== currentDraftKey();
|
||||
}
|
||||
|
||||
function updateForkBanner() {
|
||||
if (!introForkBannerEl) return;
|
||||
introForkBannerEl.style.display = (hasForkRisk && isDirty()) ? '' : 'none';
|
||||
}
|
||||
|
||||
function scheduleDirtyCheck() {
|
||||
if (dirtyCheckQueued) return;
|
||||
dirtyCheckQueued = true;
|
||||
requestAnimationFrame(() => {
|
||||
dirtyCheckQueued = false;
|
||||
updateForkBanner();
|
||||
});
|
||||
}
|
||||
|
||||
function resetBaselineDraft() {
|
||||
baselineDraftKey = currentDraftKey();
|
||||
updateForkBanner();
|
||||
}
|
||||
|
||||
// ─── render ─────────────────────────────────────────────────────────
|
||||
|
||||
function syncOptionInputTypes(qNode) {
|
||||
const isMulti = $('.q-multi', qNode).checked;
|
||||
const qName = `q-correct-${Math.random().toString(36).slice(2)}`;
|
||||
$$('.opt-correct', qNode).forEach((input) => {
|
||||
input.type = isMulti ? 'checkbox' : 'radio';
|
||||
if (isMulti) input.removeAttribute('name');
|
||||
else input.setAttribute('name', qName);
|
||||
input.classList.add('question-option-row__mark');
|
||||
});
|
||||
}
|
||||
|
||||
function renderQuestion(q) {
|
||||
const node = tplQ.content.firstElementChild.cloneNode(true);
|
||||
node._q = { id: q.id || null };
|
||||
@@ -43,6 +103,7 @@
|
||||
(q.options || []).forEach((o) => optsEl.appendChild(renderOption(o)));
|
||||
|
||||
bindQuestionEvents(node);
|
||||
syncOptionInputTypes(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
@@ -61,41 +122,86 @@
|
||||
if (!confirm('Удалить вопрос?')) return;
|
||||
node.remove();
|
||||
renumber();
|
||||
scheduleDirtyCheck();
|
||||
});
|
||||
$('.q-up', node).addEventListener('click', () => {
|
||||
if (node.previousElementSibling) {
|
||||
node.parentNode.insertBefore(node, node.previousElementSibling);
|
||||
renumber();
|
||||
scheduleDirtyCheck();
|
||||
}
|
||||
});
|
||||
$('.q-down', node).addEventListener('click', () => {
|
||||
if (node.nextElementSibling) {
|
||||
node.parentNode.insertBefore(node.nextElementSibling, node);
|
||||
renumber();
|
||||
scheduleDirtyCheck();
|
||||
}
|
||||
});
|
||||
$('.q-add-option', node).addEventListener('click', () => {
|
||||
$('.q-options', node).appendChild(renderOption({ text: '', isCorrect: false }));
|
||||
syncOptionInputTypes(node);
|
||||
scheduleDirtyCheck();
|
||||
});
|
||||
$('.q-ai', node).addEventListener('click', () => aiGenerateQuestion(node));
|
||||
$('.q-multi', node).addEventListener('change', () => {
|
||||
syncOptionInputTypes(node);
|
||||
scheduleDirtyCheck();
|
||||
});
|
||||
}
|
||||
|
||||
function renumber() {
|
||||
$$('#questions .q-item').forEach((li, i) => {
|
||||
$('.q-num', li).textContent = `Вопрос #${i + 1}`;
|
||||
});
|
||||
qCountEl.textContent = $$('#questions .q-item').length;
|
||||
const n = $$('#questions .q-item').length;
|
||||
if (qCountEl) qCountEl.textContent = n;
|
||||
const mirror = document.getElementById('q-count-mirror');
|
||||
if (mirror) mirror.textContent = n;
|
||||
}
|
||||
|
||||
function autoResize(el) {
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = el.scrollHeight + 'px';
|
||||
}
|
||||
|
||||
function loadInitial() {
|
||||
titleEl.value = initial.test.title || '';
|
||||
descEl.value = initial.test.description || '';
|
||||
autoResize(titleEl);
|
||||
autoResize(descEl);
|
||||
if (titleEl && titleEl.tagName === 'TEXTAREA') {
|
||||
titleEl.addEventListener('input', () => autoResize(titleEl));
|
||||
titleEl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') e.preventDefault();
|
||||
});
|
||||
}
|
||||
if (descEl) descEl.addEventListener('input', () => autoResize(descEl));
|
||||
thresholdEl.value =
|
||||
initial.test.passingThreshold == null ? '' : Number(initial.test.passingThreshold);
|
||||
|
||||
questionsEl.innerHTML = '';
|
||||
(initial.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
||||
renumber();
|
||||
if (aiTopicEl && !aiTopicEl.value.trim()) {
|
||||
aiTopicEl.value = initial.test.title || '';
|
||||
}
|
||||
}
|
||||
|
||||
function fmtDt(iso) {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
return new Date(iso).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' });
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ─── collect ───────────────────────────────────────────────────────
|
||||
@@ -144,6 +250,7 @@
|
||||
}),
|
||||
);
|
||||
renumber();
|
||||
scheduleDirtyCheck();
|
||||
});
|
||||
|
||||
$('#save-draft').addEventListener('click', async () => {
|
||||
@@ -167,6 +274,7 @@
|
||||
saveStatusEl.textContent = data.forked
|
||||
? 'Сохранено (создана новая версия — есть попытки прохождения).'
|
||||
: 'Сохранено.';
|
||||
resetBaselineDraft();
|
||||
setTimeout(() => (saveStatusEl.textContent = ''), 4000);
|
||||
} catch (e) {
|
||||
saveStatusEl.textContent = '';
|
||||
@@ -175,18 +283,24 @@
|
||||
});
|
||||
|
||||
$('#ai-generate-test').addEventListener('click', async () => {
|
||||
const shape = collectShape();
|
||||
if (!shape.length) {
|
||||
alert('Сначала добавьте хотя бы один вопрос (его настройки определяют сетку).');
|
||||
const topic = (aiTopicEl?.value || titleEl.value || '').trim();
|
||||
if (!topic) {
|
||||
alert('Укажите тему.');
|
||||
return;
|
||||
}
|
||||
const nQ = Math.min(30, Math.max(1, Number(aiQCountEl?.value || 7) || 7));
|
||||
const nO = Math.min(8, Math.max(2, Number(aiOCountEl?.value || 3) || 3));
|
||||
const shape = Array.from({ length: nQ }, () => ({
|
||||
optionsCount: nO,
|
||||
hasMultipleAnswers: false,
|
||||
}));
|
||||
aiStatusEl.textContent = 'Генерируем…';
|
||||
try {
|
||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-test`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
testTitle: titleEl.value,
|
||||
testTitle: topic,
|
||||
testDescription: descEl.value,
|
||||
shape,
|
||||
}),
|
||||
@@ -194,11 +308,15 @@
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'AI: ошибка.');
|
||||
const draft = data.draft;
|
||||
if (draft.title) titleEl.value = draft.title;
|
||||
if (draft.title) {
|
||||
titleEl.value = draft.title;
|
||||
if (aiTopicEl) aiTopicEl.value = draft.title;
|
||||
}
|
||||
if (draft.description) descEl.value = draft.description;
|
||||
questionsEl.innerHTML = '';
|
||||
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
||||
renumber();
|
||||
scheduleDirtyCheck();
|
||||
aiStatusEl.textContent = `Готово: ${draft.questions?.length || 0} вопросов.`;
|
||||
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
||||
} catch (e) {
|
||||
@@ -242,6 +360,7 @@
|
||||
questionsEl.innerHTML = '';
|
||||
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
||||
renumber();
|
||||
scheduleDirtyCheck();
|
||||
aiStatusEl.textContent = `Применено: ${draft.questions?.length || 0} вопросов.`;
|
||||
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
||||
} catch (e) {
|
||||
@@ -263,12 +382,6 @@
|
||||
alert(msg);
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
const modal = $('#ai-modal');
|
||||
const modalTitle = $('#ai-modal-title');
|
||||
const modalBody = $('#ai-modal-body');
|
||||
@@ -290,7 +403,8 @@
|
||||
modal.showModal();
|
||||
}
|
||||
|
||||
$('#ai-generate-by-title').addEventListener('click', async () => {
|
||||
const aiGenerateByTitleBtn = $('#ai-generate-by-title');
|
||||
if (aiGenerateByTitleBtn) aiGenerateByTitleBtn.addEventListener('click', async () => {
|
||||
const title = titleEl.value.trim();
|
||||
if (!title) {
|
||||
alert('Сначала заполните название теста.');
|
||||
@@ -334,6 +448,7 @@
|
||||
questionsEl.innerHTML = '';
|
||||
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
||||
renumber();
|
||||
scheduleDirtyCheck();
|
||||
aiStatusEl.textContent = `Применено: ${draft.questions.length} вопросов.`;
|
||||
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
||||
} catch (e) {
|
||||
@@ -342,7 +457,8 @@
|
||||
}
|
||||
});
|
||||
|
||||
$('#ai-check').addEventListener('click', async () => {
|
||||
const aiCheckBtn = $('#ai-check');
|
||||
if (aiCheckBtn) aiCheckBtn.addEventListener('click', async () => {
|
||||
const payload = collectPayload();
|
||||
if (!payload.questions.length) {
|
||||
alert('В тесте нет вопросов — нечего проверять.');
|
||||
@@ -394,7 +510,8 @@
|
||||
}
|
||||
});
|
||||
|
||||
$('#ai-improve').addEventListener('click', async () => {
|
||||
const aiImproveBtn = $('#ai-improve');
|
||||
if (aiImproveBtn) aiImproveBtn.addEventListener('click', async () => {
|
||||
const payload = collectPayload();
|
||||
if (!payload.questions.length) {
|
||||
alert('В тесте нет вопросов — нечего улучшать.');
|
||||
@@ -480,6 +597,7 @@
|
||||
it.suggested.options.forEach((o) => optsEl.appendChild(renderOption(o)));
|
||||
});
|
||||
modal.close();
|
||||
scheduleDirtyCheck();
|
||||
aiStatusEl.textContent = 'Изменения применены. Не забудьте сохранить.';
|
||||
setTimeout(() => (aiStatusEl.textContent = ''), 5000);
|
||||
},
|
||||
@@ -517,6 +635,7 @@
|
||||
data.options.forEach((o) => optsEl.appendChild(renderOption(o)));
|
||||
$('.q-multi', node).checked = !!data.hasMultipleAnswers;
|
||||
}
|
||||
scheduleDirtyCheck();
|
||||
aiStatusEl.textContent = data.mode === 'full' ? 'AI: вопрос сгенерирован.' : 'AI: формулировка обновлена.';
|
||||
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
||||
} catch (e) {
|
||||
@@ -542,5 +661,195 @@
|
||||
chainActiveEl.checked = true;
|
||||
});
|
||||
|
||||
function renderVersions(rows) {
|
||||
if (!versionsListEl) return;
|
||||
versionsListEl.innerHTML = '';
|
||||
(rows || []).forEach((r) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'surface-card version-card-list__item';
|
||||
li.innerHTML = `
|
||||
<div class="version-card-list__row">
|
||||
<div class="version-card-list__main">
|
||||
<div class="version-card-list__title-line">
|
||||
<span class="font-headline" style="font-size:1rem;">v${r.version}</span>
|
||||
${r.is_active ? '<span class="code-inline" style="font-size:0.7rem;">текущая</span>' : ''}
|
||||
</div>
|
||||
<p class="muted mono" style="margin:.4rem 0 0; font-size:.8rem;">${fmtDt(r.created_at)}</p>
|
||||
<p class="muted" style="margin:.2rem 0 0; font-size:.8rem;">Активна: ${r.is_active ? 'да' : 'нет'}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
versionsListEl.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function renderAttempts(rows) {
|
||||
if (!attemptsListEl) return;
|
||||
attemptsListEl.innerHTML = '';
|
||||
(rows || []).forEach((a) => {
|
||||
const when = a.completedAt ? fmtDt(a.completedAt) : (a.startedAt ? fmtDt(a.startedAt) : '—');
|
||||
const result = a.status === 'completed' && a.totalQuestions != null
|
||||
? `${a.correctCount}/${a.totalQuestions}${a.passed ? ' · зачёт' : ' · незачёт'}`
|
||||
: a.status;
|
||||
const li = document.createElement('li');
|
||||
li.className = 'surface-card attempts-card-list__item';
|
||||
li.innerHTML = `
|
||||
<div class="attempts-card-list__row">
|
||||
<div class="attempts-card-list__main">
|
||||
<p class="muted mono" style="margin:0; font-size:.8rem;">${when}</p>
|
||||
<p style="margin:.35rem 0 0; font-weight:600;">${escHtml(a.attempterName || '—')}
|
||||
${a.attempterLogin ? `<span class="code-inline" style="font-size:11px; margin-left:6px;">${escHtml(a.attempterLogin)}</span>` : ''}
|
||||
</p>
|
||||
<p class="muted" style="margin:.25rem 0 0; font-size:.85rem;">v${a.testVersion} · ${escHtml(result)}</p>
|
||||
</div>
|
||||
${a.status === 'completed'
|
||||
? `<a class="btn btn-ghost btn--sm attempts-card-list__action" href="/tests/${TEST_ID}/attempts/${a.id}/review">Разбор</a>`
|
||||
: ''}
|
||||
</div>`;
|
||||
attemptsListEl.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function renderAssignList() {
|
||||
if (!assignListEl) return;
|
||||
assignListEl.innerHTML = '';
|
||||
assignPeople.forEach((p) => {
|
||||
const row = document.createElement('label');
|
||||
row.className = `assign-row${assignSelected.has(String(p.staffId)) ? ' assign-row--selected' : ''}`;
|
||||
row.innerHTML = `
|
||||
<input type="checkbox" ${assignSelected.has(String(p.staffId)) ? 'checked' : ''} />
|
||||
<span class="assign-row__text">
|
||||
<span class="assign-row__fio">${escHtml(p.fio || '—')}</span>
|
||||
${p.webLogin ? `<span class="assign-row__login">${escHtml(p.webLogin)}</span>` : ''}
|
||||
<span class="assign-row__meta">${escHtml(p.department || '—')}${p.clinicUserId ? ' · есть учётка' : ' · нет учётки (создадим при назначении)'}</span>
|
||||
</span>`;
|
||||
const cb = row.querySelector('input');
|
||||
cb.addEventListener('change', () => {
|
||||
const k = String(p.staffId);
|
||||
if (cb.checked) assignSelected.add(k); else assignSelected.delete(k);
|
||||
row.classList.toggle('assign-row--selected', cb.checked);
|
||||
});
|
||||
assignListEl.appendChild(row);
|
||||
});
|
||||
if (!assignPeople.length) assignListEl.innerHTML = '<p class="muted" style="padding:.75rem;">Никого не найдено.</p>';
|
||||
}
|
||||
|
||||
async function loadDirectory() {
|
||||
if (!assignListEl) return;
|
||||
assignStatusEl.textContent = 'Загружаем…';
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (assignSearchEl.value.trim()) params.set('q', assignSearchEl.value.trim());
|
||||
if (assignDeptEl.value && assignDeptEl.value !== '__all__') params.set('department', assignDeptEl.value);
|
||||
params.set('clinic', assignClinicEl.value || 'all');
|
||||
const r = await fetch(`/api/auth/dev/assignment-directory?${params.toString()}`);
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'Не удалось загрузить сотрудников');
|
||||
assignPeople = data.people || [];
|
||||
const depts = data.departments || [];
|
||||
if (assignDeptEl.options.length <= 1) {
|
||||
depts.forEach((d) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = d;
|
||||
o.textContent = d;
|
||||
assignDeptEl.appendChild(o);
|
||||
});
|
||||
}
|
||||
assignSelected = new Set();
|
||||
renderAssignList();
|
||||
assignStatusEl.textContent = '';
|
||||
} catch (e) {
|
||||
assignStatusEl.textContent = e.message || 'Ошибка загрузки';
|
||||
}
|
||||
}
|
||||
|
||||
if (assignSearchEl) {
|
||||
let t = null;
|
||||
assignSearchEl.addEventListener('input', () => {
|
||||
clearTimeout(t);
|
||||
t = setTimeout(loadDirectory, 350);
|
||||
});
|
||||
assignDeptEl.addEventListener('change', loadDirectory);
|
||||
assignClinicEl.addEventListener('change', loadDirectory);
|
||||
assignSelectAllBtn.addEventListener('click', () => {
|
||||
assignPeople.forEach((p) => assignSelected.add(String(p.staffId)));
|
||||
renderAssignList();
|
||||
});
|
||||
assignSubmitBtn.addEventListener('click', async () => {
|
||||
const selectedRows = assignPeople.filter((p) => assignSelected.has(String(p.staffId)));
|
||||
const userIds = selectedRows.filter((p) => p.clinicUserId).map((p) => p.clinicUserId);
|
||||
const staffIds = selectedRows.filter((p) => !p.clinicUserId && p.staffId != null).map((p) => p.staffId);
|
||||
if (!userIds.length && !staffIds.length) return;
|
||||
assignStatusEl.textContent = 'Назначаем…';
|
||||
try {
|
||||
const r = await fetch(`/api/tests/${TEST_ID}/assign`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userIds, staffIds }),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'Ошибка назначения');
|
||||
assignStatusEl.textContent = `Назначено: ${data.count ?? selectedRows.length}`;
|
||||
} catch (e) {
|
||||
assignStatusEl.textContent = e.message || 'Ошибка назначения';
|
||||
}
|
||||
});
|
||||
loadDirectory();
|
||||
}
|
||||
|
||||
if (visibilityBtn) {
|
||||
visibilityBtn.addEventListener('click', async () => {
|
||||
const next = !chainActiveEl.checked;
|
||||
try {
|
||||
const r = await fetch(`/api/tests/${TEST_ID}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chainActive: next }),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'Ошибка изменения видимости');
|
||||
chainActiveEl.checked = !!next;
|
||||
chainActive = !!next;
|
||||
visibilityBtn.textContent = chainActive ? 'Скрыть из списка' : 'Снова показать в списке';
|
||||
} catch (e) {
|
||||
alert(e.message || 'Ошибка изменения видимости');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
fetch(`/api/tests/${TEST_ID}/versions`).then((r) => r.json()).catch(() => null),
|
||||
fetch(`/api/tests/${TEST_ID}/attempts`).then((r) => r.json()).catch(() => null),
|
||||
fetch(`/api/tests/${TEST_ID}/summary`).then((r) => r.json()).catch(() => null),
|
||||
]).then(([v, a, s]) => {
|
||||
if (v && Array.isArray(v.versions)) {
|
||||
renderVersions(v.versions);
|
||||
hasForkRisk = hasForkRisk || (v.versions.length > 1);
|
||||
if (typeof v.hasAttempts === 'boolean') {
|
||||
hasAnyAttempts = hasAnyAttempts || v.hasAttempts;
|
||||
hasForkRisk = hasForkRisk || v.hasAttempts;
|
||||
}
|
||||
}
|
||||
if (a && Array.isArray(a.attempts)) {
|
||||
renderAttempts(a.attempts);
|
||||
hasAnyAttempts = a.attempts.length > 0;
|
||||
}
|
||||
if (s && s.test) {
|
||||
if (introUpdatedEl) introUpdatedEl.textContent = fmtDt(s.test.updated_at || s.test.updatedAt);
|
||||
const versionEl = document.getElementById('intro-version');
|
||||
if (versionEl && s.test.version != null) versionEl.textContent = s.test.version;
|
||||
if (typeof s.test.hasAttempts === 'boolean') {
|
||||
hasAnyAttempts = hasAnyAttempts || s.test.hasAttempts;
|
||||
hasForkRisk = hasForkRisk || s.test.hasAttempts;
|
||||
}
|
||||
if (typeof s.test.versionCount === 'number') {
|
||||
hasForkRisk = hasForkRisk || s.test.versionCount > 1;
|
||||
}
|
||||
}
|
||||
updateForkBanner();
|
||||
});
|
||||
|
||||
loadInitial();
|
||||
resetBaselineDraft();
|
||||
root.addEventListener('input', scheduleDirtyCheck);
|
||||
root.addEventListener('change', scheduleDirtyCheck);
|
||||
})();
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -42,6 +42,14 @@ from ..services.document_extract import (
|
||||
from ..services.document_gen import generation_for_import_document
|
||||
from ..services.draft_validator import LlmError
|
||||
from ..services.editor_content import HttpError as EditorHttpError, get_editor_content
|
||||
from ..services.test_attempt import (
|
||||
HttpError as AttemptHttpError,
|
||||
get_attempt_review_for_user,
|
||||
get_play_content,
|
||||
list_test_attempts_for_author,
|
||||
start_attempt,
|
||||
submit_attempt,
|
||||
)
|
||||
from ..services.test_access import is_test_author, list_hidden_by_author, list_visible_tests
|
||||
from ..services.test_chain import has_any_attempt_for_test
|
||||
from ..services.test_draft import (
|
||||
@@ -150,6 +158,10 @@ def api_test_summary(test_id):
|
||||
if not acc.ok:
|
||||
return jsonify(error=RU['notFound']), 404
|
||||
|
||||
has_attempts = False
|
||||
with eng.connect() as conn:
|
||||
has_attempts = has_any_attempt_for_test(conn, test_id)
|
||||
|
||||
return jsonify(
|
||||
test={
|
||||
'id': str(row['id']),
|
||||
@@ -163,6 +175,7 @@ def api_test_summary(test_id):
|
||||
'updatedAt': row['updated_at'].isoformat() if row['updated_at'] else None,
|
||||
'createdBy': str(row['created_by']) if row['created_by'] else None,
|
||||
'authorFullName': row['author_full_name'],
|
||||
'hasAttempts': bool(has_attempts),
|
||||
},
|
||||
isAuthor=is_author,
|
||||
hasActiveVersion=row['active_version_id'] is not None,
|
||||
@@ -293,6 +306,85 @@ def api_patch_test(test_id):
|
||||
return jsonify(id=test_id, chainActive=chain)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/attempts/start', methods=['POST'])
|
||||
@login_required
|
||||
def api_start_attempt(test_id):
|
||||
user = current_user()
|
||||
eng = get_engine()
|
||||
try:
|
||||
out = start_attempt(eng, user.id, test_id)
|
||||
except AttemptHttpError as e:
|
||||
return jsonify(error=e.message), e.status
|
||||
return jsonify(out), 201
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/attempts/<attempt_id>/play', methods=['GET'])
|
||||
@login_required
|
||||
def api_attempt_play(test_id, attempt_id):
|
||||
user = current_user()
|
||||
eng = get_engine()
|
||||
try:
|
||||
out = get_play_content(eng, user.id, test_id, attempt_id)
|
||||
except AttemptHttpError as e:
|
||||
return jsonify(error=e.message), e.status
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/attempts/<attempt_id>/submit', methods=['POST'])
|
||||
@login_required
|
||||
def api_attempt_submit(test_id, attempt_id):
|
||||
user = current_user()
|
||||
eng = get_engine()
|
||||
body = request.get_json(silent=True) or {}
|
||||
try:
|
||||
out = submit_attempt(eng, user.id, test_id, attempt_id, body.get('answers'))
|
||||
except AttemptHttpError as e:
|
||||
return jsonify(error=e.message), e.status
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/attempts/<attempt_id>/review', methods=['GET'])
|
||||
@login_required
|
||||
def api_attempt_review(test_id, attempt_id):
|
||||
user = current_user()
|
||||
eng = get_engine()
|
||||
try:
|
||||
out = get_attempt_review_for_user(eng, user.id, test_id, attempt_id)
|
||||
except AttemptHttpError as e:
|
||||
return jsonify(error=e.message), e.status
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/attempts', methods=['GET'])
|
||||
@login_required
|
||||
def api_attempts_list(test_id):
|
||||
user = current_user()
|
||||
eng = get_engine()
|
||||
try:
|
||||
rows = list_test_attempts_for_author(eng, user.id, test_id)
|
||||
except AttemptHttpError as e:
|
||||
return jsonify(error=e.message), e.status
|
||||
return jsonify(
|
||||
attempts=[
|
||||
{
|
||||
'id': str(r['id']),
|
||||
'userId': str(r['user_id']),
|
||||
'status': r['status'],
|
||||
'attemptNumber': r['attempt_number'],
|
||||
'startedAt': r['started_at'].isoformat() if r['started_at'] else None,
|
||||
'completedAt': r['completed_at'].isoformat() if r['completed_at'] else None,
|
||||
'correctCount': r['correct_count'],
|
||||
'totalQuestions': r['total_questions'],
|
||||
'passed': r['passed'],
|
||||
'testVersion': r['test_version'],
|
||||
'attempterName': r['attempter_name'],
|
||||
'attempterLogin': r['attempter_login'],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# ─── AI ──────────────────────────────────────────────────────────────
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/ai/generate-test', methods=['POST'])
|
||||
@@ -463,3 +555,23 @@ def tests_editor_page(test_id):
|
||||
return ('Доступ запрещён.', 403)
|
||||
return render_template('500.html'), 500
|
||||
return render_template('tests/editor.html', content=content, test_id=test_id)
|
||||
|
||||
|
||||
@tests_bp.route('/tests/<test_id>/attempt/<attempt_id>', methods=['GET'])
|
||||
@login_required
|
||||
def tests_attempt_page(test_id, attempt_id):
|
||||
return render_template('tests/attempt.html', test_id=test_id, attempt_id=attempt_id)
|
||||
|
||||
|
||||
@tests_bp.route('/tests/<test_id>/attempts/<attempt_id>/review', methods=['GET'])
|
||||
@login_required
|
||||
def tests_attempt_review_page(test_id, attempt_id):
|
||||
user = current_user()
|
||||
eng = get_engine()
|
||||
try:
|
||||
review = get_attempt_review_for_user(eng, user.id, test_id, attempt_id)
|
||||
except AttemptHttpError as e:
|
||||
if e.status == 404:
|
||||
return render_template('404.html'), 404
|
||||
return (e.message, e.status)
|
||||
return render_template('tests/attempt_review.html', test_id=test_id, review=review)
|
||||
|
||||
Reference in New Issue
Block a user