docs: миграция на tgFlaskForm и производительность Flask; контур flask_app; UI без лишних описаний
Made-with: Cursor
This commit is contained in:
@@ -74,8 +74,7 @@ export default function CabinetLayout() {
|
||||
school
|
||||
</span>
|
||||
<div>
|
||||
<div className="cabinet-brand__title">Система тестрования</div>
|
||||
<div className="cabinet-brand__subtitle">Портал</div>
|
||||
<div className="cabinet-brand__title">Тестирование</div>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="cabinet-header__actions">
|
||||
|
||||
@@ -29,8 +29,7 @@ export default function Login() {
|
||||
<div className="login-logo__frame" aria-hidden>
|
||||
<span className="material-symbols-outlined">school</span>
|
||||
</div>
|
||||
<h1 className="font-headline">Система тестрования</h1>
|
||||
<p className="login-subtitle">Войдите в систему</p>
|
||||
<h1 className="font-headline">Тестирование</h1>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
@@ -70,10 +69,6 @@ export default function Login() {
|
||||
Войти
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-muted" style={{ marginTop: '1.25rem', marginBottom: 0 }}>
|
||||
Локальный пользователь в <code className="code-inline">clinic_tests</code> (если
|
||||
отключён вход через персонал HR).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -107,9 +107,7 @@ export default function TestAttempt() {
|
||||
{result.percent}%). Порог: {result.passingThreshold}%.
|
||||
</p>
|
||||
<p className={result.passed ? 'text-muted' : 'error-text'}>
|
||||
{result.passed
|
||||
? 'Тест пройден по порогу.'
|
||||
: 'Порог не достигнут — при необходимости начните новую попытку на карточке теста.'}
|
||||
{result.passed ? 'Зачёт.' : 'Незачёт.'}
|
||||
</p>
|
||||
{result.review && (
|
||||
<>
|
||||
@@ -121,17 +119,15 @@ export default function TestAttempt() {
|
||||
</h2>
|
||||
<AttemptReviewBlock review={result.review} showAttempter={false} />
|
||||
{result.attemptId && (
|
||||
<p className="text-muted" style={{ fontSize: 14, marginTop: '0.75rem' }}>
|
||||
<Link to={`/tests/${testId}/attempts/${result.attemptId}/review`}>
|
||||
Полная страница разбора
|
||||
</Link>
|
||||
<p style={{ fontSize: 14, marginTop: '0.75rem' }}>
|
||||
<Link to={`/tests/${testId}/attempts/${result.attemptId}/review`}>Разбор</Link>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="inline-actions" style={{ marginTop: '1rem' }}>
|
||||
<Link to={`/tests/${testId}`} className="btn btn-ghost">
|
||||
К настройкам теста
|
||||
К тесту
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,7 +152,7 @@ export default function TestAttempt() {
|
||||
{play.testTitle}
|
||||
</h1>
|
||||
<p className="text-muted" style={{ marginTop: 0 }}>
|
||||
Отметьте ответы и нажмите «Завершить». Порог для зачёта: {play.passingThreshold}%.
|
||||
Порог зачёта: {play.passingThreshold}%.
|
||||
</p>
|
||||
|
||||
<ol style={{ paddingLeft: '1.25rem', maxWidth: 640 }}>
|
||||
|
||||
@@ -91,7 +91,6 @@ export default function TestDetail() {
|
||||
const [assignClinic, setAssignClinic] = useState('all');
|
||||
const [assignPeople, setAssignPeople] = useState([]);
|
||||
const [assignDepts, setAssignDepts] = useState([]);
|
||||
const [assignSource, setAssignSource] = useState('');
|
||||
const [assignSelected, setAssignSelected] = useState(() => new Set());
|
||||
const [assignMsg, setAssignMsg] = useState('');
|
||||
const [assignErr, setAssignErr] = useState(null);
|
||||
@@ -198,7 +197,6 @@ export default function TestDetail() {
|
||||
}
|
||||
setAssignPeople(r.people || []);
|
||||
setAssignDepts(r.departments || []);
|
||||
setAssignSource(r.source || '');
|
||||
setAssignSelected(new Set());
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
@@ -261,8 +259,8 @@ export default function TestDetail() {
|
||||
});
|
||||
setAssignMsg(
|
||||
out.count != null
|
||||
? `Создано назначение на ${out.count} сотр. (dev).`
|
||||
: 'Назначение в БД создано (dev).'
|
||||
? `Назначено: ${out.count} сотр.`
|
||||
: 'Назначение сохранено.'
|
||||
);
|
||||
setAssignSelected(new Set());
|
||||
} catch (e) {
|
||||
@@ -295,11 +293,7 @@ export default function TestDetail() {
|
||||
questions,
|
||||
}),
|
||||
});
|
||||
setDraftStatus(
|
||||
out.forked
|
||||
? 'Создана новая версия (вилка) и применён черновик'
|
||||
: 'Черновик применён на месте'
|
||||
);
|
||||
setDraftStatus(out.forked ? 'Сохранено как новая версия' : 'Сохранено');
|
||||
load();
|
||||
} catch (e) {
|
||||
setDraftStatus(e.message);
|
||||
@@ -582,15 +576,6 @@ export default function TestDetail() {
|
||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: '0.4rem', fontSize: 14 }}>
|
||||
{formatTestAuthorLabel(user, t?.createdBy, t?.authorFullName)}
|
||||
</p>
|
||||
<p className="muted" style={{ marginTop: 0, marginBottom: '0.5rem' }}>
|
||||
Режим сотрудника: одна цепочка — одна активная версия (v{t?.version ?? '—'}
|
||||
{t?.activeVersionId && (
|
||||
<span className="code-inline" style={{ marginLeft: 6, fontSize: '0.8rem' }}>
|
||||
· {String(t.activeVersionId).slice(0, 8)}…
|
||||
</span>
|
||||
)}
|
||||
)
|
||||
</p>
|
||||
{t?.description && (
|
||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: '1rem' }}>
|
||||
{t.description}
|
||||
@@ -604,11 +589,6 @@ export default function TestDetail() {
|
||||
Активная версия недоступна. Обратитесь к автору теста.
|
||||
</div>
|
||||
)}
|
||||
<p className="text-muted" style={{ marginTop: '1.25rem' }}>
|
||||
Пройдите тест в{' '}
|
||||
<Link to="/tests">каталоге</Link> — в строке с названием слева откроется карточка, справа
|
||||
кнопка «Пройти».
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -645,13 +625,6 @@ export default function TestDetail() {
|
||||
{aiTestBusy ? 'Генерация…' : 'Сгенерировать тест (ИИ)'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: '0.75rem', fontSize: 13 }}>
|
||||
Заполняет все вопросы и варианты по <strong>текущей структуре</strong> (число карточек
|
||||
вопросов и вариантов в каждой — задайте кнопками «+ вопрос» / «+ вариант»). Верные ответы
|
||||
отмечает модель. Нужен ключ в backend:{' '}
|
||||
<code className="code-inline">DEEPSEEK_API_KEY</code> или{' '}
|
||||
<code className="code-inline">OPENAI_API_KEY</code>.
|
||||
</p>
|
||||
{test?.description && (
|
||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: '1rem' }}>
|
||||
{test.description}
|
||||
@@ -668,17 +641,13 @@ export default function TestDetail() {
|
||||
|
||||
{test?.chainActive === false && (
|
||||
<div className="callout callout--warning" role="status" style={{ marginTop: '0.75rem' }}>
|
||||
Эта цепочка <strong>не показывается</strong> в верхнем списке на «Тесты».
|
||||
Карточку всегда можно открыть из раздела <strong>«Скрытые вами из списка»</strong>{' '}
|
||||
на той же странице или по закладке с адресом карточки. Снова включить
|
||||
отображение — в блоке «Публикация»; данные не удаляются.
|
||||
Скрыт из общего списка.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chain?.hasAnyAttempt && (
|
||||
<div className="callout callout--warning" role="status" style={{ marginTop: '0.75rem' }}>
|
||||
Уже были попытки по этой цепочке. Сохранение черновика с вопросами создаст{' '}
|
||||
<strong>новую версию</strong> (см. V.1–V.3); старая останется в истории.
|
||||
При сохранении будет создана новая версия теста.
|
||||
</div>
|
||||
)}
|
||||
{data && attemptsErr && (
|
||||
@@ -689,10 +658,6 @@ export default function TestDetail() {
|
||||
</div>
|
||||
|
||||
<AccSection title="История версий" defaultOpen={false}>
|
||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: '0.75rem' }}>
|
||||
Активная — та, по которой сейчас идут новые попытки. Можно вручную сделать
|
||||
активной другую строку.
|
||||
</p>
|
||||
<div className="surface-card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<table className="table-cabinet">
|
||||
<thead>
|
||||
@@ -735,20 +700,10 @@ export default function TestDetail() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{hasAttempts && (
|
||||
<p className="text-muted" style={{ marginTop: '0.75rem', marginBottom: 0 }}>
|
||||
По цепочке есть зафиксированные прогоны — разбор идёт по той версии, с
|
||||
которой проходили.
|
||||
</p>
|
||||
)}
|
||||
</AccSection>
|
||||
|
||||
{attemptsList != null && attemptsList.length > 0 && (
|
||||
<AccSection title="Прогоны и разбор" defaultOpen={false}>
|
||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: '0.75rem' }}>
|
||||
Попытки по всем версиям цепочки. Подробный разбор вариантов — для завершённых
|
||||
прогонов.
|
||||
</p>
|
||||
{attemptsErr && (
|
||||
<p className="error-text" role="alert">
|
||||
{attemptsErr}
|
||||
@@ -813,10 +768,6 @@ export default function TestDetail() {
|
||||
)}
|
||||
|
||||
<AccSection title="Публикация (видимость в списке)" defaultOpen={false}>
|
||||
<p className="text-muted" style={{ marginTop: 0 }}>
|
||||
Деактивация убирает тест из общего списка; карточка открывается по прямой
|
||||
ссылке.
|
||||
</p>
|
||||
<div className="inline-actions" style={{ marginTop: '0.5rem' }}>
|
||||
{test?.chainActive !== false ? (
|
||||
<button
|
||||
@@ -841,12 +792,6 @@ export default function TestDetail() {
|
||||
</AccSection>
|
||||
|
||||
<AccSection title="Импорт из файла" defaultOpen={false}>
|
||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: '0.5rem' }}>
|
||||
PDF, DOCX, TXT/MD, до 10 МБ. Текст извлекается на сервере; при{' '}
|
||||
<code className="code-inline">DEEPSEEK_API_KEY</code> или{' '}
|
||||
<code className="code-inline">OPENAI_API_KEY</code> в backend строится черновик теста
|
||||
(см. <code className="code-inline">generation.draft</code>).
|
||||
</p>
|
||||
<div className="inline-actions" style={{ marginBottom: '0.5rem' }}>
|
||||
<input
|
||||
type="file"
|
||||
@@ -909,12 +854,6 @@ export default function TestDetail() {
|
||||
</AccSection>
|
||||
|
||||
<AccSection title="Содержание: название, порог, вопросы" defaultOpen>
|
||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: '0.75rem' }}>
|
||||
Редактируется активная версия. Сохранение отправляет вопросы на сервер; при уже
|
||||
существующих попытках по цепочке создаётся новая версия с копией контента.
|
||||
Порог зачёта — в таблице <code className="code-inline">tests</code>, на все
|
||||
версии цепочки.
|
||||
</p>
|
||||
<div className="draft-block">
|
||||
<label className="form-label" htmlFor="draft-title">
|
||||
Название
|
||||
@@ -938,9 +877,6 @@ export default function TestDetail() {
|
||||
<label className="form-label" htmlFor="draft-pass" style={{ marginTop: '0.75rem' }}>
|
||||
Порог зачёта, %
|
||||
</label>
|
||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: 6, fontSize: 13 }}>
|
||||
Минимум правильных ответов (в процентах) для «зачёта»; по умолчанию в БД 70.
|
||||
</p>
|
||||
<input
|
||||
id="draft-pass"
|
||||
type="number"
|
||||
@@ -977,10 +913,6 @@ export default function TestDetail() {
|
||||
{aiQBusy === qi ? '…' : 'Сгенерировать вопрос (ИИ)'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: 6, fontSize: 12 }}>
|
||||
Пустое поле — вопрос и все варианты по числу кнопок; непустое — сформулировать
|
||||
нормальным языком (1–3 предложения).
|
||||
</p>
|
||||
<textarea
|
||||
id={`qtext-${q.key}`}
|
||||
className="form-input"
|
||||
@@ -1113,11 +1045,6 @@ export default function TestDetail() {
|
||||
|
||||
{assignmentUi && data && (
|
||||
<AccSection title="Назначение сотрудникам" defaultOpen={false}>
|
||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: '0.75rem' }}>
|
||||
Сотрудники из HR; при отсутствии учётки в модуле она создаётся по{' '}
|
||||
<code className="code-inline">staff_id</code>. Источник данных:{' '}
|
||||
<strong>{assignSource === 'hr' ? 'HR (все, с отделами)' : 'только clinic_tests'}</strong>.
|
||||
</p>
|
||||
{assignErr && (
|
||||
<p className="error-text" role="alert">
|
||||
{assignErr}
|
||||
|
||||
@@ -110,7 +110,7 @@ export default function TestsList() {
|
||||
{' '}
|
||||
·{' '}
|
||||
</span>
|
||||
v{t.version} · активная {t.active_version_id?.slice(0, 8) ?? '—'}…
|
||||
v{t.version}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -128,17 +128,7 @@ export default function TestsList() {
|
||||
))}
|
||||
</ul>
|
||||
{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>
|
||||
<p className="text-muted">Нет тестов</p>
|
||||
)}
|
||||
|
||||
{hiddenByYou.length > 0 && (
|
||||
@@ -149,10 +139,6 @@ export default function TestsList() {
|
||||
>
|
||||
Скрытые вами из списка
|
||||
</h2>
|
||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: '0.75rem' }}>
|
||||
Эти цепочки не видны в блоке выше. Откройте карточку и внизу раздела
|
||||
«Публикация» нажмите «Снова показать в списке».
|
||||
</p>
|
||||
<ul className="list-stack" aria-label="Скрытые тесты автора">
|
||||
{hiddenByYou.map((t) => (
|
||||
<li
|
||||
@@ -169,7 +155,7 @@ export default function TestsList() {
|
||||
{' '}
|
||||
·{' '}
|
||||
</span>
|
||||
v{t.version} · скрыт · {t.active_version_id?.slice(0, 8) ?? '—'}…
|
||||
v{t.version} · скрыт
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user