将 Demo 录制收敛为三条独立链路,并重做联调台 UI。

移除 demo_orch 统一编排,改为 recording_demo 与 live/simulated 服务;客户端拆分为静态资源,以模式卡片与 chip 耗材覆盖三链路联调,并同步测试与文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin
2026-05-21 16:50:23 +08:00
parent 09885b4184
commit 153c91f8ff
16 changed files with 2030 additions and 1364 deletions

789
clients/demo-client/app.js Normal file
View File

@@ -0,0 +1,789 @@
/**
* Operation Room Monitor — Demo 联调台
*/
(function () {
"use strict";
const $ = (id) => document.getElementById(id);
const DEF_CAMS = ["or-cam-03", "or-cam-02", "or-cam-04", "or-cam-01"];
const DEF_RP = ["demo1", "demo2", "demo3", "demo4"];
let activeMode = "live-rtsp";
let allLabels = [];
let selectedConsumables = new Set();
let lastVideoBatchDoctorDisplay = "";
const webcamSlotState = {};
const baseUrl = () => $("base-url").value.trim().replace(/\/+$/, "");
const surgeryId = () => $("surgery-id").value.trim();
function inferDefaultApiBase() {
try {
const host = location.hostname || "127.0.0.1";
const scheme = location.protocol === "https:" ? "https" : "http";
return `${scheme}://${host}:38080`;
} catch {
return "http://127.0.0.1:38080";
}
}
function isLoopbackOrWildcardHost(hostname) {
const h = String(hostname || "").trim().toLowerCase();
return h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "0.0.0.0";
}
function applyLanDefaultApiBase() {
const input = $("base-url");
const pageHost = location.hostname || "";
try {
const current = new URL(input.value);
if (!isLoopbackOrWildcardHost(pageHost) && isLoopbackOrWildcardHost(current.hostname)) {
input.value = inferDefaultApiBase();
}
} catch {
input.value = inferDefaultApiBase();
}
}
function getRunMode() {
return activeMode;
}
function showBanner(msg, type) {
const el = $("banner");
if (!el) return;
el.textContent = msg;
el.className = "banner show " + (type || "info");
}
function setLoading(btn, on) {
if (!btn) return;
btn.disabled = on;
btn.classList.toggle("loading", on);
}
function formatDetail(body) {
if (body == null) return "错误";
const detail = body.detail !== undefined ? body.detail : body;
if (typeof detail === "object" && detail !== null) {
return JSON.stringify(detail, null, 2);
}
return String(detail || body);
}
const logEl = $("log-scroll");
function addLog(method, url, status, body, { error = false, hint = "" } = {}) {
const item = document.createElement("div");
item.className = "log-item";
const time = new Date().toLocaleTimeString();
const statusClass = error || (typeof status === "number" && status >= 400) ? "err" : "ok";
const statusText = typeof status === "number" ? status : status || (error ? "ERROR" : "—");
item.innerHTML = `
<div class="log-head">
<span><span class="log-method">${method}</span> <span>${url}</span></span>
<span class="log-status ${statusClass}">${statusText} <span>${time}</span></span>
</div>`;
const bodyEl = document.createElement("div");
bodyEl.className = "log-body";
if (body == null || body === "") bodyEl.textContent = "(empty)";
else if (typeof body === "string") bodyEl.textContent = body;
else {
try {
bodyEl.textContent = JSON.stringify(body, null, 2);
} catch {
bodyEl.textContent = String(body);
}
}
item.appendChild(bodyEl);
if (hint) {
const h = document.createElement("div");
h.className = "log-hint";
h.textContent = hint;
item.appendChild(h);
}
if (logEl) logEl.insertBefore(item, logEl.firstChild);
}
async function apiJson(method, path, payload) {
const url = baseUrl() + path;
let res;
try {
res = await fetch(url, {
method,
headers: payload ? { "Content-Type": "application/json" } : undefined,
body: payload ? JSON.stringify(payload) : undefined,
});
} catch (e) {
addLog(method, url, "NETWORK", String(e), { error: true });
throw e;
}
const text = await res.text();
let parsed;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = text;
}
addLog(method, url, res.status, parsed, { error: !res.ok });
return { res, body: parsed };
}
async function apiMultipart(path, formData, logLabel) {
const url = baseUrl() + path;
const label = logLabel || "multipart";
let res;
try {
res = await fetch(url, { method: "POST", body: formData });
} catch (e) {
const hint = "无法连接 API请确认 Base URL 指向主服务(默认 :38080。";
addLog("POST (" + label + ")", url, "NETWORK", String(e), { error: true, hint });
throw e;
}
const text = await res.text();
let parsed;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = text;
}
let hint = "";
if (res.status === 404) {
hint = "Demo 录制模式未注册,请设 DEMO_ORCHESTRATOR_ENABLED=true 并重启。";
} else if (
res.status === 400 &&
parsed &&
String(parsed.detail || "").indexOf("OR_SITE_CONFIG") >= 0
) {
hint = "需配置可写的 OR_SITE_CONFIG_JSON_FILE。";
}
addLog("POST (" + label + ")", url, res.status, parsed, { error: !res.ok, hint });
return { res, body: parsed };
}
async function apiOfflineBatch(formData) {
return apiMultipart("/internal/demo/offline-batch", formData, "offline-batch");
}
function ensureSurgeryId() {
const sid = surgeryId();
if (!/^\d{6}$/.test(sid)) {
showBanner("手术号必须是 6 位数字", "err");
return null;
}
return sid;
}
function formatConsumablesJson(arr) {
return JSON.stringify(arr, null, 2);
}
function parseConsumablesFromJsonText(raw) {
if (!raw.trim()) return [];
let parsed;
try {
parsed = JSON.parse(raw);
} catch (e) {
throw new Error("JSON 格式错误:" + (e.message || String(e)));
}
if (!Array.isArray(parsed)) throw new Error("必须是 JSON 数组");
const out = [];
for (let i = 0; i < parsed.length; i++) {
const x = parsed[i];
if (typeof x === "string") {
const s = x.trim();
if (s) out.push(s);
continue;
}
if (x !== null && typeof x === "object" && !Array.isArray(x)) {
const name = x["名称"] != null ? x["名称"] : x["name"];
if (typeof name === "string") {
const s = name.trim();
if (s) out.push(s);
}
}
}
return out;
}
function getSelectedConsumables() {
const ta = $("candidate-consumables-json");
const advanced = ta?.closest("details.advanced");
if (ta && (advanced?.open || document.activeElement === ta)) {
return parseConsumablesFromJsonText(ta.value);
}
return [...selectedConsumables];
}
function syncJsonFromChips() {
const ta = $("candidate-consumables-json");
if (ta) ta.value = formatConsumablesJson(getSelectedConsumables());
}
function syncChipsFromJson() {
try {
const arr = parseConsumablesFromJsonText($("candidate-consumables-json").value);
selectedConsumables = new Set(arr);
renderConsumableChips();
} catch {
/* ignore while typing invalid json */
}
}
function renderConsumableChips() {
const container = $("chips");
const q = ($("chip-search")?.value || "").trim().toLowerCase();
if (!container) return;
container.innerHTML = "";
const filtered = allLabels.filter((l) => !q || l.toLowerCase().includes(q));
filtered.forEach((label) => {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "chip" + (selectedConsumables.has(label) ? " selected" : "");
chip.textContent = label;
chip.onclick = () => {
if (selectedConsumables.has(label)) selectedConsumables.delete(label);
else selectedConsumables.add(label);
renderConsumableChips();
syncJsonFromChips();
};
container.appendChild(chip);
});
const meta = $("labels-meta");
if (meta) {
meta.textContent = `已选 ${selectedConsumables.size} / 共 ${allLabels.length} 个标签`;
}
}
async function loadLabels() {
try {
const res = await fetch("/labels.json");
if (!res.ok) throw new Error("HTTP " + res.status);
const data = await res.json();
allLabels = Array.isArray(data.labels) ? data.labels : [];
selectedConsumables = new Set(allLabels.slice(0, 5));
renderConsumableChips();
syncJsonFromChips();
} catch (e) {
allLabels = [];
selectedConsumables = new Set();
renderConsumableChips();
$("labels-meta").textContent = "标签加载失败";
console.warn(e);
}
}
function setActiveMode(mode) {
activeMode = mode;
document.querySelectorAll(".mode-card").forEach((card) => {
card.classList.toggle("active", card.dataset.mode === mode);
});
document.querySelectorAll(".mode-panel").forEach((panel) => {
panel.classList.toggle("active", panel.dataset.mode === mode);
});
const voice = $("voice-callout");
if (voice) voice.classList.toggle("hidden", mode === "offline-batch");
const btnStart = $("btn-start");
const btnEnd = $("btn-end");
if (btnStart) {
btnStart.textContent =
mode === "offline-batch"
? "上传并处理"
: mode === "live-simulated"
? "开始模拟开录"
: "开始手术";
}
if (btnEnd) btnEnd.disabled = mode === "offline-batch";
$("pill-mode").textContent =
mode === "live-rtsp"
? "链路 1 · 真摄像头"
: mode === "live-simulated"
? "链路 2 · 模拟实时"
: "链路 3 · 离线精确";
refreshRecordingModesStatus();
}
async function refreshHealth() {
const pill = $("pill-health");
try {
const { res } = await apiJson("GET", "/health");
if (pill) {
pill.textContent = res.ok ? "API 正常" : "API 异常 " + res.status;
pill.className = "pill " + (res.ok ? "ok" : "err");
}
} catch {
if (pill) {
pill.textContent = "API 不可达";
pill.className = "pill err";
}
}
}
async function refreshRecordingModesStatus() {
const pill = $("pill-demo-modes");
if (activeMode === "live-rtsp") {
if (pill) {
pill.textContent = "链路 2/3 未检测";
pill.className = "pill";
}
return;
}
const url = baseUrl() + "/internal/demo/recording-modes-status";
try {
const res = await fetch(url);
const data = await res.json();
addLog("GET", url, res.status, data, { error: !res.ok });
if (!pill) return;
const on = data.demo_recording_modes_enabled === true || data.orchestrator_enabled === true;
const fset = data.or_site_config_json_file_set === true;
if (!res.ok) {
pill.textContent = "状态拉取失败";
pill.className = "pill err";
return;
}
if (on && fset) {
pill.textContent = "链路 2/3 已就绪";
pill.className = "pill ok";
} else if (on) {
pill.textContent = "缺 OR_SITE_CONFIG";
pill.className = "pill warn";
} else {
pill.textContent = "DEMO 未开启";
pill.className = "pill err";
}
} catch (e) {
if (pill) {
pill.textContent = "状态失败";
pill.className = "pill err";
}
}
}
function hideVideoBatchVisualization() {
const wrap = $("video-batch-vis");
const player = $("video-batch-vis-player");
if (wrap) wrap.classList.add("hidden");
if (player) {
player.removeAttribute("src");
player.load();
}
}
function showVideoBatchVisualization(sid, urlPath, doctorDisplay) {
const wrap = $("video-batch-vis");
const player = $("video-batch-vis-player");
const hint = $("video-batch-vis-hint");
if (!wrap || !player) return;
const path = urlPath || `/internal/demo/offline-batch/${sid}/visualization`;
const src = baseUrl() + path + "?t=" + Date.now();
wrap.classList.remove("hidden");
const docEl = $("video-batch-doctor-info");
const text = (doctorDisplay || lastVideoBatchDoctorDisplay || "").trim();
if (docEl) {
docEl.textContent = text ? "识别医生:" + text : "";
docEl.style.display = text ? "block" : "none";
}
player.onerror = () => {
if (hint) hint.textContent = "视频加载失败,请稍后重试或新标签页打开链接。";
};
player.onloadeddata = () => {
if (hint) hint.textContent = "标注视频已加载";
};
player.src = src;
player.load();
if (hint) hint.textContent = "正在加载…";
}
function getDebugStreamCount() {
const sel = $("debug-stream-count");
const v = sel ? parseInt(sel.value, 10) : 1;
return v >= 1 && v <= 4 ? v : 1;
}
function applyDebugStreamVisibility() {
const n = getDebugStreamCount();
for (let i = 1; i <= 4; i++) {
const el = $("sim-stream-" + i);
if (el) el.classList.toggle("hidden", i > n);
}
}
function assignFileToInput(inputEl, file) {
const dt = new DataTransfer();
dt.items.add(file);
inputEl.files = dt.files;
}
function setSlotFile(slot, file, source) {
const vfile = $("sim-vfile-" + slot);
const hint = $("sim-hint-" + slot);
const fname = $("sim-fname-" + slot);
if (vfile && file) assignFileToInput(vfile, file);
if (fname) fname.textContent = file ? file.name : "";
if (hint) hint.textContent = file ? "已选:" + file.name + (source ? " (" + source + ")" : "") : "";
}
function setOfflineFile(file, source) {
const vfile = $("offline-vfile");
const fname = $("offline-fname");
const hint = $("offline-hint");
if (vfile && file) assignFileToInput(vfile, file);
if (fname) fname.textContent = file ? file.name : "点击或拖放 MP4 到此处";
if (hint) hint.textContent = file ? "已选:" + file.name + (source ? " (" + source + ")" : "") : "";
}
function bindDropZone(zoneEl, onFile) {
if (!zoneEl) return;
zoneEl.addEventListener("dragover", (ev) => {
ev.preventDefault();
zoneEl.classList.add("drag-over");
});
zoneEl.addEventListener("dragleave", () => zoneEl.classList.remove("drag-over"));
zoneEl.addEventListener("drop", (ev) => {
ev.preventDefault();
zoneEl.classList.remove("drag-over");
const f = ev.dataTransfer?.files?.[0];
if (f && looksLikeVideo(f)) onFile(f, "拖放");
else showBanner("请拖入视频文件MP4 等)", "warn");
});
}
function looksLikeVideo(f) {
return (
/^video\//.test(f.type || "") ||
/\.(mp4|mov|mkv|avi|webm|m4v)$/i.test(f.name || "")
);
}
function pickMediaRecorderMime() {
if (typeof MediaRecorder === "undefined" || !MediaRecorder.isTypeSupported) return "";
for (const m of [
"video/webm;codecs=vp9,opus",
"video/webm;codecs=vp8,opus",
"video/webm",
"video/mp4",
]) {
if (MediaRecorder.isTypeSupported(m)) return m;
}
return "";
}
async function toggleWebcamSlot(slot) {
const st = webcamSlotState[slot] || (webcamSlotState[slot] = {});
const btn = $("sim-webcam-" + slot);
const hint = $("sim-hint-" + slot);
if (!st.recording) {
if (!navigator.mediaDevices?.getUserMedia) {
showBanner("需要 HTTPS 或 localhost 才能使用摄像头", "warn");
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "user" },
audio: true,
});
const mime = pickMediaRecorderMime();
st.chunks = [];
const rec = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream);
rec.ondataavailable = (ev) => {
if (ev.data?.size) st.chunks.push(ev.data);
};
rec.start(250);
st.recording = true;
st.stream = stream;
st.recorder = rec;
if (btn) {
btn.textContent = "停止录制";
btn.classList.add("warn");
}
if (hint) hint.textContent = "录制中…";
} catch (e) {
showBanner("无法打开摄像头:" + (e.message || String(e)), "err");
}
return;
}
const rec = st.recorder;
const stream = st.stream;
st.recording = false;
st.recorder = null;
st.stream = null;
if (btn) {
btn.textContent = "摄像头";
btn.classList.remove("warn");
}
await new Promise((resolve) => {
rec.addEventListener("stop", resolve, { once: true });
rec.stop();
});
stream?.getTracks().forEach((t) => t.stop());
const blob = new Blob(st.chunks, { type: rec.mimeType || "video/webm" });
st.chunks = [];
if (!blob.size) {
if (hint) hint.textContent = "未录到数据";
return;
}
const ext = (rec.mimeType || "").includes("mp4") ? ".mp4" : ".webm";
const file = new File([blob], "webcam-" + slot + "-" + Date.now() + ext, { type: blob.type });
setSlotFile(slot, file, "摄像头");
}
async function handleStart() {
const sid = ensureSurgeryId();
if (!sid) return;
let candidateConsumables;
try {
candidateConsumables = getSelectedConsumables();
} catch (e) {
showBanner(e.message || String(e), "err");
return;
}
const btn = $("btn-start");
setLoading(btn, true);
showBanner("请求进行中…", "info");
try {
const mode = getRunMode();
if (mode === "offline-batch") {
const f = $("offline-vfile")?.files?.[0];
if (!f) {
showBanner("请先选择完整 MP4 文件", "err");
return;
}
const fd = new FormData();
fd.append("surgery_id", sid);
fd.append("video1", f, f.name);
fd.append("candidate_consumables_json", JSON.stringify(candidateConsumables));
fd.append(
"include_visualization",
$("offline-batch-include-vis")?.checked ? "true" : "false",
);
const { res, body } = await apiOfflineBatch(fd);
if (!res.ok) {
showBanner("离线处理失败:" + formatDetail(body), "err");
return;
}
lastVideoBatchDoctorDisplay = body?.doctor_display || "";
if ($("offline-batch-include-vis")?.checked && body?.visualization_url) {
showVideoBatchVisualization(sid, body.visualization_url, lastVideoBatchDoctorDisplay);
} else hideVideoBatchVisualization();
showBanner("离线处理完成,正在查询结果…", "ok");
await handleResult();
return;
}
if (mode === "live-simulated") {
const n = getDebugStreamCount();
const files = [];
for (let i = 1; i <= n; i++) {
files.push($("sim-vfile-" + i)?.files?.[0]);
}
if (files.length !== n || !files.every(Boolean)) {
showBanner("请为路 1…" + n + " 全部选择视频文件", "err");
return;
}
const fd = new FormData();
fd.append("surgery_id", sid);
fd.append("video1", files[0], files[0].name);
if (n >= 2) fd.append("video2", files[1], files[1].name);
if (n >= 3) fd.append("video3", files[2], files[2].name);
if (n >= 4) fd.append("video4", files[3], files[3].name);
for (let i = 1; i <= 4; i++) {
fd.append(
"camera_" + i,
($("adv-cam-" + i)?.value || DEF_CAMS[i - 1]).trim() || DEF_CAMS[i - 1],
);
fd.append(
"rtsp_path_" + i,
($("adv-rpath-" + i)?.value || DEF_RP[i - 1]).trim() || DEF_RP[i - 1],
);
}
fd.append("candidate_consumables_json", JSON.stringify(candidateConsumables));
const { res, body } = await apiMultipart(
"/internal/demo/simulated-start",
fd,
"simulated-start",
);
if (!res.ok) {
showBanner("模拟开录失败:" + formatDetail(body), "err");
return;
}
showBanner("模拟开录已接受,请打开语音终端", "ok");
return;
}
const camera_ids = $("camera-ids")
.value.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (!camera_ids.length) {
showBanner("请填写至少一个 camera_id", "err");
return;
}
const { res, body } = await apiJson("POST", "/client/surgeries/start", {
surgery_id: sid,
camera_ids,
candidate_consumables: candidateConsumables,
});
if (!res.ok) {
showBanner("开录失败:" + formatDetail(body), "err");
return;
}
showBanner("开录已接受,请打开语音终端", "ok");
} catch (e) {
showBanner("网络错误:" + String(e), "err");
} finally {
setLoading(btn, false);
}
}
async function handleEnd() {
const sid = ensureSurgeryId();
if (!sid) return;
const btn = $("btn-end");
setLoading(btn, true);
try {
const { res, body } = await apiJson("POST", "/client/surgeries/end", { surgery_id: sid });
showBanner(res.ok ? "手术已结束" : "结束失败:" + formatDetail(body), res.ok ? "ok" : "err");
} finally {
setLoading(btn, false);
}
}
async function handleResult() {
const sid = ensureSurgeryId();
if (!sid) return;
if (getRunMode() === "offline-batch" && $("offline-batch-include-vis")?.checked) {
showVideoBatchVisualization(sid, null);
} else if (getRunMode() !== "offline-batch") {
hideVideoBatchVisualization();
}
const { res, body } = await apiJson("GET", `/client/surgeries/${sid}/result`);
const target = $("result-render");
if (!target) return;
target.innerHTML = "";
if (!res.ok || !body || typeof body !== "object") {
showBanner(res.ok ? "无结果数据" : "查询失败:" + formatDetail(body), "err");
return;
}
const { details = [], summary = [] } = body;
const renderTable = (title, rows, cols) => {
const h = document.createElement("h3");
h.textContent = title;
target.appendChild(h);
const t = document.createElement("table");
t.innerHTML =
"<thead><tr>" + cols.map((c) => `<th>${c.label}</th>`).join("") + "</tr></thead>";
const tbody = document.createElement("tbody");
if (!rows.length) {
tbody.innerHTML = `<tr><td colspan="${cols.length}">(空)</td></tr>`;
} else {
rows.forEach((row) => {
const tr = document.createElement("tr");
tr.innerHTML = cols.map((c) => `<td>${row[c.key] ?? ""}</td>`).join("");
tbody.appendChild(tr);
});
}
t.appendChild(tbody);
target.appendChild(t);
};
renderTable("消耗明细", details, [
{ key: "timestamp", label: "时间" },
{ key: "item_name", label: "耗材" },
{ key: "qty", label: "数量" },
{ key: "doctor_id", label: "医生" },
]);
renderTable("汇总", summary, [
{ key: "item_name", label: "耗材" },
{ key: "total_quantity", label: "合计" },
]);
showBanner("结果已加载", "ok");
}
function initModeCards() {
document.querySelectorAll(".mode-card").forEach((card) => {
card.addEventListener("click", () => setActiveMode(card.dataset.mode));
});
setActiveMode("live-rtsp");
}
function initSimulatedUploads() {
$("debug-stream-count")?.addEventListener("change", applyDebugStreamVisibility);
applyDebugStreamVisibility();
for (let i = 1; i <= 4; i++) {
const pick = $("sim-pick-" + i);
const vfile = $("sim-vfile-" + i);
const zone = $("sim-zone-" + i);
if (pick && vfile) {
pick.onclick = () => vfile.click();
vfile.onchange = () => {
const f = vfile.files?.[0];
if (f) setSlotFile(i, f, "选择");
};
}
bindDropZone(zone, (f) => setSlotFile(i, f, "拖放"));
zone?.addEventListener("click", (ev) => {
if (ev.target.closest("button")) return;
vfile?.click();
});
$("sim-webcam-" + i)?.addEventListener("click", () => toggleWebcamSlot(i));
}
}
function initOfflineUpload() {
const zone = $("offline-zone");
const vfile = $("offline-vfile");
$("offline-pick")?.addEventListener("click", () => vfile?.click());
vfile?.addEventListener("change", () => {
const f = vfile.files?.[0];
if (f) setOfflineFile(f, "选择");
});
bindDropZone(zone, (f) => setOfflineFile(f, "拖放"));
zone?.addEventListener("click", (ev) => {
if (ev.target.closest("button")) return;
vfile?.click();
});
}
function initConsumables() {
$("chip-search")?.addEventListener("input", renderConsumableChips);
$("btn-chips-all")?.addEventListener("click", () => {
allLabels.forEach((l) => selectedConsumables.add(l));
renderConsumableChips();
syncJsonFromChips();
});
$("btn-chips-clear")?.addEventListener("click", () => {
selectedConsumables.clear();
renderConsumableChips();
syncJsonFromChips();
});
$("candidate-consumables-json")?.addEventListener("change", syncChipsFromJson);
$("candidate-consumables-json")?.addEventListener("blur", syncChipsFromJson);
}
function init() {
applyLanDefaultApiBase();
initModeCards();
initSimulatedUploads();
initOfflineUpload();
initConsumables();
loadLabels();
$("btn-start")?.addEventListener("click", handleStart);
$("btn-end")?.addEventListener("click", handleEnd);
$("btn-result")?.addEventListener("click", handleResult);
$("btn-refresh-status")?.addEventListener("click", () => {
refreshHealth();
refreshRecordingModesStatus();
});
$("btn-clear-log")?.addEventListener("click", () => {
if (logEl) logEl.innerHTML = "";
});
$("base-url")?.addEventListener("change", refreshRecordingModesStatus);
refreshHealth();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();