docs: миграция на tgFlaskForm и производительность Flask; контур flask_app; UI без лишних описаний

Made-with: Cursor
This commit is contained in:
Константин Лебединский
2026-04-27 18:43:51 +05:00
parent 47279c72e3
commit b3e3757a92
20 changed files with 680 additions and 115 deletions
+1 -2
View File
@@ -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">
+1 -6
View File
@@ -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>
+5 -9
View File
@@ -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 }}>
+5 -78
View File
@@ -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.1V.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 }}>
Пустое поле вопрос и все варианты по числу кнопок; непустое сформулировать
нормальным языком (13 предложения).
</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}
+3 -17
View File
@@ -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>