Browse Source

UI: фамилия с инициалами в шапке, подпись автора у тестов

- Хедер: отображать Фамилия И. О., полное ФИО в title.
- Список тестов и карточка: «Автор: Вы» для своих, иначе «Автор: Фамилия И. О.».
- API: в каталоге и summary/versions — created_by, author full_name (camelCase в JSON).

Made-with: Cursor
dev
Константин Лебединский 2 weeks ago
parent
commit
4801ea9f19
  1. 455
      backend/src/routes/tests.js
  2. 64
      backend/src/services/testAccessService.js
  3. 102
      frontend/src/components/CabinetLayout.jsx
  4. 1216
      frontend/src/pages/TestDetail.jsx
  5. 187
      frontend/src/pages/TestsList.jsx
  6. 39
      frontend/src/utils/formatUserName.js

455
backend/src/routes/tests.js

@ -9,6 +9,28 @@ import pool, { query } from '../db/db.js';
import { authenticate } from '../middleware/auth.js';
import { hasAnyAttemptForTest } from '../services/testChainService.js';
import { saveTestDraft, createTestWithVersion } from '../services/testDraftService.js';
import {
getEditorContent,
getPlayContent,
submitAttempt,
getAttemptReviewForUser,
listTestAttemptsForAuthor,
} from '../services/testAttemptService.js';
import { extractTextFromFile } from '../services/documentExtractService.js';
import { generationForImportDocument } from '../services/documentGenService.js';
import { RU } from '../messages/ru.js';
import { isTestAuthor } from '../config/devAuthor.js';
import { ensureClinicUserIdForStaff } from '../services/assignmentUserService.js';
import {
queryTestsVisibleToUser,
userHasTestAccess,
} from '../services/testAccessService.js';
import { isAssignmentFeatureEnabled } from '../config/featureFlags.js';
import {
generateFullTestByShape,
generateOrRephraseQuestion,
parseAndValidateShape,
} from '../services/aiEditorService.js';
const router = express.Router();
const upload = multer({
@ -28,35 +50,59 @@ function asyncHandler(fn) {
};
}
/** D.1 — раньше /:id, иначе id = "import" */
/** D.1 + D.2 + D.3 (заглушка) — `POST` до маршрутов `/:id` */
router.post(
'/import/document',
authenticate,
upload.single('file'),
(req, res, next) => {
upload.single('file')(req, res, (err) => {
if (err) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: RU.fileTooLarge });
}
return next(err);
}
next();
});
},
asyncHandler(async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'file field required' });
return res.status(400).json({ error: RU.fileFieldRequired });
}
const p = req.file.path;
let size = 0;
const { mimetype, originalname } = req.file;
let size;
let extractedText;
try {
const st = await fs.stat(p);
size = st.size;
await fs.unlink(p);
} catch {
extractedText = await extractTextFromFile(mimetype, p, originalname);
} catch (e) {
try {
await fs.unlink(p);
} catch {
// ignore
}
return res.status(500).json({ error: 'upload failed' });
if (e.status) {
return res.status(e.status).json({ error: e.message });
}
console.error('import document:', e);
return res.status(500).json({ error: RU.uploadFailed });
}
try {
await fs.unlink(p);
} catch {
// D.5: временный файл удалён; при ошибке extract уже удалили выше
}
const generation = await generationForImportDocument(extractedText);
res.json({
received: true,
originalName: req.file.originalname,
originalName: originalname,
mime: mimetype,
size,
message:
'Файл принят; извлечение текста и генерация (D.2–D.3) — в следующем шаге',
extractedText,
textLength: extractedText.length,
generation,
});
})
);
@ -65,15 +111,19 @@ router.get(
'/',
authenticate,
asyncHandler(async (req, res) => {
const { rows } = await query(
const { rows } = await queryTestsVisibleToUser(req.user.id);
const { rows: hidden } = await query(
`SELECT t.id, t.title, t.description, t.is_active AS chain_active,
t.created_at, t.updated_at, tv.id AS active_version_id, tv.version
t.created_at, t.updated_at, tv.id AS active_version_id, tv.version,
t.created_by, u.full_name AS author_full_name
FROM tests t
INNER JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true
WHERE t.is_active = true
ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC`
INNER JOIN users u ON u.id = t.created_by
WHERE t.is_active = false AND t.created_by = $1
ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC`,
[req.user.id]
);
res.json({ tests: rows });
res.json({ tests: rows, hiddenByYou: hidden });
})
);
@ -83,7 +133,7 @@ router.post(
asyncHandler(async (req, res) => {
const { title, description } = req.body;
if (!title || typeof title !== 'string') {
return res.status(400).json({ error: 'title required' });
return res.status(400).json({ error: RU.titleRequired });
}
const out = await createTestWithVersion(pool, req.user.id, {
title,
@ -93,27 +143,172 @@ router.post(
})
);
/**
* V.8: краткая карточка цепочки одна строка (активная версия), без дублей.
* Не-автор не видит скрытую с общего списка цепочку (кроме прямой ссылки автора).
*/
router.get(
'/:id/summary',
authenticate,
asyncHandler(async (req, res) => {
const testId = req.params.id;
const { rows } = await query(
`SELECT t.id, t.title, t.description, t.passing_threshold, t.is_active AS chain_active,
t.created_by, t.created_at, t.updated_at, tv.id AS active_version_id, tv.version,
u.full_name AS author_full_name
FROM tests t
LEFT JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true
LEFT JOIN users u ON u.id = t.created_by
WHERE t.id = $1`,
[testId]
);
if (!rows.length) {
return res.status(404).json({ error: RU.testNotFound });
}
const row = rows[0];
const isAuthor = isTestAuthor(row.created_by, req.user.id);
if (row.chain_active === false && !isAuthor) {
return res.status(404).json({ error: RU.testNotFound });
}
if (!isAuthor) {
const acc = await userHasTestAccess(req.user.id, testId);
if (!acc.ok) {
return res.status(404).json({ error: RU.testNotFound });
}
}
res.json({
test: {
id: row.id,
title: row.title,
description: row.description,
passingThreshold: row.passing_threshold,
chainActive: row.chain_active,
activeVersionId: row.active_version_id,
version: row.version,
createdAt: row.created_at,
updatedAt: row.updated_at,
createdBy: row.created_by,
authorFullName: row.author_full_name,
},
isAuthor,
hasActiveVersion: row.active_version_id != null,
});
})
);
router.get(
'/:id/versions',
authenticate,
asyncHandler(async (req, res) => {
const testId = req.params.id;
const { rows: t } = await query(`SELECT id, created_by FROM tests WHERE id = $1`, [
testId,
]);
const { rows: t } = await query(
`SELECT t.id, t.title, t.created_by, t.is_active, t.created_at, t.updated_at, t.description,
u.full_name AS author_full_name
FROM tests t
INNER JOIN users u ON u.id = t.created_by
WHERE t.id = $1`,
[testId]
);
if (!t.length) {
return res.status(404).json({ error: 'Test not found' });
return res.status(404).json({ error: RU.testNotFound });
}
if (t[0].created_by !== req.user.id) {
return res.status(403).json({ error: 'Forbidden' });
if (!isTestAuthor(t[0].created_by, req.user.id)) {
return res.status(403).json({ error: RU.forbidden });
}
const testRow = t[0];
const { rows } = await query(
`SELECT id, version, is_active, parent_id, created_at
FROM test_versions WHERE test_id = $1 ORDER BY version`,
[testId]
);
const hasAttempts = await hasAnyAttemptForTest(pool, testId);
res.json({ versions: rows, hasAttempts });
res.json({
test: {
id: testRow.id,
title: testRow.title,
description: testRow.description,
chainActive: testRow.is_active,
createdAt: testRow.created_at,
updatedAt: testRow.updated_at,
createdBy: testRow.created_by,
authorFullName: testRow.author_full_name,
},
versions: rows,
hasAttempts,
});
})
);
router.get(
'/:id/editor',
authenticate,
asyncHandler(async (req, res) => {
const out = await getEditorContent(pool, req.user.id, req.params.id);
res.json(out);
})
);
/**
* ИИ: заполнить тест по текущей сетке (число вопросов = len(shape), варианты = optionsCount).
* Только автор. Тело: { testTitle?, testDescription?, shape: [{ optionsCount, hasMultipleAnswers }] }
*/
router.post(
'/:id/ai/generate-test',
authenticate,
asyncHandler(async (req, res) => {
const testId = req.params.id;
const { rows: t } = await query(
`SELECT id, created_by FROM tests WHERE id = $1`,
[testId]
);
if (!t.length) {
return res.status(404).json({ error: RU.testNotFound });
}
if (!isTestAuthor(t[0].created_by, req.user.id)) {
return res.status(403).json({ error: RU.forbidden });
}
const b = req.body && typeof req.body === 'object' ? req.body : {};
const shape = parseAndValidateShape(b.shape);
const testTitle = typeof b.testTitle === 'string' ? b.testTitle : '';
const testDescription = typeof b.testDescription === 'string' ? b.testDescription : '';
const draft = await generateFullTestByShape(testTitle, testDescription, shape);
res.json({ ok: true, draft });
})
);
/**
* ИИ: один вопрос пустой текст сгенерировать вопрос и варианты; иначе переформулировать только текст.
* Только автор. Тело: { testTitle?, testDescription?, questionText, optionsCount, hasMultipleAnswers }
*/
router.post(
'/:id/ai/generate-question',
authenticate,
asyncHandler(async (req, res) => {
const testId = req.params.id;
const { rows: tr } = await query(
`SELECT id, created_by FROM tests WHERE id = $1`,
[testId]
);
if (!tr.length) {
return res.status(404).json({ error: RU.testNotFound });
}
if (!isTestAuthor(tr[0].created_by, req.user.id)) {
return res.status(403).json({ error: RU.forbidden });
}
const b = req.body && typeof req.body === 'object' ? req.body : {};
const testTitle = typeof b.testTitle === 'string' ? b.testTitle : '';
const testDescription = typeof b.testDescription === 'string' ? b.testDescription : '';
const questionText = typeof b.questionText === 'string' ? b.questionText : '';
const optionsCount = b.optionsCount;
const hasMultipleAnswers = Boolean(b.hasMultipleAnswers);
const out = await generateOrRephraseQuestion(
testTitle,
testDescription,
questionText,
optionsCount,
hasMultipleAnswers
);
res.json({ ok: true, ...out });
})
);
@ -128,17 +323,17 @@ router.post(
[testId]
);
if (!t.length) {
return res.status(404).json({ error: 'Test not found' });
return res.status(404).json({ error: RU.testNotFound });
}
if (t[0].created_by !== req.user.id) {
return res.status(403).json({ error: 'Forbidden' });
if (!isTestAuthor(t[0].created_by, req.user.id)) {
return res.status(403).json({ error: RU.forbidden });
}
const { rows: v } = await query(
`SELECT id FROM test_versions WHERE test_id = $1 AND id = $2`,
[testId, versionId]
);
if (!v.length) {
return res.status(404).json({ error: 'Version not found' });
return res.status(404).json({ error: RU.versionNotFound });
}
const client = await pool.connect();
try {
@ -170,17 +365,17 @@ router.patch(
const { isActive, chainActive } = req.body;
const chain = chainActive ?? isActive;
if (typeof chain !== 'boolean') {
return res.status(400).json({ error: 'chainActive (boolean) required' });
return res.status(400).json({ error: RU.chainActiveRequired });
}
const { rows: t } = await query(
`SELECT id, created_by FROM tests WHERE id = $1`,
[testId]
);
if (!t.length) {
return res.status(404).json({ error: 'Test not found' });
return res.status(404).json({ error: RU.testNotFound });
}
if (t[0].created_by !== req.user.id) {
return res.status(403).json({ error: 'Forbidden' });
if (!isTestAuthor(t[0].created_by, req.user.id)) {
return res.status(403).json({ error: RU.forbidden });
}
await query(
`UPDATE tests SET is_active = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1`,
@ -190,6 +385,111 @@ router.patch(
})
);
/**
* Пакетное назначение `test_assignments` + `test_assignment_targets` (user).
* Включение: `development` или `CLINIC_ASSIGNMENT_ENABLED=1`. Только автор. Тело: `userIds`, `staffIds` и/или одиночные `userId` / `staffId`.
*/
router.post(
'/:id/assign',
authenticate,
asyncHandler(async (req, res) => {
if (!isAssignmentFeatureEnabled()) {
return res.status(404).json({ error: RU.notFound });
}
const testId = req.params.id;
const b = req.body && typeof req.body === 'object' ? req.body : {};
const userIds = new Set();
if (Array.isArray(b.userIds)) {
for (const u of b.userIds) {
if (typeof u === 'string' && u.trim()) {
userIds.add(u.trim());
}
}
}
if (Array.isArray(b.staffIds)) {
for (const s of b.staffIds) {
const n = Number(s);
if (Number.isFinite(n) && n >= 1) {
userIds.add(await ensureClinicUserIdForStaff(pool, n));
}
}
}
if (userIds.size === 0) {
if (b.staffId != null && b.userId) {
return res.status(400).json({ error: RU.assignmentUserOrStaff });
}
if (b.staffId != null) {
const sid = Number(b.staffId);
if (Number.isNaN(sid)) {
return res.status(400).json({ error: RU.assignmentUserRequired });
}
userIds.add(await ensureClinicUserIdForStaff(pool, sid));
} else if (typeof b.userId === 'string' && b.userId.trim()) {
userIds.add(b.userId.trim());
}
}
if (userIds.size === 0) {
return res.status(400).json({ error: RU.assignmentUserRequired });
}
const { rows: t } = await query(
`SELECT id, created_by FROM tests WHERE id = $1`,
[testId]
);
if (!t.length) {
return res.status(404).json({ error: RU.testNotFound });
}
if (!isTestAuthor(t[0].created_by, req.user.id)) {
return res.status(403).json({ error: RU.forbidden });
}
const { rows: tv } = await query(
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true LIMIT 1`,
[testId]
);
if (!tv.length) {
return res.status(400).json({ error: RU.noActiveVersion });
}
for (const uid of userIds) {
const { rows: u } = await query(
`SELECT id FROM users WHERE id = $1 AND is_active = true`,
[uid]
);
if (!u.length) {
return res.status(400).json({ error: RU.userNotFound });
}
}
const versionId = tv[0].id;
const client = await pool.connect();
let assignmentId;
try {
await client.query('BEGIN');
const { rows: ins } = await client.query(
`INSERT INTO test_assignments (test_version_id, assigned_by, max_attempts)
VALUES ($1, $2, 5) RETURNING id`,
[versionId, req.user.id]
);
assignmentId = ins[0].id;
for (const uid of userIds) {
await client.query(
`INSERT INTO test_assignment_targets (assignment_id, target_type, target_id)
VALUES ($1, 'user', $2)`,
[assignmentId, uid]
);
}
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
res
.status(201)
.json({ ok: true, assignmentId, count: userIds.size });
})
);
router.post(
'/:id/draft',
authenticate,
@ -204,6 +504,10 @@ router.post(
authenticate,
asyncHandler(async (req, res) => {
const testId = req.params.id;
const acc = await userHasTestAccess(req.user.id, testId);
if (!acc.ok) {
return res.status(404).json({ error: RU.testNotFound });
}
const { rows: tv } = await query(
`SELECT tv.id AS test_version_id
FROM test_versions tv
@ -211,7 +515,7 @@ router.post(
[testId]
);
if (!tv.length) {
return res.status(404).json({ error: 'No active version' });
return res.status(404).json({ error: RU.noActiveVersion });
}
const testVersionId = tv[0].test_version_id;
const { rows: mx } = await query(
@ -230,11 +534,98 @@ router.post(
})
);
/** Только автор: список попыток по цепочке (все версии) */
router.get(
'/:id/attempts',
authenticate,
asyncHandler(async (req, res) => {
const rows = await listTestAttemptsForAuthor(pool, req.user.id, req.params.id);
res.json({
attempts: rows.map((r) => ({
id: r.id,
userId: r.user_id,
status: r.status,
attemptNumber: r.attempt_number,
startedAt: r.started_at,
completedAt: r.completed_at,
correctCount: r.correct_count,
totalQuestions: r.total_questions,
passed: r.passed,
testVersion: r.test_version,
attempterName: r.attempter_name,
attempterLogin: r.attempter_login,
})),
});
})
);
/** Разбор завершённой попытки: владелец или автор */
router.get(
'/:id/attempts/:aid/review',
authenticate,
asyncHandler(async (req, res) => {
const review = await getAttemptReviewForUser(
pool,
req.user.id,
req.params.id,
req.params.aid
);
res.json(review);
})
);
router.get(
'/:id/attempts/:aid/play',
authenticate,
asyncHandler(async (req, res) => {
const out = await getPlayContent(pool, req.user.id, req.params.id, req.params.aid);
res.json(out);
})
);
router.post(
'/:id/attempts/:aid/submit',
authenticate,
asyncHandler(async (req, res) => {
const out = await submitAttempt(
pool,
req.user.id,
req.params.id,
req.params.aid,
req.body?.answers
);
res.json(out);
})
);
router.get(
'/:id/chain-info',
authenticate,
asyncHandler(async (req, res) => {
const testId = req.params.id;
const acc = await userHasTestAccess(req.user.id, testId);
if (acc.notFound) {
return res.status(404).json({ error: RU.testNotFound });
}
if (!acc.ok) {
return res.status(404).json({ error: RU.testNotFound });
}
const { rows: tr } = await query(
`SELECT t.is_active AS chain_active FROM tests t WHERE t.id = $1`,
[testId]
);
if (!tr.length) {
return res.status(404).json({ error: RU.testNotFound });
}
if (tr[0].chain_active === false) {
const { rows: auth } = await query(
`SELECT created_by FROM tests WHERE id = $1`,
[testId]
);
if (!isTestAuthor(auth[0].created_by, req.user.id)) {
return res.status(404).json({ error: RU.testNotFound });
}
}
const has = await hasAnyAttemptForTest(pool, testId);
res.json({ testId, hasAnyAttempt: has });
})

64
backend/src/services/testAccessService.js

@ -0,0 +1,64 @@
/**
* Кто видит тест: автор цепочки и пользователи с назначением (target user = clinic user id).
*/
import { isTestAuthor } from '../config/devAuthor.js';
import { query } from '../db/db.js';
/**
* @param {string} userId
* @param {string} testId
* @returns {Promise<{ ok: boolean, isAuthor: boolean, notFound: boolean }>}
*/
export async function userHasTestAccess(userId, testId) {
const { rows } = await query(
`SELECT t.created_by FROM tests t WHERE t.id = $1`,
[testId]
);
if (!rows.length) {
return { ok: false, isAuthor: false, notFound: true };
}
if (isTestAuthor(rows[0].created_by, userId)) {
return { ok: true, isAuthor: true, notFound: false };
}
const { rows: ar } = await query(
`SELECT 1
FROM test_assignments ta
INNER JOIN test_versions tv_a ON tv_a.id = ta.test_version_id
INNER JOIN test_assignment_targets tat ON tat.assignment_id = ta.id
WHERE tv_a.test_id = $1
AND tat.target_type = 'user'
AND tat.target_id = $2
LIMIT 1`,
[testId, userId]
);
return { ok: ar.length > 0, isAuthor: false, notFound: false };
}
/**
* Список тестов в каталоге: только `is_active` цепочка + (автор OR назначен).
*/
export async function queryTestsVisibleToUser(userId) {
return query(
`SELECT DISTINCT t.id, t.title, t.description, t.is_active AS chain_active,
t.created_at, t.updated_at, tv.id AS active_version_id, tv.version,
t.created_by, u.full_name AS author_full_name
FROM tests t
INNER JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true
INNER JOIN users u ON u.id = t.created_by
WHERE t.is_active = true
AND (
t.created_by = $1
OR EXISTS (
SELECT 1
FROM test_assignments ta
INNER JOIN test_versions tv2 ON tv2.id = ta.test_version_id
INNER JOIN test_assignment_targets tat ON tat.assignment_id = ta.id
WHERE tv2.test_id = t.id
AND tat.target_type = 'user'
AND tat.target_id = $1
)
)
ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC`,
[userId]
);
}

102
frontend/src/components/CabinetLayout.jsx

@ -0,0 +1,102 @@
import { useCallback, useEffect, useState } from 'react';
import { Link, Outlet, useNavigate } from 'react-router-dom';
import { api } from '../api';
import { formatSurnameWithInitials } from '../utils/formatUserName';
export default function CabinetLayout() {
const nav = useNavigate();
const [user, setUser] = useState(null);
/** Backend: `devUi` при NODE_ENV=development */
const [devUi, setDevUi] = useState(false);
/** Каталог HR + блок назначения (env CLINIC_ASSIGNMENT_ENABLED в prod) */
const [assignmentUi, setAssignmentUi] = useState(false);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const loadMe = useCallback(async () => {
setErr(null);
try {
const me = await api('/api/auth/me');
setUser(me.user);
setDevUi(!!me.devUi);
setAssignmentUi(!!me.assignmentUi);
} catch (e) {
if (e.status === 401) {
nav('/login', { replace: true });
return;
}
setErr(e.message);
} finally {
setLoading(false);
}
}, [nav]);
useEffect(() => {
loadMe();
}, [loadMe]);
async function logout() {
try {
await api('/api/auth/logout', { method: 'POST' });
} catch {
// ignore; still go to login
}
nav('/login');
}
if (loading) {
return (
<div className="cabinet-app cabinet-page--center">
<p className="text-muted">Загрузка</p>
</div>
);
}
if (err) {
return (
<div className="cabinet-app cabinet-page--center">
<p className="callout callout--error" role="alert">
{err}
</p>
</div>
);
}
return (
<div className="cabinet-app">
<header className="cabinet-header">
<div className="cabinet-header__inner">
<Link to="/tests" className="cabinet-brand">
<span
className="material-symbols-outlined cabinet-brand__icon"
aria-hidden
>
school
</span>
<div>
<div className="cabinet-brand__title">Система тестрования</div>
<div className="cabinet-brand__subtitle">Портал</div>
</div>
</Link>
<div className="cabinet-header__actions">
{user && (
<span
className="cabinet-user"
title={user.fullName ? `${user.fullName} · ${user.role}` : user.role}
>
{formatSurnameWithInitials(user.fullName) || user.login || '—'}
<span className="cabinet-user__role"> · {user.role}</span>
</span>
)}
<button type="button" className="btn btn-ghost" onClick={logout}>
Выйти
</button>
</div>
</div>
</header>
<main className="cabinet-main">
<Outlet context={{ user, devUi, assignmentUi, refreshUser: loadMe }} />
</main>
</div>
);
}

1216
frontend/src/pages/TestDetail.jsx

File diff suppressed because it is too large Load Diff

187
frontend/src/pages/TestsList.jsx

@ -1,20 +1,27 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Link, useNavigate, useOutletContext } from 'react-router-dom';
import { api } from '../api';
import { formatTestAuthorLabel } from '../utils/formatUserName';
export default function TestsList() {
const { user, devUi } = useOutletContext() || {};
const isEmployee = user?.role === 'employee';
const canCreate = devUi || !isEmployee;
const [tests, setTests] = useState([]);
const [user, setUser] = useState(null);
const [hiddenByYou, setHiddenByYou] = useState([]);
const [err, setErr] = useState(null);
const [title, setTitle] = useState('');
const [passBusy, setPassBusy] = useState(null);
const [passErr, setPassErr] = useState(null);
const nav = useNavigate();
async function load() {
setErr(null);
setPassErr(null);
try {
const me = await api('/api/auth/me');
setUser(me.user);
const t = await api('/api/tests');
setTests(t.tests || []);
setHiddenByYou(t.hiddenByYou || []);
} catch (e) {
if (e.status === 401) {
nav('/login');
@ -28,6 +35,22 @@ export default function TestsList() {
load();
}, [nav]);
async function startPass(testId) {
setPassErr(null);
setPassBusy(testId);
try {
const o = await api(`/api/tests/${testId}/attempts/start`, {
method: 'POST',
body: JSON.stringify({}),
});
nav(`/tests/${testId}/attempt/${o.attempt.id}`);
} catch (e) {
setPassErr(e.message);
} finally {
setPassBusy(null);
}
}
async function createTest(e) {
e.preventDefault();
if (!title.trim()) {
@ -45,56 +68,126 @@ export default function TestsList() {
}
}
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>}
<div>
<h1 className="font-headline" style={{ fontSize: '1.5rem' }}>
Тесты
</h1>
{err && (
<p className="error-text" role="alert">
{err}
</p>
)}
{passErr && (
<p className="error-text" role="alert">
{passErr}
</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>
{canCreate && (
<form onSubmit={createTest} className="create-row" aria-label="Создать тест">
<input
className="form-input"
placeholder="Новый тест — название"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<button type="submit" className="btn btn-ghost">
Создать
</button>
</form>
)}
<ul style={{ listStyle: 'none', padding: 0 }}>
<ul className="list-stack" aria-label="Тесты в общем списке">
{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 key={t.id} className="list-row list-row--split">
<div className="list-row__main">
<Link to={`/tests/${t.id}`} className="list-row__link">
<span className="list-row__title">{t.title}</span>
<span className="list-row__meta">
{formatTestAuthorLabel(user, t.created_by, t.author_full_name)}
<span className="list-row__meta-sep" aria-hidden>
{' '}
·{' '}
</span>
v{t.version} · активная {t.active_version_id?.slice(0, 8) ?? '—'}
</span>
</Link>
</div>
<div className="list-row__side">
<button
type="button"
className="btn btn-ghost"
disabled={passBusy != null}
onClick={() => startPass(t.id)}
>
{passBusy === t.id ? '…' : 'Пройти'}
</button>
</div>
</li>
))}
</ul>
{tests.length === 0 && <p>Нет тестов создайте первый.</p>}
{tests.length === 0 && hiddenByYou.length === 0 && (
<p className="text-muted">
{canCreate
? 'Пусто. В списке — только тесты, которые вы ведёте, и назначенные вам. Создайте цепочку или дождитесь назначения.'
: 'Пусто: вам пока ничего не назначено и нет цепочек, где вы автор.'}
</p>
)}
{tests.length === 0 && hiddenByYou.length > 0 && (
<p className="text-muted" style={{ marginBottom: '0.75rem' }}>
В общем списке пусто у вас есть скрытые тесты ниже; откройте карточку,
чтобы снова включить отображение.
</p>
)}
{hiddenByYou.length > 0 && (
<>
<h2
className="font-headline"
style={{ fontSize: '1.1rem', marginTop: '1.5rem', marginBottom: '0.5rem' }}
>
Скрытые вами из списка
</h2>
<p className="text-muted" style={{ marginTop: 0, marginBottom: '0.75rem' }}>
Эти цепочки не видны в блоке выше. Откройте карточку и внизу раздела
«Публикация» нажмите «Снова показать в списке».
</p>
<ul className="list-stack" aria-label="Скрытые тесты автора">
{hiddenByYou.map((t) => (
<li
key={t.id}
className="list-row list-row--split"
style={{ borderStyle: 'dashed', opacity: 0.95 }}
>
<div className="list-row__main">
<Link to={`/tests/${t.id}`} className="list-row__link">
<span className="list-row__title">{t.title}</span>
<span className="list-row__meta">
{formatTestAuthorLabel(user, t.created_by, t.author_full_name)}
<span className="list-row__meta-sep" aria-hidden>
{' '}
·{' '}
</span>
v{t.version} · скрыт · {t.active_version_id?.slice(0, 8) ?? '—'}
</span>
</Link>
</div>
<div className="list-row__side">
<button
type="button"
className="btn btn-ghost"
disabled={passBusy != null}
onClick={() => startPass(t.id)}
>
{passBusy === t.id ? '…' : 'Пройти'}
</button>
</div>
</li>
))}
</ul>
</>
)}
</div>
);
}

39
frontend/src/utils/formatUserName.js

@ -0,0 +1,39 @@
/**
* «Иванов Иван Иванович» «Иванов И. И.»; «Иванов Иван» «Иванов И.».
* @param {string | null | undefined} fullName
* @returns {string}
*/
export function formatSurnameWithInitials(fullName) {
if (fullName == null || typeof fullName !== 'string') {
return '';
}
const parts = fullName.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) {
return '';
}
if (parts.length === 1) {
return parts[0];
}
const [surname, first, ...rest] = parts;
const i1 = first.charAt(0).toLocaleUpperCase('ru-RU');
if (parts.length === 2) {
return `${surname} ${i1}.`;
}
const pat = rest.length ? rest[0] : '';
const i2 = pat ? pat.charAt(0).toLocaleUpperCase('ru-RU') : '';
return i2 ? `${surname} ${i1}. ${i2}.` : `${surname} ${i1}.`;
}
/**
* Подпись автора в списке и карточке.
* @param {{ id?: string } | null | undefined} user
* @param {string | null | undefined} createdBy
* @param {string | null | undefined} authorFullName
*/
export function formatTestAuthorLabel(user, createdBy, authorFullName) {
if (user?.id != null && createdBy != null && String(user.id) === String(createdBy)) {
return 'Автор: Вы';
}
const short = formatSurnameWithInitials(authorFullName);
return short ? `Автор: ${short}` : 'Автор: —';
}
Loading…
Cancel
Save