lct-11: таймеры ГОСТ и баннер режима на АРМ курсанта
Опрос (75 с) — крупным индикатором: это центральная метрика продукта, и она обязана быть видна курсанту, преподавателю и залу одновременно. Три состояния норматива — единственное место, где цвет несёт смысл. Фронт время не считает и рисует ровно те цифры, что прислал сервер: метрика по часам браузера недоказуема. Баннер режима определяет доступность подсказки: в контрольном режиме кнопки нет, и это часть нормы контроля.
This commit is contained in:
parent
48c936ea6e
commit
78f66cf3b5
5 changed files with 101 additions and 1 deletions
17
frontend/src/features/mode-banner/ModeBanner.tsx
Normal file
17
frontend/src/features/mode-banner/ModeBanner.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
// Баннер режима. Виден на всех четырёх экранах и определяет доступность подсказки:
|
||||||
|
// в контрольном режиме опоры нет, и это часть нормы контроля (docs/product/MODES.md).
|
||||||
|
|
||||||
|
import type { SessionMode } from "@/shared/types/generated";
|
||||||
|
|
||||||
|
const LABELS: Record<SessionMode, string> = {
|
||||||
|
training: "Тренировочный режим — подсказки доступны",
|
||||||
|
exam: "Контрольный режим — подсказки недоступны",
|
||||||
|
self: "Самостоятельная работа — подсказки доступны",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hintsAllowed = (mode: SessionMode | undefined): boolean => mode !== "exam";
|
||||||
|
|
||||||
|
export function ModeBanner({ mode }: { mode: SessionMode | undefined }) {
|
||||||
|
if (!mode) return null;
|
||||||
|
return <div className={`mode-banner mode-${mode}`}>{LABELS[mode]}</div>;
|
||||||
|
}
|
||||||
54
frontend/src/features/timers/Timers.tsx
Normal file
54
frontend/src/features/timers/Timers.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
// Таймеры ГОСТ Р 22.7.03-2021.
|
||||||
|
//
|
||||||
|
// Три состояния — единственное место, где цвет несёт смысл (docs/arch/FRONTEND.md).
|
||||||
|
// `interview` (75 с) — центральная метрика продукта: она крупная и видна курсанту,
|
||||||
|
// преподавателю и на внешнем мониторе одновременно (docs/arch/CONTRACT.md).
|
||||||
|
|
||||||
|
import type { TimerSnapshot } from "@/shared/types/generated";
|
||||||
|
|
||||||
|
import { clock } from "./clock";
|
||||||
|
|
||||||
|
const TITLES: Record<string, string> = {
|
||||||
|
answer: "Ответ на звонок",
|
||||||
|
interview: "Опрос",
|
||||||
|
dds_notify: "Оповещение ДДС",
|
||||||
|
dds_ack: "Подтверждение ДДС",
|
||||||
|
zone_check: "Зона ответственности",
|
||||||
|
callback: "Обратный дозвон",
|
||||||
|
close: "Снятие с контроля",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Крупный индикатор опроса. `big` — для внешнего монитора: читается с последнего ряда. */
|
||||||
|
export function InterviewTimer({ timers, big = false }: { timers: TimerSnapshot[]; big?: boolean }) {
|
||||||
|
const interview = timers.find((timer) => timer.code === "interview");
|
||||||
|
if (!interview) return null;
|
||||||
|
return (
|
||||||
|
<div className={`interview state-${interview.state}${big ? " interview-big" : ""}`}>
|
||||||
|
<span className="interview-label">Опрос</span>
|
||||||
|
<span className="interview-value">{clock(interview.elapsed_ms)}</span>
|
||||||
|
<span className="interview-limit">норматив {clock(interview.limit_ms)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Timers({ timers }: { timers: TimerSnapshot[] }) {
|
||||||
|
if (timers.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<table className="grid">
|
||||||
|
<tbody>
|
||||||
|
{timers.map((timer) => (
|
||||||
|
<tr key={timer.code}>
|
||||||
|
<th>
|
||||||
|
{TITLES[timer.code] ?? timer.code}
|
||||||
|
{(timer.attempt ?? 1) > 1 && ` · попытка ${timer.attempt}`}
|
||||||
|
</th>
|
||||||
|
<td className={`state-${timer.state}`}>
|
||||||
|
{clock(timer.elapsed_ms)} из {clock(timer.limit_ms)}
|
||||||
|
{timer.stopped && " · остановлен"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
frontend/src/features/timers/clock.ts
Normal file
5
frontend/src/features/timers/clock.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
/** Миллисекунды → мм:сс. Отдельным модулем без импортов: так проверяется в Node. */
|
||||||
|
export function clock(ms: number): string {
|
||||||
|
const total = Math.max(0, Math.round(ms / 1000));
|
||||||
|
return `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
// Карточка КИО — lct-10, таймеры — lct-11.
|
// Карточка КИО — lct-10, таймеры — lct-11.
|
||||||
|
|
||||||
import { KioCard } from "@/features/kio-card/KioCard";
|
import { KioCard } from "@/features/kio-card/KioCard";
|
||||||
|
import { ModeBanner, hintsAllowed } from "@/features/mode-banner/ModeBanner";
|
||||||
|
import { InterviewTimer, Timers } from "@/features/timers/Timers";
|
||||||
import { display } from "@/features/kio-card/merge";
|
import { display } from "@/features/kio-card/merge";
|
||||||
import { useCall } from "@/features/call/useCall";
|
import { useCall } from "@/features/call/useCall";
|
||||||
import { sessionIdFromUrl } from "@/shared/api/session";
|
import { sessionIdFromUrl } from "@/shared/api/session";
|
||||||
|
|
@ -28,7 +30,9 @@ export function Call() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="page">
|
<main className="page">
|
||||||
|
<ModeBanner mode={call.incoming?.mode} />
|
||||||
<h1>АРМ оператора 112</h1>
|
<h1>АРМ оператора 112</h1>
|
||||||
|
<InterviewTimer timers={call.timers} />
|
||||||
<table className="grid">
|
<table className="grid">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><th>Канал</th><td className={call.status === "open" ? "" : "warn"}>{STATUS_LABEL[call.status]}</td></tr>
|
<tr><th>Канал</th><td className={call.status === "open" ? "" : "warn"}>{STATUS_LABEL[call.status]}</td></tr>
|
||||||
|
|
@ -43,7 +47,7 @@ export function Call() {
|
||||||
<button type="button" onClick={() => void call.answer()}>Ответить</button>
|
<button type="button" onClick={() => void call.answer()}>Ответить</button>
|
||||||
)}
|
)}
|
||||||
{call.micOn && <button type="button" onClick={call.hangup}>Завершить вызов</button>}{" "}
|
{call.micOn && <button type="button" onClick={call.hangup}>Завершить вызов</button>}{" "}
|
||||||
{call.micOn && call.incoming?.mode !== "exam" && (
|
{call.micOn && hintsAllowed(call.incoming?.mode) && (
|
||||||
<button type="button" onClick={call.hint}>Подсказка</button>
|
<button type="button" onClick={call.hint}>Подсказка</button>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -70,6 +74,8 @@ export function Call() {
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
|
<h2>Нормативы</h2>
|
||||||
|
<Timers timers={call.timers} />
|
||||||
<h2>Разговор</h2>
|
<h2>Разговор</h2>
|
||||||
<table className="grid">
|
<table className="grid">
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
|
||||||
|
|
@ -51,3 +51,21 @@ button:disabled { opacity: 0.5; cursor: default; }
|
||||||
.required { color: var(--warn); font-weight: 700; }
|
.required { color: var(--warn); font-weight: 700; }
|
||||||
/* Правка, которую сервер ещё не подтвердил. */
|
/* Правка, которую сервер ещё не подтвердил. */
|
||||||
.pending { background: #fffbe6; }
|
.pending { background: #fffbe6; }
|
||||||
|
|
||||||
|
/* Три состояния норматива — единственное место, где цвет несёт смысл.
|
||||||
|
На внешнем мониторе они должны различаться с последнего ряда. */
|
||||||
|
.state-ok { color: var(--ok); }
|
||||||
|
.state-warn { color: var(--warn); font-weight: 600; }
|
||||||
|
.state-violated { color: var(--violated); font-weight: 700; }
|
||||||
|
|
||||||
|
.interview { display: flex; align-items: baseline; gap: 12px; margin: 8px 0 16px; }
|
||||||
|
.interview-label { text-transform: uppercase; letter-spacing: 0.08em; font-size: 12px; }
|
||||||
|
.interview-value { font-size: 40px; font-variant-numeric: tabular-nums; font-weight: 700; line-height: 1; }
|
||||||
|
.interview-limit { font-size: 12px; color: #5a6068; }
|
||||||
|
.interview-big .interview-value { font-size: 96px; }
|
||||||
|
.interview-big .interview-label, .interview-big .interview-limit { font-size: 20px; }
|
||||||
|
|
||||||
|
.mode-banner { padding: 4px 24px; margin: -16px -24px 12px; border-bottom: 1px solid var(--line); font-weight: 600; }
|
||||||
|
.mode-training { background: #e8f0fb; }
|
||||||
|
.mode-exam { background: #fde8e8; }
|
||||||
|
.mode-self { background: #eef3ea; }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue