From c4a7d2ef085bd568f6a4cc2cad93d1beb1fccd28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BD=D1=81=D1=82=D0=B0=D0=BD=D1=82=D0=B8?= =?UTF-8?q?=D0=BD=20=D0=9B=D0=B5=D0=B1=D0=B5=D0=B4=D0=B8=D0=BD=D1=81=D0=BA?= =?UTF-8?q?=D0=B8=D0=B9?= Date: Wed, 29 Apr 2026 21:59:49 +0500 Subject: [PATCH] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D1=85=D0=BE=D0=B6=D0=B4?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=82=D0=B5=D1=81=D1=82=D0=B0:=20?= =?UTF-8?q?=D0=BE=D0=B4=D0=B8=D0=BD=20=D0=B2=D0=BE=D0=BF=D1=80=D0=BE=D1=81?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD,=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B3=D1=80=D0=B5=D1=81=D1=81=20=D1=81=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=85=D1=83,=20=D0=BC=D0=BE=D0=B1=D0=B8=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=B0=D1=8F=20=D0=B2=D1=91=D1=80=D1=81=D1=82=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- flask_app/app/static/css/app.css | 306 +++++++++++++++++++ flask_app/app/static/js/attempt.js | 324 +++++++++++++++++++++ flask_app/app/templates/tests/attempt.html | 282 +++--------------- 3 files changed, 673 insertions(+), 239 deletions(-) create mode 100644 flask_app/app/static/js/attempt.js diff --git a/flask_app/app/static/css/app.css b/flask_app/app/static/css/app.css index 06df7f1..92ceaa4 100644 --- a/flask_app/app/static/css/app.css +++ b/flask_app/app/static/css/app.css @@ -1024,3 +1024,309 @@ body.ui-legacy .attempts-card-list__main p + p { body.ui-legacy .attempts-card-list__action { 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% + ); +} diff --git a/flask_app/app/static/js/attempt.js b/flask_app/app/static/js/attempt.js new file mode 100644 index 0000000..bc2a12f --- /dev/null +++ b/flask_app/app/static/js/attempt.js @@ -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 = `Вопрос ${currentIdx + 1}`; + card.appendChild(meta); + + const textP = document.createElement('div'); + textP.className = 'attempt-q-text'; + let badge = ''; + if (isChk) { + const ok = checked[qid].isCorrect; + badge = `${ok ? '✓ верно' : '✗ неверно'}`; + } + 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 = ' верно'; + else if (isSelected(q.id, o.id)) mark = ' ваш ответ'; + } + row.innerHTML = + `'; + 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 = + `

Результат

` + + `

Правильно: ${data.correctCount} из ${data.totalQuestions}` + + ` (${data.percent}%). Порог: ${data.passingThreshold}%.

` + + `

${data.passed ? 'Зачёт' : 'Незачёт'}

` + + `

Разбор попытки

`; + } catch (e) { + setErr(e.message); + btnFinish.disabled = false; + btnFinish.textContent = 'Завершить тест'; + btnPrev.disabled = false; + btnNext.disabled = false; + if (playData) updateNav(); + } + } + + load(); +})(); diff --git a/flask_app/app/templates/tests/attempt.html b/flask_app/app/templates/tests/attempt.html index be94496..ac47b68 100644 --- a/flask_app/app/templates/tests/attempt.html +++ b/flask_app/app/templates/tests/attempt.html @@ -2,254 +2,58 @@ {% block title %}Прохождение теста{% endblock %} {% block content %} -
- -

Загрузка…

-

- - +
-
    + ← К тестам -
    - +
    +
    +
    + + +
    +
    +
    +
    +

    Загрузка…

    +

    +
    + +
    + +
    + +
    - + + + - -
    -

    Подсказка

    -

    -

    -

    -
    + +
    +

    Подсказка

    +

    +

    +

    +
    +{% endblock %} - +{% block scripts %} + {% endblock %}