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
+19
View File
@@ -0,0 +1,19 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import Login from './pages/Login';
import TestsList from './pages/TestsList';
import TestDetail from './pages/TestDetail';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/tests" element={<TestsList />} />
<Route path="/tests/:id" element={<TestDetail />} />
<Route path="/" element={<Navigate to="/tests" replace />} />
</Routes>
</BrowserRouter>
);
}
export default App;
+26
View File
@@ -0,0 +1,26 @@
const base = '';
export async function api(path, opts = {}) {
const r = await fetch(`${base}${path}`, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(opts.headers || {}),
},
...opts,
});
const text = await r.text();
let data;
try {
data = text ? JSON.parse(text) : {};
} catch {
data = { raw: text };
}
if (!r.ok) {
const err = new Error(data.error || r.statusText);
err.status = r.status;
err.data = data;
throw err;
}
return data;
}
+9
View File
@@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+63
View File
@@ -0,0 +1,63 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../api';
export default function Login() {
const [login, setLogin] = useState('');
const [password, setPassword] = useState('');
const [err, setErr] = useState(null);
const nav = useNavigate();
async function onSubmit(e) {
e.preventDefault();
setErr(null);
try {
await api('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ login, password }),
});
nav('/tests');
} catch (e) {
setErr(e.message);
}
}
return (
<div style={{ maxWidth: 360, margin: '48px auto', fontFamily: 'system-ui' }}>
<h1>Вход</h1>
<p style={{ color: '#666', fontSize: 14 }}>
Локальный пользователь из <code>clinic_tests</code> (если HR_AUTH не
включён).
</p>
<form onSubmit={onSubmit}>
<div style={{ marginBottom: 12 }}>
<label>
Логин
<br />
<input
value={login}
onChange={(e) => setLogin(e.target.value)}
style={{ width: '100%', padding: 8 }}
autoComplete="username"
/>
</label>
</div>
<div style={{ marginBottom: 12 }}>
<label>
Пароль
<br />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
style={{ width: '100%', padding: 8 }}
autoComplete="current-password"
/>
</label>
</div>
{err && <p style={{ color: 'coral' }}>{err}</p>}
<button type="submit">Войти</button>
</form>
</div>
);
}
+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>
);
}
+100
View File
@@ -0,0 +1,100 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { api } from '../api';
export default function TestsList() {
const [tests, setTests] = useState([]);
const [user, setUser] = useState(null);
const [err, setErr] = useState(null);
const [title, setTitle] = useState('');
const nav = useNavigate();
async function load() {
try {
const me = await api('/api/auth/me');
setUser(me.user);
const t = await api('/api/tests');
setTests(t.tests || []);
} catch (e) {
if (e.status === 401) {
nav('/login');
return;
}
setErr(e.message);
}
}
useEffect(() => {
load();
}, [nav]);
async function createTest(e) {
e.preventDefault();
if (!title.trim()) {
return;
}
try {
const o = await api('/api/tests', {
method: 'POST',
body: JSON.stringify({ title: title.trim() }),
});
setTitle('');
nav(`/tests/${o.testId}`);
} catch (e) {
setErr(e.message);
}
}
async function logout() {
await api('/api/auth/logout', { method: 'POST' });
nav('/login');
}
return (
<div style={{ maxWidth: 800, margin: '24px auto', fontFamily: 'system-ui' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<h1>Тесты</h1>
<div>
{user && (
<span style={{ marginRight: 12 }}>
{user.fullName} ({user.role})
</span>
)}
<button type="button" onClick={logout}>
Выйти
</button>
</div>
</div>
{err && <p style={{ color: 'coral' }}>{err}</p>}
<form onSubmit={createTest} style={{ margin: '20px 0' }}>
<input
placeholder="Новый тест — название"
value={title}
onChange={(e) => setTitle(e.target.value)}
style={{ padding: 8, width: 320 }}
/>
<button type="submit" style={{ marginLeft: 8 }}>
Создать
</button>
</form>
<ul style={{ listStyle: 'none', padding: 0 }}>
{tests.map((t) => (
<li
key={t.id}
style={{ border: '1px solid #ddd', marginBottom: 8, padding: 12 }}
>
<Link to={`/tests/${t.id}`}>
{t.title}
</Link>
<span style={{ color: '#888', fontSize: 13, marginLeft: 8 }}>
v{t.version} · активная версия {t.active_version_id?.slice(0, 8)}
</span>
</li>
))}
</ul>
{tests.length === 0 && <p>Нет тестов создайте первый.</p>}
</div>
);
}