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:
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.1–V.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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user