Files
operating-room-monitor-server/clients/demo-client/index.html
Kevin 09885b4184 实现 video batch 自动清理与按需标注视频,并补充子进程调用测试。
batch 完成后仅保留数据库文本结果,勾选时才生成临时标注视频(24h TTL);新增 FastAPI 到 reference bundle 与 algorithm_runner 的单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 16:30:48 +08:00

1100 lines
45 KiB
HTML
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Operation Room Monitor · Demo Client</title>
<style>
:root {
--bg: #0f172a;
--panel: #111827;
--panel-2: #1f2937;
--border: #334155;
--text: #e2e8f0;
--muted: #94a3b8;
--accent: #38bdf8;
--accent-2: #22c55e;
--danger: #ef4444;
--warn: #f59e0b;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; 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: 14px;
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(360px, 1fr);
gap: 16px;
padding: 16px;
min-height: 100vh;
}
h1 { font-size: 18px; margin: 0 0 4px; }
h2 { font-size: 15px; margin: 0 0 10px; color: var(--accent); }
h3 { font-size: 13px; margin: 14px 0 6px; color: var(--muted); }
section.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 16px;
margin-bottom: 14px;
}
label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
input[type=text], input[type=url], input[type=number], select, textarea {
width: 100%;
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 7px 9px;
font-size: 13px;
font-family: inherit;
}
input:focus, textarea:focus, select:focus { outline: 1px solid var(--accent); }
button {
background: var(--accent);
color: #0b1220;
border: 0;
border-radius: 6px;
padding: 7px 14px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
button.secondary { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); }
button.danger { background: var(--danger); color: #fff; }
button.warn { background: var(--warn); color: #0b1220; }
button:disabled { opacity: .5; cursor: not-allowed; }
.row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; }
.actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.kv { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; color: var(--muted); }
#candidate-consumables-json {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.45;
resize: vertical;
min-height: 96px;
}
.log {
background: #0b1220;
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px;
height: calc(100vh - 64px);
overflow-y: auto;
position: sticky;
top: 16px;
}
.log-item { border-bottom: 1px dashed var(--border); padding: 8px 4px; }
.log-item:last-child { border-bottom: 0; }
.log-head { display: flex; justify-content: space-between; gap: 6px; align-items: baseline; }
.log-method { font-weight: 700; color: var(--accent); font-size: 11px; letter-spacing: .5px; }
.log-status { font-size: 11px; }
.log-status.ok { color: var(--accent-2); }
.log-status.err { color: var(--danger); }
.log-url { font-size: 11px; color: var(--muted); word-break: break-all; }
.log-body {
background: var(--panel-2);
border-radius: 4px;
padding: 6px 8px;
margin-top: 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
max-height: 220px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
.log-hint {
margin-top: 6px;
padding: 6px 8px;
font-size: 11px;
line-height: 1.4;
color: #fcd34d;
background: rgba(245, 158, 11, 0.12);
border: 1px solid rgba(245, 158, 11, 0.35);
border-radius: 4px;
}
#orch-status-banner { border: 1px solid var(--border); }
.callout-ok {
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(34, 197, 94, 0.4);
border-radius: 8px;
padding: 10px 12px;
margin: 0 0 10px;
line-height: 1.5;
}
.log-time { color: var(--muted); font-size: 11px; }
.badge {
display: inline-block;
font-size: 10px;
padding: 2px 6px;
border-radius: 3px;
margin-left: 6px;
background: var(--panel-2);
color: var(--muted);
}
.badge.on { background: #064e3b; color: #a7f3d0; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td { padding: 6px 8px; border-bottom: 1px solid var(--border); text-align: left; }
th { color: var(--muted); font-weight: 500; }
.muted { color: var(--muted); }
.err { color: var(--danger); }
.ok { color: var(--accent-2); }
.warn { color: var(--warn); }
.small { font-size: 12px; }
.grow { flex: 1; }
@media (max-width: 960px) {
.layout { grid-template-columns: 1fr; }
.log { position: static; height: auto; max-height: 50vh; }
}
pre.cmd {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 12px;
font-size: 11px;
line-height: 1.45;
overflow-x: auto;
margin: 8px 0 0;
white-space: pre-wrap;
word-break: break-all;
}
</style>
</head>
<body>
<div class="layout">
<main>
<section class="card">
<h1>Operation Room Monitor · Demo Client</h1>
<p id="orch-status-banner" class="small" style="display:none;margin:8px 0 0;padding:8px 10px;border-radius:6px"></p>
<p class="muted small">手动触发 <code>/client/*</code> 中与 Demo 相关的接口(开始 / 结束手术、查询结果等)。语音待确认与录音请使用专用客户端。</p>
<div class="row" style="margin-top:10px">
<div>
<label>服务端 Base URL</label>
<input id="base-url" type="url" value="http://127.0.0.1:38080" />
</div>
<div>
<label>手术号 surgery_id6 位数字)</label>
<input id="surgery-id" type="text" inputmode="numeric" pattern="\d{6}" maxlength="6" value="123456" />
</div>
</div>
<div class="actions">
<button id="btn-health" class="secondary">GET /health</button>
<button type="button" class="secondary" id="btn-orch-status" title="检查一键联调接口是否已注册">GET 联调状态</button>
<span id="health-status" class="small muted"></span>
</div>
</section>
<section class="card">
<h2>调试:多路视频 14 路(可选:一键假流 / 否则真实 RTSP</h2>
<p class="callout-ok small">
第 4.1 节勾选「一键联调」且<strong>为路 1…N 全部选好上传文件</strong>时,点「开始手术」会走假 RTSP 并合并写入 <code>OR_SITE_CONFIG_JSON_FILE</code><code>video_rtsp_urls</code>
<strong>不选文件</strong>(或未满路),即使勾选一键也会<strong>直接开录</strong>,使用站点 JSON 里已配置的<strong>真实 RTSP</strong>(与未勾选一键时相同)。手跑假流见 <code>README.md</code><code>fake_rtsp_from_file.py</code>
</p>
<div class="row" style="margin-top:8px; max-width:28rem">
<div>
<label>模拟路数14</label>
<select id="debug-stream-count">
<option value="1" selected>1 路</option>
<option value="2">2 路</option>
<option value="3">3 路</option>
<option value="4">4 路</option>
</select>
</div>
</div>
<h3>各路视频(仅在上传假流时需要;真实 RTSP 开录可不选。假流时每路 <code>RTSP_PATH</code> 须不同,<code>camera_id</code> 须与 §4.1 一致)</h3>
<div id="debug-streams-grid" class="row" style="margin-top:10px; align-items:stretch; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr))">
<div class="debug-stream" id="debug-stream-1" data-stream-slot="1" style="border:1px solid var(--border); border-radius:8px; padding:10px">
<h3 style="margin:0 0 8px; color:var(--accent)">路 1</h3>
<label>视频(一键上传:选文件或摄像头录制;路径框仅备注)</label>
<input id="debug-vpath-1" type="text" placeholder="/path/a.mp4 或 ./a.mp4" />
<div class="actions" style="margin-top:6px; align-items:center; flex-wrap:wrap">
<input type="file" id="debug-vfile-1" hidden />
<button type="button" class="secondary" id="btn-dbg-pick-1">选择文件…</button>
<button type="button" class="secondary" id="btn-dbg-webcam-1" title="用本机摄像头+麦克风录一段,停止后填入本路">摄像头录制…</button>
<span id="debug-hint-1" class="small muted"></span>
</div>
<div class="row" style="margin-top:8px">
<div>
<label>RTSP 路径名 <code>RTSP_PATH</code></label>
<input id="debug-rpath-1" type="text" value="demo1" />
</div>
<div>
<label>camera_id</label>
<input id="debug-cam-1" type="text" value="or-cam-03" />
</div>
</div>
</div>
<div class="debug-stream" id="debug-stream-2" data-stream-slot="2" style="border:1px solid var(--border); border-radius:8px; padding:10px;display:none">
<h3 style="margin:0 0 8px; color:var(--accent)">路 2</h3>
<label>视频(一键上传:选文件或摄像头录制;路径框仅备注)</label>
<input id="debug-vpath-2" type="text" placeholder="/path/b.mp4 或 ./b.mp4" />
<div class="actions" style="margin-top:6px; align-items:center; flex-wrap:wrap">
<input type="file" id="debug-vfile-2" hidden />
<button type="button" class="secondary" id="btn-dbg-pick-2">选择文件…</button>
<button type="button" class="secondary" id="btn-dbg-webcam-2" title="用本机摄像头+麦克风录一段,停止后填入本路">摄像头录制…</button>
<span id="debug-hint-2" class="small muted"></span>
</div>
<div class="row" style="margin-top:8px">
<div>
<label>RTSP 路径名 <code>RTSP_PATH</code></label>
<input id="debug-rpath-2" type="text" value="demo2" />
</div>
<div>
<label>camera_id</label>
<input id="debug-cam-2" type="text" value="or-cam-02" />
</div>
</div>
</div>
<div class="debug-stream" id="debug-stream-3" data-stream-slot="3" style="border:1px solid var(--border); border-radius:8px; padding:10px; display:none">
<h3 style="margin:0 0 8px; color:var(--accent)">路 3</h3>
<label>视频(一键上传:选文件或摄像头录制;路径框仅备注)</label>
<input id="debug-vpath-3" type="text" placeholder="/path/c.mp4 或 ./c.mp4" />
<div class="actions" style="margin-top:6px; align-items:center; flex-wrap:wrap">
<input type="file" id="debug-vfile-3" hidden />
<button type="button" class="secondary" id="btn-dbg-pick-3">选择文件…</button>
<button type="button" class="secondary" id="btn-dbg-webcam-3" title="用本机摄像头+麦克风录一段,停止后填入本路">摄像头录制…</button>
<span id="debug-hint-3" class="small muted"></span>
</div>
<div class="row" style="margin-top:8px">
<div>
<label>RTSP 路径名 <code>RTSP_PATH</code></label>
<input id="debug-rpath-3" type="text" value="demo3" />
</div>
<div>
<label>camera_id</label>
<input id="debug-cam-3" type="text" value="or-cam-04" />
</div>
</div>
</div>
<div class="debug-stream" id="debug-stream-4" data-stream-slot="4" style="border:1px solid var(--border); border-radius:8px; padding:10px; display:none">
<h3 style="margin:0 0 8px; color:var(--accent)">路 4</h3>
<label>视频(一键上传:选文件或摄像头录制;路径框仅备注)</label>
<input id="debug-vpath-4" type="text" placeholder="/path/d.mp4 或 ./d.mp4" />
<div class="actions" style="margin-top:6px; align-items:center; flex-wrap:wrap">
<input type="file" id="debug-vfile-4" hidden />
<button type="button" class="secondary" id="btn-dbg-pick-4">选择文件…</button>
<button type="button" class="secondary" id="btn-dbg-webcam-4" title="用本机摄像头+麦克风录一段,停止后填入本路">摄像头录制…</button>
<span id="debug-hint-4" class="small muted"></span>
</div>
<div class="row" style="margin-top:8px">
<div>
<label>RTSP 路径名 <code>RTSP_PATH</code></label>
<input id="debug-rpath-4" type="text" value="demo4" />
</div>
<div>
<label>camera_id</label>
<input id="debug-cam-4" type="text" value="or-cam-01" />
</div>
</div>
</div>
</div>
<p id="debug-file-note" class="muted small" style="margin:8px 0 0">
一键联调会<strong>直接上传</strong>你在此为各路选择的文件。选文件时会把框内填成 <code>./文件名</code>,仅作展示;真正上传以文件选择器为准,无需在框里改路径。
</p>
<div class="actions" style="margin-top:8px">
<button type="button" class="secondary" id="btn-debug-apply-cams" title="根据当前调试区各路 camera_id 刷新 §4.1(通常会自动同步)">同步 camera_id 到 §4.1</button>
</div>
</section>
<section class="card">
<h2>§4.1 开始手术</h2>
<div class="row">
<div>
<label>camera_ids逗号分隔至少一个</label>
<input id="camera-ids" type="text" value="or-cam-03" />
</div>
<div>
<label>candidate_consumablesJSON 数组)<span id="labels-hint" class="badge">loading…</span></label>
<textarea id="candidate-consumables-json" rows="7" spellcheck="false" placeholder='例如:[ "耗材A", "耗材B" ]'></textarea>
<p class="small muted" style="margin:6px 0 0">填写字符串数组的 JSON开录 POST body 与一键联调表单字段 <code>candidate_consumables_json</code> 均由此解析。</p>
</div>
</div>
<p class="small muted" style="margin:8px 0 0">
<label style="display:inline-flex;align-items:flex-start;gap:8px;cursor:pointer;max-width:52rem">
<input type="checkbox" id="orch-oneclick" style="margin-top:2px" />
<span><strong>一键联调</strong>:勾选后,若调试区<strong>已为路 1…N 全部选择上传文件</strong>,则按「模拟路数」走 multipart 假 RTSP<code>DEMO_ORCHESTRATOR_ENABLED=true</code>、可写 <code>OR_SITE_CONFIG_JSON_FILE</code>、docker+ffmpeg 环境,详见 README。若<strong>未选满文件</strong>,则<strong>不强制上传</strong>,本次与未勾选一样:<code>POST /client/surgeries/start</code>,使用站点 JSON 中的<strong>真实 RTSP</strong>。不勾选时始终为普通开录。</span>
</label>
</p>
<p class="small muted" style="margin:8px 0 0">
<label style="display:inline-flex;align-items:flex-start;gap:8px;cursor:pointer;max-width:52rem">
<input type="checkbox" id="video-batch-mode" style="margin-top:2px" />
<span><strong>非实时精确模式</strong>:使用调试区「路 1」上传的完整 MP4调用主 FastAPI 的 <code>POST /internal/demo/video-batch-surgery</code>,复用 <code>algorithm_subprocesses/5.15/main.py</code>。该模式不会启动 RTSP 实时会话,处理完成后用查询结果接口查看文本明细。</span>
</label>
</p>
<p class="small muted" style="margin:8px 0 0">
<label style="display:inline-flex;align-items:flex-start;gap:8px;cursor:pointer;max-width:52rem">
<input type="checkbox" id="video-batch-include-vis" style="margin-top:2px" />
<span><strong>生成标注视频</strong>临时预览24 小时内可播放;需同时勾选「非实时精确模式」)。</span>
</label>
</p>
<div class="actions">
<button id="btn-start">开始手术</button>
<button id="btn-load-all-labels" class="secondary" type="button">载入全部标签</button>
<button id="btn-clear-labels" class="secondary" type="button">清空</button>
</div>
</section>
<section class="card">
<h2>§4.2 结束手术</h2>
<div class="actions">
<button id="btn-end" class="warn">POST /client/surgeries/end</button>
</div>
</section>
<section class="card">
<h2>§4.3 查询结果</h2>
<div class="actions">
<button id="btn-result" class="secondary">GET /client/surgeries/{id}/result</button>
</div>
<div id="video-batch-vis" style="display:none;margin:10px 0 14px">
<h3>非实时精确标注视频</h3>
<p class="small" id="video-batch-doctor-info" style="margin:0 0 8px;display:none"></p>
<video id="video-batch-vis-player" controls playsinline style="width:100%;max-height:520px;background:#000;border:1px solid var(--border);border-radius:8px"></video>
<p class="small muted" id="video-batch-vis-hint" style="margin:6px 0 0"></p>
</div>
<div id="result-render"></div>
</section>
</main>
<aside>
<div class="log" id="log">
<div class="small muted" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
<strong style="color:var(--text)">响应日志</strong>
<button class="secondary" id="btn-clear-log" style="padding:3px 8px;font-size:11px">清空</button>
</div>
</div>
</aside>
</div>
<script>
// ============================================================
// Utilities
// ============================================================
const $ = (id) => document.getElementById(id);
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();
}
}
const logEl = $("log");
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 class="log-url">${url}</span></span>
<span><span class="log-status ${statusClass}">${statusText}</span> <span class="log-time">${time}</span></span>
</div>`;
const bodyEl = document.createElement("div");
bodyEl.className = "log-body";
if (body === undefined || 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);
}
logEl.insertBefore(item, logEl.children[1] ?? null);
}
$("btn-clear-log").onclick = () => {
[...logEl.querySelectorAll(".log-item")].forEach(n => n.remove());
};
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);
return { res, body: parsed };
}
async function apiMultipart(path, formData) {
const url = baseUrl() + path;
const bu = baseUrl();
console.info("[demo-client] orchestrate request", { baseUrl: bu, path, fullUrl: url });
let res;
try {
res = await fetch(url, { method: "POST", body: formData });
} catch (e) {
console.error("[demo-client] orchestrate network error", e);
const netHint = "无法连接 " + url + "。请确认「服务端 Base URL」指向监控 API默认 :38080且本页在 :38081 打开时勿把 Base URL 填成 demo 页自身。";
addLog("POST (orchestrate)", url, "NETWORK", String(e), { error: true, hint: netHint });
throw e;
}
const text = await res.text();
let parsed;
try { parsed = text ? JSON.parse(text) : null; } catch { parsed = text; }
const err = !res.ok;
let hint = "";
if (res.status === 404) {
hint = "HTTP 404本路径在服务端未注册。常见原因1) 未设 DEMO_ORCHESTRATOR_ENABLED=true 并重启主进程POST /internal/demo/orchestrate-and-start 未挂载2)「服务端 Base URL」填错须指向主 API 如 http://127.0.0.1:38080不是本 demo 静态站 :38081。可点「GET 联调状态」或打开浏览器控制台查看 [demo-client] 日志。";
} else if (res.status === 400 && parsed && (parsed.detail || "").toString().indexOf("OR_SITE_CONFIG") >= 0) {
hint = "需配置可写的 OR_SITE_CONFIG_JSON_FILE严格站点 JSON且 Docker 下请 bind-mount 到容器内同路径。";
} else if (res.status === 503) {
hint = "合成假 RTSP 或开录失败,请见响应体与主服务终端 logdemo orchestrate-and-start / ffmpeg / docker。";
}
if (err) {
console.error("[demo-client] orchestrate response", { status: res.status, statusText: res.statusText, body: parsed, url });
} else {
console.info("[demo-client] orchestrate ok", { status: res.status, url });
}
addLog("POST (orchestrate)", url, res.status, parsed, { error: err, hint });
return { res, body: parsed };
}
async function apiVideoBatch(formData) {
const path = "/internal/demo/video-batch-surgery";
const url = baseUrl() + path;
let res;
try {
res = await fetch(url, { method: "POST", body: formData });
} catch (e) {
const netHint = "无法连接 " + url + "。请确认「服务端 Base URL」指向主 API默认 :38080不是本 demo 静态站 :38081。";
addLog("POST (video-batch)", url, "NETWORK", String(e), { error: true, hint: netHint });
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 = "HTTP 404非实时精确接口未注册。请确认主服务开启 DEMO_ORCHESTRATOR_ENABLED=true 并重启,且 Base URL 指向主 API。";
} else if (res.status === 503) {
hint = "5-6 batch 运行失败,请查看响应体与主服务终端日志。";
}
addLog("POST (video-batch)", url, res.status, parsed, { error: !res.ok, hint });
return { res, body: parsed };
}
let lastVideoBatchDoctorDisplay = "";
function showVideoBatchDoctorInfo(displayText) {
const el = $("video-batch-doctor-info");
if (!el) return;
const text = (displayText || "").trim();
if (!text) {
el.style.display = "none";
el.textContent = "";
return;
}
el.style.display = "block";
el.innerHTML = `<strong>识别医生:</strong>${text}`;
}
function hideVideoBatchVisualization() {
const wrap = $("video-batch-vis");
const player = $("video-batch-vis-player");
const hint = $("video-batch-vis-hint");
if (wrap) wrap.style.display = "none";
if (player) {
player.removeAttribute("src");
player.load();
}
if (hint) hint.textContent = "";
}
function videoBatchIncludeVis() {
return $("video-batch-include-vis") && $("video-batch-include-vis").checked;
}
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/video-batch-surgery/${sid}/visualization`;
const src = baseUrl() + path + (path.includes("?") ? "&" : "?") + "t=" + Date.now();
player.onerror = () => {
if (hint) {
hint.innerHTML =
`视频加载失败。请确认已跑完非实时 batch 且主 API 已重启;` +
`也可<a href="${src}" target="_blank" rel="noreferrer">新标签页打开</a>。`;
}
};
player.onloadeddata = () => {
if (hint) {
hint.innerHTML =
`已加载手部打框+耗材标签视频algorithm_subprocesses/5.15 visualize_result_video.py` +
`<a href="${src}" target="_blank" rel="noreferrer">${path}</a>`;
}
};
player.src = src;
player.load();
wrap.style.display = "block";
showVideoBatchDoctorInfo(doctorDisplay || lastVideoBatchDoctorDisplay);
if (hint) hint.textContent = "正在加载标注视频…";
}
// ============================================================
// Surgery ID validation
// ============================================================
function ensureSurgeryId() {
const sid = surgeryId();
if (!/^\d{6}$/.test(sid)) {
alert("surgery_id 必须是 6 位数字");
return null;
}
return sid;
}
// ============================================================
// candidate_consumablesJSON 数组字符串)
// ============================================================
function formatConsumablesJson(arr) {
try {
return JSON.stringify(arr, null, 2);
} catch {
return "[]";
}
}
/** @returns {string[]} */
function parseCandidateConsumablesJson() {
const ta = $("candidate-consumables-json");
const raw = (ta && ta.value) ? ta.value.trim() : "";
if (!raw) return [];
let parsed;
try {
parsed = JSON.parse(raw);
} catch (e) {
throw new Error("candidate_consumables 不是合法 JSON" + (e && e.message ? e.message : String(e)));
}
if (!Array.isArray(parsed)) {
throw new Error("candidate_consumables 必须是 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);
}
continue;
}
throw new Error(
"candidate_consumables[" +
i +
"] 必须是字符串,或含「名称」/ name 字段的对象(耗材导出表格式)",
);
}
return out;
}
async function loadLabels() {
const hint = $("labels-hint");
const ta = $("candidate-consumables-json");
try {
const res = await fetch("/labels.json");
if (!res.ok) throw new Error("HTTP " + res.status);
const data = await res.json();
const labels = Array.isArray(data.labels) ? data.labels : [];
if (ta) ta.value = formatConsumablesJson(labels.slice(0, 5));
hint.textContent = `${labels.length} 个标签`;
hint.classList.add("on");
window.__ALL_LABELS__ = labels;
} catch (e) {
hint.textContent = "labels.json 加载失败";
if (ta) ta.value = "[]";
console.warn(e);
}
}
$("btn-load-all-labels").onclick = () => {
const all = window.__ALL_LABELS__ || [];
const ta = $("candidate-consumables-json");
if (ta) ta.value = formatConsumablesJson(all);
};
$("btn-clear-labels").onclick = () => {
const ta = $("candidate-consumables-json");
if (ta) ta.value = "[]";
};
// ============================================================
// 联调状态(不依赖一键开关,用于诊断 404
// ============================================================
async function refreshOrchStatus() {
const b = $("orch-status-banner");
const url = baseUrl() + "/internal/demo/orchestrator-status";
try {
const res = await fetch(url);
const text = await res.text();
let data;
try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
console.info("[demo-client] GET orchestrator-status", { url, httpStatus: res.status, data });
addLog("GET (联调状态)", url, res.status, data, { error: !res.ok });
b.style.display = "block";
if (!res.ok) {
b.style.background = "rgba(239, 68, 68, 0.1)";
b.style.color = "var(--text)";
b.textContent = "无法拉取 " + url + "HTTP " + res.status + ")。请把「服务端 Base URL」设为主 API如 http://127.0.0.1:38080。";
return;
}
const on = data.orchestrator_enabled === true;
const fset = data.or_site_config_json_file_set === true;
b.style.background = on && fset ? "rgba(34, 197, 94, 0.1)" : "rgba(245, 158, 11, 0.12)";
b.style.color = "var(--text)";
const fp = data.or_site_config_json_file || "(未设)";
b.innerHTML = on
? ("一键 <code>POST " + (data.orchestrate_path || "/internal/demo/orchestrate-and-start") + "</code>" + (fset ? "已开放;站点配置 " : "未设 ") + "<code>" + fp + "</code>")
: ("一键开录 <strong>未注册</strong>:请在主服务 .env 设 <code>DEMO_ORCHESTRATOR_ENABLED=true</code> 并<strong>重启</strong>。当前 " + (data.orchestrate_path || "") + " 会 404。");
} catch (e) {
console.error("[demo-client] orchestrator-status failed", e);
b.style.display = "block";
b.style.background = "rgba(239, 68, 68, 0.1)";
b.textContent = "联调状态请求失败: " + e;
}
}
// ============================================================
// §health
// ============================================================
$("btn-health").onclick = async () => {
const { res } = await apiJson("GET", "/health");
$("health-status").textContent = `HTTP ${res.status}`;
$("health-status").className = "small " + (res.ok ? "ok" : "err");
};
$("btn-orch-status").onclick = () => { refreshOrchStatus(); };
// ============================================================
// §4.1 start
// ============================================================
$("btn-start").onclick = async () => {
const sid = ensureSurgeryId();
if (!sid) return;
let candidateConsumables;
try {
candidateConsumables = parseCandidateConsumablesJson();
} catch (e) {
alert(e.message || String(e));
return;
}
if ($("video-batch-mode") && $("video-batch-mode").checked) {
const f = $("debug-vfile-1").files && $("debug-vfile-1").files[0];
if (!f) {
alert("非实时精确模式需要先在调试区「路 1」选择一个完整 MP4。");
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", videoBatchIncludeVis() ? "true" : "false");
const { res, body } = await apiVideoBatch(fd);
if (!res.ok) {
const detail = (body && (body.detail !== undefined)) ? body.detail : body;
const errText = (typeof detail === "object" && detail !== null) ? JSON.stringify(detail, null, 2) : String(detail || body || "错误");
alert("非实时精确视频处理失败 HTTP " + res.status + "\n\n" + errText);
return;
}
lastVideoBatchDoctorDisplay = (body && body.doctor_display) || "";
if (videoBatchIncludeVis() && body && body.visualization_url) {
showVideoBatchVisualization(sid, body.visualization_url, lastVideoBatchDoctorDisplay);
} else {
hideVideoBatchVisualization();
}
await apiJson("GET", `/client/surgeries/${sid}/result`);
return;
}
if ($("orch-oneclick") && $("orch-oneclick").checked) {
const n = getDebugStreamCount();
const files = [];
for (let i = 1; i <= n; i++) {
const f = $("debug-vfile-" + i).files && $("debug-vfile-" + i).files[0];
files.push(f);
}
const allSlotsHaveFile = files.length === n && files.every((x) => !!x);
if (allSlotsHaveFile) {
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);
const defCams = ["or-cam-03", "or-cam-02", "or-cam-04", "or-cam-01"];
const defRp = ["demo1", "demo2", "demo3", "demo4"];
for (let i = 1; i <= 4; i++) {
fd.append(
"camera_" + i,
($("debug-cam-" + i).value || defCams[i - 1]).trim() || defCams[i - 1],
);
fd.append(
"rtsp_path_" + i,
($("debug-rpath-" + i).value || defRp[i - 1]).trim() || defRp[i - 1],
);
}
fd.append("candidate_consumables_json", JSON.stringify(candidateConsumables));
const { res, body } = await apiMultipart("/internal/demo/orchestrate-and-start", fd);
if (!res.ok) {
const detail = (body && (body.detail !== undefined)) ? body.detail : body;
const errText = (typeof detail === "object" && detail !== null) ? JSON.stringify(detail, null, 2) : String(detail || body || "错误");
alert("一键开录失败 HTTP " + res.status + "\n\n" + errText);
return;
}
return;
}
syncCameraIdsFromDebugSlots();
addLog(
"INFO",
"(demo-client)",
"—",
"已勾选「一键联调」但未为全部 " +
n +
" 路选择上传文件:跳过假流接口,使用站点 JSON 中的真实 RTSP 调用 POST /client/surgeries/start。",
);
}
const camera_ids = $("camera-ids").value.split(",").map(s => s.trim()).filter(Boolean);
if (camera_ids.length === 0) { alert("camera_ids 至少要 1 个"); return; }
await apiJson("POST", "/client/surgeries/start", {
surgery_id: sid,
camera_ids,
candidate_consumables: candidateConsumables,
});
};
// ============================================================
// §4.2 end
// ============================================================
$("btn-end").onclick = async () => {
const sid = ensureSurgeryId();
if (!sid) return;
await apiJson("POST", "/client/surgeries/end", { surgery_id: sid });
};
// ============================================================
// §4.3 result
// ============================================================
$("btn-result").onclick = async () => {
const sid = ensureSurgeryId();
if (!sid) return;
if ($("video-batch-mode") && $("video-batch-mode").checked && videoBatchIncludeVis()) {
showVideoBatchVisualization(sid, null);
} else {
hideVideoBatchVisualization();
}
const { res, body } = await apiJson("GET", `/client/surgeries/${sid}/result`);
const target = $("result-render");
target.innerHTML = "";
if (!res.ok || !body || typeof body !== "object") 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");
const thead = document.createElement("thead");
thead.innerHTML = "<tr>" + cols.map(c => `<th>${c.label}</th>`).join("") + "</tr>";
t.appendChild(thead);
const tbody = document.createElement("tbody");
if (!rows.length) {
tbody.innerHTML = `<tr><td colspan="${cols.length}" class="muted">(空)</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);
};
if ($("video-batch-mode") && $("video-batch-mode").checked && details.length) {
showVideoBatchDoctorInfo(details[0].doctor_id || lastVideoBatchDoctorDisplay);
}
renderTable("明细 details[]", details, [
{ key: "timestamp", label: "time" },
{ key: "item_id", label: "item_id" },
{ key: "item_name", label: "item_name" },
{ key: "qty", label: "qty" },
{ key: "doctor_id", label: "医生(姓名+ID" },
]);
renderTable("汇总 summary[]", summary, [
{ key: "item_id", label: "item_id" },
{ key: "item_name", label: "item_name" },
{ key: "total_quantity", label: "total" },
]);
};
// ============================================================
// Debug: 14 streams for one-click upload
// ============================================================
function getDebugStreamCount() {
const sel = $("debug-stream-count");
const v = sel ? parseInt(sel.value, 10) : 1;
if (v === 1 || v === 2 || v === 3 || v === 4) return v;
return 1;
}
function syncCameraIdsFromDebugSlots() {
const defCams = ["or-cam-03", "or-cam-02", "or-cam-04", "or-cam-01"];
const n = getDebugStreamCount();
const parts = [];
for (let i = 1; i <= n; i++) {
const inp = $("debug-cam-" + i);
const a = inp
? (inp.value || defCams[i - 1]).trim() || defCams[i - 1]
: defCams[i - 1];
parts.push(a);
}
const ci = $("camera-ids");
if (ci) ci.value = parts.join(",");
}
function applyDebugStreamVisibility() {
const n = getDebugStreamCount();
for (let i = 1; i <= 4; i++) {
const el = $("debug-stream-" + i);
if (!el) continue;
el.style.display = i <= n ? "block" : "none";
}
syncCameraIdsFromDebugSlots();
}
if ($("debug-stream-count")) {
$("debug-stream-count").addEventListener("change", () => {
applyDebugStreamVisibility();
});
applyDebugStreamVisibility();
} else {
syncCameraIdsFromDebugSlots();
}
for (let i = 1; i <= 4; i++) {
const cam = $("debug-cam-" + i);
if (cam) {
cam.addEventListener("input", () => {
syncCameraIdsFromDebugSlots();
});
}
}
for (let i = 1; i <= 4; i++) {
const pick = $("btn-dbg-pick-" + i);
const vfile = $("debug-vfile-" + i);
const vpath = $("debug-vpath-" + i);
const hint = $("debug-hint-" + i);
if (pick && vfile) {
pick.onclick = () => vfile.click();
vfile.addEventListener("change", (e) => {
const f = e.target.files && e.target.files[0];
if (!f) return;
vpath.value = "./" + f.name;
hint.textContent = "已选: " + f.name;
});
}
}
const webcamSlotState = {};
function pickMediaRecorderMime() {
if (typeof MediaRecorder === "undefined" || !MediaRecorder.isTypeSupported) {
return "";
}
const candidates = [
"video/webm;codecs=vp9,opus",
"video/webm;codecs=vp8,opus",
"video/webm",
"video/mp4",
];
for (let i = 0; i < candidates.length; i++) {
if (MediaRecorder.isTypeSupported(candidates[i])) return candidates[i];
}
return "";
}
function assignFileToInput(inputEl, file) {
const dt = new DataTransfer();
dt.items.add(file);
inputEl.files = dt.files;
}
async function toggleWebcamSlot(slot) {
const st = webcamSlotState[slot] || (webcamSlotState[slot] = {});
const btn = $("btn-dbg-webcam-" + slot);
const hint = $("debug-hint-" + slot);
const vfile = $("debug-vfile-" + slot);
const vpath = $("debug-vpath-" + slot);
if (!btn || !hint || !vfile || !vpath) return;
if (!st.recording) {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
alert("当前环境不支持摄像头(需要 HTTPS 或 localhost且浏览器允许媒体权限。");
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 && ev.data.size) st.chunks.push(ev.data);
};
rec.start(250);
st.recording = true;
st.stream = stream;
st.recorder = rec;
btn.textContent = "停止摄像头";
btn.classList.add("warn");
hint.textContent = "录制中,再点一次停止并填入本路";
} catch (e) {
alert("无法打开摄像头/麦克风: " + (e.message || String(e)));
}
return;
}
const rec = st.recorder;
const stream = st.stream;
st.recording = false;
st.recorder = null;
st.stream = null;
btn.textContent = "摄像头录制…";
btn.classList.remove("warn");
await new Promise((resolve) => {
rec.addEventListener("stop", resolve, { once: true });
rec.stop();
});
if (stream) {
stream.getTracks().forEach((t) => t.stop());
}
const parts = st.chunks;
st.chunks = [];
const mt = (rec.mimeType || "").toLowerCase();
const blobType = mt || "video/webm";
const blob = new Blob(parts, { type: blobType });
if (!blob.size) {
hint.textContent = "未录到数据(请稍录长一点再停)";
return;
}
const ext = mt.includes("mp4") ? ".mp4" : ".webm";
const name = "webcam-" + slot + "-" + Date.now() + ext;
const file = new File([blob], name, { type: blobType });
assignFileToInput(vfile, file);
vpath.value = "./" + name;
hint.textContent = "已录: " + name;
}
for (let i = 1; i <= 4; i++) {
const wbtn = $("btn-dbg-webcam-" + i);
if (wbtn) wbtn.onclick = () => { toggleWebcamSlot(i); };
}
$("btn-debug-apply-cams").onclick = () => {
syncCameraIdsFromDebugSlots();
};
(function setupDebugVideoDrop() {
function bindStreamCard(el, vpathId, hintId, vfileId) {
if (!el) return;
el.addEventListener("dragover", (ev) => {
ev.preventDefault();
el.style.outline = "1px dashed var(--accent)";
});
el.addEventListener("dragleave", () => {
el.style.outline = "";
});
el.addEventListener("drop", (ev) => {
ev.preventDefault();
el.style.outline = "";
const f = ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files[0];
const looksVideo =
f &&
(/^video\//.test(f.type || "") ||
/\.(mp4|mov|mkv|avi|webm|m4v|mpeg|mpg|ts|m2ts|3gp|wmv|flv|ogv)$/i.test(
f.name || "",
));
if (!looksVideo) {
$(hintId).textContent = "请拖入常见视频文件";
return;
}
const vfileEl = $(vfileId);
if (vfileEl) assignFileToInput(vfileEl, f);
$(vpathId).value = "./" + f.name;
$(hintId).textContent = "已选: " + f.name + "(拖放)";
});
}
for (let i = 1; i <= 4; i++) {
bindStreamCard($("debug-stream-" + i), "debug-vpath-" + i, "debug-hint-" + i, "debug-vfile-" + i);
}
})();
// ============================================================
// Boot
// ============================================================
applyLanDefaultApiBase();
loadLabels();
$("base-url").addEventListener("change", () => { refreshOrchStatus(); });
refreshOrchStatus();
</script>
</body>
</html>