Merge branch 'feat/session-report-hook'
refactor: общий hook отчёта сессии и объект параметров useControl.start
This commit is contained in:
commit
783b63a683
13 changed files with 224 additions and 112 deletions
|
|
@ -12,7 +12,9 @@
|
||||||
"check:a11y": "node scripts/check-accessible-controls.mjs",
|
"check:a11y": "node scripts/check-accessible-controls.mjs",
|
||||||
"test:ws-outbox": "node scripts/test-ws-outbox.mjs",
|
"test:ws-outbox": "node scripts/test-ws-outbox.mjs",
|
||||||
"test:dds-history": "node scripts/test-dds-history.mjs",
|
"test:dds-history": "node scripts/test-dds-history.mjs",
|
||||||
"test:dds-ack": "node scripts/test-dds-ack.mjs"
|
"test:dds-ack": "node scripts/test-dds-ack.mjs",
|
||||||
|
"test:session-report": "node scripts/test-session-report.mjs",
|
||||||
|
"test:scenario-start": "node scripts/test-scenario-start.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.103.1",
|
"@tanstack/react-query": "^5.103.1",
|
||||||
|
|
|
||||||
31
frontend/scripts/test-scenario-start.mjs
Normal file
31
frontend/scripts/test-scenario-start.mjs
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import { scenarioStartMessage } from "../src/features/instructor/start.mjs";
|
||||||
|
|
||||||
|
const minimal = JSON.parse(JSON.stringify(scenarioStartMessage({
|
||||||
|
scenarioId: "s1", mode: "exam", trainee: "Иванов",
|
||||||
|
})));
|
||||||
|
assert.deepEqual(minimal, {
|
||||||
|
type: "scenario.start", scenario_id: "s1", mode: "exam", trainee: "Иванов",
|
||||||
|
group_id: null, trainee_id: null, dds_service: null, exercise: "dds", handoff_to_dds: false,
|
||||||
|
dds_arrival_interval_seconds: 60, dds_max_waiting: 3,
|
||||||
|
}, "умолчания прежние, пустые списки и критерии в сокет не уходят");
|
||||||
|
|
||||||
|
const criteria = { allowed_errors: 2 };
|
||||||
|
const full = scenarioStartMessage({
|
||||||
|
scenarioId: "s1", mode: "training", trainee: "Петров", exercise: "call", groupId: "g1",
|
||||||
|
traineeId: "t1", ddsService: "01", scenarioIds: ["s1", "s2"], randomScenarioIds: ["s3"],
|
||||||
|
ddsArrivalIntervalSeconds: 30, ddsMaxWaiting: 5, handoffToDds: true, criteria,
|
||||||
|
});
|
||||||
|
assert.deepEqual(full, {
|
||||||
|
type: "scenario.start", scenario_id: "s1", mode: "training", trainee: "Петров",
|
||||||
|
group_id: "g1", trainee_id: "t1", dds_service: "01", exercise: "call", handoff_to_dds: true,
|
||||||
|
scenario_ids: ["s1", "s2"], random_scenario_ids: ["s3"],
|
||||||
|
dds_arrival_interval_seconds: 30, dds_max_waiting: 5, criteria,
|
||||||
|
});
|
||||||
|
|
||||||
|
const empty = scenarioStartMessage({ scenarioId: "s1", mode: "exam", trainee: "И", scenarioIds: [], randomScenarioIds: [] });
|
||||||
|
assert.equal(empty.scenario_ids, undefined, "пустой список не отправляется");
|
||||||
|
assert.equal(empty.random_scenario_ids, undefined);
|
||||||
|
|
||||||
|
console.log("scenario.start: named options build the same socket message.");
|
||||||
21
frontend/scripts/test-session-report.mjs
Normal file
21
frontend/scripts/test-session-report.mjs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import { REPORT_RETRY_LIMIT, reportErrorMessage, reportRetryDelay, shouldRetryReport } from "../src/shared/api/report.mjs";
|
||||||
|
|
||||||
|
assert.equal(reportErrorMessage(403), "Отчёт недоступен: это занятие другого обучающегося.");
|
||||||
|
assert.equal(reportErrorMessage(409), "Оценка ещё не готова. Попробуйте получить разбор чуть позже.");
|
||||||
|
assert.equal(reportErrorMessage(500), "Не удалось получить отчёт (HTTP 500).");
|
||||||
|
assert.equal(reportErrorMessage(null), "Не удалось получить отчёт.", "сеть упала — статуса нет");
|
||||||
|
|
||||||
|
assert.equal(shouldRetryReport(0, 409), true, "оценка ещё считается — повторяем сами");
|
||||||
|
assert.equal(shouldRetryReport(REPORT_RETRY_LIMIT - 1, 409), true);
|
||||||
|
assert.equal(shouldRetryReport(REPORT_RETRY_LIMIT, 409), false, "число попыток ограничено");
|
||||||
|
assert.equal(shouldRetryReport(0, 403), false, "чужое занятие не станет своим — не повторяем");
|
||||||
|
assert.equal(shouldRetryReport(0, 404), false);
|
||||||
|
assert.equal(shouldRetryReport(0, 500), false);
|
||||||
|
|
||||||
|
assert.ok(reportRetryDelay(0) > 0);
|
||||||
|
assert.ok(reportRetryDelay(1) >= reportRetryDelay(0), "паузы не сокращаются");
|
||||||
|
assert.ok(reportRetryDelay(50) <= 5_000, "пауза ограничена сверху");
|
||||||
|
|
||||||
|
console.log("Session report: unified errors, 409 retried with a limit, 403 not retried.");
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { useSessionReport } from "@/shared/api/http";
|
||||||
import { type ChannelStatus, callChannel } from "@/shared/api/ws";
|
import { type ChannelStatus, callChannel } from "@/shared/api/ws";
|
||||||
import { Ambience } from "@/shared/audio/ambience";
|
import { Ambience } from "@/shared/audio/ambience";
|
||||||
import { type Capture, startCapture } from "@/shared/audio/capture";
|
import { type Capture, startCapture } from "@/shared/audio/capture";
|
||||||
|
|
@ -18,7 +19,6 @@ import type {
|
||||||
CardBriefing,
|
CardBriefing,
|
||||||
DDSCode,
|
DDSCode,
|
||||||
ServerToTrainee,
|
ServerToTrainee,
|
||||||
SessionReport,
|
|
||||||
TimerSnapshot,
|
TimerSnapshot,
|
||||||
} from "@/shared/types/generated";
|
} from "@/shared/types/generated";
|
||||||
|
|
||||||
|
|
@ -48,8 +48,7 @@ export function useCall(sessionId: string | null) {
|
||||||
const [checklist, setChecklist] = useState<ChecklistItem[]>([]);
|
const [checklist, setChecklist] = useState<ChecklistItem[]>([]);
|
||||||
const [selfAssessed, setSelfAssessed] = useState(false);
|
const [selfAssessed, setSelfAssessed] = useState(false);
|
||||||
const [scoreReady, setScoreReady] = useState(false);
|
const [scoreReady, setScoreReady] = useState(false);
|
||||||
const [report, setReport] = useState<SessionReport | null>(null);
|
const { report, error: reportError } = useSessionReport(sessionId, scoreReady);
|
||||||
const [reportError, setReportError] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const channel = useRef<ReturnType<typeof callChannel> | null>(null);
|
const channel = useRef<ReturnType<typeof callChannel> | null>(null);
|
||||||
|
|
@ -153,16 +152,6 @@ export function useCall(sessionId: string | null) {
|
||||||
break;
|
break;
|
||||||
case "score.ready":
|
case "score.ready":
|
||||||
setScoreReady(true);
|
setScoreReady(true);
|
||||||
setReportError(null);
|
|
||||||
void fetch(`/api/sessions/${sessionId}/report`)
|
|
||||||
.then((response) => {
|
|
||||||
if (response.status === 403) throw new Error("Отчёт недоступен: занятие привязано к другому обучающемуся.");
|
|
||||||
if (response.status === 409) throw new Error("Оценка ещё не готова. Обновите страницу позже.");
|
|
||||||
if (!response.ok) throw new Error(`Не удалось загрузить отчёт (HTTP ${response.status}).`);
|
|
||||||
return response.json();
|
|
||||||
})
|
|
||||||
.then(setReport)
|
|
||||||
.catch((cause: unknown) => setReportError(cause instanceof Error ? cause.message : "Не удалось загрузить отчёт."));
|
|
||||||
break;
|
break;
|
||||||
case "error":
|
case "error":
|
||||||
setError(event.message);
|
setError(event.message);
|
||||||
|
|
|
||||||
36
frontend/src/features/instructor/start.d.mts
Normal file
36
frontend/src/features/instructor/start.d.mts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import type { LessonCriteria, SessionMode } from "@/shared/types/generated";
|
||||||
|
|
||||||
|
export interface ScenarioStartOptions {
|
||||||
|
scenarioId: string;
|
||||||
|
mode: SessionMode;
|
||||||
|
trainee: string;
|
||||||
|
exercise?: "dds" | "call" | "card";
|
||||||
|
groupId?: string;
|
||||||
|
traineeId?: string;
|
||||||
|
ddsService?: string;
|
||||||
|
scenarioIds?: string[];
|
||||||
|
randomScenarioIds?: string[];
|
||||||
|
ddsArrivalIntervalSeconds?: number;
|
||||||
|
ddsMaxWaiting?: number;
|
||||||
|
handoffToDds?: boolean;
|
||||||
|
criteria?: LessonCriteria;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScenarioStartMessage {
|
||||||
|
type: "scenario.start";
|
||||||
|
scenario_id: string;
|
||||||
|
mode: SessionMode;
|
||||||
|
trainee: string;
|
||||||
|
group_id: string | null;
|
||||||
|
trainee_id: string | null;
|
||||||
|
dds_service: string | null;
|
||||||
|
exercise: "dds" | "call" | "card";
|
||||||
|
handoff_to_dds: boolean;
|
||||||
|
scenario_ids: string[] | undefined;
|
||||||
|
random_scenario_ids: string[] | undefined;
|
||||||
|
dds_arrival_interval_seconds: number;
|
||||||
|
dds_max_waiting: number;
|
||||||
|
criteria: LessonCriteria | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scenarioStartMessage(options: ScenarioStartOptions): ScenarioStartMessage;
|
||||||
26
frontend/src/features/instructor/start.mjs
Normal file
26
frontend/src/features/instructor/start.mjs
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
// Сборка `scenario.start` из именованных параметров консоли преподавателя.
|
||||||
|
// Отдельный .mjs, чтобы состав сообщения проверялся node-скриптом без сборки.
|
||||||
|
|
||||||
|
/** @param {import("./start.d.mts").ScenarioStartOptions} options */
|
||||||
|
export function scenarioStartMessage({
|
||||||
|
scenarioId, mode, trainee, exercise = "dds", groupId, traineeId, ddsService,
|
||||||
|
scenarioIds, randomScenarioIds, ddsArrivalIntervalSeconds = 60, ddsMaxWaiting = 3,
|
||||||
|
handoffToDds = false, criteria,
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
type: "scenario.start",
|
||||||
|
scenario_id: scenarioId,
|
||||||
|
mode,
|
||||||
|
trainee,
|
||||||
|
group_id: groupId || null,
|
||||||
|
trainee_id: traineeId || null,
|
||||||
|
dds_service: ddsService || null,
|
||||||
|
exercise,
|
||||||
|
handoff_to_dds: handoffToDds,
|
||||||
|
scenario_ids: scenarioIds?.length ? scenarioIds : undefined,
|
||||||
|
random_scenario_ids: randomScenarioIds?.length ? randomScenarioIds : undefined,
|
||||||
|
dds_arrival_interval_seconds: ddsArrivalIntervalSeconds,
|
||||||
|
dds_max_waiting: ddsMaxWaiting,
|
||||||
|
criteria,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,8 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { controlChannel, type ChannelStatus } from "@/shared/api/ws";
|
import { controlChannel, type ChannelStatus } from "@/shared/api/ws";
|
||||||
import type { LessonCriteria, SessionMode } from "@/shared/types/generated";
|
|
||||||
|
import { scenarioStartMessage, type ScenarioStartOptions } from "./start.mjs";
|
||||||
|
|
||||||
export function useControl(sessionId: string | null) {
|
export function useControl(sessionId: string | null) {
|
||||||
const [status, setStatus] = useState<ChannelStatus>("closed");
|
const [status, setStatus] = useState<ChannelStatus>("closed");
|
||||||
|
|
@ -37,30 +38,11 @@ export function useControl(sessionId: string | null) {
|
||||||
};
|
};
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
const start = useCallback(
|
const start = useCallback((targetSessionId: string, options: ScenarioStartOptions) => {
|
||||||
(targetSessionId: string, scenarioId: string, mode: SessionMode, trainee: string, exercise: "dds" | "call" | "card" = "dds", group?: string, traineeId?: string, ddsService?: string, scenarioIds?: string[], ddsArrivalIntervalSeconds = 60, ddsMaxWaiting = 3, handoffToDds = false, criteria?: LessonCriteria, randomScenarioIds?: string[]) => {
|
|
||||||
const message = {
|
|
||||||
type: "scenario.start",
|
|
||||||
scenario_id: scenarioId,
|
|
||||||
mode,
|
|
||||||
trainee,
|
|
||||||
group_id: group || null,
|
|
||||||
trainee_id: traineeId || null,
|
|
||||||
dds_service: ddsService || null,
|
|
||||||
exercise,
|
|
||||||
handoff_to_dds: handoffToDds,
|
|
||||||
scenario_ids: scenarioIds?.length ? scenarioIds : undefined,
|
|
||||||
random_scenario_ids: randomScenarioIds?.length ? randomScenarioIds : undefined,
|
|
||||||
dds_arrival_interval_seconds: ddsArrivalIntervalSeconds,
|
|
||||||
dds_max_waiting: ddsMaxWaiting,
|
|
||||||
criteria,
|
|
||||||
} as const;
|
|
||||||
const current = channel.current;
|
const current = channel.current;
|
||||||
if (!current || current.sessionId !== targetSessionId) return false;
|
if (!current || current.sessionId !== targetSessionId) return false;
|
||||||
return current.socket.send(message);
|
return current.socket.send(scenarioStartMessage(options));
|
||||||
},
|
}, []);
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const note = useCallback(
|
const note = useCallback(
|
||||||
(transcriptRef: string, text: string) =>
|
(transcriptRef: string, text: string) =>
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@
|
||||||
// и он должен показать текущее состояние, а не ждать событий. Отправлять в этот
|
// и он должен показать текущее состояние, а не ждать событий. Отправлять в этот
|
||||||
// канал нечего и нельзя: тип `Out` — `never` (docs/arch/CONTRACT.md).
|
// канал нечего и нельзя: тип `Out` — `never` (docs/arch/CONTRACT.md).
|
||||||
|
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { type CardState, applyState, empty as emptyCard } from "@/features/kio-card/merge";
|
import { type CardState, applyState, empty as emptyCard } from "@/features/kio-card/merge";
|
||||||
|
import { useSessionReport } from "@/shared/api/http";
|
||||||
import { observeChannel, type ChannelStatus } from "@/shared/api/ws";
|
import { observeChannel, type ChannelStatus } from "@/shared/api/ws";
|
||||||
import type {
|
import type {
|
||||||
SessionMode,
|
SessionMode,
|
||||||
|
|
@ -43,7 +45,12 @@ export function useObserve(sessionId: string | null): ObservedSession {
|
||||||
const [hintsUsed, setHintsUsed] = useState(0);
|
const [hintsUsed, setHintsUsed] = useState(0);
|
||||||
const [notes, setNotes] = useState<Map<string, string>>(new Map());
|
const [notes, setNotes] = useState<Map<string, string>>(new Map());
|
||||||
const [ended, setEnded] = useState(false);
|
const [ended, setEnded] = useState(false);
|
||||||
const [report, setReport] = useState<SessionReport | null>(null);
|
// Сессия, для которой отчёт уже стоит просить. Привязка к id, а не флаг:
|
||||||
|
// при переключении истории старый флаг не должен дёрнуть запрос новой сессии.
|
||||||
|
const [reportFor, setReportFor] = useState<string | null>(null);
|
||||||
|
// Ошибку отчёта наблюдение не показывает: пока оценки нет, это не ошибка.
|
||||||
|
const { report } = useSessionReport(sessionId, reportFor === sessionId);
|
||||||
|
const queries = useQueryClient();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -58,7 +65,6 @@ export function useObserve(sessionId: string | null): ObservedSession {
|
||||||
setHintsUsed(0);
|
setHintsUsed(0);
|
||||||
setNotes(new Map());
|
setNotes(new Map());
|
||||||
setEnded(false);
|
setEnded(false);
|
||||||
setReport(null);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
let active = true;
|
let active = true;
|
||||||
|
|
@ -66,23 +72,7 @@ export function useObserve(sessionId: string | null): ObservedSession {
|
||||||
// could only return score_not_ready, competing with the snapshot under load.
|
// could only return score_not_ready, competing with the snapshot under load.
|
||||||
// Ask for the report only after completion or when no live session exists
|
// Ask for the report only after completion or when no live session exists
|
||||||
// (e.g. history opened after a backend restart).
|
// (e.g. history opened after a backend restart).
|
||||||
let reportLoading = false;
|
const loadReport = () => setReportFor(sessionId);
|
||||||
let reportLoaded = false;
|
|
||||||
const loadReport = () => {
|
|
||||||
if (reportLoading || reportLoaded) return;
|
|
||||||
reportLoading = true;
|
|
||||||
void fetch(`/api/sessions/${sessionId}/report`, { credentials: "same-origin" })
|
|
||||||
.then((response) => (response.ok ? response.json() : null))
|
|
||||||
.then((saved) => {
|
|
||||||
if (!active) return;
|
|
||||||
if (saved) {
|
|
||||||
reportLoaded = true;
|
|
||||||
setReport(saved as SessionReport);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => undefined)
|
|
||||||
.finally(() => { reportLoading = false; });
|
|
||||||
};
|
|
||||||
const channel = observeChannel(sessionId, {
|
const channel = observeChannel(sessionId, {
|
||||||
onStatus: (next) => { if (active) setStatus(next); },
|
onStatus: (next) => { if (active) setStatus(next); },
|
||||||
onEvent: (event) => {
|
onEvent: (event) => {
|
||||||
|
|
@ -128,12 +118,14 @@ export function useObserve(sessionId: string | null): ObservedSession {
|
||||||
break;
|
break;
|
||||||
case "score.ready":
|
case "score.ready":
|
||||||
loadReport();
|
loadReport();
|
||||||
|
// После session.ended hook мог исчерпать повторы 409 до готовности оценки.
|
||||||
|
void queries.invalidateQueries({ queryKey: ["session-report", sessionId] });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}).connect();
|
}).connect();
|
||||||
return () => { active = false; channel.close(); };
|
return () => { active = false; channel.close(); };
|
||||||
}, [sessionId]);
|
}, [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, report, error };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,10 @@ import { Timers } from "@/features/timers/Timers";
|
||||||
import { applyState, empty as emptyCard, readPath, type CardState } from "@/features/kio-card/merge";
|
import { applyState, empty as emptyCard, readPath, type CardState } from "@/features/kio-card/merge";
|
||||||
import { ackDeadline } from "./ack.mjs";
|
import { ackDeadline } from "./ack.mjs";
|
||||||
import { filterSortHistory } from "./history.mjs";
|
import { filterSortHistory } from "./history.mjs";
|
||||||
|
import { useSessionReport } from "@/shared/api/http";
|
||||||
import { sessionIdFromUrl } from "@/shared/api/session";
|
import { sessionIdFromUrl } from "@/shared/api/session";
|
||||||
import { stationChannel, type ChannelStatus } from "@/shared/api/ws";
|
import { stationChannel, type ChannelStatus } from "@/shared/api/ws";
|
||||||
import type { DdsQueueCard, ServiceStatus, SessionReport, StationSnapshot, StationToServer, TimerSnapshot } from "@/shared/types/generated";
|
import type { DdsQueueCard, ServiceStatus, StationSnapshot, StationToServer, TimerSnapshot } from "@/shared/types/generated";
|
||||||
import { ALARMING, CARD_STATUS_LABELS, SERVICE_STATUS_LABELS } from "./statuses";
|
import { ALARMING, CARD_STATUS_LABELS, SERVICE_STATUS_LABELS } from "./statuses";
|
||||||
|
|
||||||
const SipPhone = lazy(() => import("@/features/sip/SipPhone").then(({ SipPhone }) => ({ default: SipPhone })));
|
const SipPhone = lazy(() => import("@/features/sip/SipPhone").then(({ SipPhone }) => ({ default: SipPhone })));
|
||||||
|
|
@ -232,9 +233,10 @@ export function Dds() {
|
||||||
const [checkText, setCheckText] = useState("");
|
const [checkText, setCheckText] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [ended, setEnded] = useState(false);
|
const [ended, setEnded] = useState(false);
|
||||||
const [report, setReport] = useState<SessionReport | null>(null);
|
// Разбор просим только после `score.ready`; новая карточка снимает флаг.
|
||||||
const [reportState, setReportState] = useState<"idle" | "loading" | "ready" | "error">("idle");
|
const [scoreReady, setScoreReady] = useState(false);
|
||||||
const [reportError, setReportError] = useState("");
|
const { report, loading: reportLoading, error: reportError, refetch: refetchReport } =
|
||||||
|
useSessionReport(sessionId, scoreReady);
|
||||||
const [zone, setZone] = useState<boolean | null>(null);
|
const [zone, setZone] = useState<boolean | null>(null);
|
||||||
const [reply, setReply] = useState({ text: "", dirty: false });
|
const [reply, setReply] = useState({ text: "", dirty: false });
|
||||||
const [advancePending, setAdvancePending] = useState(false);
|
const [advancePending, setAdvancePending] = useState(false);
|
||||||
|
|
@ -244,28 +246,6 @@ export function Dds() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
const controller = new AbortController();
|
|
||||||
async function loadReport() {
|
|
||||||
setReportState("loading");
|
|
||||||
setReportError("");
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/sessions/${sessionId}/report`, {
|
|
||||||
credentials: "same-origin",
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 403) throw new Error("Отчёт недоступен: это занятие другого обучающегося.");
|
|
||||||
if (response.status === 409) throw new Error("Оценка ещё не готова. Попробуйте получить разбор чуть позже.");
|
|
||||||
throw new Error(`Не удалось получить отчёт (HTTP ${response.status}).`);
|
|
||||||
}
|
|
||||||
setReport(await response.json() as SessionReport);
|
|
||||||
setReportState("ready");
|
|
||||||
} catch (cause) {
|
|
||||||
if (controller.signal.aborted) return;
|
|
||||||
setReportState("error");
|
|
||||||
setReportError(cause instanceof Error ? cause.message : "Не удалось получить отчёт.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const ch = stationChannel(sessionId, role, {
|
const ch = stationChannel(sessionId, role, {
|
||||||
onStatus: (next) => { setStatus(next); if (next !== "open") setDialing(false); },
|
onStatus: (next) => { setStatus(next); if (next !== "open") setDialing(false); },
|
||||||
onEvent: (event) => {
|
onEvent: (event) => {
|
||||||
|
|
@ -300,8 +280,7 @@ export function Dds() {
|
||||||
setBrief({ address: "", incident: "", request: "" });
|
setBrief({ address: "", incident: "", request: "" });
|
||||||
setCheckText("");
|
setCheckText("");
|
||||||
setZone(null);
|
setZone(null);
|
||||||
setReport(null);
|
setScoreReady(false);
|
||||||
setReportState("idle");
|
|
||||||
setView(openCardAfterSwitch.current ? "card" : "registry");
|
setView(openCardAfterSwitch.current ? "card" : "registry");
|
||||||
openCardAfterSwitch.current = false;
|
openCardAfterSwitch.current = false;
|
||||||
setPane(null);
|
setPane(null);
|
||||||
|
|
@ -319,7 +298,7 @@ export function Dds() {
|
||||||
} else if (event.type === "timer.tick") setTimers(event.timers);
|
} 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 === "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.ended") { setEnded(true); setDialing(false); setAdvancePending(false); setPane(null); setView("registry"); }
|
||||||
else if (event.type === "score.ready") { void loadReport(); }
|
else if (event.type === "score.ready") setScoreReady(true);
|
||||||
else if (event.type === "phone.report") {
|
else if (event.type === "phone.report") {
|
||||||
setLastReport(event);
|
setLastReport(event);
|
||||||
setDialing(false);
|
setDialing(false);
|
||||||
|
|
@ -331,7 +310,7 @@ export function Dds() {
|
||||||
},
|
},
|
||||||
}).connect();
|
}).connect();
|
||||||
channel.current = ch;
|
channel.current = ch;
|
||||||
return () => { controller.abort(); ch.close(); channel.current = null; };
|
return () => { ch.close(); channel.current = null; };
|
||||||
}, [sessionId, role]);
|
}, [sessionId, role]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -390,7 +369,7 @@ export function Dds() {
|
||||||
</span>
|
</span>
|
||||||
{error && <div className="dds-alert" role="alert">Действие не принято: {error}</div>}
|
{error && <div className="dds-alert" role="alert">Действие не принято: {error}</div>}
|
||||||
{ended && <div className="dds-alert dds-done">Занятие завершено. Оценка появится после расчёта.</div>}
|
{ended && <div className="dds-alert dds-done">Занятие завершено. Оценка появится после расчёта.</div>}
|
||||||
{(ended || reportState !== "idle") && <section className="dds-panel" aria-live="polite">
|
{(ended || scoreReady) && <section className="dds-panel" aria-live="polite">
|
||||||
<div className="dds-panel-title"><h2>Итог упражнения</h2></div>
|
<div className="dds-panel-title"><h2>Итог упражнения</h2></div>
|
||||||
{report ? <div style={{ padding: 12 }}>
|
{report ? <div style={{ padding: 12 }}>
|
||||||
<p><strong>Итоговая оценка: {report.score_final.toFixed(0)} из 100</strong>
|
<p><strong>Итоговая оценка: {report.score_final.toFixed(0)} из 100</strong>
|
||||||
|
|
@ -425,20 +404,8 @@ export function Dds() {
|
||||||
{report.findings.length === 0 && <tr><td>Замечаний нет.</td></tr>}
|
{report.findings.length === 0 && <tr><td>Замечаний нет.</td></tr>}
|
||||||
</tbody></table>
|
</tbody></table>
|
||||||
</div> : <div style={{ padding: 12 }}>
|
</div> : <div style={{ padding: 12 }}>
|
||||||
{reportState === "loading" ? "Подготавливаем разбор…" : reportError || "Ожидаем итоговую оценку…"}
|
{reportLoading ? "Подготавливаем разбор…" : reportError || "Ожидаем итоговую оценку…"}
|
||||||
{reportState === "error" && <button type="button" onClick={() => {
|
{reportError && <button type="button" onClick={() => void refetchReport()}>Повторить запрос</button>}
|
||||||
setReportState("loading");
|
|
||||||
void fetch(`/api/sessions/${sessionId}/report`, { credentials: "same-origin" }).then(async (response) => {
|
|
||||||
if (response.status === 403) throw new Error("Отчёт недоступен: это занятие другого обучающегося.");
|
|
||||||
if (response.status === 409) throw new Error("Оценка ещё не готова. Попробуйте получить разбор чуть позже.");
|
|
||||||
if (!response.ok) throw new Error(`Не удалось получить отчёт (HTTP ${response.status}).`);
|
|
||||||
setReport(await response.json() as SessionReport);
|
|
||||||
setReportState("ready");
|
|
||||||
}).catch((cause: unknown) => {
|
|
||||||
setReportError(cause instanceof Error ? cause.message : "Не удалось получить отчёт.");
|
|
||||||
setReportState("error");
|
|
||||||
});
|
|
||||||
}}>Повторить запрос</button>}
|
|
||||||
</div>}
|
</div>}
|
||||||
</section>}
|
</section>}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -283,19 +283,27 @@ export function Instructor() {
|
||||||
&& !(exercise === "dds" && wholeTicket)
|
&& !(exercise === "dds" && wholeTicket)
|
||||||
? visibleScenarios.filter((item) => item.outcome === "card").map((item) => item.id)
|
? visibleScenarios.filter((item) => item.outcome === "card").map((item) => item.id)
|
||||||
: undefined;
|
: undefined;
|
||||||
const sent = control.start(sessionId, primaryScenarioId, mode, selected?.name || trainee || "Демо-курсант",
|
const sent = control.start(sessionId, {
|
||||||
exercise === "card_dds" ? "card" : exercise,
|
scenarioId: primaryScenarioId,
|
||||||
undefined, selected?.id, selected?.service ?? undefined,
|
mode,
|
||||||
scenarioIds && scenarioIds.length > 1 ? scenarioIds : undefined,
|
trainee: selected?.name || trainee || "Демо-курсант",
|
||||||
ddsArrivalIntervalSeconds, ddsMaxWaiting,
|
exercise: exercise === "card_dds" ? "card" : exercise,
|
||||||
exercise === "card_dds", {
|
traineeId: selected?.id,
|
||||||
|
ddsService: selected?.service ?? undefined,
|
||||||
|
scenarioIds: scenarioIds && scenarioIds.length > 1 ? scenarioIds : undefined,
|
||||||
|
randomScenarioIds,
|
||||||
|
ddsArrivalIntervalSeconds,
|
||||||
|
ddsMaxWaiting,
|
||||||
|
handoffToDds: exercise === "card_dds",
|
||||||
|
criteria: {
|
||||||
decision_time_limit_seconds: decisionTimeLimit,
|
decision_time_limit_seconds: decisionTimeLimit,
|
||||||
card_fill_time_limit_seconds: cardFillTimeLimit,
|
card_fill_time_limit_seconds: cardFillTimeLimit,
|
||||||
dds_card_work_time_limit_seconds: ddsCardWorkTimeLimit,
|
dds_card_work_time_limit_seconds: ddsCardWorkTimeLimit,
|
||||||
allowed_errors: allowedErrors,
|
allowed_errors: allowedErrors,
|
||||||
require_correct_grammar: requireCorrectGrammar,
|
require_correct_grammar: requireCorrectGrammar,
|
||||||
score_weights: lessonScoreWeights,
|
score_weights: lessonScoreWeights,
|
||||||
}, randomScenarioIds);
|
},
|
||||||
|
});
|
||||||
if (sent) toStart.current = null;
|
if (sent) toStart.current = null;
|
||||||
}, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data,
|
}, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data,
|
||||||
exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,
|
exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import type { Level, SessionMode, StationSnapshot } from "@/shared/types/generated";
|
import type { Level, SessionMode, SessionReport, StationSnapshot } from "@/shared/types/generated";
|
||||||
|
|
||||||
|
import { reportErrorMessage, reportRetryDelay, shouldRetryReport } from "./report.mjs";
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
|
|
@ -328,6 +330,30 @@ export const useProfile = (traineeId: string | null) =>
|
||||||
enabled: Boolean(traineeId),
|
enabled: Boolean(traineeId),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const errorStatus = (error: unknown) => (error instanceof ApiError ? error.status : null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отчёт сессии. `ready = false` — запроса нет и отчёт не показывается: его
|
||||||
|
* просят после `score.ready` или у уже завершённой сессии. 409 (оценка ещё
|
||||||
|
* считается) повторяется сам с ограниченным числом попыток.
|
||||||
|
*/
|
||||||
|
export function useSessionReport(sessionId: string | null, ready: boolean) {
|
||||||
|
const enabled = Boolean(sessionId) && ready;
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["session-report", sessionId],
|
||||||
|
queryFn: () => request<SessionReport>(`/api/sessions/${sessionId}/report`),
|
||||||
|
enabled,
|
||||||
|
retry: (failureCount, error) => shouldRetryReport(failureCount, errorStatus(error)),
|
||||||
|
retryDelay: reportRetryDelay,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
report: enabled ? query.data ?? null : null,
|
||||||
|
loading: enabled && query.isFetching,
|
||||||
|
error: enabled && query.isError ? reportErrorMessage(errorStatus(query.error)) : "",
|
||||||
|
refetch: query.refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const useCreateSession = () =>
|
export const useCreateSession = () =>
|
||||||
useMutation({
|
useMutation({
|
||||||
mutationFn: (body: { scenario_id: string; mode: SessionMode; trainee?: string; group?: string }) =>
|
mutationFn: (body: { scenario_id: string; mode: SessionMode; trainee?: string; group?: string }) =>
|
||||||
|
|
|
||||||
4
frontend/src/shared/api/report.d.mts
Normal file
4
frontend/src/shared/api/report.d.mts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export const REPORT_RETRY_LIMIT: number;
|
||||||
|
export function reportErrorMessage(status: number | null): string;
|
||||||
|
export function shouldRetryReport(failureCount: number, status: number | null): boolean;
|
||||||
|
export function reportRetryDelay(attempt: number): number;
|
||||||
28
frontend/src/shared/api/report.mjs
Normal file
28
frontend/src/shared/api/report.mjs
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
// Правила чтения отчёта сессии — одни для пульта ДДС, звонка 112 и наблюдения.
|
||||||
|
// Отдельный .mjs, чтобы проверять их node-скриптом без сборки.
|
||||||
|
|
||||||
|
/** Сколько раз повторять 409: оценка к этому моменту обычно уже посчитана. */
|
||||||
|
export const REPORT_RETRY_LIMIT = 6;
|
||||||
|
|
||||||
|
/** @param {number | null} status HTTP-статус; `null` — ответа не было. */
|
||||||
|
export function reportErrorMessage(status) {
|
||||||
|
if (status === 403) return "Отчёт недоступен: это занятие другого обучающегося.";
|
||||||
|
if (status === 409) return "Оценка ещё не готова. Попробуйте получить разбор чуть позже.";
|
||||||
|
if (status == null) return "Не удалось получить отчёт.";
|
||||||
|
return `Не удалось получить отчёт (HTTP ${status}).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 409 — оценка ещё считается, она появится сама. 403 и прочее от повтора
|
||||||
|
* не изменятся.
|
||||||
|
* @param {number} failureCount
|
||||||
|
* @param {number | null} status
|
||||||
|
*/
|
||||||
|
export function shouldRetryReport(failureCount, status) {
|
||||||
|
return status === 409 && failureCount < REPORT_RETRY_LIMIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {number} attempt */
|
||||||
|
export function reportRetryDelay(attempt) {
|
||||||
|
return Math.min(1_000 * 2 ** attempt, 5_000);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue