UI bugfixes with boss
This commit is contained in:
@@ -23,3 +23,8 @@ DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/clinic_tests
|
|||||||
# (Werkzeug-хеш в public.users.password по web_login = username).
|
# (Werkzeug-хеш в public.users.password по web_login = username).
|
||||||
# HR_AUTH=1
|
# HR_AUTH=1
|
||||||
# HR_DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/hr_bot_test
|
# HR_DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/hr_bot_test
|
||||||
|
|
||||||
|
# ─── Редактирование тестов (до внедрения RBAC) ───────────────────
|
||||||
|
# По умолчанию любой залогиненный пользователь может править любой тест.
|
||||||
|
# Чтобы снова ограничить сохранение/ИИ/версии только автором цепочки:
|
||||||
|
# CLINIC_TESTS_RESTRICT_EDIT_TO_AUTHOR=1
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import Any
|
|||||||
from .draft_validator import (
|
from .draft_validator import (
|
||||||
assert_draft_matches_shape,
|
assert_draft_matches_shape,
|
||||||
parse_json_from_llm_text,
|
parse_json_from_llm_text,
|
||||||
|
shuffle_options_in_questions,
|
||||||
validate_and_normalize_draft,
|
validate_and_normalize_draft,
|
||||||
)
|
)
|
||||||
from .llm_client import LlmError, chat_completion_text_content, get_llm_config
|
from .llm_client import LlmError, chat_completion_text_content, get_llm_config
|
||||||
@@ -103,6 +104,7 @@ def generate_full_test_by_shape(test_title: str, test_description: str, shape: l
|
|||||||
parsed = parse_json_from_llm_text(raw)
|
parsed = parse_json_from_llm_text(raw)
|
||||||
draft = validate_and_normalize_draft(parsed)
|
draft = validate_and_normalize_draft(parsed)
|
||||||
assert_draft_matches_shape({'questions': draft['questions']}, shape)
|
assert_draft_matches_shape({'questions': draft['questions']}, shape)
|
||||||
|
shuffle_options_in_questions(draft['questions'])
|
||||||
return {
|
return {
|
||||||
'title': draft['title'],
|
'title': draft['title'],
|
||||||
'description': draft['description'],
|
'description': draft['description'],
|
||||||
@@ -153,6 +155,7 @@ def generate_test_by_title(
|
|||||||
raw = chat_completion_text_content(cfg, system, user, 0.45)
|
raw = chat_completion_text_content(cfg, system, user, 0.45)
|
||||||
parsed = parse_json_from_llm_text(raw)
|
parsed = parse_json_from_llm_text(raw)
|
||||||
draft = validate_and_normalize_draft(parsed)
|
draft = validate_and_normalize_draft(parsed)
|
||||||
|
shuffle_options_in_questions(draft['questions'])
|
||||||
return {
|
return {
|
||||||
'title': draft['title'],
|
'title': draft['title'],
|
||||||
'description': draft['description'],
|
'description': draft['description'],
|
||||||
@@ -475,9 +478,11 @@ def generate_or_rephrase_question(
|
|||||||
shape = [{'optionsCount': n, 'hasMultipleAnswers': bool(has_multiple_answers)}]
|
shape = [{'optionsCount': n, 'hasMultipleAnswers': bool(has_multiple_answers)}]
|
||||||
assert_draft_matches_shape({'questions': [parsed]}, shape)
|
assert_draft_matches_shape({'questions': [parsed]}, shape)
|
||||||
draft = validate_and_normalize_draft({'title': 'временно', 'questions': [parsed]})
|
draft = validate_and_normalize_draft({'title': 'временно', 'questions': [parsed]})
|
||||||
|
q0 = draft['questions'][0]
|
||||||
|
shuffle_options_in_questions([q0])
|
||||||
return {
|
return {
|
||||||
'mode': 'full',
|
'mode': 'full',
|
||||||
'text': draft['questions'][0]['text'],
|
'text': q0['text'],
|
||||||
'hasMultipleAnswers': draft['questions'][0]['hasMultipleAnswers'],
|
'hasMultipleAnswers': q0['hasMultipleAnswers'],
|
||||||
'options': draft['questions'][0]['options'],
|
'options': q0['options'],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
from .draft_validator import (
|
from .draft_validator import (
|
||||||
normalize_draft_to_shape,
|
normalize_draft_to_shape,
|
||||||
parse_json_from_llm_text,
|
parse_json_from_llm_text,
|
||||||
|
shuffle_options_in_questions,
|
||||||
validate_and_normalize_draft,
|
validate_and_normalize_draft,
|
||||||
)
|
)
|
||||||
from .llm_client import LlmError, chat_completion_text_content, get_llm_config
|
from .llm_client import LlmError, chat_completion_text_content, get_llm_config
|
||||||
@@ -64,6 +65,7 @@ def generation_for_import_document(extracted_text: str, user_hint: str = '', sha
|
|||||||
draft = validate_and_normalize_draft(parsed)
|
draft = validate_and_normalize_draft(parsed)
|
||||||
if shape:
|
if shape:
|
||||||
draft = normalize_draft_to_shape(draft, shape)
|
draft = normalize_draft_to_shape(draft, shape)
|
||||||
|
shuffle_options_in_questions(draft['questions'])
|
||||||
return {
|
return {
|
||||||
'available': True,
|
'available': True,
|
||||||
'message': (
|
'message': (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json as _json
|
import json as _json
|
||||||
|
import random
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -79,6 +80,18 @@ def validate_and_normalize_draft(o: Any) -> dict:
|
|||||||
return {'title': title, 'description': description, 'questions': questions}
|
return {'title': title, 'description': description, 'questions': questions}
|
||||||
|
|
||||||
|
|
||||||
|
def shuffle_options_in_questions(questions: list[dict] | None) -> None:
|
||||||
|
"""Случайный порядок вариантов в каждом вопросе; isCorrect остаётся у соответствующего текста."""
|
||||||
|
if not questions:
|
||||||
|
return
|
||||||
|
for q in questions:
|
||||||
|
if not isinstance(q, dict):
|
||||||
|
continue
|
||||||
|
opts = q.get('options')
|
||||||
|
if isinstance(opts, list) and len(opts) >= 2:
|
||||||
|
random.shuffle(opts)
|
||||||
|
|
||||||
|
|
||||||
def assert_draft_matches_shape(o: dict, shape: list[dict]) -> None:
|
def assert_draft_matches_shape(o: dict, shape: list[dict]) -> None:
|
||||||
"""Проверяет, что число вопросов и вариантов = ровно как в shape."""
|
"""Проверяет, что число вопросов и вариантов = ровно как в shape."""
|
||||||
qs = o.get('questions') if isinstance(o, dict) else None
|
qs = o.get('questions') if isinstance(o, dict) else None
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from sqlalchemy.orm import selectinload
|
|||||||
from ..db import get_session
|
from ..db import get_session
|
||||||
from ..messages import RU
|
from ..messages import RU
|
||||||
from ..models import AnswerOption, Question, Test, TestVersion
|
from ..models import AnswerOption, Question, Test, TestVersion
|
||||||
from .test_access import is_test_author
|
from .test_access import is_test_author, is_test_edit_open
|
||||||
from .test_chain import has_any_attempt_for_test
|
from .test_chain import has_any_attempt_for_test
|
||||||
|
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ def get_editor_content(user_id: str, test_id: str) -> dict:
|
|||||||
test = session.get(Test, tid)
|
test = session.get(Test, tid)
|
||||||
if not test:
|
if not test:
|
||||||
raise HttpError(404, 'Тест не найден.')
|
raise HttpError(404, 'Тест не найден.')
|
||||||
if not is_test_author(test.created_by, user_id):
|
if not is_test_author(test.created_by, user_id) and not is_test_edit_open():
|
||||||
raise HttpError(403, 'Доступ запрещён.')
|
raise HttpError(403, 'Доступ запрещён.')
|
||||||
|
|
||||||
active_version = (
|
active_version = (
|
||||||
|
|||||||
@@ -1,12 +1,26 @@
|
|||||||
"""Кто видит тест: автор + назначенные пользователи."""
|
"""Кто видит тест: автор + назначенные пользователи."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from sqlalchemy import exists, select
|
from sqlalchemy import exists, func
|
||||||
|
|
||||||
from ..db import get_session
|
from ..db import get_session
|
||||||
from ..models import Test, TestAssignment, TestAssignmentTarget, TestAttempt, TestVersion, User
|
from ..models import Question, Test, TestAssignment, TestAssignmentTarget, TestAttempt, TestVersion, User
|
||||||
|
|
||||||
|
|
||||||
|
def _truthy_env(val: str | None) -> bool:
|
||||||
|
return (val or '').strip().lower() in ('1', 'true', 'yes', 'on')
|
||||||
|
|
||||||
|
|
||||||
|
def is_test_edit_open() -> bool:
|
||||||
|
"""Пока без RBAC: любой залогиненный пользователь может править любой тест.
|
||||||
|
|
||||||
|
Задайте ``CLINIC_TESTS_RESTRICT_EDIT_TO_AUTHOR=1``, чтобы снова требовать роль автора
|
||||||
|
для редактирования, подсказок, списка попыток и разбора чужих попыток.
|
||||||
|
"""
|
||||||
|
return not _truthy_env(os.environ.get('CLINIC_TESTS_RESTRICT_EDIT_TO_AUTHOR'))
|
||||||
|
|
||||||
|
|
||||||
def is_test_author(created_by, user_id) -> bool:
|
def is_test_author(created_by, user_id) -> bool:
|
||||||
@@ -64,10 +78,20 @@ def list_visible_tests(user_id: str) -> list[dict]:
|
|||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
uid = None
|
uid = None
|
||||||
|
|
||||||
|
qcount_sq = (
|
||||||
|
session.query(
|
||||||
|
Question.test_version_id.label('tv_id'),
|
||||||
|
func.count(Question.id).label('qc'),
|
||||||
|
)
|
||||||
|
.group_by(Question.test_version_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
|
||||||
rows = (
|
rows = (
|
||||||
session.query(Test, TestVersion, User)
|
session.query(Test, TestVersion, User, qcount_sq.c.qc)
|
||||||
.join(TestVersion, (TestVersion.test_id == Test.id) & TestVersion.is_active.is_(True))
|
.join(TestVersion, (TestVersion.test_id == Test.id) & TestVersion.is_active.is_(True))
|
||||||
.outerjoin(User, User.id == Test.created_by)
|
.outerjoin(User, User.id == Test.created_by)
|
||||||
|
.outerjoin(qcount_sq, qcount_sq.c.tv_id == TestVersion.id)
|
||||||
.filter(Test.is_active.is_(True))
|
.filter(Test.is_active.is_(True))
|
||||||
.order_by(Test.updated_at.desc().nullslast(), Test.created_at.desc())
|
.order_by(Test.updated_at.desc().nullslast(), Test.created_at.desc())
|
||||||
.all()
|
.all()
|
||||||
@@ -85,6 +109,11 @@ def list_visible_tests(user_id: str) -> list[dict]:
|
|||||||
'version': tv.version,
|
'version': tv.version,
|
||||||
'created_by': str(t.created_by) if t.created_by else None,
|
'created_by': str(t.created_by) if t.created_by else None,
|
||||||
'author_full_name': u.full_name if u else '—',
|
'author_full_name': u.full_name if u else '—',
|
||||||
|
'passing_threshold': int(t.passing_threshold or 0),
|
||||||
|
'time_limit': t.time_limit,
|
||||||
|
'result_mode': (t.result_mode or 'end'),
|
||||||
|
'hints_enabled': bool(t.hints_enabled),
|
||||||
|
'questions_count': int(qc or 0),
|
||||||
'has_in_progress_attempt': bool(
|
'has_in_progress_attempt': bool(
|
||||||
uid and session.query(
|
uid and session.query(
|
||||||
exists().where(
|
exists().where(
|
||||||
@@ -96,7 +125,7 @@ def list_visible_tests(user_id: str) -> list[dict]:
|
|||||||
).scalar()
|
).scalar()
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
for t, tv, u in rows
|
for t, tv, u, qc in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -108,10 +137,20 @@ def list_hidden_by_author(user_id: str) -> list[dict]:
|
|||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
qcount_sq = (
|
||||||
|
session.query(
|
||||||
|
Question.test_version_id.label('tv_id'),
|
||||||
|
func.count(Question.id).label('qc'),
|
||||||
|
)
|
||||||
|
.group_by(Question.test_version_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
|
||||||
rows = (
|
rows = (
|
||||||
session.query(Test, TestVersion, User)
|
session.query(Test, TestVersion, User, qcount_sq.c.qc)
|
||||||
.join(TestVersion, (TestVersion.test_id == Test.id) & TestVersion.is_active.is_(True))
|
.join(TestVersion, (TestVersion.test_id == Test.id) & TestVersion.is_active.is_(True))
|
||||||
.join(User, User.id == Test.created_by)
|
.join(User, User.id == Test.created_by)
|
||||||
|
.outerjoin(qcount_sq, qcount_sq.c.tv_id == TestVersion.id)
|
||||||
.filter(Test.is_active.is_(False), Test.created_by == uid)
|
.filter(Test.is_active.is_(False), Test.created_by == uid)
|
||||||
.order_by(Test.updated_at.desc().nullslast(), Test.created_at.desc())
|
.order_by(Test.updated_at.desc().nullslast(), Test.created_at.desc())
|
||||||
.all()
|
.all()
|
||||||
@@ -129,6 +168,21 @@ def list_hidden_by_author(user_id: str) -> list[dict]:
|
|||||||
'version': tv.version,
|
'version': tv.version,
|
||||||
'created_by': str(t.created_by),
|
'created_by': str(t.created_by),
|
||||||
'author_full_name': u.full_name,
|
'author_full_name': u.full_name,
|
||||||
|
'passing_threshold': int(t.passing_threshold or 0),
|
||||||
|
'time_limit': t.time_limit,
|
||||||
|
'result_mode': (t.result_mode or 'end'),
|
||||||
|
'hints_enabled': bool(t.hints_enabled),
|
||||||
|
'questions_count': int(qc or 0),
|
||||||
|
'has_in_progress_attempt': bool(
|
||||||
|
session.query(
|
||||||
|
exists().where(
|
||||||
|
TestAttempt.user_id == uid,
|
||||||
|
TestAttempt.status == 'in_progress',
|
||||||
|
TestAttempt.test_version_id == TestVersion.id,
|
||||||
|
TestVersion.test_id == t.id,
|
||||||
|
)
|
||||||
|
).scalar()
|
||||||
|
),
|
||||||
}
|
}
|
||||||
for t, tv, u in rows
|
for t, tv, u, qc in rows
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from ..models import (
|
|||||||
User,
|
User,
|
||||||
UserAnswer,
|
UserAnswer,
|
||||||
)
|
)
|
||||||
from .test_access import is_test_author, user_has_test_access
|
from .test_access import is_test_author, is_test_edit_open, user_has_test_access
|
||||||
|
|
||||||
|
|
||||||
class HttpError(Exception):
|
class HttpError(Exception):
|
||||||
@@ -311,6 +311,9 @@ def build_review_from_db(session: Session, attempt_id: str) -> dict:
|
|||||||
'testId': str(test.id),
|
'testId': str(test.id),
|
||||||
'testTitle': test.title,
|
'testTitle': test.title,
|
||||||
'passingThreshold': int(test.passing_threshold or 0),
|
'passingThreshold': int(test.passing_threshold or 0),
|
||||||
|
'timeLimit': test.time_limit,
|
||||||
|
'resultMode': test.result_mode or 'end',
|
||||||
|
'hintsEnabled': bool(test.hints_enabled),
|
||||||
'correctCount': int(attempt.correct_count or 0),
|
'correctCount': int(attempt.correct_count or 0),
|
||||||
'totalQuestions': total,
|
'totalQuestions': total,
|
||||||
'percent': percent,
|
'percent': percent,
|
||||||
@@ -343,7 +346,7 @@ def get_attempt_review_for_user(session_or_eng, current_user_id: str, test_id: s
|
|||||||
|
|
||||||
is_owner = str(attempt.user_id) == str(current_user_id)
|
is_owner = str(attempt.user_id) == str(current_user_id)
|
||||||
is_author = is_test_author(attempt.test_version.test.created_by, current_user_id)
|
is_author = is_test_author(attempt.test_version.test.created_by, current_user_id)
|
||||||
if not is_owner and not is_author:
|
if not is_owner and not is_author and not is_test_edit_open():
|
||||||
raise HttpError(403, 'Доступ запрещён.')
|
raise HttpError(403, 'Доступ запрещён.')
|
||||||
return build_review_from_db(session, attempt_id)
|
return build_review_from_db(session, attempt_id)
|
||||||
|
|
||||||
@@ -387,7 +390,7 @@ def generate_missing_hints_for_test(session_or_eng, author_id: str, test_id: str
|
|||||||
test = session.get(Test, tid)
|
test = session.get(Test, tid)
|
||||||
if not test:
|
if not test:
|
||||||
raise HttpError(404, 'Тест не найден.')
|
raise HttpError(404, 'Тест не найден.')
|
||||||
if not is_test_author(test.created_by, author_id):
|
if not is_test_author(test.created_by, author_id) and not is_test_edit_open():
|
||||||
raise HttpError(403, 'Доступ запрещён.')
|
raise HttpError(403, 'Доступ запрещён.')
|
||||||
|
|
||||||
active_version = (
|
active_version = (
|
||||||
@@ -507,7 +510,7 @@ def list_test_attempts_for_author(session_or_eng, author_id: str, test_id: str)
|
|||||||
test = session.get(Test, tid)
|
test = session.get(Test, tid)
|
||||||
if not test:
|
if not test:
|
||||||
raise HttpError(404, 'Тест не найден.')
|
raise HttpError(404, 'Тест не найден.')
|
||||||
if not is_test_author(test.created_by, author_id):
|
if not is_test_author(test.created_by, author_id) and not is_test_edit_open():
|
||||||
raise HttpError(403, 'Доступ запрещён.')
|
raise HttpError(403, 'Доступ запрещён.')
|
||||||
|
|
||||||
rows = (
|
rows = (
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from sqlalchemy.orm import Session
|
|||||||
from ..db import get_session
|
from ..db import get_session
|
||||||
from ..messages import RU
|
from ..messages import RU
|
||||||
from ..models import AnswerOption, Question, Test, TestVersion
|
from ..models import AnswerOption, Question, Test, TestVersion
|
||||||
from .test_access import is_test_author
|
from .test_access import is_test_author, is_test_edit_open
|
||||||
from .test_chain import has_any_attempt_for_test
|
from .test_chain import has_any_attempt_for_test
|
||||||
|
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ def save_test_draft(author_id: str, test_id: str, payload: dict) -> dict:
|
|||||||
test = session.get(Test, tid)
|
test = session.get(Test, tid)
|
||||||
if not test:
|
if not test:
|
||||||
raise HttpError(404, 'Тест не найден.')
|
raise HttpError(404, 'Тест не найден.')
|
||||||
if not is_test_author(test.created_by, author_id):
|
if not is_test_author(test.created_by, author_id) and not is_test_edit_open():
|
||||||
raise HttpError(403, 'Доступ запрещён.')
|
raise HttpError(403, 'Доступ запрещён.')
|
||||||
|
|
||||||
if payload.get('title') is not None:
|
if payload.get('title') is not None:
|
||||||
|
|||||||
@@ -1234,6 +1234,41 @@ body.ui-legacy .attempts-card-list__action {
|
|||||||
color: #b42318;
|
color: #b42318;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.attempt-feedback-panel {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--outline-variant) 55%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--surface-container-low) 88%, var(--surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-feedback-verdict {
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-feedback-verdict--ok {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-feedback-verdict--bad {
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-feedback-correct {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-feedback-explanation {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
.attempt-answer-actions {
|
.attempt-answer-actions {
|
||||||
margin-top: 1.25rem;
|
margin-top: 1.25rem;
|
||||||
padding-top: 0.25rem;
|
padding-top: 0.25rem;
|
||||||
@@ -1309,19 +1344,6 @@ body.ui-legacy .attempts-card-list__action {
|
|||||||
color: #b42318;
|
color: #b42318;
|
||||||
}
|
}
|
||||||
|
|
||||||
.attempt-hint-verdict {
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attempt-hint-verdict--ok {
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.attempt-hint-verdict--bad {
|
|
||||||
color: #b42318;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.ui-modern .attempt-flow {
|
body.ui-modern .attempt-flow {
|
||||||
min-height: min(75dvh, 880px);
|
min-height: min(75dvh, 880px);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,12 +22,6 @@
|
|||||||
const btnFinish = document.getElementById('attempt-finish');
|
const btnFinish = document.getElementById('attempt-finish');
|
||||||
const resultEl = document.getElementById('attempt-result');
|
const resultEl = document.getElementById('attempt-result');
|
||||||
|
|
||||||
const hintModal = document.getElementById('hint-modal');
|
|
||||||
const hintVerdict = document.getElementById('hint-verdict');
|
|
||||||
const hintCorrect = document.getElementById('hint-correct');
|
|
||||||
const hintExplanation = document.getElementById('hint-explanation');
|
|
||||||
const hintCloseBtn = document.getElementById('hint-close-btn');
|
|
||||||
|
|
||||||
let playData = null;
|
let playData = null;
|
||||||
const selections = {};
|
const selections = {};
|
||||||
const checked = {};
|
const checked = {};
|
||||||
@@ -65,6 +59,41 @@
|
|||||||
return playData && playData.resultMode === 'immediate';
|
return playData && playData.resultMode === 'immediate';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatAttemptMeta(d) {
|
||||||
|
const qs = d.questions || [];
|
||||||
|
const n = qs.length;
|
||||||
|
const tl = d.timeLimit;
|
||||||
|
const timestr = !tl || Number(tl) <= 0 ? 'без ограничения' : `${tl} мин`;
|
||||||
|
const res = d.resultMode === 'immediate' ? 'сразу' : 'в конце';
|
||||||
|
const hint = d.resultMode !== 'immediate'
|
||||||
|
? 'недоступны'
|
||||||
|
: (d.hintsEnabled ? 'вкл' : 'выкл');
|
||||||
|
const th = d.passingThreshold ?? 0;
|
||||||
|
return `Порог: ${th}% · Вопросов: ${n} · Время: ${timestr} · Результат: ${res} · Подсказки: ${hint}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFeedbackPanel(data) {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'attempt-feedback-panel';
|
||||||
|
const ok = data.isCorrect;
|
||||||
|
const verdict = document.createElement('p');
|
||||||
|
verdict.className = `attempt-feedback-verdict ${ok ? 'attempt-feedback-verdict--ok' : 'attempt-feedback-verdict--bad'}`;
|
||||||
|
verdict.textContent = ok ? 'Верно!' : 'Неверно.';
|
||||||
|
wrap.appendChild(verdict);
|
||||||
|
const correct = (data.correctOptionTexts || []).join('; ');
|
||||||
|
if (correct) {
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.className = 'attempt-feedback-correct';
|
||||||
|
p.textContent = `Правильный ответ: ${correct}`;
|
||||||
|
wrap.appendChild(p);
|
||||||
|
}
|
||||||
|
const exp = document.createElement('p');
|
||||||
|
exp.className = 'attempt-feedback-explanation';
|
||||||
|
exp.textContent = data.explanation || 'Объяснение недоступно.';
|
||||||
|
wrap.appendChild(exp);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
function stepAnswered(q) {
|
function stepAnswered(q) {
|
||||||
const k = String(q.id);
|
const k = String(q.id);
|
||||||
if (isImmediate()) return !!checked[k];
|
if (isImmediate()) return !!checked[k];
|
||||||
@@ -151,6 +180,10 @@
|
|||||||
}
|
}
|
||||||
card.appendChild(ul);
|
card.appendChild(ul);
|
||||||
|
|
||||||
|
if (isChk && isImmediate() && playData.hintsEnabled) {
|
||||||
|
card.appendChild(renderFeedbackPanel(checked[qid]));
|
||||||
|
}
|
||||||
|
|
||||||
if (isImmediate() && !isChk) {
|
if (isImmediate() && !isChk) {
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
wrap.className = 'attempt-answer-actions';
|
wrap.className = 'attempt-answer-actions';
|
||||||
@@ -204,29 +237,11 @@
|
|||||||
if (!r.ok) throw new Error(data.error || 'Не удалось проверить ответ.');
|
if (!r.ok) throw new Error(data.error || 'Не удалось проверить ответ.');
|
||||||
checked[k] = data;
|
checked[k] = data;
|
||||||
renderStep();
|
renderStep();
|
||||||
if (playData.hintsEnabled) {
|
|
||||||
showHint(data);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(e.message);
|
setErr(e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showHint(data) {
|
|
||||||
hintVerdict.textContent = data.isCorrect ? 'Верно!' : 'Неверно.';
|
|
||||||
hintVerdict.className = `attempt-hint-verdict ${data.isCorrect ? 'attempt-hint-verdict--ok' : 'attempt-hint-verdict--bad'}`;
|
|
||||||
const correct = (data.correctOptionTexts || []).join('; ');
|
|
||||||
hintCorrect.textContent = correct ? (`Правильный ответ: ${correct}`) : '';
|
|
||||||
hintExplanation.textContent = data.explanation || 'Объяснение недоступно.';
|
|
||||||
if (typeof hintModal.showModal === 'function') hintModal.showModal();
|
|
||||||
else hintModal.setAttribute('open', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
hintCloseBtn.addEventListener('click', () => {
|
|
||||||
if (typeof hintModal.close === 'function') hintModal.close();
|
|
||||||
else hintModal.removeAttribute('open');
|
|
||||||
});
|
|
||||||
|
|
||||||
btnPrev.addEventListener('click', () => {
|
btnPrev.addEventListener('click', () => {
|
||||||
if (currentIdx <= 0) return;
|
if (currentIdx <= 0) return;
|
||||||
currentIdx -= 1;
|
currentIdx -= 1;
|
||||||
@@ -243,9 +258,14 @@
|
|||||||
btnFinish.addEventListener('click', () => submit(false));
|
btnFinish.addEventListener('click', () => submit(false));
|
||||||
|
|
||||||
function startTimer(minutes) {
|
function startTimer(minutes) {
|
||||||
if (!minutes || minutes <= 0) return;
|
if (!timerEl) return;
|
||||||
deadlineMs = Date.now() + minutes * 60 * 1000;
|
|
||||||
timerEl.style.display = '';
|
timerEl.style.display = '';
|
||||||
|
if (!minutes || minutes <= 0) {
|
||||||
|
deadlineMs = null;
|
||||||
|
timerEl.textContent = 'без ограничения';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deadlineMs = Date.now() + minutes * 60 * 1000;
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
const left = Math.max(0, deadlineMs - Date.now());
|
const left = Math.max(0, deadlineMs - Date.now());
|
||||||
const m = Math.floor(left / 60000);
|
const m = Math.floor(left / 60000);
|
||||||
@@ -267,10 +287,7 @@
|
|||||||
if (!r.ok) throw new Error(data.error || 'Не удалось открыть попытку.');
|
if (!r.ok) throw new Error(data.error || 'Не удалось открыть попытку.');
|
||||||
playData = data;
|
playData = data;
|
||||||
titleEl.textContent = data.testTitle || 'Прохождение теста';
|
titleEl.textContent = data.testTitle || 'Прохождение теста';
|
||||||
const parts = [`Порог зачёта ${data.passingThreshold ?? 0}%`];
|
subEl.textContent = formatAttemptMeta(data);
|
||||||
if (data.resultMode === 'immediate') parts.push('обратная связь после каждого ответа');
|
|
||||||
if (data.hintsEnabled) parts.push('с подсказками');
|
|
||||||
subEl.textContent = parts.join(' · ');
|
|
||||||
if (!Array.isArray(data.questions) || !data.questions.length) {
|
if (!Array.isArray(data.questions) || !data.questions.length) {
|
||||||
setErr('В активной версии нет вопросов.');
|
setErr('В активной версии нет вопросов.');
|
||||||
btnNext.disabled = true;
|
btnNext.disabled = true;
|
||||||
@@ -279,7 +296,7 @@
|
|||||||
}
|
}
|
||||||
currentIdx = 0;
|
currentIdx = 0;
|
||||||
renderStep();
|
renderStep();
|
||||||
if (data.timeLimit) startTimer(Number(data.timeLimit));
|
startTimer(Number(data.timeLimit));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(e.message);
|
setErr(e.message);
|
||||||
btnFinish.disabled = true;
|
btnFinish.disabled = true;
|
||||||
|
|||||||
@@ -34,9 +34,6 @@
|
|||||||
const templateGlobalMultiEl = $('#template-global-multi');
|
const templateGlobalMultiEl = $('#template-global-multi');
|
||||||
const templateMinCorrectEl = $('#template-min-correct');
|
const templateMinCorrectEl = $('#template-min-correct');
|
||||||
const templateMaxCorrectEl = $('#template-max-correct');
|
const templateMaxCorrectEl = $('#template-max-correct');
|
||||||
const hintsStatusEl = $('#hints-status');
|
|
||||||
const hintsActionsEl = $('#test-hints-actions');
|
|
||||||
const generateHintsBtn = $('#btn-generate-hints');
|
|
||||||
const docProgressEl = $('#doc-progress');
|
const docProgressEl = $('#doc-progress');
|
||||||
const introUpdatedEl = $('#intro-updated');
|
const introUpdatedEl = $('#intro-updated');
|
||||||
const introForkBannerEl = $('#intro-fork-banner');
|
const introForkBannerEl = $('#intro-fork-banner');
|
||||||
@@ -365,14 +362,36 @@
|
|||||||
m.textContent = v;
|
m.textContent = v;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncEditorHeroExtra() {
|
||||||
|
const timeVal = document.getElementById('editor-hero-time-val');
|
||||||
|
const resVal = document.getElementById('editor-hero-result-val');
|
||||||
|
const hintsVal = document.getElementById('editor-hero-hints-val');
|
||||||
|
if (!timeVal || !resVal || !hintsVal) return;
|
||||||
|
const tlEl = document.getElementById('test-time-limit');
|
||||||
|
const raw = tlEl && tlEl.value !== '' ? Number(tlEl.value) : 0;
|
||||||
|
timeVal.textContent = (!raw || raw <= 0) ? 'без ограничения' : `${raw} мин`;
|
||||||
|
const mode = document.querySelector('input[name="result-mode"]:checked');
|
||||||
|
const imm = mode && mode.value === 'immediate';
|
||||||
|
resVal.textContent = imm ? 'сразу' : 'в конце';
|
||||||
|
const hintsCheckbox = document.getElementById('test-hints-enabled');
|
||||||
|
if (!imm) hintsVal.textContent = 'недоступны';
|
||||||
|
else if (hintsCheckbox && hintsCheckbox.checked) hintsVal.textContent = 'вкл';
|
||||||
|
else hintsVal.textContent = 'выкл';
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncHeroMetaRow() {
|
||||||
|
syncThresholdMirror();
|
||||||
|
syncEditorHeroExtra();
|
||||||
|
}
|
||||||
|
|
||||||
function loadInitial() {
|
function loadInitial() {
|
||||||
titleEl.value = initial.test.title || '';
|
titleEl.value = initial.test.title || '';
|
||||||
descEl.value = initial.test.description || '';
|
descEl.value = initial.test.description || '';
|
||||||
autoResize(titleEl);
|
autoResize(titleEl);
|
||||||
autoResize(descEl);
|
autoResize(descEl);
|
||||||
if (thresholdEl) {
|
if (thresholdEl) {
|
||||||
thresholdEl.addEventListener('input', syncThresholdMirror);
|
thresholdEl.addEventListener('input', syncHeroMetaRow);
|
||||||
thresholdEl.addEventListener('change', syncThresholdMirror);
|
thresholdEl.addEventListener('change', syncHeroMetaRow);
|
||||||
}
|
}
|
||||||
if (titleEl && titleEl.tagName === 'TEXTAREA') {
|
if (titleEl && titleEl.tagName === 'TEXTAREA') {
|
||||||
titleEl.addEventListener('input', () => {
|
titleEl.addEventListener('input', () => {
|
||||||
@@ -396,7 +415,7 @@
|
|||||||
if (docUserHint) docUserHint.addEventListener('input', () => autoResize(docUserHint));
|
if (docUserHint) docUserHint.addEventListener('input', () => autoResize(docUserHint));
|
||||||
thresholdEl.value =
|
thresholdEl.value =
|
||||||
initial.test.passingThreshold == null ? '' : Number(initial.test.passingThreshold);
|
initial.test.passingThreshold == null ? '' : Number(initial.test.passingThreshold);
|
||||||
syncThresholdMirror();
|
syncHeroMetaRow();
|
||||||
|
|
||||||
const timeLimitEl = document.getElementById('test-time-limit');
|
const timeLimitEl = document.getElementById('test-time-limit');
|
||||||
const hintsEl = document.getElementById('test-hints-enabled');
|
const hintsEl = document.getElementById('test-hints-enabled');
|
||||||
@@ -405,7 +424,10 @@
|
|||||||
|
|
||||||
if (timeLimitEl) {
|
if (timeLimitEl) {
|
||||||
timeLimitEl.value = initial.test.timeLimit == null ? '' : Number(initial.test.timeLimit);
|
timeLimitEl.value = initial.test.timeLimit == null ? '' : Number(initial.test.timeLimit);
|
||||||
timeLimitEl.addEventListener('input', scheduleDirtyCheck);
|
timeLimitEl.addEventListener('input', () => {
|
||||||
|
scheduleDirtyCheck();
|
||||||
|
syncEditorHeroExtra();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const initMode = (initial.test.resultMode === 'immediate') ? 'immediate' : 'end';
|
const initMode = (initial.test.resultMode === 'immediate') ? 'immediate' : 'end';
|
||||||
resultModeRadios.forEach((r) => {
|
resultModeRadios.forEach((r) => {
|
||||||
@@ -414,8 +436,8 @@
|
|||||||
const mode = document.querySelector('input[name="result-mode"]:checked');
|
const mode = document.querySelector('input[name="result-mode"]:checked');
|
||||||
const isImmediate = mode && mode.value === 'immediate';
|
const isImmediate = mode && mode.value === 'immediate';
|
||||||
if (hintsRow) hintsRow.style.display = isImmediate ? '' : 'none';
|
if (hintsRow) hintsRow.style.display = isImmediate ? '' : 'none';
|
||||||
if (hintsActionsEl) hintsActionsEl.style.display = (isImmediate && hintsEl && hintsEl.checked) ? '' : 'none';
|
|
||||||
if (hintsEl && !isImmediate) hintsEl.checked = false;
|
if (hintsEl && !isImmediate) hintsEl.checked = false;
|
||||||
|
syncHeroMetaRow();
|
||||||
scheduleDirtyCheck();
|
scheduleDirtyCheck();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -424,12 +446,11 @@
|
|||||||
hintsEl.addEventListener('change', () => {
|
hintsEl.addEventListener('change', () => {
|
||||||
const mode = document.querySelector('input[name="result-mode"]:checked');
|
const mode = document.querySelector('input[name="result-mode"]:checked');
|
||||||
const isImmediate = mode && mode.value === 'immediate';
|
const isImmediate = mode && mode.value === 'immediate';
|
||||||
if (hintsActionsEl) hintsActionsEl.style.display = (isImmediate && hintsEl.checked) ? '' : 'none';
|
syncHeroMetaRow();
|
||||||
scheduleDirtyCheck();
|
scheduleDirtyCheck();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (hintsRow) hintsRow.style.display = (initMode === 'immediate') ? '' : 'none';
|
if (hintsRow) hintsRow.style.display = (initMode === 'immediate') ? '' : 'none';
|
||||||
if (hintsActionsEl) hintsActionsEl.style.display = (initMode === 'immediate' && hintsEl && hintsEl.checked) ? '' : 'none';
|
|
||||||
|
|
||||||
questionsEl.innerHTML = '';
|
questionsEl.innerHTML = '';
|
||||||
(initial.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
(initial.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
||||||
@@ -519,6 +540,60 @@
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyServerTestState(test) {
|
||||||
|
if (!test || typeof test !== 'object') return;
|
||||||
|
if (typeof test.title === 'string' && titleEl) titleEl.value = test.title;
|
||||||
|
if (typeof test.description === 'string' && descEl) descEl.value = test.description;
|
||||||
|
if (thresholdEl && test.passingThreshold != null) {
|
||||||
|
thresholdEl.value = Number(test.passingThreshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeLimitEl = document.getElementById('test-time-limit');
|
||||||
|
if (timeLimitEl) timeLimitEl.value = test.timeLimit == null ? '' : Number(test.timeLimit);
|
||||||
|
|
||||||
|
const hintsEl = document.getElementById('test-hints-enabled');
|
||||||
|
const hintsRow = document.getElementById('test-hints-row');
|
||||||
|
const mode = (test.resultMode === 'immediate') ? 'immediate' : 'end';
|
||||||
|
const modeEl = document.querySelector(`input[name="result-mode"][value="${mode}"]`);
|
||||||
|
if (modeEl) modeEl.checked = true;
|
||||||
|
if (hintsRow) hintsRow.style.display = (mode === 'immediate') ? '' : 'none';
|
||||||
|
if (hintsEl) {
|
||||||
|
hintsEl.checked = !!test.hintsEnabled && mode === 'immediate';
|
||||||
|
}
|
||||||
|
|
||||||
|
autoResize(titleEl);
|
||||||
|
autoResize(descEl);
|
||||||
|
syncHeroMetaRow();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshMetaAfterSave() {
|
||||||
|
const [v, s, e] = await Promise.all([
|
||||||
|
fetch(`/api/tests/${TEST_ID}/versions`).then((r) => r.json()).catch(() => null),
|
||||||
|
fetch(`/api/tests/${TEST_ID}/summary`).then((r) => r.json()).catch(() => null),
|
||||||
|
fetch(`/api/tests/${TEST_ID}/editor`).then((r) => r.json()).catch(() => null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
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 (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 (e && e.test) applyServerTestState(e.test);
|
||||||
|
updateForkBanner();
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshHintsInForm() {
|
async function refreshHintsInForm() {
|
||||||
const r = await fetch(`/api/tests/${TEST_ID}/editor`);
|
const r = await fetch(`/api/tests/${TEST_ID}/editor`);
|
||||||
const data = await r.json().catch(() => ({}));
|
const data = await r.json().catch(() => ({}));
|
||||||
@@ -572,6 +647,7 @@
|
|||||||
});
|
});
|
||||||
if (r2.ok) chainActive = chainActiveEl.checked;
|
if (r2.ok) chainActive = chainActiveEl.checked;
|
||||||
}
|
}
|
||||||
|
await refreshMetaAfterSave();
|
||||||
resetBaselineDraft();
|
resetBaselineDraft();
|
||||||
const msg = data.forked
|
const msg = data.forked
|
||||||
? 'Сохранено. Создана новая версия — у теста есть попытки прохождения.'
|
? 'Сохранено. Создана новая версия — у теста есть попытки прохождения.'
|
||||||
@@ -587,23 +663,36 @@
|
|||||||
const sr = await fetch(`/api/tests/${TEST_ID}/ai/hints/status`);
|
const sr = await fetch(`/api/tests/${TEST_ID}/ai/hints/status`);
|
||||||
const st = await sr.json().catch(() => ({}));
|
const st = await sr.json().catch(() => ({}));
|
||||||
if (sr.ok && Number(st.missing) > 0) {
|
if (sr.ok && Number(st.missing) > 0) {
|
||||||
saveStatusEl.textContent = `Создаём ИИ-подсказки (${st.missing} из ${st.total})…`;
|
const okGen = confirm(
|
||||||
const gr = await fetch(`/api/tests/${TEST_ID}/ai/hints/generate`, { method: 'POST' });
|
`Подсказок пока нет или заполнены не все: не хватает ${st.missing} из ${st.total}.\n\n`
|
||||||
const gd = await gr.json().catch(() => ({}));
|
+ 'Сгенерировать недостающие подсказки через ИИ?',
|
||||||
if (!gr.ok) {
|
);
|
||||||
saveStatusEl.textContent = '';
|
if (okGen) {
|
||||||
alert(gd.error || 'Не удалось сгенерировать подсказки.');
|
saveStatusEl.textContent = `Создаём ИИ-подсказки (${st.missing} из ${st.total})…`;
|
||||||
if (saveMsg) saveMsg.textContent = msg + ' (часть подсказок не создана)';
|
const gr = await fetch(`/api/tests/${TEST_ID}/ai/hints/generate`, { method: 'POST' });
|
||||||
if (saveModal) saveModal.showModal();
|
const gd = await gr.json().catch(() => ({}));
|
||||||
return;
|
if (!gr.ok) {
|
||||||
|
saveStatusEl.textContent = '';
|
||||||
|
alert(gd.error || 'Не удалось сгенерировать подсказки.');
|
||||||
|
if (saveMsg) saveMsg.textContent = msg + ' (подсказки не созданы)';
|
||||||
|
if (saveModal) saveModal.showModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const skipped = Number(gd.skipped || 0);
|
||||||
|
const tail = gd.failed
|
||||||
|
? ` Подсказки: ${gd.generated} создано, ${gd.failed} не удалось${skipped ? `, пропущено ${skipped}` : ''}.`
|
||||||
|
: ` Подсказки созданы (${gd.generated})${skipped ? `, пропущено ${skipped}` : ''}.`;
|
||||||
|
if (saveMsg) saveMsg.textContent = msg + tail;
|
||||||
|
try {
|
||||||
|
await refreshHintsInForm();
|
||||||
|
} catch (_) {
|
||||||
|
/* не блокируем успех сохранения */
|
||||||
|
}
|
||||||
|
} else if (saveMsg) {
|
||||||
|
saveMsg.textContent = msg;
|
||||||
}
|
}
|
||||||
const skipped = Number(gd.skipped || 0);
|
} else if (saveMsg) {
|
||||||
const tail = gd.failed
|
saveMsg.textContent = msg;
|
||||||
? ` Подсказки: ${gd.generated} создано, ${gd.failed} не удалось${skipped ? `, пропущено ${skipped}` : ''}.`
|
|
||||||
: ` Подсказки созданы (${gd.generated})${skipped ? `, пропущено ${skipped}` : ''}.`;
|
|
||||||
if (saveMsg) saveMsg.textContent = msg + tail;
|
|
||||||
} else {
|
|
||||||
if (saveMsg) saveMsg.textContent = msg;
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (saveMsg) saveMsg.textContent = msg + ' (ИИ-подсказки не созданы)';
|
if (saveMsg) saveMsg.textContent = msg + ' (ИИ-подсказки не созданы)';
|
||||||
@@ -972,18 +1061,6 @@
|
|||||||
const cancelBtn = document.getElementById('btn-cancel');
|
const cancelBtn = document.getElementById('btn-cancel');
|
||||||
if (cancelBtn) cancelBtn.addEventListener('click', doCancel);
|
if (cancelBtn) cancelBtn.addEventListener('click', doCancel);
|
||||||
|
|
||||||
const cancelBtnInline = document.getElementById('btn-cancel-inline');
|
|
||||||
if (cancelBtnInline) cancelBtnInline.addEventListener('click', doCancel);
|
|
||||||
|
|
||||||
// Кнопка «Сохранить» под вопросами — дублирует основную
|
|
||||||
const saveDraftInlineBtn = document.getElementById('save-draft-inline');
|
|
||||||
const saveStatusInlineEl = document.getElementById('save-status-inline');
|
|
||||||
if (saveDraftInlineBtn) {
|
|
||||||
saveDraftInlineBtn.addEventListener('click', () => {
|
|
||||||
document.getElementById('save-draft')?.click();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function openModal(title, bodyHtml, actions) {
|
function openModal(title, bodyHtml, actions) {
|
||||||
modalTitle.textContent = title;
|
modalTitle.textContent = title;
|
||||||
modalBody.innerHTML = bodyHtml;
|
modalBody.innerHTML = bodyHtml;
|
||||||
@@ -1205,6 +1282,19 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Перемешивает строки вариантов в DOM; чекбоксы «верный» остаются при своих полях. */
|
||||||
|
function shuffleQuestionOptionsDom(qNode) {
|
||||||
|
const optsEl = $('.q-options', qNode);
|
||||||
|
if (!optsEl) return;
|
||||||
|
const rows = Array.from(optsEl.querySelectorAll('.opt-item'));
|
||||||
|
if (rows.length < 2) return;
|
||||||
|
for (let i = rows.length - 1; i > 0; i -= 1) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[rows[i], rows[j]] = [rows[j], rows[i]];
|
||||||
|
}
|
||||||
|
rows.forEach((el) => optsEl.appendChild(el));
|
||||||
|
}
|
||||||
|
|
||||||
async function aiGenerateQuestion(node) {
|
async function aiGenerateQuestion(node) {
|
||||||
const qTextEl = $('.q-text', node);
|
const qTextEl = $('.q-text', node);
|
||||||
const qText = qTextEl.value.trim();
|
const qText = qTextEl.value.trim();
|
||||||
@@ -1278,6 +1368,7 @@
|
|||||||
dIdx++;
|
dIdx++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
shuffleQuestionOptionsDom(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
syncOptionInputTypes(node);
|
syncOptionInputTypes(node);
|
||||||
@@ -1507,73 +1598,69 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Создать шаблон ────────────────────────────────────────────
|
// ─── Автосоздание шаблона ──────────────────────────────────────
|
||||||
const createTemplateBtn = $('#create-template');
|
let templateRebuildTimer = null;
|
||||||
if (createTemplateBtn) {
|
let lastAppliedTemplateKey = '';
|
||||||
createTemplateBtn.addEventListener('click', () => {
|
|
||||||
const qCount = Math.min(30, Math.max(1, parseInt($('#ai-q-count').value || '7', 10)));
|
function hasMeaningfulQuestions() {
|
||||||
const oCount = Math.min(MAX_OPTIONS, Math.max(2, parseInt($('#ai-o-count').value || '3', 10)));
|
return $$('#questions .q-item').some((node) => {
|
||||||
const globalMulti = !!(templateGlobalMultiEl && templateGlobalMultiEl.checked);
|
const qText = ($('.q-text', node)?.value || '').trim();
|
||||||
const range = getTemplateCorrectRange(oCount, globalMulti);
|
if (qText) return true;
|
||||||
const existing = $$('#questions .q-item').length;
|
return $$('.opt-text', node).some((o) => (o.value || '').trim());
|
||||||
if (existing > 0) {
|
|
||||||
const ok = confirm(
|
|
||||||
`Создать шаблон: ${qCount} вопросов × ${oCount} вариантов?\n` +
|
|
||||||
'Текущие вопросы будут заменены.'
|
|
||||||
);
|
|
||||||
if (!ok) return;
|
|
||||||
}
|
|
||||||
questionsEl.innerHTML = '';
|
|
||||||
for (let qi = 0; qi < qCount; qi++) {
|
|
||||||
const opts = [];
|
|
||||||
for (let oi = 0; oi < oCount; oi++) {
|
|
||||||
opts.push({ text: '', isCorrect: oi < range.minCorrect });
|
|
||||||
}
|
|
||||||
questionsEl.appendChild(renderQuestion({ text: '', hasMultipleAnswers: globalMulti, options: opts }));
|
|
||||||
}
|
|
||||||
renumber();
|
|
||||||
scheduleDirtyCheck();
|
|
||||||
// Прокручиваем к первому вопросу
|
|
||||||
questionsEl.firstElementChild?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateHintsForCurrentTest() {
|
function buildTemplateFromControls({ askConfirm } = { askConfirm: true }) {
|
||||||
if (!generateHintsBtn) return;
|
const qCount = Math.min(30, Math.max(1, parseInt(aiQCountEl?.value || '7', 10)));
|
||||||
generateHintsBtn.disabled = true;
|
const oCount = Math.min(MAX_OPTIONS, Math.max(2, parseInt(aiOCountEl?.value || '3', 10)));
|
||||||
if (hintsStatusEl) hintsStatusEl.textContent = 'Сохраняем текущие изменения…';
|
const globalMulti = !!(templateGlobalMultiEl && templateGlobalMultiEl.checked);
|
||||||
try {
|
const range = getTemplateCorrectRange(oCount, globalMulti);
|
||||||
await saveCurrentDraftQuietly();
|
const key = JSON.stringify({ qCount, oCount, globalMulti, min: range.minCorrect, max: range.maxCorrect });
|
||||||
if (hintsStatusEl) hintsStatusEl.textContent = 'Генерируем подсказки…';
|
if (key === lastAppliedTemplateKey) return;
|
||||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/hints/generate`, { method: 'POST' });
|
|
||||||
const data = await r.json().catch(() => ({}));
|
if (askConfirm && hasMeaningfulQuestions()) {
|
||||||
if (!r.ok) throw new Error(data.error || 'Не удалось сгенерировать подсказки.');
|
const ok = confirm(
|
||||||
try {
|
`Обновить шаблон: ${qCount} вопросов × ${oCount} вариантов?\n` +
|
||||||
await refreshHintsInForm();
|
'Текущие вопросы будут заменены.'
|
||||||
} catch (_) {
|
);
|
||||||
// Статус покажем как успешный; пользователь может перезагрузить страницу.
|
if (!ok) return;
|
||||||
}
|
|
||||||
const skipped = Number(data.skipped || 0);
|
|
||||||
if (hintsStatusEl) {
|
|
||||||
hintsStatusEl.textContent = data.failed
|
|
||||||
? `Создано ${data.generated}, ошибок ${data.failed}${skipped ? `, пропущено ${skipped}` : ''}.`
|
|
||||||
: `Подсказки созданы: ${data.generated}${skipped ? `, пропущено ${skipped}` : ''}.`;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (hintsStatusEl) hintsStatusEl.textContent = e.message || 'Ошибка генерации подсказок.';
|
|
||||||
} finally {
|
|
||||||
generateHintsBtn.disabled = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
questionsEl.innerHTML = '';
|
||||||
|
for (let qi = 0; qi < qCount; qi++) {
|
||||||
|
const opts = [];
|
||||||
|
for (let oi = 0; oi < oCount; oi++) {
|
||||||
|
opts.push({ text: '', isCorrect: oi < range.minCorrect });
|
||||||
|
}
|
||||||
|
questionsEl.appendChild(renderQuestion({ text: '', hasMultipleAnswers: globalMulti, options: opts }));
|
||||||
|
}
|
||||||
|
renumber();
|
||||||
|
scheduleDirtyCheck();
|
||||||
|
lastAppliedTemplateKey = key;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (generateHintsBtn) {
|
function scheduleTemplateRebuild() {
|
||||||
generateHintsBtn.addEventListener('click', generateHintsForCurrentTest);
|
if (templateRebuildTimer) clearTimeout(templateRebuildTimer);
|
||||||
|
templateRebuildTimer = setTimeout(() => buildTemplateFromControls({ askConfirm: true }), 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (templateGlobalMultiEl) templateGlobalMultiEl.addEventListener('change', syncTemplateRangeUi);
|
if (templateGlobalMultiEl) templateGlobalMultiEl.addEventListener('change', () => {
|
||||||
if (templateMinCorrectEl) templateMinCorrectEl.addEventListener('input', syncTemplateRangeUi);
|
syncTemplateRangeUi();
|
||||||
if (templateMaxCorrectEl) templateMaxCorrectEl.addEventListener('input', syncTemplateRangeUi);
|
scheduleTemplateRebuild();
|
||||||
if (aiOCountEl) aiOCountEl.addEventListener('input', syncTemplateRangeUi);
|
});
|
||||||
|
if (templateMinCorrectEl) templateMinCorrectEl.addEventListener('change', () => {
|
||||||
|
syncTemplateRangeUi();
|
||||||
|
scheduleTemplateRebuild();
|
||||||
|
});
|
||||||
|
if (templateMaxCorrectEl) templateMaxCorrectEl.addEventListener('change', () => {
|
||||||
|
syncTemplateRangeUi();
|
||||||
|
scheduleTemplateRebuild();
|
||||||
|
});
|
||||||
|
if (aiOCountEl) aiOCountEl.addEventListener('change', () => {
|
||||||
|
syncTemplateRangeUi();
|
||||||
|
scheduleTemplateRebuild();
|
||||||
|
});
|
||||||
|
if (aiQCountEl) aiQCountEl.addEventListener('change', scheduleTemplateRebuild);
|
||||||
syncTemplateRangeUi();
|
syncTemplateRangeUi();
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
|
|||||||
@@ -39,18 +39,6 @@
|
|||||||
<p id="attempt-error" class="callout callout--error attempt-error-box" style="display:none;"></p>
|
<p id="attempt-error" class="callout callout--error attempt-error-box" style="display:none;"></p>
|
||||||
|
|
||||||
<div id="attempt-result" class="surface-card attempt-result-card" style="display:none;"></div>
|
<div id="attempt-result" class="surface-card attempt-result-card" style="display:none;"></div>
|
||||||
|
|
||||||
<dialog id="hint-modal" class="save-modal attempt-hint-dialog">
|
|
||||||
<div class="save-modal__inner attempt-hint-inner">
|
|
||||||
<h3 class="font-headline text-base font-semibold mb-2" id="hint-title">Подсказка</h3>
|
|
||||||
<p id="hint-verdict" class="attempt-hint-verdict"></p>
|
|
||||||
<p id="hint-correct" class="text-sm text-ink-600 mb-2"></p>
|
|
||||||
<p id="hint-explanation" class="text-sm text-ink-800 leading-relaxed"></p>
|
|
||||||
<div class="mt-4 flex justify-end">
|
|
||||||
<button type="button" class="btn btn-primary btn--sm" id="hint-close-btn">Понятно</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</dialog>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,14 @@
|
|||||||
<div class="test-detail-page attempt-review-page">
|
<div class="test-detail-page attempt-review-page">
|
||||||
<p class="link-back"><a href="/tests">← к списку тестов</a></p>
|
<p class="link-back"><a href="/tests">← к списку тестов</a></p>
|
||||||
<h1 class="font-headline" style="font-size:1.35rem;margin-top:0;">Разбор: {{ review.testTitle }}</h1>
|
<h1 class="font-headline" style="font-size:1.35rem;margin-top:0;">Разбор: {{ review.testTitle }}</h1>
|
||||||
|
{% set tl = review.timeLimit %}
|
||||||
|
{% set timestr = 'без ограничения' if tl is none or tl == 0 else (tl|string ~ ' мин') %}
|
||||||
|
{% set rm = review.resultMode or 'end' %}
|
||||||
|
{% set res = 'сразу' if rm == 'immediate' else 'в конце' %}
|
||||||
|
{% set hint = 'недоступны' if rm != 'immediate' else ('вкл' if review.hintsEnabled else 'выкл') %}
|
||||||
|
<p class="text-sm text-muted" style="margin:0.35rem 0 0.75rem;line-height:1.45;">
|
||||||
|
Порог: {{ review.passingThreshold }}% · Вопросов: {{ review.totalQuestions }} · Время: {{ timestr }} · Результат: {{ res }} · Подсказки: {{ hint }}
|
||||||
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Правильно: <strong>{{ review.correctCount }}</strong> из {{ review.totalQuestions }}
|
Правильно: <strong>{{ review.correctCount }}</strong> из {{ review.totalQuestions }}
|
||||||
({{ review.percent }}%). Порог: {{ review.passingThreshold }}%.
|
({{ review.percent }}%). Порог: {{ review.passingThreshold }}%.
|
||||||
|
|||||||
@@ -12,9 +12,6 @@
|
|||||||
<textarea id="test-title" maxlength="200" rows="1" placeholder="Название теста"
|
<textarea id="test-title" maxlength="200" rows="1" placeholder="Название теста"
|
||||||
class="hero-brick__title font-headline"></textarea>
|
class="hero-brick__title font-headline"></textarea>
|
||||||
|
|
||||||
<textarea id="test-title" maxlength="200" rows="1" placeholder="Название теста"
|
|
||||||
class="hero-brick__title font-headline"></textarea>
|
|
||||||
|
|
||||||
<textarea id="test-description" rows="2" placeholder="Краткое описание (необязательно)"
|
<textarea id="test-description" rows="2" placeholder="Краткое описание (необязательно)"
|
||||||
class="hero-brick__desc"></textarea>
|
class="hero-brick__desc"></textarea>
|
||||||
|
|
||||||
@@ -33,6 +30,12 @@
|
|||||||
<span class="hero-brick__sep">·</span>
|
<span class="hero-brick__sep">·</span>
|
||||||
<span>Вопросов: <b id="q-count">0</b></span>
|
<span>Вопросов: <b id="q-count">0</b></span>
|
||||||
<span class="hero-brick__sep">·</span>
|
<span class="hero-brick__sep">·</span>
|
||||||
|
<span>Время: <b id="editor-hero-time-val">—</b></span>
|
||||||
|
<span class="hero-brick__sep">·</span>
|
||||||
|
<span>Результат: <b id="editor-hero-result-val">—</b></span>
|
||||||
|
<span class="hero-brick__sep">·</span>
|
||||||
|
<span>Подсказки: <b id="editor-hero-hints-val">—</b></span>
|
||||||
|
<span class="hero-brick__sep">·</span>
|
||||||
<span id="chain-active-display">Активна в каталоге</span>
|
<span id="chain-active-display">Активна в каталоге</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -55,7 +58,7 @@
|
|||||||
<summary class="cabinet-disclosure__summary">
|
<summary class="cabinet-disclosure__summary">
|
||||||
<span class="cabinet-disclosure__summary-text">
|
<span class="cabinet-disclosure__summary-text">
|
||||||
<span class="cabinet-disclosure__summary-title font-headline">Параметры теста</span>
|
<span class="cabinet-disclosure__summary-title font-headline">Параметры теста</span>
|
||||||
<span class="cabinet-disclosure__summary-sub">Порог, таймер, режим показа результата и подсказок</span>
|
<span class="cabinet-disclosure__summary-sub">Порог, таймер, режим результата, подсказки и шаблон структуры вопросов</span>
|
||||||
</span>
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="cabinet-disclosure__body">
|
<div class="cabinet-disclosure__body">
|
||||||
@@ -79,24 +82,56 @@
|
|||||||
<legend class="settings-row__label">Когда показывать результат</legend>
|
<legend class="settings-row__label">Когда показывать результат</legend>
|
||||||
<label class="settings-radio">
|
<label class="settings-radio">
|
||||||
<input type="radio" name="result-mode" value="end" />
|
<input type="radio" name="result-mode" value="end" />
|
||||||
<span>В конце теста <span class="settings-row__hint">(подсказок не будет)</span></span>
|
<span>В конце теста</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="settings-radio">
|
<label class="settings-radio">
|
||||||
<input type="radio" name="result-mode" value="immediate" />
|
<input type="radio" name="result-mode" value="immediate" />
|
||||||
<span>Сразу после ответа <span class="settings-row__hint">(с подсказкой)</span></span>
|
<span>Сразу после ответа</span>
|
||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<label class="settings-row settings-row--toggle" id="test-hints-row" style="display:none;">
|
<label class="settings-row settings-row--toggle" id="test-hints-row" style="display:none;">
|
||||||
<span class="settings-row__label">
|
<span class="settings-row__label">
|
||||||
Показывать подсказку после ответа
|
Показывать подсказку после ответа
|
||||||
<span class="settings-row__hint">Краткое объяснение во всплывающем окне</span>
|
<span class="settings-row__hint">Краткое объяснение под вариантами ответа</span>
|
||||||
</span>
|
</span>
|
||||||
<input id="test-hints-enabled" type="checkbox" />
|
<input id="test-hints-enabled" type="checkbox" />
|
||||||
</label>
|
</label>
|
||||||
<div class="settings-row settings-row--block" id="test-hints-actions" style="display:none;">
|
|
||||||
<button id="btn-generate-hints" class="btn btn-ghost btn--sm" type="button">Сгенерировать подсказки</button>
|
<div class="settings-row settings-row--block">
|
||||||
<p id="hints-status" class="settings-row__hint" style="margin-top:0.35rem;"></p>
|
<span class="settings-row__label">Шаблон структуры вопросов</span>
|
||||||
|
<p class="settings-row__hint" style="margin-bottom:0.5rem;">
|
||||||
|
Задаёт сетку для автосборки пустых вопросов и для генерации через ИИ. При смене чисел список вопросов пересобирается (с подтверждением, если уже есть текст).
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap items-end gap-3">
|
||||||
|
<label class="block">
|
||||||
|
<span class="form-label">Вопросов</span>
|
||||||
|
<input id="ai-q-count" type="number" min="1" max="30" step="1" value="7"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 grid gap-2 sm:grid-cols-3 items-end">
|
||||||
|
<label class="inline-flex items-center gap-2 min-h-9">
|
||||||
|
<input id="template-global-multi" type="checkbox"
|
||||||
|
class="rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
|
||||||
|
<span class="text-sm">Несколько правильных ответов (все вопросы)</span>
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="form-label">Правильных: от</span>
|
||||||
|
<input id="template-min-correct" type="number" min="1" max="8" step="1" value="1"
|
||||||
|
class="form-input" style="width:90px;" />
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="form-label">до</span>
|
||||||
|
<input id="template-max-correct" type="number" min="1" max="8" step="1" value="1"
|
||||||
|
class="form-input" style="width:90px;" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-row settings-row--block" style="padding-top:0.75rem; border-top:1px solid var(--outline-variant); margin-top:0.25rem;">
|
<div class="settings-row settings-row--block" style="padding-top:0.75rem; border-top:1px solid var(--outline-variant); margin-top:0.25rem;">
|
||||||
@@ -108,60 +143,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="cabinet-disclosure cabinet-brick" open>
|
<details class="cabinet-disclosure cabinet-brick">
|
||||||
<summary class="cabinet-disclosure__summary">
|
<summary class="cabinet-disclosure__summary">
|
||||||
<span class="cabinet-disclosure__summary-text">
|
<span class="cabinet-disclosure__summary-text">
|
||||||
<span class="cabinet-disclosure__summary-title font-headline">Вопросы</span>
|
<span class="cabinet-disclosure__summary-title font-headline">Инструменты генерации</span>
|
||||||
<span class="cabinet-disclosure__summary-sub">Тексты, варианты и при необходимости загрузка из файла</span>
|
<span class="cabinet-disclosure__summary-sub">Генерация через ИИ, проверка, улучшение и импорт документа</span>
|
||||||
</span>
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="cabinet-disclosure__body">
|
<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">
|
<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">Структура теста</h3>
|
|
||||||
<p class="muted text-xs mb-3">
|
|
||||||
Укажите количество вопросов и вариантов — создайте шаблон и заполните его вручную или через ИИ.
|
|
||||||
</p>
|
|
||||||
<div class="flex flex-wrap items-end gap-3">
|
|
||||||
<label class="block">
|
|
||||||
<span class="form-label">Вопросов</span>
|
|
||||||
<input id="ai-q-count" type="number" min="1" max="30" step="1" value="7"
|
|
||||||
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="create-template"
|
|
||||||
class="btn btn-ghost" type="button" style="min-height:43px;">
|
|
||||||
<span class="material-symbols-outlined text-base" style="vertical-align:-3px;">grid_view</span>
|
|
||||||
Создать шаблон
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="mt-3 grid gap-2 sm:grid-cols-3 items-end">
|
|
||||||
<label class="inline-flex items-center gap-2 min-h-9">
|
|
||||||
<input id="template-global-multi" type="checkbox"
|
|
||||||
class="rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
|
|
||||||
<span class="text-sm">Несколько правильных ответов (все вопросы)</span>
|
|
||||||
</label>
|
|
||||||
<label class="block">
|
|
||||||
<span class="form-label">Правильных: от</span>
|
|
||||||
<input id="template-min-correct" type="number" min="1" max="8" step="1" value="1"
|
|
||||||
class="form-input" style="width:90px;" />
|
|
||||||
</label>
|
|
||||||
<label class="block">
|
|
||||||
<span class="form-label">до</span>
|
|
||||||
<input id="template-max-correct" type="number" min="1" max="8" step="1" value="1"
|
|
||||||
class="form-input" style="width:90px;" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# ── Заполнить через ИИ по теме ──────────────────────────── #}
|
{# ── Заполнить через ИИ по теме ──────────────────────────── #}
|
||||||
<div class="question-editor-block">
|
<div class="question-editor-block question-editor-block--first">
|
||||||
<h3 class="test-detail-subsection__title">Заполнить через ИИ</h3>
|
<h3 class="test-detail-subsection__title">Заполнить через ИИ</h3>
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span class="form-label">Тема / промпт</span>
|
<span class="form-label">Тема / промпт</span>
|
||||||
@@ -233,7 +226,17 @@
|
|||||||
<p id="ai-status" class="mt-3 text-sm text-ink-500 min-h-[1.25rem]"></p>
|
<p id="ai-status" class="mt-3 text-sm text-ink-500 min-h-[1.25rem]"></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{# ── 3. Вопросы ─────────────────────────────────────────────── #}
|
</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">
|
||||||
<section class="mt-1">
|
<section class="mt-1">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<h2 class="font-semibold text-ink-900">Вопросы (<span id="q-count-mirror">0</span>)</h2>
|
<h2 class="font-semibold text-ink-900">Вопросы (<span id="q-count-mirror">0</span>)</h2>
|
||||||
@@ -249,20 +252,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Кнопка «Сохранить» под вопросами #}
|
|
||||||
<div class="mt-5 flex items-center gap-3">
|
|
||||||
<button id="save-draft-inline"
|
|
||||||
class="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg
|
|
||||||
bg-brand-600 hover:bg-brand-700 text-white font-medium min-h-11 btn btn-primary">
|
|
||||||
<span class="material-symbols-outlined text-base">save</span>
|
|
||||||
Сохранить
|
|
||||||
</button>
|
|
||||||
<button id="btn-cancel-inline"
|
|
||||||
class="inline-flex px-3 py-2 rounded-lg text-ink-700 hover:bg-ink-100 text-sm btn btn-ghost">
|
|
||||||
Отмена
|
|
||||||
</button>
|
|
||||||
<p id="save-status-inline" class="text-xs text-ink-500"></p>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Тесты — каталог{% endblock %}
|
{% block title %}Тесты — каталог{% endblock %}
|
||||||
|
|
||||||
|
{% macro catalog_test_params_line(t) -%}
|
||||||
|
{%- set tl = t.time_limit -%}
|
||||||
|
{%- set timestr = 'без ограничения' if tl is none or tl == 0 else (tl|string ~ ' мин') -%}
|
||||||
|
{%- set rm = t.result_mode or 'end' -%}
|
||||||
|
{%- set res = 'сразу' if rm == 'immediate' else 'в конце' -%}
|
||||||
|
{%- if rm != 'immediate' -%}
|
||||||
|
{%- set hint = 'недоступны' -%}
|
||||||
|
{%- elif t.hints_enabled -%}
|
||||||
|
{%- set hint = 'вкл' -%}
|
||||||
|
{%- else -%}
|
||||||
|
{%- set hint = 'выкл' -%}
|
||||||
|
{%- endif -%}
|
||||||
|
Порог: {{ t.passing_threshold }}% · Вопросов: {{ t.questions_count }} · Время: {{ timestr }} · Результат: {{ res }} · Подсказки: {{ hint }}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if ui_variant == 'legacy' %}
|
{% if ui_variant == 'legacy' %}
|
||||||
<section class="legacy-list-shell">
|
<section class="legacy-list-shell">
|
||||||
@@ -22,6 +37,7 @@
|
|||||||
{{ t.author_full_name or '—' }}
|
{{ t.author_full_name or '—' }}
|
||||||
<span class="list-row__meta-tail"> · Версия {{ t.version }}</span>
|
<span class="list-row__meta-tail"> · Версия {{ t.version }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<span class="list-row__params muted" style="display:block;font-size:0.78rem;margin-top:0.2rem;line-height:1.35;">{{ catalog_test_params_line(t) }}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-row__side">
|
<div class="list-row__side">
|
||||||
@@ -48,6 +64,7 @@
|
|||||||
{{ t.author_full_name or '—' }}
|
{{ t.author_full_name or '—' }}
|
||||||
<span class="list-row__meta-tail"> · Версия {{ t.version }} · скрыт</span>
|
<span class="list-row__meta-tail"> · Версия {{ t.version }} · скрыт</span>
|
||||||
</span>
|
</span>
|
||||||
|
<span class="list-row__params muted" style="display:block;font-size:0.78rem;margin-top:0.2rem;line-height:1.35;">{{ catalog_test_params_line(t) }}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-row__side">
|
<div class="list-row__side">
|
||||||
@@ -96,6 +113,7 @@
|
|||||||
Открыть
|
Открыть
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-1.5 text-xs text-ink-500 leading-snug">{{ catalog_test_params_line(t) }}</p>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -111,10 +129,12 @@
|
|||||||
</summary>
|
</summary>
|
||||||
<ul class="mt-3 space-y-2">
|
<ul class="mt-3 space-y-2">
|
||||||
{% for t in hidden %}
|
{% for t in hidden %}
|
||||||
<li class="flex items-center justify-between gap-2 text-sm">
|
<li class="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between text-sm">
|
||||||
<span>{{ t.title }} <span class="text-ink-500">· v{{ t.version }}</span></span>
|
<span class="min-w-0">{{ t.title }} <span class="text-ink-500">· v{{ t.version }}</span>
|
||||||
|
<span class="block text-xs text-ink-500 mt-0.5 leading-snug">{{ catalog_test_params_line(t) }}</span>
|
||||||
|
</span>
|
||||||
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}"
|
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}"
|
||||||
class="text-brand-700 hover:underline">Открыть</a>
|
class="text-brand-700 hover:underline shrink-0">Открыть</a>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -56,7 +56,13 @@ from ..services.test_attempt import (
|
|||||||
start_attempt,
|
start_attempt,
|
||||||
submit_attempt,
|
submit_attempt,
|
||||||
)
|
)
|
||||||
from ..services.test_access import is_test_author, list_hidden_by_author, list_visible_tests
|
from ..services.test_access import (
|
||||||
|
is_test_author,
|
||||||
|
is_test_edit_open,
|
||||||
|
list_hidden_by_author,
|
||||||
|
list_visible_tests,
|
||||||
|
user_has_test_access,
|
||||||
|
)
|
||||||
from ..services.test_chain import has_any_attempt_for_test
|
from ..services.test_chain import has_any_attempt_for_test
|
||||||
from ..services.test_draft import (
|
from ..services.test_draft import (
|
||||||
HttpError as DraftHttpError,
|
HttpError as DraftHttpError,
|
||||||
@@ -97,7 +103,7 @@ def _check_test_author_or_404(test_id: str, user_id: str) -> Test:
|
|||||||
if not test:
|
if not test:
|
||||||
from werkzeug.exceptions import NotFound
|
from werkzeug.exceptions import NotFound
|
||||||
raise NotFound(RU['notFound'])
|
raise NotFound(RU['notFound'])
|
||||||
if not is_test_author(test.created_by, user_id):
|
if not is_test_author(test.created_by, user_id) and not is_test_edit_open():
|
||||||
from werkzeug.exceptions import Forbidden
|
from werkzeug.exceptions import Forbidden
|
||||||
raise Forbidden('Доступ запрещён.')
|
raise Forbidden('Доступ запрещён.')
|
||||||
return test
|
return test
|
||||||
@@ -150,11 +156,11 @@ def api_test_summary(test_id):
|
|||||||
return jsonify(error=RU['notFound']), 404
|
return jsonify(error=RU['notFound']), 404
|
||||||
|
|
||||||
is_author = is_test_author(test.created_by, user.id)
|
is_author = is_test_author(test.created_by, user.id)
|
||||||
if not test.is_active and not is_author:
|
open_edit = is_test_edit_open()
|
||||||
|
if not test.is_active and not is_author and not open_edit:
|
||||||
return jsonify(error=RU['notFound']), 404
|
return jsonify(error=RU['notFound']), 404
|
||||||
|
|
||||||
if not is_author:
|
if not is_author and not open_edit:
|
||||||
from ..services.test_access import user_has_test_access
|
|
||||||
acc = user_has_test_access(user.id, test_id)
|
acc = user_has_test_access(user.id, test_id)
|
||||||
if not acc.ok:
|
if not acc.ok:
|
||||||
return jsonify(error=RU['notFound']), 404
|
return jsonify(error=RU['notFound']), 404
|
||||||
@@ -199,7 +205,7 @@ def api_test_versions(test_id):
|
|||||||
)
|
)
|
||||||
if not test:
|
if not test:
|
||||||
return jsonify(error=RU['notFound']), 404
|
return jsonify(error=RU['notFound']), 404
|
||||||
if not is_test_author(test.created_by, user.id):
|
if not is_test_author(test.created_by, user.id) and not is_test_edit_open():
|
||||||
return jsonify(error='Доступ запрещён.'), 403
|
return jsonify(error='Доступ запрещён.'), 403
|
||||||
|
|
||||||
has_attempts = has_any_attempt_for_test(session, tid)
|
has_attempts = has_any_attempt_for_test(session, tid)
|
||||||
|
|||||||
Reference in New Issue
Block a user