lct-39: пауза занятия с пульта, таймеры и очередь ДДС стоят на время простоя

This commit is contained in:
kaifarikman 2026-09-27 16:35:13 +03:00
commit b77d65c82e
23 changed files with 410 additions and 20 deletions

View file

@ -50,6 +50,7 @@ export function useCall(sessionId: string | null) {
const [scoreReady, setScoreReady] = useState(false);
const { report, error: reportError } = useSessionReport(sessionId, scoreReady);
const [error, setError] = useState<string | null>(null);
const [paused, setPaused] = useState(false);
const channel = useRef<ReturnType<typeof callChannel> | null>(null);
const capture = useRef<Capture | null>(null);
@ -153,6 +154,9 @@ export function useCall(sessionId: string | null) {
case "score.ready":
setScoreReady(true);
break;
case "session.paused":
setPaused(event.paused);
break;
case "error":
setError(event.message);
break;
@ -253,7 +257,7 @@ export function useCall(sessionId: string | null) {
return {
resolve,
status, phase, incoming, briefing, cardSubmitted, lines, timers, callerSpeaking, micOn, error, card,
status, phase, incoming, briefing, cardSubmitted, lines, timers, callerSpeaking, micOn, error, paused, card,
checklist, selfAssessed, scoreReady, report, reportError,
answer, hangup, hint, patchKio, dispatch, submitCard, submitSelfAssessment, sendText,
};

View file

@ -63,7 +63,9 @@ export function useControl(sessionId: string | null) {
);
const stop = useCallback(() => channel.current?.socket.send({ type: "session.stop" }), []);
const pause = useCallback(() => channel.current?.socket.send({ type: "session.pause" }), []);
const resume = useCallback(() => channel.current?.socket.send({ type: "session.resume" }), []);
const playReference = useCallback(() => channel.current?.socket.send({ type: "reference.play" }), []);
return { status, openSessionId, start, note, inject, stop, playReference };
return { status, openSessionId, start, note, inject, stop, pause, resume, playReference };
}

View file

@ -29,6 +29,7 @@ export interface ObservedSession {
hintsUsed: number;
notes: Map<string, string>;
ended: boolean;
paused: boolean;
report: SessionReport | null;
error: string | null;
}
@ -45,6 +46,7 @@ export function useObserve(sessionId: string | null): ObservedSession {
const [hintsUsed, setHintsUsed] = useState(0);
const [notes, setNotes] = useState<Map<string, string>>(new Map());
const [ended, setEnded] = useState(false);
const [paused, setPaused] = useState(false);
// Сессия, для которой отчёт уже стоит просить. Привязка к id, а не флаг:
// при переключении истории старый флаг не должен дёрнуть запрос новой сессии.
const [reportFor, setReportFor] = useState<string | null>(null);
@ -65,6 +67,7 @@ export function useObserve(sessionId: string | null): ObservedSession {
setHintsUsed(0);
setNotes(new Map());
setEnded(false);
setPaused(false);
setError(null);
if (!sessionId) return;
let active = true;
@ -88,6 +91,7 @@ export function useObserve(sessionId: string | null): ObservedSession {
setTimers(event.timers);
setHintsUsed(event.hints_used ?? 0);
setEnded(event.ended ?? false);
setPaused(event.paused ?? false);
if (event.ended) loadReport();
break;
case "kio.state":
@ -112,6 +116,9 @@ export function useObserve(sessionId: string | null): ObservedSession {
setEnded(true);
loadReport();
break;
case "session.paused":
setPaused(event.paused);
break;
case "error":
setError(event.message);
if (event.code === "session_not_found") loadReport();
@ -127,5 +134,5 @@ export function useObserve(sessionId: string | null): ObservedSession {
return () => { active = false; channel.close(); };
}, [sessionId, queries]);
return { status, title, mode, trainee, card, requiredFields, transcript, timers, hintsUsed, notes, ended, report, error };
return { status, title, mode, trainee, card, requiredFields, transcript, timers, hintsUsed, notes, ended, paused, report, error };
}

View file

@ -233,6 +233,7 @@ export function Dds() {
const [checkText, setCheckText] = useState("");
const [error, setError] = useState("");
const [ended, setEnded] = useState(false);
const [paused, setPaused] = useState(false);
// Разбор просим только после `score.ready`; новая карточка снимает флаг.
const [scoreReady, setScoreReady] = useState(false);
const { report, loading: reportLoading, error: reportError, refetch: refetchReport } =
@ -252,6 +253,7 @@ export function Dds() {
if (event.type === "station.state") {
const snapshot = event.snapshot as DdsSnapshot;
setStation(snapshot);
setPaused(snapshot.paused ?? false);
setZone(snapshot.zone_decision ?? null);
if (snapshot.card_id == null && (snapshot.pending_cards_count ?? 0) > 0) {
activeCardId.current = null;
@ -298,6 +300,7 @@ export function Dds() {
} else if (event.type === "timer.tick") setTimers(event.timers);
else if (event.type === "error") { setError(event.message); setDialing(false); setAdvancePending(false); }
else if (event.type === "session.ended") { setEnded(true); setDialing(false); setAdvancePending(false); setPane(null); setView("registry"); }
else if (event.type === "session.paused") setPaused(event.paused);
else if (event.type === "score.ready") setScoreReady(true);
else if (event.type === "phone.report") {
setLastReport(event);
@ -368,6 +371,7 @@ export function Dds() {
АРМ {role}: {isConnected ? "на связи" : status}
</span>
{error && <div className="dds-alert" role="alert">Действие не принято: {error}</div>}
{paused && !ended && <div className="dds-alert dds-paused">Пауза, ждите преподавателя</div>}
{ended && <div className="dds-alert dds-done">Занятие завершено. Оценка появится после расчёта.</div>}
{(ended || scoreReady) && <section className="dds-panel" aria-live="polite">
<div className="dds-panel-title"><h2>Итог упражнения</h2></div>

View file

@ -695,7 +695,15 @@ export function Instructor() {
{sessionId && session.title && (
<p>
{session.ended ? "занятие завершено" : (
<button type="button" onClick={control.stop}>Завершить занятие</button>
<>
<button type="button" onClick={control.stop}>Завершить занятие</button>{" "}
{session.paused ? (
<button type="button" onClick={control.resume}>Возобновить</button>
) : (
<button type="button" onClick={control.pause}>Пауза</button>
)}
{session.paused && <small className="ref" role="status">Занятие на паузе — время не идёт</small>}
</>
)}
</p>
)}

View file

@ -126,6 +126,7 @@ export function Call() {
placeholder="Ваша реплика…" onChange={(event) => setTextTurn(event.target.value)} />
<button type="submit" disabled={call.status !== "open" || !textTurn.trim() || call.cardSubmitted}>Отправить</button>
</form>
{call.paused && <p role="status" className="state-warn">Пауза, ждите преподавателя</p>}
{call.error && <p role="alert" className="state-violated">{call.error}</p>}
</section>
<div className="arm-card-wrap">
@ -190,6 +191,7 @@ export function Call() {
: "Ожидайте входящего вызова"}</span>
</div>
{call.paused && <p className="state-warn arm-message" role="status">Пауза, ждите преподавателя</p>}
{call.error && <p className="violated arm-message" role="alert">{call.error}</p>}
<div className="arm-card-wrap">

View file

@ -585,6 +585,17 @@ export interface SessionEnded {
/** Режим сессии. Меняет доступность подсказок и протоколирование, но не поведение звонящего (docs/product/MODES.md). */
export type SessionMode = "training" | "exam" | "self";
/** Остановить время занятия; карточку курсанта команда не меняет (lct-39). */
export interface SessionPause {
type: "session.pause";
}
/** Пульт остановил или возобновил время занятия (lct-39). */
export interface SessionPaused {
type: "session.paused";
paused: boolean;
}
/** Единица истории: из отчётов складываются профиль, дельта попыток, аналитика. */
export interface SessionReport {
session_id: string;
@ -611,6 +622,11 @@ export interface SessionReport {
score_final: number;
overridden_by?: string | null;
override_comment?: string | null;
total_paused_ms?: number;
}
export interface SessionResume {
type: "session.resume";
}
/** Обязательно при подключении: монитор в классе включают посреди занятия. */
@ -631,6 +647,7 @@ export interface SessionSnapshot {
timers: Array<TimerSnapshot>;
hints_used?: number;
ended?: boolean;
paused?: boolean;
}
export interface SessionStop {
@ -667,6 +684,7 @@ export interface StationSnapshot {
pending_cards_count?: number;
next_arrival_in_seconds?: number | null;
max_waiting_cards?: number;
paused?: boolean;
}
/** Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше. */
@ -775,15 +793,15 @@ export interface ZoneDecision {
// Союзы по каналам — смотри docs/arch/CONTRACT.md#каналы
export type ServerToTrainee = CallIncoming | CardBriefing | TextTurnAccepted | CallStarted | SttPartial | SttFinal | CallerUtterance | TtsBegin | TtsEnd | TtsCancel | BgStart | BgStop | EraData | KioPatchOut | HintShown | TimerTick | CallEnded | ScoreReady | ErrorEvent;
export type ServerToTrainee = CallIncoming | CardBriefing | TextTurnAccepted | CallStarted | SttPartial | SttFinal | CallerUtterance | TtsBegin | TtsEnd | TtsCancel | BgStart | BgStop | EraData | KioPatchOut | HintShown | TimerTick | CallEnded | ScoreReady | SessionPaused | ErrorEvent;
export type TraineeToServer = CallAnswer | CardSubmit | TextTurn | KioPatchIn | HintRequest | SelfAssessmentSubmit | DdsDispatch | CallResolve | CallHangup | CallbackDial;
export type ServerToObserver = SessionSnapshot | TranscriptAppend | KioState | TimerTick | ModeSet | HintShown | BgStart | BgStop | CallerUtterance | SessionEnded | ScoreReady | InstructorNoteShown | ReferenceStarted | ErrorEvent;
export type ServerToObserver = SessionSnapshot | TranscriptAppend | KioState | TimerTick | ModeSet | HintShown | BgStart | BgStop | CallerUtterance | SessionEnded | ScoreReady | InstructorNoteShown | ReferenceStarted | SessionPaused | ErrorEvent;
export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay | InstructorNoteAdd | ScoreOverride | SessionStop;
export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay | InstructorNoteAdd | ScoreOverride | SessionStop | SessionPause | SessionResume;
export type ServerToStation = CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | CommandAck | ErrorEvent;
export type ServerToStation = CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | CommandAck | SessionPaused | ErrorEvent;
export type StationToServer = CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck | PhoneHangup | StationFinish | CardReply | CardNext | CardOpen | ZoneDecision | CrewDispatched | CrewArrived;
@ -835,6 +853,9 @@ export type EventName =
| "score.ready"
| "self_assessment.submit"
| "session.ended"
| "session.pause"
| "session.paused"
| "session.resume"
| "session.snapshot"
| "session.stop"
| "station.finish"

View file

@ -349,6 +349,7 @@ button:disabled { opacity: 0.5; cursor: default; }
.dds-status-action button { padding: 4px 8px; }
.dds-alert { padding: 10px 12px; margin: 0 0 12px; color: var(--violated); background: #fde8e8; border: 1px solid var(--violated); }
.dds-done { color: var(--ok); background: #e7f4e9; border-color: var(--ok); }
.dds-paused { color: var(--warn); background: #fdf3d8; border-color: var(--warn); }
.dds-phone > label { display: block; margin: 14px 12px 5px; font-size: 13px; font-weight: 600; }
.dds-phone > select { display: block; width: calc(100% - 24px); margin: 0 12px; padding: 7px; box-sizing: border-box; }
.dds-dial { display: block; margin: 12px; width: calc(100% - 24px); text-align: center; background: #23577e; border-color: #23577e; color: white; font-weight: 600; }