feat(card1): версии тестов API, черновик, HR-login, import, UI

- V.1–V.3: saveTestDraft, fork при попытках; миграция 003 staff_id
- V.4–V.6: REST /api/tests, activate, PATCH, start attempt
- A: HR_DATABASE_URL + Werkzeug/bcrypt, JWT staffId, HR_AUTH
- D.1: multipart /api/tests/import/document
- Frontend: login, список тестов, экран версий/черновика/попытки
- ТЗ: V.10 назначения vs активная версия; журнал приёма

Made-with: Cursor
This commit is contained in:
Константин Лебединский
2026-04-24 20:30:09 +05:00
parent 7fa6f98ee1
commit 5631d85238
37 changed files with 9687 additions and 59 deletions
+177
View File
@@ -0,0 +1,177 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams, Link } from 'react-router-dom';
import { api } from '../api';
export default function TestDetail() {
const { id } = useParams();
const nav = useNavigate();
const [data, setData] = useState(null);
const [chain, setChain] = useState(null);
const [err, setErr] = useState(null);
const [qText, setQText] = useState('Пример вопроса?');
const [draftStatus, setDraftStatus] = useState('');
async function load() {
setErr(null);
try {
const v = await api(`/api/tests/${id}/versions`);
const c = await api(`/api/tests/${id}/chain-info`);
setData(v);
setChain(c);
} catch (e) {
if (e.status === 401) {
nav('/login');
return;
}
setErr(e.message);
}
}
useEffect(() => {
load();
}, [id, nav]);
async function saveDraft() {
setDraftStatus('…');
try {
const out = await api(`/api/tests/${id}/draft`, {
method: 'POST',
body: JSON.stringify({
title: 'Обновлённый заголовок (через черновик)',
questions: [
{
text: qText,
question_order: 1,
hasMultipleAnswers: false,
options: [
{ text: 'Верно', isCorrect: true, option_order: 1 },
{ text: 'Неверно 1', isCorrect: false, option_order: 2 },
{ text: 'Неверно 2', isCorrect: false, option_order: 3 },
],
},
],
}),
});
setDraftStatus(
out.forked
? 'Создана новая версия (вилка) и применён черновик'
: 'Черновик применён на месте'
);
load();
} catch (e) {
setDraftStatus(e.message);
}
}
async function startAttempt() {
try {
const o = await api(`/api/tests/${id}/attempts/start`, {
method: 'POST',
body: JSON.stringify({}),
});
setDraftStatus(`Попытка стартовала: ${o.attempt.id}`);
load();
} catch (e) {
setDraftStatus(e.message);
}
}
async function activateVersion(vid) {
if (!window.confirm('Сделать эту версию активной?')) {
return;
}
try {
await api(`/api/tests/${id}/versions/${vid}/activate`, {
method: 'POST',
body: JSON.stringify({}),
});
load();
} catch (e) {
setErr(e.message);
}
}
if (err) {
return <p style={{ color: 'coral' }}>{err}</p>;
}
if (!data) {
return <p>Загрузка</p>;
}
return (
<div style={{ maxWidth: 900, margin: '24px auto', fontFamily: 'system-ui' }}>
<p>
<Link to="/tests"> к списку</Link>
</p>
{chain?.hasAnyAttempt && (
<div
style={{
background: '#fff3cd',
border: '1px solid #ffc107',
padding: 12,
marginBottom: 16,
}}
>
По этой цепочке уже есть попытка. Сохранение черновика с вопросами
создаст <strong>новую версию</strong> (V.1V.3).
</div>
)}
<h2>Версии</h2>
<table
style={{ borderCollapse: 'collapse', width: '100%' }}
cellPadding={8}
>
<thead>
<tr>
<th>ver</th>
<th>id</th>
<th>active</th>
<th />
</tr>
</thead>
<tbody>
{data.versions.map((r) => (
<tr key={r.id} style={{ borderTop: '1px solid #ddd' }}>
<td>{r.version}</td>
<td style={{ fontSize: 12, wordBreak: 'break-all' }}>{r.id}</td>
<td>{r.is_active ? 'да' : 'нет'}</td>
<td>
{!r.is_active && (
<button type="button" onClick={() => activateVersion(r.id)}>
сделать активной
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
{data.hasAttempts && (
<p style={{ color: '#666', fontSize: 14 }}>hasAttempts: да</p>
)}
<h3>Черновик (V.3)</h3>
<label>
Текст вопроса
<br />
<input
value={qText}
onChange={(e) => setQText(e.target.value)}
style={{ width: '100%', padding: 8, marginTop: 4 }}
/>
</label>
<div style={{ marginTop: 8 }}>
<button type="button" onClick={saveDraft}>
Сохранить черновик
</button>
<span style={{ marginLeft: 12, color: '#666' }}>{draftStatus}</span>
</div>
<h3 style={{ marginTop: 24 }}>Прохождение (V.4)</h3>
<button type="button" onClick={startAttempt}>
Старт попытки
</button>
</div>
);
}