lct-17: пульт преподавателя — сценарий, наблюдение, пометки, ссылки
Компоновка от того, по чему пульт оценит действующий преподаватель: задать сценарий и не вмешиваться, видеть работу курсанта вживую, получить готовый разбор, показать группе. Кнопка запуска одна и крупная, ссылки на АРМ курсанта и монитор печатаются рядом. Разделение прав архитектурное и здесь: пульт смотрит через observe, пишет через control, карточка курсанта у него только для чтения. Две ловушки запуска: scenario.start уходил из эффекта и мог повториться при перерисовке, а открытая из истории ссылка на идущее занятие запустила бы его заново и сбросила. Запускается только занятие, созданное кнопкой.
This commit is contained in:
parent
fadfa3e479
commit
978d7dc1aa
5 changed files with 269 additions and 1 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import { createBrowserRouter, Navigate } from "react-router-dom";
|
||||
|
||||
import { Call } from "@/pages/trainee/Call";
|
||||
import { Instructor } from "@/pages/instructor/Instructor";
|
||||
import { Wall } from "@/pages/wall/Wall";
|
||||
import { Stub } from "@/shared/ui/Stub";
|
||||
|
||||
|
|
@ -9,7 +10,7 @@ import { Stub } from "@/shared/ui/Stub";
|
|||
export const router = createBrowserRouter([
|
||||
{ path: "/", element: <Navigate to="/trainee" replace /> },
|
||||
{ path: "/trainee", element: <Call /> },
|
||||
{ path: "/instructor", element: <Stub title="Пульт преподавателя" card="lct-17" /> },
|
||||
{ path: "/instructor", element: <Instructor /> },
|
||||
{ path: "/wall", element: <Wall /> },
|
||||
{ path: "/dds", element: <Stub title="АРМ диспетчера ДДС" card="lct-20" /> },
|
||||
]);
|
||||
|
|
|
|||
49
frontend/src/features/instructor/useControl.ts
Normal file
49
frontend/src/features/instructor/useControl.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// Канал преподавателя: только передача.
|
||||
//
|
||||
// Преподаватель смотрит через `observe`, а пишет через `control`, и это разные
|
||||
// сокеты. В `control` нет ни одной команды, меняющей карточку курсанта: он
|
||||
// управляет ситуацией, а не работой обучаемого, иначе оценка перестаёт быть
|
||||
// оценкой курсанта (docs/arch/CONTRACT.md).
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { controlChannel, type ChannelStatus } from "@/shared/api/ws";
|
||||
import type { SessionMode } from "@/shared/types/generated";
|
||||
|
||||
export function useControl(sessionId: string | null) {
|
||||
const [status, setStatus] = useState<ChannelStatus>("closed");
|
||||
const channel = useRef<ReturnType<typeof controlChannel> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
const ch = controlChannel(sessionId, { onStatus: setStatus }).connect();
|
||||
channel.current = ch;
|
||||
return () => {
|
||||
ch.close();
|
||||
channel.current = null;
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
const start = useCallback(
|
||||
(scenarioId: string, mode: SessionMode, trainee: string, group?: string) =>
|
||||
channel.current?.send({
|
||||
type: "scenario.start",
|
||||
scenario_id: scenarioId,
|
||||
mode,
|
||||
trainee,
|
||||
group_id: group || null,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const note = useCallback(
|
||||
(transcriptRef: string, text: string) =>
|
||||
channel.current?.send({ type: "instructor_note.add", transcript_ref: transcriptRef, text }),
|
||||
[],
|
||||
);
|
||||
|
||||
const stop = useCallback(() => channel.current?.send({ type: "session.stop" }), []);
|
||||
const playReference = useCallback(() => channel.current?.send({ type: "reference.play" }), []);
|
||||
|
||||
return { status, start, note, stop, playReference };
|
||||
}
|
||||
201
frontend/src/pages/instructor/Instructor.tsx
Normal file
201
frontend/src/pages/instructor/Instructor.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
// Пульт преподавателя.
|
||||
//
|
||||
// Компоновка от того, по чему его оценит действующий преподаватель: задать
|
||||
// сценарий и не вмешиваться, видеть работу курсанта вживую, получить готовый
|
||||
// разбор, показать группе (docs/arch/FRONTEND.md).
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Debrief } from "@/features/debrief/Debrief";
|
||||
import { useControl } from "@/features/instructor/useControl";
|
||||
import { KioCard } from "@/features/kio-card/KioCard";
|
||||
import { ModeBanner } from "@/features/mode-banner/ModeBanner";
|
||||
import { useObserve } from "@/features/observe/useObserve";
|
||||
import { InterviewTimer, Timers } from "@/features/timers/Timers";
|
||||
import { useScenarios, useSessions } 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 [trainee, setTrainee] = 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 session = useObserve(sessionId);
|
||||
const control = useControl(sessionId);
|
||||
|
||||
useEffect(() => {
|
||||
if (scenarios.data?.length && !scenarioId) setScenarioId(scenarios.data[0].id);
|
||||
}, [scenarios.data, scenarioId]);
|
||||
|
||||
function startLesson() {
|
||||
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;
|
||||
control.start(scenarioId, mode, trainee || "Курсант");
|
||||
}, [sessionId, control, scenarioId, mode, trainee]);
|
||||
|
||||
const links = sessionId
|
||||
? {
|
||||
trainee: `${location.origin}/trainee?session=${sessionId}`,
|
||||
wall: `${location.origin}/wall?session=${sessionId}`,
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<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>
|
||||
{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>
|
||||
<input type="text" value={trainee} placeholder="фамилия"
|
||||
onChange={(event) => setTrainee(event.target.value)} />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>
|
||||
<button type="button" className="start" onClick={startLesson} disabled={!scenarioId}>
|
||||
Начать занятие
|
||||
</button>
|
||||
</p>
|
||||
{sessionId && (
|
||||
<p>
|
||||
{session.ended ? "занятие завершено" : (
|
||||
<button type="button" onClick={control.stop}>Завершить занятие</button>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{links && (
|
||||
<table className="grid">
|
||||
<tbody>
|
||||
<tr><th>АРМ курсанта</th><td><a href={links.trainee}>открыть</a></td></tr>
|
||||
<tr><th>Внешний монитор</th><td><a href={links.wall}>открыть</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>
|
||||
<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>
|
||||
|
||||
{session.report && <Debrief report={session.report} />}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -59,6 +59,17 @@ export const useHealth = () =>
|
|||
export const useScenarios = () =>
|
||||
useQuery({ queryKey: ["scenarios"], queryFn: () => request<ScenarioSummary[]>("/api/scenarios") });
|
||||
|
||||
export const useSessions = (params: { trainee?: string; group?: string; mode?: SessionMode } = {}) => {
|
||||
const query = new URLSearchParams(
|
||||
Object.entries(params).filter(([, value]) => value) as [string, string][],
|
||||
).toString();
|
||||
return useQuery({
|
||||
queryKey: ["sessions", query],
|
||||
queryFn: () => request<SessionInfo[]>(`/api/sessions${query ? `?${query}` : ""}`),
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateSession = () =>
|
||||
useMutation({
|
||||
mutationFn: (body: { scenario_id: string; mode: SessionMode; trainee?: string; group?: string }) =>
|
||||
|
|
|
|||
|
|
@ -82,3 +82,9 @@ button:disabled { opacity: 0.5; cursor: default; }
|
|||
.wall h2 { font-size: 24px; }
|
||||
.wall .grid th { width: 160px; }
|
||||
.debrief-big { font-size: 20px; }
|
||||
|
||||
/* Пульт: кнопка запуска одна и крупная. */
|
||||
.start { font-size: 16px; padding: 10px 24px; font-weight: 600; }
|
||||
.mode-choice { display: block; }
|
||||
.clickable { cursor: pointer; }
|
||||
.clickable:hover th { background: #e8ecf1; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue