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,29 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2022": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
}
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended"
|
||||
],
|
||||
"plugins": ["react", "react-hooks"],
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/prop-types": "warn"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Клинические Тесты</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+4937
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "clinic-tests-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src/",
|
||||
"lint:fix": "eslint src/ --fix",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react": "^7.34.4",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"prettier": "^3.3.3",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user