lct-hack/frontend/src/pages/instructor/Instructor.tsx

245 lines
11 KiB
TypeScript
Raw Normal View History

// Пульт преподавателя.
//
// Компоновка от того, по чему его оценит действующий преподаватель: задать
// сценарий и не вмешиваться, видеть работу курсанта вживую, получить готовый
// разбор, показать группе (docs/arch/FRONTEND.md).
import { useEffect, useRef, useState } from "react";
import { Debrief } from "@/features/debrief/Debrief";
lct-22: пульт директив — преподаватель ведёт ситуацию Мягкие директивы перекрывают дугу сценария и применяются со следующей реплики: разговор не дёргается от того, что преподаватель что-то нажал. Жёсткие правят ситуацию — обрыв связи рвёт звук тем же механизмом, что перебивание, и запускает норматив обратного дозвона; второй пострадавший правит эталон; неточный адрес снимает раскрытый факт, и оператор обязан переспросить. Своя копия сценария на занятие: директивы правят факты и эталон, а сценарий был общим на библиотеку — правка в одной группе протекла бы во все остальные. Тест проверяет, что библиотека не изменилась. Свободный текст честно отказывает: офлайн-дерево предгенерировано, произвольную фразу взять неоткуда, и преподаватель видит это на пульте. Ни одна директива не трогает карточку курсанта — он управляет ситуацией, а не работой обучаемого. Занятие теперь собирается целиком и только потом регистрируется: наблюдатель мог увидеть его без слот-автомата и звонящего.
2026-09-17 21:38:43 +03:00
import { Director } from "@/features/instructor/Director";
import { ScenarioEditor } from "@/features/instructor/ScenarioEditor";
import { useControl } from "@/features/instructor/useControl";
import { KioCard } from "@/features/kio-card/KioCard";
lct-21: make demo, честный README, готовность стенда make demo поднимает стенд целиком: проверяет модели, базу, миграции, сценарии, бэкенд с голосом в офлайн-режиме и фронт. Обкатан заранее, как и требует карточка, — и сразу поймал ошибку: `cd backend && команда &` уводит в фон всю связку, родительская оболочка остаётся на месте, и фронт запускался не из репозитория. Пять диалогов подряд через make demo: без сбоев, ход 965–1210 мс, первая реплика 78–115 мс. make dev проверен целиком — образ фронта собирается, страница отдаётся. README переписан под факт: два шага до стенда и таблица того, что работает, вместо обещаний. Экран готовности показывает курсанту, почему звонящий молчит, вместо тишины без объяснений. В Makefile записано, что make test и make demo нельзя запускать одновременно: они просят у uv разные группы зависимостей, uv пересобирает одно окружение, и второй запуск молча ждёт блокировку.
2026-09-17 22:24:48 +03:00
import { StandStatus } from "@/features/health/StandStatus";
import { ModeBanner } from "@/features/mode-banner/ModeBanner";
import { useObserve } from "@/features/observe/useObserve";
import { InterviewTimer, Timers } from "@/features/timers/Timers";
import { useScenarios, useSessions, useTrainees } from "@/shared/api/http";
import { sessionIdFromUrl } from "@/shared/api/session";
import type { SessionMode } from "@/shared/types/generated";
const MODES: { value: SessionMode; label: string }[] = [
{ value: "training", label: "тренировочный" },
{ value: "exam", label: "контрольный" },
{ value: "self", label: "самостоятельный" },
];
export function Instructor() {
const [sessionId, setSessionId] = useState<string | null>(sessionIdFromUrl());
const [scenarioId, setScenarioId] = useState("");
const [mode, setMode] = useState<SessionMode>("training");
const [exercise, setExercise] = useState<"dds" | "call" | "card">("dds");
const [trainee, setTrainee] = useState("");
const [traineeId, setTraineeId] = useState("");
const [noteFor, setNoteFor] = useState<string | null>(null);
// Запускается только занятие, созданное этой кнопкой. Открытая из истории
// ссылка на идущее занятие не должна запускать его заново и сбрасывать.
const toStart = useRef<string | null>(null);
const [noteText, setNoteText] = useState("");
const scenarios = useScenarios();
const history = useSessions();
const trainees = useTrainees();
const session = useObserve(sessionId);
const control = useControl(sessionId);
useEffect(() => {
if (scenarios.data?.length && !scenarioId) setScenarioId(scenarios.data[0].id);
}, [scenarios.data, scenarioId]);
function startLesson() {
if (trainees.data?.length && !traineeId) return;
const id = crypto.randomUUID();
// Номер занятия живёт в адресе: монитор и АРМ курсанта открываются ссылкой.
window.history.replaceState(null, "", `/instructor?session=${id}`);
toStart.current = id;
setSessionId(id);
}
// Занятие запускается, когда канал управления открылся.
useEffect(() => {
if (!sessionId || control.status !== "open" || toStart.current !== sessionId) return;
toStart.current = null;
const selected = trainees.data?.find((item) => item.id === traineeId);
control.start(scenarioId, mode, selected?.name || trainee || "Демо-курсант", exercise, undefined, selected?.id);
}, [sessionId, control, scenarioId, mode, trainee, traineeId, trainees.data, exercise]);
const links = sessionId
? {
trainee: `${location.origin}/trainee?session=${sessionId}`,
wall: `${location.origin}/wall?session=${sessionId}`,
dds: `${location.origin}/dds?session=${sessionId}&role=dds_01`,
}
: null;
return (
<main className="page">
lct-21: make demo, честный README, готовность стенда make demo поднимает стенд целиком: проверяет модели, базу, миграции, сценарии, бэкенд с голосом в офлайн-режиме и фронт. Обкатан заранее, как и требует карточка, — и сразу поймал ошибку: `cd backend && команда &` уводит в фон всю связку, родительская оболочка остаётся на месте, и фронт запускался не из репозитория. Пять диалогов подряд через make demo: без сбоев, ход 965–1210 мс, первая реплика 78–115 мс. make dev проверен целиком — образ фронта собирается, страница отдаётся. README переписан под факт: два шага до стенда и таблица того, что работает, вместо обещаний. Экран готовности показывает курсанту, почему звонящий молчит, вместо тишины без объяснений. В Makefile записано, что make test и make demo нельзя запускать одновременно: они просят у uv разные группы зависимостей, uv пересобирает одно окружение, и второй запуск молча ждёт блокировку.
2026-09-17 22:24:48 +03:00
<StandStatus />
<ModeBanner mode={session.mode ?? mode} />
<h1>Рабочее место преподавателя</h1>
<div className="columns">
<section style={{ flex: "0 0 280px" }}>
<h2>Занятие</h2>
<table className="grid">
<tbody>
<tr>
<th>Сценарий</th>
<td>
<select value={scenarioId} onChange={(event) => setScenarioId(event.target.value)}>
{scenarios.data?.map((scenario) => (
<option key={scenario.id} value={scenario.id}>
{scenario.title} · {scenario.level}
</option>
))}
</select>
</td>
</tr>
<tr>
<th>Упражнение</th>
<td>
<label className="mode-choice"><input type="radio" checked={exercise === "dds"}
onChange={() => setExercise("dds")} /> Диспетчер ДДС · готовая карточка</label>
<label className="mode-choice"><input type="radio" checked={exercise === "call"}
onChange={() => setExercise("call")} /> Оператор 112 · звонок</label>
<label className="mode-choice"><input type="radio" checked={exercise === "card"}
onChange={() => setExercise("card")} /> Оператор 112 · текстовая вводная</label>
</td>
</tr>
<tr>
<th>Режим</th>
<td>
{MODES.map((item) => (
<label key={item.value} className="mode-choice">
<input
type="radio"
checked={mode === item.value}
onChange={() => setMode(item.value)}
/>{" "}
{item.label}
</label>
))}
</td>
</tr>
<tr>
<th>Курсант</th>
<td>
{trainees.data?.length ? (
<select value={traineeId} onChange={(event) => setTraineeId(event.target.value)}>
<option value="">Выберите учётную запись</option>
{trainees.data.map((item) => (
<option key={item.id} value={item.id}>{item.name}{item.group ? ` · ${item.group}` : ""}</option>
))}
</select>
) : (
<>
<input type="text" value={trainee} placeholder="имя для демо"
onChange={(event) => setTrainee(event.target.value)} />
<small className="ref">Без учётной записи: личный отчёт курсанту недоступен.</small>
</>
)}
</td>
</tr>
</tbody>
</table>
<p>
<button type="button" className="start" onClick={startLesson}
disabled={!scenarioId || trainees.isLoading || Boolean(trainees.data?.length && !traineeId)}>
Начать занятие
</button>
</p>
{sessionId && (
<p>
{session.ended ? "занятие завершено" : (
<button type="button" onClick={control.stop}>Завершить занятие</button>
)}
</p>
)}
lct-22: пульт директив — преподаватель ведёт ситуацию Мягкие директивы перекрывают дугу сценария и применяются со следующей реплики: разговор не дёргается от того, что преподаватель что-то нажал. Жёсткие правят ситуацию — обрыв связи рвёт звук тем же механизмом, что перебивание, и запускает норматив обратного дозвона; второй пострадавший правит эталон; неточный адрес снимает раскрытый факт, и оператор обязан переспросить. Своя копия сценария на занятие: директивы правят факты и эталон, а сценарий был общим на библиотеку — правка в одной группе протекла бы во все остальные. Тест проверяет, что библиотека не изменилась. Свободный текст честно отказывает: офлайн-дерево предгенерировано, произвольную фразу взять неоткуда, и преподаватель видит это на пульте. Ни одна директива не трогает карточку курсанта — он управляет ситуацией, а не работой обучаемого. Занятие теперь собирается целиком и только потом регистрируется: наблюдатель мог увидеть его без слот-автомата и звонящего.
2026-09-17 21:38:43 +03:00
{sessionId && !session.ended && (
exercise === "call" && <Director onInject={control.inject} disabled={control.status !== "open"} error={session.error} />
lct-22: пульт директив — преподаватель ведёт ситуацию Мягкие директивы перекрывают дугу сценария и применяются со следующей реплики: разговор не дёргается от того, что преподаватель что-то нажал. Жёсткие правят ситуацию — обрыв связи рвёт звук тем же механизмом, что перебивание, и запускает норматив обратного дозвона; второй пострадавший правит эталон; неточный адрес снимает раскрытый факт, и оператор обязан переспросить. Своя копия сценария на занятие: директивы правят факты и эталон, а сценарий был общим на библиотеку — правка в одной группе протекла бы во все остальные. Тест проверяет, что библиотека не изменилась. Свободный текст честно отказывает: офлайн-дерево предгенерировано, произвольную фразу взять неоткуда, и преподаватель видит это на пульте. Ни одна директива не трогает карточку курсанта — он управляет ситуацией, а не работой обучаемого. Занятие теперь собирается целиком и только потом регистрируется: наблюдатель мог увидеть его без слот-автомата и звонящего.
2026-09-17 21:38:43 +03:00
)}
{links && (
<table className="grid">
<tbody>
<tr><th>АРМ курсанта</th><td><a href={exercise === "dds" ? links.dds : links.trainee}>открыть {exercise === "dds" ? "ДДС" : "112"}</a></td></tr>
<tr><th>Внешний монитор</th><td><a href={links.wall}>открыть</a></td></tr>
<tr><th>АРМ ДДС</th><td><a href={links.dds}>открыть</a></td></tr>
</tbody>
</table>
)}
</section>
<section>
<h2>Ход занятия</h2>
{!sessionId && <p>Занятие не запущено.</p>}
{sessionId && (
<>
<InterviewTimer timers={session.timers} />
<p className="ref">
канал: {session.status} · подсказок использовано: {session.hintsUsed}
</p>
<h3>Транскрипт — клик по реплике добавляет пометку</h3>
<table className="grid">
<tbody>
{session.transcript.map((line) => (
<tr key={line.ref} onClick={() => setNoteFor(line.ref)} className="clickable">
<th>{line.speaker === "caller" ? "звонящий" : "оператор"}</th>
<td>
{line.text}
{session.notes.has(line.ref) && (
<div className="ref">пометка: {session.notes.get(line.ref)}</div>
)}
{noteFor === line.ref && (
<div>
<input type="text" value={noteText} autoFocus
placeholder="здесь надо было предупредить о прибытии"
onChange={(event) => setNoteText(event.target.value)} />{" "}
<button type="button" onClick={() => {
if (noteText.trim()) control.note(line.ref, noteText.trim());
setNoteText("");
setNoteFor(null);
}}>Сохранить</button>
</div>
)}
</td>
</tr>
))}
{session.transcript.length === 0 && <tr><td colSpan={2}>—</td></tr>}
</tbody>
</table>
<h3>Нормативы</h3>
<Timers timers={session.timers} />
<h3>Карточка курсанта</h3>
{/* Только чтение: преподаватель управляет ситуацией, а не работой обучаемого. */}
<KioCard state={session.card} required={session.requiredFields} readOnly />
</>
)}
</section>
<section style={{ flex: "0 0 260px" }}>
<h2>История группы</h2>
<p><a href="/profile">профили курсантов</a></p>
<table className="grid">
<tbody>
{history.data?.slice(0, 15).map((item) => (
<tr key={item.session_id}>
<th>{item.mode} · попытка {item.attempt}</th>
<td><a href={`/instructor?session=${item.session_id}`}>{item.scenario_id}</a></td>
</tr>
))}
{!history.data?.length && <tr><td colSpan={2}>занятий ещё не было</td></tr>}
</tbody>
</table>
</section>
</div>
<ScenarioEditor scenarios={scenarios.data ?? []} onApproved={async (id) => {
setScenarioId(id);
await scenarios.refetch();
}} />
{session.report && <Debrief report={session.report} />}
</main>
);
}