UI: фамилия с инициалами в шапке, подпись автора у тестов
- Хедер: отображать Фамилия И. О., полное ФИО в title. - Список тестов и карточка: «Автор: Вы» для своих, иначе «Автор: Фамилия И. О.». - API: в каталоге и summary/versions — created_by, author full_name (camelCase в JSON). Made-with: Cursor
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
+1130
-94
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}` : 'Автор: —';
|
||||
}
|
||||
Reference in New Issue
Block a user