From 978d7dc1aa79a266f5644f2a249b7962366e9007 Mon Sep 17 00:00:00 2001 From: Ivan Gerasimov Date: Thu, 17 Sep 2026 21:34:01 +0300 Subject: [PATCH] =?UTF-8?q?lct-17:=20=D0=BF=D1=83=D0=BB=D1=8C=D1=82=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B5=D0=BF=D0=BE=D0=B4=D0=B0=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D1=8F=20=E2=80=94=20=D1=81=D1=86=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=D1=80=D0=B8=D0=B9,=20=D0=BD=D0=B0=D0=B1=D0=BB=D1=8E=D0=B4?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5,=20=D0=BF=D0=BE=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B8,=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Компоновка от того, по чему пульт оценит действующий преподаватель: задать сценарий и не вмешиваться, видеть работу курсанта вживую, получить готовый разбор, показать группе. Кнопка запуска одна и крупная, ссылки на АРМ курсанта и монитор печатаются рядом. Разделение прав архитектурное и здесь: пульт смотрит через observe, пишет через control, карточка курсанта у него только для чтения. Две ловушки запуска: scenario.start уходил из эффекта и мог повториться при перерисовке, а открытая из истории ссылка на идущее занятие запустила бы его заново и сбросила. Запускается только занятие, созданное кнопкой. --- frontend/src/app/router.tsx | 3 +- .../src/features/instructor/useControl.ts | 49 +++++ frontend/src/pages/instructor/Instructor.tsx | 201 ++++++++++++++++++ frontend/src/shared/api/http.ts | 11 + frontend/src/styles.css | 6 + 5 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 frontend/src/features/instructor/useControl.ts create mode 100644 frontend/src/pages/instructor/Instructor.tsx diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 261fd3e..8ee9e59 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -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: }, { path: "/trainee", element: }, - { path: "/instructor", element: }, + { path: "/instructor", element: }, { path: "/wall", element: }, { path: "/dds", element: }, ]); diff --git a/frontend/src/features/instructor/useControl.ts b/frontend/src/features/instructor/useControl.ts new file mode 100644 index 0000000..10ac6ba --- /dev/null +++ b/frontend/src/features/instructor/useControl.ts @@ -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("closed"); + const channel = useRef | 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 }; +} diff --git a/frontend/src/pages/instructor/Instructor.tsx b/frontend/src/pages/instructor/Instructor.tsx new file mode 100644 index 0000000..67ae728 --- /dev/null +++ b/frontend/src/pages/instructor/Instructor.tsx @@ -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(sessionIdFromUrl()); + const [scenarioId, setScenarioId] = useState(""); + const [mode, setMode] = useState("training"); + const [trainee, setTrainee] = useState(""); + const [noteFor, setNoteFor] = useState(null); + // Запускается только занятие, созданное этой кнопкой. Открытая из истории + // ссылка на идущее занятие не должна запускать его заново и сбрасывать. + const toStart = useRef(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 ( +
+ +

Рабочее место преподавателя

+ +
+
+

Занятие

+ + + + + + + + + + + + + + + +
Сценарий + +
Режим + {MODES.map((item) => ( + + ))} +
Курсант + setTrainee(event.target.value)} /> +
+

+ +

+ {sessionId && ( +

+ {session.ended ? "занятие завершено" : ( + + )} +

+ )} + {links && ( + + + + + +
АРМ курсантаоткрыть
Внешний монитороткрыть
+ )} +
+ +
+

Ход занятия

+ {!sessionId &&

Занятие не запущено.

} + {sessionId && ( + <> + +

+ канал: {session.status} · подсказок использовано: {session.hintsUsed} +

+

Транскрипт — клик по реплике добавляет пометку

+ + + {session.transcript.map((line) => ( + setNoteFor(line.ref)} className="clickable"> + + + + ))} + {session.transcript.length === 0 && } + +
{line.speaker === "caller" ? "звонящий" : "оператор"} + {line.text} + {session.notes.has(line.ref) && ( +
пометка: {session.notes.get(line.ref)}
+ )} + {noteFor === line.ref && ( +
+ setNoteText(event.target.value)} />{" "} + +
+ )} +
—
+

Нормативы

+ +

Карточка курсанта

+ {/* Только чтение: преподаватель управляет ситуацией, а не работой обучаемого. */} + + + )} +
+ +
+

История группы

+ + + {history.data?.slice(0, 15).map((item) => ( + + + + + ))} + {!history.data?.length && } + +
{item.mode} · попытка {item.attempt}{item.scenario_id}
занятий ещё не было
+
+
+ + {session.report && } +
+ ); +} diff --git a/frontend/src/shared/api/http.ts b/frontend/src/shared/api/http.ts index dc43342..ad00fdd 100644 --- a/frontend/src/shared/api/http.ts +++ b/frontend/src/shared/api/http.ts @@ -59,6 +59,17 @@ export const useHealth = () => export const useScenarios = () => useQuery({ queryKey: ["scenarios"], queryFn: () => request("/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(`/api/sessions${query ? `?${query}` : ""}`), + refetchInterval: 15_000, + }); +}; + export const useCreateSession = () => useMutation({ mutationFn: (body: { scenario_id: string; mode: SessionMode; trainee?: string; group?: string }) => diff --git a/frontend/src/styles.css b/frontend/src/styles.css index e79c876..d4b2552 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; }