将 Demo 录制收敛为三条独立链路,并重做联调台 UI。
移除 demo_orch 统一编排,改为 recording_demo 与 live/simulated 服务;客户端拆分为静态资源,以模式卡片与 chip 耗材覆盖三链路联调,并同步测试与文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
789
clients/demo-client/app.js
Normal file
789
clients/demo-client/app.js
Normal 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();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user