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

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

Made-with: Cursor
This commit is contained in:
Константин Лебединский
2026-04-24 22:08:45 +05:00
parent 89da5b60b7
commit 4801ea9f19
6 changed files with 1898 additions and 173 deletions
+102
View File
@@ -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>
);
}
File diff suppressed because it is too large Load Diff
+140 -47
View File
@@ -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}>
Выйти
<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>
)}
{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>
</div>
</div>
{err && <p style={{ color: 'coral' }}>{err}</p>}
</form>
)}
<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 }}>
<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
View File
@@ -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}` : 'Автор: —';
}