lct-20: АРМ диспетчера ДДС и цепочка 112 → ДДС
Возврат карточки — самая ценная механика цепочки: неполнота КИО перестаёт быть процентом в отчёте и становится сорванным выездом с конкретной причиной. В разборе это отметка E6: «не заполнено floor, victims_count — выезд сорван». Снимок карточки не меняется после передачи: оператор не дописывает задним числом поле, которое забыл. Тест правит карточку после передачи и проверяет, что в снимке этого нет. Диспетчер садится за АРМ, когда вызов уже идёт, — станция, подключённая после передачи, всё равно получает карточку. У станции нет аудио вообще: ни VAD, ни распознавания, ни синтеза.
This commit is contained in:
parent
38082e2af3
commit
ee2a526831
15 changed files with 439 additions and 13 deletions
|
|
@ -120,6 +120,9 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
case "dds.dispatch":
|
||||
state.on_event("dds.dispatch")
|
||||
state.dispatch(event.service.value)
|
||||
# Карточка замораживается снимком и уходит диспетчеру: оператор
|
||||
# не должен иметь возможности дописать поле задним числом.
|
||||
hub.to_station(session_id, state.card_received_event())
|
||||
hub.to_observers(session_id, KioState(kio=state.kio))
|
||||
hub.to_observers(session_id, TimerTick(timers=state.timers.snapshot()))
|
||||
|
||||
|
|
|
|||
89
backend/app/api/ws/station.py
Normal file
89
backend/app/api/ws/station.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"""Сокет диспетчера ДДС.
|
||||
|
||||
**Только JSON: аудио здесь нет вообще** — значит, нет ни VAD, ни распознавания,
|
||||
ни синтеза. Голосовой контур станция не трогает (docs/arch/CONTRACT.md).
|
||||
|
||||
Самая ценная механика цепочки — `card.bounce`: диспетчер видит, что не указан
|
||||
этаж, и отбивает карточку обратно. Неполнота КИО перестаёт быть процентом
|
||||
в отчёте и становится сорванным выездом с конкретной причиной.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from app.domain.events import ErrorEvent, ErrorKind, StationToServer
|
||||
from app.session.hub import hub
|
||||
from app.session.state import now_utc
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
_adapter = TypeAdapter(StationToServer)
|
||||
|
||||
|
||||
async def _handle(session_id: UUID, state, event) -> None:
|
||||
match event.type:
|
||||
case "card.ack":
|
||||
state.on_event("card.ack")
|
||||
state.dds_log.append(("card.ack", now_utc(), None))
|
||||
case "card.bounce":
|
||||
# Карточка вернулась: в разборе это E6 с конкретной причиной.
|
||||
state.bounced_fields = list(event.missing_fields)
|
||||
state.dds_log.append(("card.bounce", now_utc(), event.comment))
|
||||
case "zone.decision":
|
||||
state.on_event("zone.decision")
|
||||
state.dds_log.append(("zone.decision", now_utc(), "в зоне" if event.in_zone else "не в зоне"))
|
||||
case "crew.dispatched":
|
||||
state.kio = state.kio.model_copy(update={"dispatch_order_at": event.at})
|
||||
state.dds_log.append(("crew.dispatched", now_utc(), None))
|
||||
case "crew.arrived":
|
||||
state.on_event("crew.arrived")
|
||||
state.kio = state.kio.model_copy(update={"arrival_at": event.at})
|
||||
state.dds_log.append(("crew.arrived", now_utc(), None))
|
||||
hub.to_observers(session_id, state.snapshot())
|
||||
|
||||
|
||||
async def _pump(ws: WebSocket, queue: asyncio.Queue) -> None:
|
||||
while True:
|
||||
event = await queue.get()
|
||||
await ws.send_text(event.model_dump_json())
|
||||
|
||||
|
||||
@router.websocket("/ws/station/{session_id}")
|
||||
async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
|
||||
await ws.accept()
|
||||
|
||||
state = hub.get(session_id)
|
||||
if state is None:
|
||||
await ws.send_text(
|
||||
ErrorEvent(code=ErrorKind.SESSION_NOT_FOUND, message="Занятие не запущено").model_dump_json()
|
||||
)
|
||||
await ws.close()
|
||||
return
|
||||
|
||||
with hub.station(session_id) as queue:
|
||||
sender = asyncio.create_task(_pump(ws, queue))
|
||||
try:
|
||||
# Карточка, переданная до подключения станции, не теряется:
|
||||
# диспетчер садится за АРМ, когда вызов уже идёт.
|
||||
if state.dispatched_card is not None:
|
||||
hub.to_station(session_id, state.card_received_event())
|
||||
while True:
|
||||
payload = await ws.receive_json()
|
||||
try:
|
||||
event = _adapter.validate_python(payload)
|
||||
except ValidationError:
|
||||
hub.to_station(
|
||||
session_id,
|
||||
ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=str(payload)[:200]),
|
||||
)
|
||||
continue
|
||||
await _handle(session_id, state, event)
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
finally:
|
||||
sender.cancel()
|
||||
|
|
@ -13,6 +13,7 @@ from app.api.http import trainees
|
|||
from app.api.ws import call as call_ws
|
||||
from app.api.ws import control as control_ws
|
||||
from app.api.ws import observe as observe_ws
|
||||
from app.api.ws import station as station_ws
|
||||
from app.config import get_settings
|
||||
from app.db.base import get_sessionmaker
|
||||
from app.dialog.runtime import get_embedder
|
||||
|
|
@ -65,6 +66,7 @@ app.include_router(trainees.router)
|
|||
app.include_router(call_ws.router)
|
||||
app.include_router(observe_ws.router)
|
||||
app.include_router(control_ws.router)
|
||||
app.include_router(station_ws.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from app.scoring.taxonomy import METRIC_MAP, METRIC_WEIGHTS
|
|||
from app.session.timers import SessionTimers
|
||||
|
||||
SOURCE_BY_CODE = {
|
||||
"E6": FindingSource.CHAIN,
|
||||
"E1": FindingSource.SLOTS,
|
||||
"E2": FindingSource.GROUND_TRUTH,
|
||||
"E3": FindingSource.TIMERS,
|
||||
|
|
@ -110,6 +111,7 @@ def evaluate(
|
|||
timers: SessionTimers,
|
||||
revealed_facts: list[str] | None,
|
||||
end_reason: CallEndReason | None = None,
|
||||
bounced_fields: list[str] | None = None,
|
||||
) -> GostResult:
|
||||
"""Посчитать детерминированный слой по завершённому занятию.
|
||||
|
||||
|
|
@ -230,4 +232,15 @@ def evaluate(
|
|||
finding=f"Не заполнены обязательные поля: {', '.join(empty)}" if empty else None,
|
||||
)
|
||||
|
||||
# ── цепочка 112 → ДДС, E6 ──
|
||||
if bounced_fields:
|
||||
build.add(
|
||||
"dds_chain", "Карточка принята ДДС",
|
||||
f"возвращена на уточнение: {', '.join(bounced_fields)}",
|
||||
"карточка пригодна для выезда",
|
||||
passed=False,
|
||||
ref="цепочка 112 → ДДС",
|
||||
finding=f"Диспетчер вернул карточку: не заполнено {', '.join(bounced_fields)} — выезд сорван",
|
||||
)
|
||||
|
||||
return build.result
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from app.domain.taxonomy import Competency, ErrorCode
|
|||
#: Метрика → (код ошибки при провале, компетенция радара).
|
||||
METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
|
||||
"answer_time": (ErrorCode.E3, Competency.INTAKE),
|
||||
"dds_chain": (ErrorCode.E6, Competency.CARD),
|
||||
"callback": (ErrorCode.E3, Competency.INTAKE),
|
||||
"checklist_completeness": (ErrorCode.E1, Competency.INTERVIEW),
|
||||
"interview_time": (ErrorCode.E3, Competency.NORMS),
|
||||
|
|
@ -36,6 +37,7 @@ METRIC_WEIGHTS: dict[str, float] = {
|
|||
"victims_count": 1.0,
|
||||
"answer_time": 1.0,
|
||||
"callback": 1.0,
|
||||
"dds_chain": 2.0,
|
||||
}
|
||||
|
||||
#: Вес детерминированного слоя в итоговой оценке. Остальное — LLM-судья
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ async def finish(session_id: UUID, state) -> None:
|
|||
timers=state.timers,
|
||||
revealed_facts=[fact.id for fact in state.slots.revealed_facts()] if state.slots else None,
|
||||
end_reason=state.end_reason,
|
||||
bounced_fields=state.bounced_fields,
|
||||
)
|
||||
# Сводка числами: по ней считается дельта между попытками в профиле.
|
||||
# Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам.
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ class SessionHub:
|
|||
self._sessions: dict[UUID, SessionState] = {}
|
||||
self._observers: dict[UUID, set[asyncio.Queue]] = {}
|
||||
self._trainees: dict[UUID, set[asyncio.Queue]] = {}
|
||||
self._stations: dict[UUID, set[asyncio.Queue]] = {}
|
||||
self._tickers: dict[UUID, asyncio.Task] = {}
|
||||
|
||||
# ── реестр ──
|
||||
|
|
@ -77,6 +78,9 @@ class SessionHub:
|
|||
def trainee(self, session_id: UUID):
|
||||
return self._subscribe(self._trainees, session_id)
|
||||
|
||||
def station(self, session_id: UUID):
|
||||
return self._subscribe(self._stations, session_id)
|
||||
|
||||
# ── вещание ──
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -93,6 +97,9 @@ class SessionHub:
|
|||
def to_trainee(self, session_id: UUID, event: BaseModel | bytes) -> None:
|
||||
self._put(self._trainees.get(session_id, set()), event)
|
||||
|
||||
def to_station(self, session_id: UUID, event: BaseModel) -> None:
|
||||
self._put(self._stations.get(session_id, set()), event)
|
||||
|
||||
def broadcast(self, session_id: UUID, event: BaseModel) -> None:
|
||||
self.to_trainee(session_id, event)
|
||||
self.to_observers(session_id, event)
|
||||
|
|
|
|||
|
|
@ -77,6 +77,10 @@ class SessionState:
|
|||
ended_at: datetime | None = None
|
||||
end_reason: CallEndReason | None = None
|
||||
dispatched_card: KIO | None = None
|
||||
dispatched_at: datetime | None = None
|
||||
#: Поля, из-за которых диспетчер вернул карточку, — основание E6.
|
||||
bounced_fields: list[str] = field(default_factory=list)
|
||||
dds_log: list[tuple[str, datetime, str | None]] = field(default_factory=list)
|
||||
|
||||
def on_event(self, event_type: str) -> None:
|
||||
"""Единственная точка, где событие двигает таймеры."""
|
||||
|
|
@ -102,8 +106,19 @@ class SessionState:
|
|||
возможности дописать задним числом поле, которое забыл."""
|
||||
self.kio = apply_patch(self.kio, {"dds": service, "response_status": "transferred"})
|
||||
self.dispatched_card = self.kio.model_copy(deep=True)
|
||||
self.dispatched_at = now_utc()
|
||||
return self.dispatched_card
|
||||
|
||||
def card_received_event(self):
|
||||
"""Снимок карточки для станции ДДС."""
|
||||
from app.domain.events import CardReceived
|
||||
|
||||
return CardReceived(
|
||||
card=self.dispatched_card,
|
||||
from_operator=self.trainee_name or "оператор 112",
|
||||
at=self.dispatched_at or now_utc(),
|
||||
)
|
||||
|
||||
@property
|
||||
def ended(self) -> bool:
|
||||
return self.ended_at is not None
|
||||
|
|
|
|||
126
backend/tests/test_station.py
Normal file
126
backend/tests/test_station.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"""Цепочка 112 → ДДС: карточка уходит снимком, диспетчер подтверждает или отбивает."""
|
||||
|
||||
import time
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.session.hub import hub
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with TestClient(app) as test_client:
|
||||
hub.journal = None
|
||||
yield test_client
|
||||
|
||||
|
||||
def wait_for(predicate, timeout: float = 3.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
value = predicate()
|
||||
if value:
|
||||
return value
|
||||
time.sleep(0.02)
|
||||
raise AssertionError("не дождались")
|
||||
|
||||
|
||||
def read_until(ws, event_type: str, limit: int = 20) -> dict:
|
||||
for _ in range(limit):
|
||||
message = ws.receive_json()
|
||||
if message["type"] == event_type:
|
||||
return message
|
||||
raise AssertionError(f"событие {event_type} не пришло")
|
||||
|
||||
|
||||
def lesson(client):
|
||||
session_id = uuid4()
|
||||
control = client.websocket_connect(f"/ws/control/{session_id}")
|
||||
socket = control.__enter__()
|
||||
socket.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
|
||||
"trainee": "Иванов", "mode": "training"})
|
||||
wait_for(lambda: hub.get(session_id))
|
||||
return session_id, control, socket
|
||||
|
||||
|
||||
def test_card_goes_to_the_dispatcher_frozen(client):
|
||||
"""Снимок не меняется после передачи: оператор не дописывает задним числом."""
|
||||
session_id, control, _ = lesson(client)
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/station/{session_id}?role=dds_01") as station:
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
trainee.send_json({"type": "call.answer"})
|
||||
trainee.send_json({"type": "kio.patch", "fields": {"address": "улица Ленина, 14", "dds": "01"}})
|
||||
wait_for(lambda: hub.get(session_id).kio.address)
|
||||
trainee.send_json({"type": "dds.dispatch", "service": "01"})
|
||||
received = read_until(station, "card.received")
|
||||
|
||||
# Правка после передачи в снимок не попадает.
|
||||
trainee.send_json({"type": "kio.patch", "fields": {"floor": "5"}})
|
||||
wait_for(lambda: hub.get(session_id).kio.floor == "5")
|
||||
|
||||
assert received["card"]["address"] == "улица Ленина, 14"
|
||||
assert received["card"]["floor"] is None, "снимок изменился после передачи"
|
||||
assert received["from_operator"] == "Иванов"
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_station_joining_late_still_gets_the_card(client):
|
||||
"""Диспетчер садится за АРМ, когда вызов уже идёт."""
|
||||
session_id, control, _ = lesson(client)
|
||||
try:
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
trainee.send_json({"type": "call.answer"})
|
||||
trainee.send_json({"type": "dds.dispatch", "service": "01"})
|
||||
wait_for(lambda: hub.get(session_id).dispatched_card)
|
||||
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
assert read_until(station, "card.received")["card"] is not None
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_acknowledgement_stops_the_four_second_norm(client):
|
||||
from app.domain.timers import TimerCode
|
||||
|
||||
session_id, control, _ = lesson(client)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
trainee.send_json({"type": "call.answer"})
|
||||
trainee.send_json({"type": "dds.dispatch", "service": "01"})
|
||||
read_until(station, "card.received")
|
||||
station.send_json({"type": "card.ack"})
|
||||
measured = wait_for(lambda: state.timers.measured_ms(TimerCode.DDS_ACK) is not None)
|
||||
assert measured
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_bounced_card_becomes_e6_with_the_reason(client):
|
||||
"""Неполнота КИО перестаёт быть процентом в отчёте и становится
|
||||
сорванным выездом с конкретной причиной."""
|
||||
session_id, control, _ = lesson(client)
|
||||
try:
|
||||
state = hub.get(session_id)
|
||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
trainee.send_json({"type": "call.answer"})
|
||||
trainee.send_json({"type": "dds.dispatch", "service": "01"})
|
||||
read_until(station, "card.received")
|
||||
station.send_json({"type": "card.bounce", "missing_fields": ["floor", "victims_count"],
|
||||
"comment": "куда ехать без этажа"})
|
||||
wait_for(lambda: state.bounced_fields)
|
||||
trainee.send_json({"type": "call.hangup"})
|
||||
wait_for(lambda: state.score is not None)
|
||||
|
||||
response = client.get(f"/api/sessions/{session_id}/report").json()
|
||||
chain = [finding for finding in response["findings"] if finding["code"] == "E6"]
|
||||
assert chain, "возврат карточки должен попасть в разбор"
|
||||
assert "floor" in chain[0]["summary"] and "выезд сорван" in chain[0]["summary"]
|
||||
finally:
|
||||
control.__exit__(None, None, None)
|
||||
|
|
@ -2,9 +2,9 @@ import { createBrowserRouter, Navigate } from "react-router-dom";
|
|||
|
||||
import { Call } from "@/pages/trainee/Call";
|
||||
import { Instructor } from "@/pages/instructor/Instructor";
|
||||
import { Dds } from "@/pages/dds/Dds";
|
||||
import { Profile } from "@/pages/profile/Profile";
|
||||
import { Wall } from "@/pages/wall/Wall";
|
||||
import { Stub } from "@/shared/ui/Stub";
|
||||
|
||||
// Четыре интерфейса — одна SPA (docs/arch/FRONTEND.md).
|
||||
// session_id живёт в URL: /instructor?session=..., монитор открывают ссылкой.
|
||||
|
|
@ -14,5 +14,5 @@ export const router = createBrowserRouter([
|
|||
{ path: "/instructor", element: <Instructor /> },
|
||||
{ path: "/wall", element: <Wall /> },
|
||||
{ path: "/profile", element: <Profile /> },
|
||||
{ path: "/dds", element: <Stub title="АРМ диспетчера ДДС" card="lct-20" /> },
|
||||
{ path: "/dds", element: <Dds /> },
|
||||
]);
|
||||
|
|
|
|||
165
frontend/src/pages/dds/Dds.tsx
Normal file
165
frontend/src/pages/dds/Dds.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// АРМ диспетчера ДДС.
|
||||
//
|
||||
// Аудио здесь нет вообще: приходит снимок карточки от оператора 112,
|
||||
// дальше — подтверждение по нормативу, возврат на уточнение и отметки времени.
|
||||
// Возврат карточки — самая ценная механика цепочки: неполнота КИО становится
|
||||
// сорванным выездом с конкретной причиной (docs/arch/CONTRACT.md).
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { GROUPS } from "@/features/kio-card/fields";
|
||||
import { KioCard } from "@/features/kio-card/KioCard";
|
||||
import { applyState, empty as emptyCard, type CardState } from "@/features/kio-card/merge";
|
||||
import { stationChannel, type ChannelStatus } from "@/shared/api/ws";
|
||||
import { sessionIdFromUrl } from "@/shared/api/session";
|
||||
|
||||
const ACK_LIMIT_S = 4; // норматив подтверждения получения карточки
|
||||
|
||||
const FIELDS = GROUPS.flatMap((group) =>
|
||||
group.fields.filter((field) => !field.readOnly).map((field) => ({ path: field.path, label: field.label })),
|
||||
);
|
||||
|
||||
export function Dds() {
|
||||
const sessionId = sessionIdFromUrl();
|
||||
const role = new URLSearchParams(location.search).get("role") ?? "dds_01";
|
||||
|
||||
const [status, setStatus] = useState<ChannelStatus>("connecting");
|
||||
const [card, setCard] = useState<CardState | null>(null);
|
||||
const [from, setFrom] = useState<string>("");
|
||||
const [receivedAt, setReceivedAt] = useState<number | null>(null);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [acked, setAcked] = useState(false);
|
||||
const [bounced, setBounced] = useState<string[] | null>(null);
|
||||
const [missing, setMissing] = useState<string[]>([]);
|
||||
const [comment, setComment] = useState("");
|
||||
const [zone, setZone] = useState<boolean | null>(null);
|
||||
const [marks, setMarks] = useState<string[]>([]);
|
||||
const channel = useRef<ReturnType<typeof stationChannel> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
const ch = stationChannel(sessionId, role, {
|
||||
onStatus: setStatus,
|
||||
onEvent: (event) => {
|
||||
if (event.type === "card.received") {
|
||||
setCard(applyState(emptyCard, event.card as unknown as Record<string, unknown>));
|
||||
setFrom(event.from_operator);
|
||||
setReceivedAt(Date.now());
|
||||
}
|
||||
},
|
||||
}).connect();
|
||||
channel.current = ch;
|
||||
return () => ch.close();
|
||||
}, [sessionId, role]);
|
||||
|
||||
// Обратный отсчёт норматива: пока карточка не подтверждена, счёт идёт.
|
||||
useEffect(() => {
|
||||
if (receivedAt === null || acked) return;
|
||||
const timer = setInterval(() => setElapsed((Date.now() - receivedAt) / 1000), 200);
|
||||
return () => clearInterval(timer);
|
||||
}, [receivedAt, acked]);
|
||||
|
||||
if (!sessionId) {
|
||||
return (
|
||||
<main className="page">
|
||||
<h1>АРМ диспетчера ДДС</h1>
|
||||
<p className="warn">Откройте ссылку вида /dds?session=…&role=dds_01</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const mark = (label: string) => setMarks((prev) => [`${new Date().toLocaleTimeString("ru-RU")} — ${label}`, ...prev]);
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<h1>АРМ диспетчера ДДС · {role}</h1>
|
||||
<table className="grid">
|
||||
<tbody>
|
||||
<tr><th>Канал</th><td className={status === "open" ? "" : "warn"}>{status}</td></tr>
|
||||
<tr><th>Карточка</th><td>{card ? `от оператора ${from}` : "ожидание карточки от 112"}</td></tr>
|
||||
{card && !acked && (
|
||||
<tr>
|
||||
<th>Подтверждение</th>
|
||||
<td className={elapsed > ACK_LIMIT_S ? "state-violated" : "state-ok"}>
|
||||
{elapsed.toFixed(1)} с из {ACK_LIMIT_S} с
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{card && (
|
||||
<>
|
||||
<p>
|
||||
<button type="button" disabled={acked} onClick={() => {
|
||||
channel.current?.send({ type: "card.ack" });
|
||||
setAcked(true);
|
||||
mark("карточка принята");
|
||||
}}>Принять карточку</button>{" "}
|
||||
<button type="button" disabled={Boolean(bounced)} onClick={() => {
|
||||
if (!missing.length) return;
|
||||
channel.current?.send({ type: "card.bounce", missing_fields: missing, comment });
|
||||
setBounced(missing);
|
||||
mark(`возврат на уточнение: ${missing.join(", ")}`);
|
||||
}}>Вернуть на уточнение</button>
|
||||
</p>
|
||||
|
||||
{!bounced && (
|
||||
<details>
|
||||
<summary>Чего не хватает для выезда</summary>
|
||||
<div className="bounce-fields">
|
||||
{FIELDS.map((field) => (
|
||||
<label key={field.path}>
|
||||
<input type="checkbox" checked={missing.includes(field.path)}
|
||||
onChange={() => setMissing((prev) =>
|
||||
prev.includes(field.path) ? prev.filter((item) => item !== field.path) : [...prev, field.path])} />{" "}
|
||||
{field.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p>
|
||||
<input type="text" value={comment} placeholder="куда ехать без этажа"
|
||||
onChange={(event) => setComment(event.target.value)} />
|
||||
</p>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<p>
|
||||
Зона ответственности:{" "}
|
||||
<button type="button" disabled={zone !== null} onClick={() => {
|
||||
channel.current?.send({ type: "zone.decision", in_zone: true });
|
||||
setZone(true); mark("наша зона");
|
||||
}}>наша</button>{" "}
|
||||
<button type="button" disabled={zone !== null} onClick={() => {
|
||||
channel.current?.send({ type: "zone.decision", in_zone: false });
|
||||
setZone(false); mark("не наша зона");
|
||||
}}>не наша</button>
|
||||
</p>
|
||||
<p>
|
||||
<button type="button" onClick={() => {
|
||||
channel.current?.send({ type: "crew.dispatched", at: new Date().toISOString() });
|
||||
mark("приказ на выезд");
|
||||
}}>Приказ на выезд</button>{" "}
|
||||
<button type="button" onClick={() => {
|
||||
channel.current?.send({ type: "crew.arrived", at: new Date().toISOString() });
|
||||
mark("прибытие на место");
|
||||
}}>Прибытие</button>
|
||||
</p>
|
||||
|
||||
<h2>Карточка от оператора 112</h2>
|
||||
{/* Снимок: после передачи он не меняется. */}
|
||||
<KioCard state={card} readOnly />
|
||||
</>
|
||||
)}
|
||||
|
||||
{marks.length > 0 && (
|
||||
<>
|
||||
<h2>Отметки времени</h2>
|
||||
<table className="grid">
|
||||
<tbody>{marks.map((line, index) => <tr key={index}><td>{line}</td></tr>)}</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -62,6 +62,7 @@ export function Instructor() {
|
|||
? {
|
||||
trainee: `${location.origin}/trainee?session=${sessionId}`,
|
||||
wall: `${location.origin}/wall?session=${sessionId}`,
|
||||
dds: `${location.origin}/dds?session=${sessionId}&role=dds_01`,
|
||||
}
|
||||
: null;
|
||||
|
||||
|
|
@ -131,6 +132,7 @@ export function Instructor() {
|
|||
<tbody>
|
||||
<tr><th>АРМ курсанта</th><td><a href={links.trainee}>открыть</a></td></tr>
|
||||
<tr><th>Внешний монитор</th><td><a href={links.wall}>открыть</a></td></tr>
|
||||
<tr><th>АРМ ДДС</th><td><a href={links.dds}>открыть</a></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@
|
|||
import type {
|
||||
InstructorToServer,
|
||||
ServerToObserver,
|
||||
ServerToStation,
|
||||
ServerToTrainee,
|
||||
StationToServer,
|
||||
TraineeToServer,
|
||||
} from "@/shared/types/generated";
|
||||
|
||||
|
|
@ -104,6 +106,13 @@ export const callChannel = (sessionId: string, options?: ChannelOptions<ServerTo
|
|||
export const observeChannel = (sessionId: string, options?: ChannelOptions<ServerToObserver>) =>
|
||||
new Channel<ServerToObserver, never>(`/ws/observe/${sessionId}`, options);
|
||||
|
||||
/** Станция ДДС: только JSON, аудио здесь нет вообще. */
|
||||
export const stationChannel = (
|
||||
sessionId: string,
|
||||
role: string,
|
||||
options?: ChannelOptions<ServerToStation>,
|
||||
) => new Channel<ServerToStation, StationToServer>(`/ws/station/${sessionId}?role=${role}`, options);
|
||||
|
||||
/** Преподаватель: только передача, входящих в этом канале нет. */
|
||||
export const controlChannel = (sessionId: string, options?: { onStatus?: (status: ChannelStatus) => void }) =>
|
||||
new Channel<never, InstructorToServer>(`/ws/control/${sessionId}`, options);
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
// Заглушка экрана. Каждая ссылается на карточку, которая её закрывает.
|
||||
export function Stub({ title, card }: { title: string; card: string }) {
|
||||
return (
|
||||
<main className="stub">
|
||||
<h1>{title}</h1>
|
||||
<p>
|
||||
Экран не реализован. Карточка: <code>tasks/{card}.md</code>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -94,3 +94,6 @@ button:disabled { opacity: 0.5; cursor: default; }
|
|||
.director-buttons button { padding: 10px 12px; text-align: left; font-size: 15px; }
|
||||
.director-buttons .danger { border-color: var(--violated); color: var(--violated); font-weight: 600; }
|
||||
.director input[type="text"] { width: 60%; }
|
||||
|
||||
/* Возврат карточки: список полей в две колонки, чтобы диспетчер не скроллил. */
|
||||
.bounce-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 16px; margin: 8px 0; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue