将 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

View File

@@ -1,43 +1,55 @@
# Demo Client
# Demo Client · 联调台
独立浏览器联调页,用于手动触发监控 API 的部分 `/client/*` 接口:开始/结束手术、查询结果等。语音待确认、TTS 与麦克风录音请使用同级的 [`../voice-confirmation/`](../voice-confirmation/) 或其它专用客户端
浏览器联调页,覆盖三条录制链路。语音待确认请使用 [`../voice-confirmation/`](../voice-confirmation/)(默认 :8080
## 结构
## 三条链路
```
clients/demo-client/
start.sh
server.py
index.html
labels.yaml # 耗材类名快照(与 backend/app/resources/consumable_classifier_labels.yaml 同步)
fake_rtsp_from_file.py
```
| 模式 | 操作 | API | 语音 | 结束手术 |
|------|------|-----|------|----------|
| **链路 1 · 真摄像头** | 填 camera_id → 开始手术 | `POST /client/surgeries/start` | 需要 | 需要 |
| **链路 2 · 模拟实时** | 选满 N 路视频 → 开始模拟开录 | `POST /internal/demo/simulated-start` | 需要 | 需要 |
| **链路 3 · 离线精确** | 选 MP4 → 上传并处理 | `POST /internal/demo/offline-batch` | 无 | 不需要 |
**`labels.yaml`**:本目录自带副本,与后端解耦。后端类名变更时,请同步更新此文件
链路 2/3 需 `DEMO_ORCHESTRATOR_ENABLED=true`;链路 2 还需可写 `OR_SITE_CONFIG_JSON_FILE`。页顶「刷新状态」可查看 API 与 Demo 模式是否就绪
## 界面说明
- **模式卡片**:点选切换,只显示当前模式相关配置
- **耗材**:标签 chip 多选;「高级」可编辑 JSON
- **链路 2**默认只需上传视频RTSP 路径与 camera_id 在「高级」折叠
- **链路 3**:独立 MP4 上传区,可选生成标注视频
- **开发者日志**:右侧折叠,记录完整 HTTP 请求/响应
## 运行
```bash
# 1) 启动 Docker 后端
cd backend && docker compose up -d --build
# 2) 在本目录启动 Demo 页
cd ../clients/demo-client && ./start.sh
# 3) 浏览器访问
open http://127.0.0.1:38081/
```
页面「服务端 Base URL」默认指向同主机 `:38080`;后端在其他机器时手动改为 `http://<GPU服务器IP>:38080`
「API 地址」默认 `http://127.0.0.1:38080`;从局域网访问 demo 页时会自动改用当前主机 IP
## 调试:无真实摄像头,用录好的视频模拟 RTSP
## 文件
```bash
python3 fake_rtsp_from_file.py /path/to/recording.mp4 --port 18554 --path demo
```
clients/demo-client/
index.html # 页面骨架
styles.css # 样式
app.js # 逻辑
server.py # 静态服务 + GET /labels.json
labels.yaml # 耗材标签(与后端同步)
start.sh
```
容器内 API 访问宿主机 RTSP 应使用 `host.docker.internal`。详见 [`../../docs/video-backends.md`](../../docs/video-backends.md)。
## 手跑假 RTSP链路 2 高级)
```bash
python3 fake_rtsp_from_file.py /path/to/video.mp4 --port 18554 --path demo
```
详见 [`../../docs/video-backends.md`](../../docs/video-backends.md)。
## CORS
跨域访问 API 时,后端 `backend/.env` `DEMO_CORS_ENABLED=true`;生产环境收窄 `DEMO_CORS_ORIGINS`
跨域访问 API 时设置 `DEMO_CORS_ENABLED=true`

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();
}
})();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,624 @@
:root {
--bg: #0c1222;
--bg-elevated: #111827;
--panel: #151d2e;
--panel-hover: #1a2438;
--border: #2a3a52;
--border-active: #38bdf8;
--text: #e8eef7;
--muted: #8b9cb3;
--accent: #38bdf8;
--accent-dim: rgba(56, 189, 248, 0.12);
--success: #34d399;
--success-dim: rgba(52, 211, 153, 0.12);
--danger: #f87171;
--danger-dim: rgba(248, 113, 113, 0.12);
--warn: #fbbf24;
--warn-dim: rgba(251, 191, 36, 0.12);
--radius: 10px;
--radius-sm: 6px;
--shadow: 0 4px 24px rgba(0, 0, 0, 0.35);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
min-height: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
font-size: 15px;
line-height: 1.5;
}
a {
color: var(--accent);
}
.app {
max-width: 1280px;
margin: 0 auto;
padding: 20px 20px 40px;
}
.header {
margin-bottom: 20px;
}
.header h1 {
font-size: 1.35rem;
font-weight: 700;
margin: 0 0 4px;
letter-spacing: -0.02em;
}
.header .subtitle {
color: var(--muted);
font-size: 0.875rem;
margin: 0;
}
.top-grid {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 12px;
margin-top: 16px;
align-items: end;
}
@media (max-width: 720px) {
.top-grid {
grid-template-columns: 1fr;
}
}
label {
display: block;
font-size: 0.75rem;
font-weight: 500;
color: var(--muted);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
input[type="text"],
input[type="url"],
input[type="number"],
select,
textarea {
width: 100%;
background: var(--bg-elevated);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 12px;
font-size: 0.9375rem;
font-family: inherit;
}
input:focus,
textarea:focus,
select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-dim);
}
.status-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
align-items: center;
}
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 999px;
font-size: 0.75rem;
background: var(--panel);
border: 1px solid var(--border);
color: var(--muted);
}
.pill.ok {
border-color: rgba(52, 211, 153, 0.4);
color: var(--success);
background: var(--success-dim);
}
.pill.err {
border-color: rgba(248, 113, 113, 0.4);
color: var(--danger);
background: var(--danger-dim);
}
.pill.warn {
border-color: rgba(251, 191, 36, 0.4);
color: var(--warn);
background: var(--warn-dim);
}
.banner {
margin-top: 12px;
padding: 10px 14px;
border-radius: var(--radius-sm);
font-size: 0.875rem;
display: none;
}
.banner.show {
display: block;
}
.banner.info {
background: var(--accent-dim);
border: 1px solid rgba(56, 189, 248, 0.35);
color: var(--text);
}
.banner.ok {
background: var(--success-dim);
border: 1px solid rgba(52, 211, 153, 0.35);
}
.banner.err {
background: var(--danger-dim);
border: 1px solid rgba(248, 113, 113, 0.35);
color: #fecaca;
}
.banner.warn {
background: var(--warn-dim);
border: 1px solid rgba(251, 191, 36, 0.35);
}
.voice-callout {
margin-top: 12px;
padding: 12px 14px;
border-radius: var(--radius-sm);
background: var(--success-dim);
border: 1px solid rgba(52, 211, 153, 0.3);
font-size: 0.875rem;
}
.voice-callout.hidden {
display: none;
}
.layout-main {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(300px, 360px);
gap: 20px;
align-items: start;
}
@media (max-width: 960px) {
.layout-main {
grid-template-columns: 1fr;
}
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px 20px;
margin-bottom: 16px;
box-shadow: var(--shadow);
}
.card h2 {
font-size: 0.8125rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
margin: 0 0 14px;
}
.mode-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
@media (max-width: 800px) {
.mode-cards {
grid-template-columns: 1fr;
}
}
.mode-card {
text-align: left;
padding: 14px 16px;
border-radius: var(--radius);
border: 2px solid var(--border);
background: var(--bg-elevated);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
font-family: inherit;
color: inherit;
}
.mode-card:hover {
background: var(--panel-hover);
border-color: #3d5168;
}
.mode-card.active {
border-color: var(--accent);
background: var(--accent-dim);
}
.mode-card .title {
font-weight: 600;
font-size: 0.9375rem;
margin-bottom: 4px;
}
.mode-card .desc {
font-size: 0.75rem;
color: var(--muted);
line-height: 1.4;
}
.mode-panel {
display: none;
}
.mode-panel.active {
display: block;
}
.chip-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
align-items: center;
}
.chip-search {
flex: 1;
min-width: 140px;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
max-height: 200px;
overflow-y: auto;
padding: 4px 0;
}
.chip {
padding: 6px 12px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg-elevated);
color: var(--muted);
font-size: 0.8125rem;
cursor: pointer;
transition: all 0.12s;
user-select: none;
}
.chip:hover {
border-color: var(--accent);
color: var(--text);
}
.chip.selected {
background: var(--accent-dim);
border-color: var(--accent);
color: var(--accent);
font-weight: 500;
}
.labels-meta {
font-size: 0.75rem;
color: var(--muted);
}
details.advanced {
margin-top: 14px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
details.advanced summary {
padding: 10px 14px;
cursor: pointer;
font-size: 0.8125rem;
color: var(--muted);
background: var(--bg-elevated);
user-select: none;
}
details.advanced summary:hover {
color: var(--text);
}
details.advanced .advanced-body {
padding: 12px 14px;
border-top: 1px solid var(--border);
}
#candidate-consumables-json {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.75rem;
min-height: 80px;
resize: vertical;
}
.upload-zone {
border: 2px dashed var(--border);
border-radius: var(--radius);
padding: 24px 16px;
text-align: center;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
background: var(--bg-elevated);
}
.upload-zone:hover,
.upload-zone.drag-over {
border-color: var(--accent);
background: var(--accent-dim);
}
.upload-zone .icon {
font-size: 2rem;
margin-bottom: 8px;
opacity: 0.5;
}
.upload-zone .filename {
margin-top: 10px;
font-size: 0.8125rem;
color: var(--accent);
word-break: break-all;
}
.stream-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
margin-top: 12px;
}
.stream-slot {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 12px;
background: var(--bg-elevated);
}
.stream-slot h4 {
margin: 0 0 10px;
font-size: 0.8125rem;
color: var(--accent);
}
.stream-slot .upload-zone {
padding: 16px 10px;
font-size: 0.8125rem;
}
.stream-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 8px;
}
.stream-hint {
font-size: 0.75rem;
color: var(--muted);
margin-top: 6px;
min-height: 1.2em;
}
.steps {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
button {
font-family: inherit;
font-size: 0.875rem;
font-weight: 600;
border: none;
border-radius: var(--radius-sm);
padding: 10px 18px;
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
}
button:active:not(:disabled) {
transform: scale(0.98);
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
button.primary {
background: var(--accent);
color: #0a1628;
}
button.primary.lg {
padding: 12px 24px;
font-size: 1rem;
}
button.secondary {
background: var(--bg-elevated);
color: var(--text);
border: 1px solid var(--border);
}
button.warn {
background: var(--warn);
color: #0a1628;
}
button.danger {
background: var(--danger);
color: #fff;
}
button.loading {
pointer-events: none;
opacity: 0.7;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
font-size: 0.875rem;
cursor: pointer;
}
.checkbox-row input {
width: auto;
}
.result-area {
margin-top: 14px;
}
.result-area table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
}
.result-area th,
.result-area td {
padding: 8px 10px;
border-bottom: 1px solid var(--border);
text-align: left;
}
.result-area th {
color: var(--muted);
font-weight: 500;
}
.result-area h3 {
font-size: 0.875rem;
margin: 16px 0 8px;
color: var(--muted);
}
.vis-block {
margin-top: 14px;
}
.vis-block video {
width: 100%;
max-height: 400px;
border-radius: var(--radius-sm);
background: #000;
}
.dev-log {
position: sticky;
top: 16px;
}
.dev-log details {
border: 1px solid var(--border);
border-radius: var(--radius);
background: #0a0f18;
}
.dev-log summary {
padding: 12px 14px;
cursor: pointer;
font-size: 0.8125rem;
font-weight: 600;
color: var(--muted);
}
.log-scroll {
max-height: 60vh;
overflow-y: auto;
padding: 0 10px 10px;
}
.log-item {
border-bottom: 1px dashed var(--border);
padding: 8px 4px;
font-size: 0.6875rem;
}
.log-item:last-child {
border-bottom: none;
}
.log-head {
display: flex;
justify-content: space-between;
gap: 6px;
}
.log-method {
color: var(--accent);
font-weight: 700;
}
.log-status.ok {
color: var(--success);
}
.log-status.err {
color: var(--danger);
}
.log-body {
background: var(--panel);
border-radius: 4px;
padding: 6px;
margin-top: 4px;
font-family: ui-monospace, monospace;
white-space: pre-wrap;
word-break: break-word;
max-height: 160px;
overflow: auto;
}
.log-hint {
margin-top: 4px;
padding: 4px 6px;
background: var(--warn-dim);
border-radius: 4px;
color: var(--warn);
}
.hidden {
display: none !important;
}