Прохождение теста: один вопрос на экран, прогресс сверху, мобильная вёрстка
Made-with: Cursor
This commit is contained in:
@@ -1024,3 +1024,309 @@ body.ui-legacy .attempts-card-list__main p + p {
|
|||||||
body.ui-legacy .attempts-card-list__action {
|
body.ui-legacy .attempts-card-list__action {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Прохождение теста: один вопрос, прогресс сверху, удобно с телефона ─── */
|
||||||
|
.attempt-root {
|
||||||
|
max-width: var(--max-content);
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-back-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-flow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
min-height: min(70dvh, 900px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-progress-head {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 15;
|
||||||
|
padding-top: max(0.25rem, env(safe-area-inset-top, 0px));
|
||||||
|
padding-bottom: 0.65rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
var(--surface-container-low) 0%,
|
||||||
|
var(--surface-container-low) 72%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-progress-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-progress-label {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-timer {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-progress-track {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--outline-variant) 45%, transparent);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, var(--primary), color-mix(in srgb, var(--primary) 75%, #fff));
|
||||||
|
transition: width 0.22s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-title {
|
||||||
|
margin: 0.65rem 0 0.25rem;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: 1.28;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-subtitle {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-stage {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 12rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-q-card {
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-q-card__meta {
|
||||||
|
margin-bottom: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-q-num {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-q-text {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-q-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-q-badge--ok {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-q-badge--bad {
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-q-options {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-opt-row {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-opt-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.65rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--outline-variant) 55%, transparent);
|
||||||
|
background: var(--surface);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-opt-label:hover:not(.attempt-opt-label--locked) {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 45%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--primary) 5%, var(--surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-opt-label--locked {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-opt-input {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1.15rem;
|
||||||
|
height: 1.15rem;
|
||||||
|
accent-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-opt-text {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.96rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-mark {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-mark--ok {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-mark--bad {
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-answer-actions {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-reply-btn {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-footer-bar {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 12;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 0.65rem;
|
||||||
|
padding-bottom: max(0.65rem, env(safe-area-inset-bottom, 0px));
|
||||||
|
margin-left: -0.25rem;
|
||||||
|
margin-right: -0.25rem;
|
||||||
|
padding-left: max(0.25rem, env(safe-area-inset-left, 0px));
|
||||||
|
padding-right: max(0.25rem, env(safe-area-inset-right, 0px));
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
transparent 0%,
|
||||||
|
color-mix(in srgb, var(--surface-container-low) 92%, transparent) 28%,
|
||||||
|
var(--surface-container-low) 100%
|
||||||
|
);
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-footer-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-width: 42rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-footer-spacer {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-footer-btn {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
padding-left: 1rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-footer-btn[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-error-box {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-result-card {
|
||||||
|
padding: 1.25rem 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-result-title {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-passed {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-failed {
|
||||||
|
font-weight: 700;
|
||||||
|
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 {
|
||||||
|
min-height: min(75dvh, 880px);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.ui-modern .attempt-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.ui-legacy .attempt-flow {
|
||||||
|
min-height: min(72dvh, 820px);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.ui-legacy .attempt-progress-head {
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
var(--surface) 0%,
|
||||||
|
var(--surface) 78%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.ui-legacy .attempt-footer-bar {
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
transparent 0%,
|
||||||
|
color-mix(in srgb, var(--surface) 94%, transparent) 35%,
|
||||||
|
var(--surface) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
/**
|
||||||
|
* Прохождение теста: один вопрос на экран, прогресс сверху.
|
||||||
|
*/
|
||||||
|
(() => {
|
||||||
|
const root = document.getElementById('attempt-root');
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
const testId = root.dataset.testId;
|
||||||
|
const attemptId = root.dataset.attemptId;
|
||||||
|
|
||||||
|
const flowEl = document.getElementById('attempt-flow');
|
||||||
|
const titleEl = document.getElementById('attempt-title');
|
||||||
|
const subEl = document.getElementById('attempt-subtitle');
|
||||||
|
const timerEl = document.getElementById('attempt-timer');
|
||||||
|
const errEl = document.getElementById('attempt-error');
|
||||||
|
const stageEl = document.getElementById('attempt-stage');
|
||||||
|
const progressLabel = document.getElementById('attempt-progress-label');
|
||||||
|
const progressFill = document.getElementById('attempt-progress-fill');
|
||||||
|
const progressTrack = document.querySelector('.attempt-progress-track');
|
||||||
|
const btnPrev = document.getElementById('attempt-prev');
|
||||||
|
const btnNext = document.getElementById('attempt-next');
|
||||||
|
const btnFinish = document.getElementById('attempt-finish');
|
||||||
|
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;
|
||||||
|
const selections = {};
|
||||||
|
const checked = {};
|
||||||
|
let timerHandle = null;
|
||||||
|
let deadlineMs = null;
|
||||||
|
let currentIdx = 0;
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '').replace(/[&<>"']/g, (m) => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||||
|
}[m]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setErr(msg) {
|
||||||
|
errEl.textContent = msg || 'Ошибка.';
|
||||||
|
errEl.style.display = msg ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 isImmediate() {
|
||||||
|
return playData && playData.resultMode === 'immediate';
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepAnswered(q) {
|
||||||
|
const k = String(q.id);
|
||||||
|
if (isImmediate()) return !!checked[k];
|
||||||
|
return (selections[k] || []).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function allAnswered() {
|
||||||
|
return (playData.questions || []).every((q) => stepAnswered(q));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProgress() {
|
||||||
|
const qs = playData.questions || [];
|
||||||
|
const total = qs.length;
|
||||||
|
const n = Math.min(currentIdx + 1, Math.max(total, 1));
|
||||||
|
if (progressLabel) progressLabel.textContent = `${n} из ${total}`;
|
||||||
|
const pct = total <= 1 ? 100 : ((currentIdx + 1) / total) * 100;
|
||||||
|
if (progressFill) progressFill.style.width = `${pct}%`;
|
||||||
|
if (progressTrack) {
|
||||||
|
progressTrack.setAttribute('aria-valuenow', String(n));
|
||||||
|
progressTrack.setAttribute('aria-valuemax', String(total));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStep() {
|
||||||
|
const qs = playData.questions || [];
|
||||||
|
const total = qs.length;
|
||||||
|
const q = qs[currentIdx];
|
||||||
|
stageEl.innerHTML = '';
|
||||||
|
if (!q) return;
|
||||||
|
|
||||||
|
updateProgress();
|
||||||
|
|
||||||
|
const card = document.createElement('article');
|
||||||
|
card.className = 'attempt-q-card surface-card';
|
||||||
|
card.dataset.qid = String(q.id);
|
||||||
|
|
||||||
|
const qid = String(q.id);
|
||||||
|
const isChk = !!checked[qid];
|
||||||
|
|
||||||
|
const meta = document.createElement('div');
|
||||||
|
meta.className = 'attempt-q-card__meta';
|
||||||
|
meta.innerHTML = `<span class="attempt-q-num">Вопрос ${currentIdx + 1}</span>`;
|
||||||
|
card.appendChild(meta);
|
||||||
|
|
||||||
|
const textP = document.createElement('div');
|
||||||
|
textP.className = 'attempt-q-text';
|
||||||
|
let badge = '';
|
||||||
|
if (isChk) {
|
||||||
|
const ok = checked[qid].isCorrect;
|
||||||
|
badge = `<span class="attempt-q-badge ${ok ? 'attempt-q-badge--ok' : 'attempt-q-badge--bad'}">${ok ? '✓ верно' : '✗ неверно'}</span>`;
|
||||||
|
}
|
||||||
|
textP.innerHTML = esc(q.text) + badge;
|
||||||
|
card.appendChild(textP);
|
||||||
|
|
||||||
|
const ul = document.createElement('ul');
|
||||||
|
ul.className = 'attempt-q-options';
|
||||||
|
const correctSet = new Set((checked[qid] && checked[qid].correctOptionIds) || []);
|
||||||
|
|
||||||
|
for (const o of (q.options || [])) {
|
||||||
|
const oid = String(o.id);
|
||||||
|
const row = document.createElement('li');
|
||||||
|
row.className = 'attempt-opt-row';
|
||||||
|
const type = q.hasMultipleAnswers ? 'checkbox' : 'radio';
|
||||||
|
const name = `q-${q.id}`;
|
||||||
|
let mark = '';
|
||||||
|
if (isChk) {
|
||||||
|
if (correctSet.has(oid)) mark = ' <span class="attempt-mark attempt-mark--ok">верно</span>';
|
||||||
|
else if (isSelected(q.id, o.id)) mark = ' <span class="attempt-mark attempt-mark--bad">ваш ответ</span>';
|
||||||
|
}
|
||||||
|
row.innerHTML =
|
||||||
|
`<label class="attempt-opt-label ${isChk ? 'attempt-opt-label--locked' : ''}">`
|
||||||
|
+ `<input type="${type}" ${q.hasMultipleAnswers ? '' : `name="${esc(name)}"`} class="attempt-opt-input" />`
|
||||||
|
+ `<span class="attempt-opt-text">${esc(o.text)}${mark}</span>`
|
||||||
|
+ '</label>';
|
||||||
|
const input = row.querySelector('input');
|
||||||
|
if (isSelected(q.id, o.id)) input.checked = true;
|
||||||
|
if (isChk) input.disabled = true;
|
||||||
|
input.addEventListener('change', () => {
|
||||||
|
if (checked[qid]) return;
|
||||||
|
toggle(q.id, o.id, q.hasMultipleAnswers);
|
||||||
|
renderStep();
|
||||||
|
});
|
||||||
|
ul.appendChild(row);
|
||||||
|
}
|
||||||
|
card.appendChild(ul);
|
||||||
|
|
||||||
|
if (isImmediate() && !isChk) {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'attempt-answer-actions';
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'btn btn-primary attempt-reply-btn';
|
||||||
|
btn.textContent = 'Ответить';
|
||||||
|
const sel = selections[qid] || [];
|
||||||
|
btn.disabled = sel.length === 0;
|
||||||
|
btn.addEventListener('click', () => checkOne(q.id));
|
||||||
|
wrap.appendChild(btn);
|
||||||
|
card.appendChild(wrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
stageEl.appendChild(card);
|
||||||
|
updateNav();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateNav() {
|
||||||
|
const qs = playData.questions || [];
|
||||||
|
const total = qs.length;
|
||||||
|
const q = qs[currentIdx];
|
||||||
|
if (!q) return;
|
||||||
|
const ok = stepAnswered(q);
|
||||||
|
|
||||||
|
btnPrev.disabled = currentIdx <= 0;
|
||||||
|
btnPrev.toggleAttribute('hidden', currentIdx <= 0);
|
||||||
|
|
||||||
|
const last = currentIdx >= total - 1;
|
||||||
|
btnNext.style.display = last ? 'none' : '';
|
||||||
|
btnFinish.style.display = last ? '' : 'none';
|
||||||
|
|
||||||
|
if (last) {
|
||||||
|
btnFinish.disabled = !allAnswered();
|
||||||
|
} else {
|
||||||
|
btnNext.disabled = !ok;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkOne(qid) {
|
||||||
|
const k = String(qid);
|
||||||
|
const sel = selections[k] || [];
|
||||||
|
if (!sel.length) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/tests/${testId}/attempts/${attemptId}/check`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ questionId: qid, selectedOptionIds: sel }),
|
||||||
|
});
|
||||||
|
const data = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) throw new Error(data.error || 'Не удалось проверить ответ.');
|
||||||
|
checked[k] = data;
|
||||||
|
renderStep();
|
||||||
|
if (playData.hintsEnabled) {
|
||||||
|
showHint(data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
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', () => {
|
||||||
|
if (currentIdx <= 0) return;
|
||||||
|
currentIdx -= 1;
|
||||||
|
renderStep();
|
||||||
|
});
|
||||||
|
|
||||||
|
btnNext.addEventListener('click', () => {
|
||||||
|
const total = (playData.questions || []).length;
|
||||||
|
if (currentIdx >= total - 1) return;
|
||||||
|
currentIdx += 1;
|
||||||
|
renderStep();
|
||||||
|
});
|
||||||
|
|
||||||
|
btnFinish.addEventListener('click', () => submit(false));
|
||||||
|
|
||||||
|
function startTimer(minutes) {
|
||||||
|
if (!minutes || minutes <= 0) return;
|
||||||
|
deadlineMs = Date.now() + minutes * 60 * 1000;
|
||||||
|
timerEl.style.display = '';
|
||||||
|
const tick = () => {
|
||||||
|
const left = Math.max(0, deadlineMs - Date.now());
|
||||||
|
const m = Math.floor(left / 60000);
|
||||||
|
const s = Math.floor((left % 60000) / 1000);
|
||||||
|
timerEl.textContent = `Осталось ${m}:${String(s).padStart(2, '0')}`;
|
||||||
|
if (left <= 0) {
|
||||||
|
clearInterval(timerHandle);
|
||||||
|
submit(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
timerHandle = setInterval(tick, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/tests/${testId}/attempts/${attemptId}/play`);
|
||||||
|
const data = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) throw new Error(data.error || 'Не удалось открыть попытку.');
|
||||||
|
playData = data;
|
||||||
|
titleEl.textContent = data.testTitle || 'Прохождение теста';
|
||||||
|
const parts = [`Порог зачёта ${data.passingThreshold ?? 0}%`];
|
||||||
|
if (data.resultMode === 'immediate') parts.push('обратная связь после каждого ответа');
|
||||||
|
if (data.hintsEnabled) parts.push('с подсказками');
|
||||||
|
subEl.textContent = parts.join(' · ');
|
||||||
|
if (!Array.isArray(data.questions) || !data.questions.length) {
|
||||||
|
setErr('В активной версии нет вопросов.');
|
||||||
|
btnNext.disabled = true;
|
||||||
|
btnFinish.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentIdx = 0;
|
||||||
|
renderStep();
|
||||||
|
if (data.timeLimit) startTimer(Number(data.timeLimit));
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message);
|
||||||
|
btnFinish.disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(auto) {
|
||||||
|
btnFinish.disabled = true;
|
||||||
|
btnNext.disabled = true;
|
||||||
|
btnPrev.disabled = true;
|
||||||
|
const label = auto ? 'Время вышло…' : 'Отправка…';
|
||||||
|
btnFinish.textContent = label;
|
||||||
|
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 || 'Не удалось завершить попытку.');
|
||||||
|
if (timerHandle) clearInterval(timerHandle);
|
||||||
|
timerEl.style.display = 'none';
|
||||||
|
if (flowEl) flowEl.style.display = 'none';
|
||||||
|
resultEl.style.display = '';
|
||||||
|
resultEl.innerHTML =
|
||||||
|
`<h3 class="attempt-result-title font-headline">Результат</h3>`
|
||||||
|
+ `<p>Правильно: <strong>${data.correctCount}</strong> из ${data.totalQuestions}`
|
||||||
|
+ ` (${data.percent}%). Порог: ${data.passingThreshold}%.</p>`
|
||||||
|
+ `<p class="${data.passed ? 'attempt-passed' : 'attempt-failed'}">${data.passed ? 'Зачёт' : 'Незачёт'}</p>`
|
||||||
|
+ `<p><a class="link-back" href="/tests/${testId}/attempts/${data.attemptId}/review">Разбор попытки</a></p>`;
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message);
|
||||||
|
btnFinish.disabled = false;
|
||||||
|
btnFinish.textContent = 'Завершить тест';
|
||||||
|
btnPrev.disabled = false;
|
||||||
|
btnNext.disabled = false;
|
||||||
|
if (playData) updateNav();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
})();
|
||||||
@@ -2,254 +2,58 @@
|
|||||||
{% block title %}Прохождение теста{% endblock %}
|
{% block title %}Прохождение теста{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="test-detail-page" id="attempt-root" data-test-id="{{ test_id }}" data-attempt-id="{{ attempt_id }}">
|
<div class="attempt-root 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="text-muted" id="attempt-timer" style="margin-top:0;display:none;font-weight:600;"></p>
|
|
||||||
<p class="error-text" id="attempt-error" style="display:none;"></p>
|
|
||||||
|
|
||||||
<ol id="questions-list" style="padding-left:1.25rem;"></ol>
|
<a href="{{ url_for('tests.tests_list_page') }}" class="link-back attempt-back-link">← К тестам</a>
|
||||||
|
|
||||||
<div class="inline-actions" style="margin-top:1rem;">
|
<div id="attempt-flow" class="attempt-flow">
|
||||||
<button type="button" class="btn btn-primary" id="submit-attempt-btn">Завершить тест</button>
|
<header class="attempt-progress-head">
|
||||||
|
<div class="attempt-progress-row">
|
||||||
|
<span id="attempt-progress-label" class="attempt-progress-label" aria-live="polite">—</span>
|
||||||
|
<span id="attempt-timer" class="attempt-timer" style="display:none;" aria-live="polite"></span>
|
||||||
|
</div>
|
||||||
|
<div class="attempt-progress-track"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuemin="1"
|
||||||
|
aria-valuenow="1"
|
||||||
|
aria-valuemax="1"
|
||||||
|
aria-label="Прогресс прохождения">
|
||||||
|
<div id="attempt-progress-fill" class="attempt-progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
<h1 class="attempt-title font-headline" id="attempt-title">Загрузка…</h1>
|
||||||
|
<p class="attempt-subtitle muted text-sm" id="attempt-subtitle"></p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="attempt-stage" class="attempt-stage"></div>
|
||||||
|
|
||||||
|
<footer class="attempt-footer-bar" aria-label="Навигация по вопросам">
|
||||||
|
<div class="attempt-footer-inner">
|
||||||
|
<button type="button" class="btn btn-ghost attempt-footer-btn" id="attempt-prev" hidden>Назад</button>
|
||||||
|
<div class="attempt-footer-spacer"></div>
|
||||||
|
<button type="button" class="btn btn-primary attempt-footer-btn" id="attempt-next">Далее</button>
|
||||||
|
<button type="button" class="btn btn-primary attempt-footer-btn" id="attempt-finish" style="display:none;">Завершить тест</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="attempt-result" class="surface-card" style="display:none;margin-top:1rem;padding:1rem;"></div>
|
<p id="attempt-error" class="callout callout--error attempt-error-box" style="display:none;"></p>
|
||||||
|
|
||||||
<dialog id="hint-modal" style="border:none;border-radius:14px;padding:0;max-width:480px;width:90%;box-shadow:0 12px 40px rgba(0,0,0,0.18);">
|
<div id="attempt-result" class="surface-card attempt-result-card" style="display:none;"></div>
|
||||||
<div style="padding:1rem 1.25rem;">
|
|
||||||
<h3 id="hint-title" style="margin:0 0 0.5rem 0;font-size:1.05rem;">Подсказка</h3>
|
<dialog id="hint-modal" class="save-modal attempt-hint-dialog">
|
||||||
<p id="hint-verdict" style="margin:0 0 0.5rem 0;font-weight:600;"></p>
|
<div class="save-modal__inner attempt-hint-inner">
|
||||||
<p id="hint-correct" style="margin:0 0 0.5rem 0;font-size:0.9rem;color:#506965;"></p>
|
<h3 class="font-headline text-base font-semibold mb-2" id="hint-title">Подсказка</h3>
|
||||||
<p id="hint-explanation" style="margin:0;font-size:0.95rem;line-height:1.4;"></p>
|
<p id="hint-verdict" class="attempt-hint-verdict"></p>
|
||||||
<div class="inline-actions" style="margin-top:0.85rem;justify-content:flex-end;">
|
<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>
|
<button type="button" class="btn btn-primary btn--sm" id="hint-close-btn">Понятно</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock %}
|
||||||
<script>
|
|
||||||
(() => {
|
{% block scripts %}
|
||||||
const root = document.getElementById('attempt-root');
|
<script src="{{ url_for('static', filename='js/attempt.js') }}" defer></script>
|
||||||
const testId = root.dataset.testId;
|
|
||||||
const attemptId = root.dataset.attemptId;
|
|
||||||
const titleEl = document.getElementById('attempt-title');
|
|
||||||
const subEl = document.getElementById('attempt-subtitle');
|
|
||||||
const timerEl = document.getElementById('attempt-timer');
|
|
||||||
const errEl = document.getElementById('attempt-error');
|
|
||||||
const listEl = document.getElementById('questions-list');
|
|
||||||
const resultEl = document.getElementById('attempt-result');
|
|
||||||
const submitBtn = document.getElementById('submit-attempt-btn');
|
|
||||||
const hintModal = document.getElementById('hint-modal');
|
|
||||||
const hintTitle = document.getElementById('hint-title');
|
|
||||||
const hintVerdict = document.getElementById('hint-verdict');
|
|
||||||
const hintCorrect = document.getElementById('hint-correct');
|
|
||||||
const hintExplanation = document.getElementById('hint-explanation');
|
|
||||||
const hintCloseBtn = document.getElementById('hint-close-btn');
|
|
||||||
|
|
||||||
let playData = null;
|
|
||||||
const selections = {};
|
|
||||||
const checked = {};
|
|
||||||
let timerHandle = null;
|
|
||||||
let deadlineMs = null;
|
|
||||||
|
|
||||||
function esc(s) {
|
|
||||||
return String(s ?? '').replace(/[&<>"']/g, (m) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 isImmediate() {
|
|
||||||
return playData && playData.resultMode === 'immediate';
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderQuestions() {
|
|
||||||
listEl.innerHTML = '';
|
|
||||||
for (const q of (playData.questions || [])) {
|
|
||||||
const qid = String(q.id);
|
|
||||||
const isChecked = !!checked[qid];
|
|
||||||
const li = document.createElement('li');
|
|
||||||
li.style.marginBottom = '1.5rem';
|
|
||||||
li.dataset.qid = qid;
|
|
||||||
let badge = '';
|
|
||||||
if (isChecked) {
|
|
||||||
const ok = checked[qid].isCorrect;
|
|
||||||
badge = '<span style="margin-left:8px;font-size:0.8rem;font-weight:600;color:' +
|
|
||||||
(ok ? '#1a7a4a' : '#b32d2d') + ';">' + (ok ? '✓ верно' : '✗ неверно') + '</span>';
|
|
||||||
}
|
|
||||||
li.innerHTML = '<p style="margin-top:0;margin-bottom:0.5rem;">' + esc(q.text) + badge + '</p>';
|
|
||||||
const ul = document.createElement('ul');
|
|
||||||
ul.style.listStyle = 'none';
|
|
||||||
ul.style.padding = '0';
|
|
||||||
ul.style.margin = '0';
|
|
||||||
const correctSet = new Set((checked[qid] && checked[qid].correctOptionIds) || []);
|
|
||||||
for (const o of (q.options || [])) {
|
|
||||||
const oid = String(o.id);
|
|
||||||
const row = document.createElement('li');
|
|
||||||
row.style.marginBottom = '6px';
|
|
||||||
const type = q.hasMultipleAnswers ? 'checkbox' : 'radio';
|
|
||||||
const name = 'q-' + q.id;
|
|
||||||
let mark = '';
|
|
||||||
if (isChecked) {
|
|
||||||
if (correctSet.has(oid)) mark = ' <span style="color:#1a7a4a;font-weight:600;">(верно)</span>';
|
|
||||||
else if (isSelected(q.id, o.id)) mark = ' <span style="color:#b32d2d;">(ваш ответ)</span>';
|
|
||||||
}
|
|
||||||
row.innerHTML =
|
|
||||||
'<label style="display:flex;align-items:flex-start;gap:8px;cursor:' + (isChecked ? 'default' : 'pointer') + ';' +
|
|
||||||
(isChecked ? 'opacity:0.85;' : '') + '">' +
|
|
||||||
'<input type="' + type + '" ' + (q.hasMultipleAnswers ? '' : ('name="' + name + '"')) + ' ' +
|
|
||||||
(isSelected(q.id, o.id) ? 'checked' : '') + ' ' + (isChecked ? 'disabled' : '') + ' />' +
|
|
||||||
'<span>' + esc(o.text) + mark + '</span>' +
|
|
||||||
'</label>';
|
|
||||||
const input = row.querySelector('input');
|
|
||||||
input.addEventListener('change', () => {
|
|
||||||
if (checked[qid]) return;
|
|
||||||
toggle(q.id, o.id, q.hasMultipleAnswers);
|
|
||||||
renderQuestions();
|
|
||||||
});
|
|
||||||
ul.appendChild(row);
|
|
||||||
}
|
|
||||||
li.appendChild(ul);
|
|
||||||
|
|
||||||
if (isImmediate() && !isChecked) {
|
|
||||||
const btn = document.createElement('button');
|
|
||||||
btn.type = 'button';
|
|
||||||
btn.className = 'btn btn-ghost btn--sm';
|
|
||||||
btn.textContent = 'Ответить';
|
|
||||||
btn.style.marginTop = '0.4rem';
|
|
||||||
const sel = selections[qid] || [];
|
|
||||||
btn.disabled = sel.length === 0;
|
|
||||||
btn.addEventListener('click', () => checkOne(q.id));
|
|
||||||
li.appendChild(btn);
|
|
||||||
}
|
|
||||||
listEl.appendChild(li);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkOne(qid) {
|
|
||||||
const k = String(qid);
|
|
||||||
const sel = selections[k] || [];
|
|
||||||
if (!sel.length) return;
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/tests/' + testId + '/attempts/' + attemptId + '/check', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ questionId: qid, selectedOptionIds: sel }),
|
|
||||||
});
|
|
||||||
const data = await r.json().catch(() => ({}));
|
|
||||||
if (!r.ok) throw new Error(data.error || 'Не удалось проверить ответ.');
|
|
||||||
checked[k] = data;
|
|
||||||
renderQuestions();
|
|
||||||
if (playData.hintsEnabled) {
|
|
||||||
showHint(data);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setErr(e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showHint(data) {
|
|
||||||
hintVerdict.textContent = data.isCorrect ? 'Верно!' : 'Неверно.';
|
|
||||||
hintVerdict.style.color = data.isCorrect ? '#1a7a4a' : '#b32d2d';
|
|
||||||
const correct = (data.correctOptionTexts || []).join('; ');
|
|
||||||
hintCorrect.textContent = correct ? ('Правильный ответ: ' + correct) : '';
|
|
||||||
hintExplanation.textContent = data.explanation || 'Объяснение недоступно.';
|
|
||||||
if (typeof hintModal.showModal === 'function') hintModal.showModal();
|
|
||||||
else hintModal.setAttribute('open', '');
|
|
||||||
}
|
|
||||||
hintCloseBtn.addEventListener('click', () => {
|
|
||||||
if (typeof hintModal.close === 'function') hintModal.close();
|
|
||||||
else hintModal.removeAttribute('open');
|
|
||||||
});
|
|
||||||
|
|
||||||
function startTimer(minutes) {
|
|
||||||
if (!minutes || minutes <= 0) return;
|
|
||||||
deadlineMs = Date.now() + minutes * 60 * 1000;
|
|
||||||
timerEl.style.display = '';
|
|
||||||
const tick = () => {
|
|
||||||
const left = Math.max(0, deadlineMs - Date.now());
|
|
||||||
const m = Math.floor(left / 60000);
|
|
||||||
const s = Math.floor((left % 60000) / 1000);
|
|
||||||
timerEl.textContent = 'Осталось: ' + m + ':' + String(s).padStart(2, '0');
|
|
||||||
if (left <= 0) {
|
|
||||||
clearInterval(timerHandle);
|
|
||||||
submit(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
tick();
|
|
||||||
timerHandle = setInterval(tick, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/tests/' + testId + '/attempts/' + attemptId + '/play');
|
|
||||||
const data = await r.json().catch(() => ({}));
|
|
||||||
if (!r.ok) throw new Error(data.error || 'Не удалось открыть попытку.');
|
|
||||||
playData = data;
|
|
||||||
titleEl.textContent = data.testTitle || 'Прохождение теста';
|
|
||||||
const parts = ['Порог зачёта: ' + (data.passingThreshold ?? 0) + '%'];
|
|
||||||
if (data.resultMode === 'immediate') parts.push('Результат сразу после ответа');
|
|
||||||
if (data.hintsEnabled) parts.push('С ИИ-подсказками');
|
|
||||||
subEl.textContent = parts.join(' · ') + '.';
|
|
||||||
if (!Array.isArray(data.questions) || !data.questions.length) {
|
|
||||||
setErr('В активной версии нет вопросов.');
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
renderQuestions();
|
|
||||||
if (data.timeLimit) startTimer(Number(data.timeLimit));
|
|
||||||
} catch (e) {
|
|
||||||
setErr(e.message);
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit(auto) {
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
submitBtn.textContent = auto ? 'Время вышло, отправка…' : 'Отправка…';
|
|
||||||
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 || 'Не удалось завершить попытку.');
|
|
||||||
if (timerHandle) clearInterval(timerHandle);
|
|
||||||
timerEl.style.display = 'none';
|
|
||||||
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(false));
|
|
||||||
load();
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user