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>
);
}