Files
operating-room-monitor-server/scripts/demo_client/index.html
Kevin 6b3adb4ad8 feat: 站点 JSON、语音终端 WebSocket 指派与客户端联调
- 用 OR_SITE_CONFIG_JSON_FILE 统一术间配置(video_rtsp_urls + voice_or_room_bindings)
- VoiceTerminalHub:assignment、WS 推送与 HTTP 查询;开录/停录后 notify
- 一键联调 orchestrate-and-start 与 /client/surgeries/start 共用指派逻辑,修复 demo 路径不发 WS
- 语音桌面端:SIGINT 退出、shutdown 清理、仅 WS 指派、固定 pending 轮询间隔、界面仅保留录音时长
- 新增/调整契约与绑定测试,文档与示例配置同步

Made-with: Cursor
2026-04-27 11:21:16 +08:00

1374 lines
55 KiB
HTML
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); }
.pill {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 999px;
padding: 3px 10px;
margin: 3px 4px 3px 0;
font-size: 12px;
}
.pill button {
background: transparent;
color: var(--muted);
padding: 0 0 0 4px;
font-size: 13px;
}
.pill button:hover { color: var(--danger); }
.tags-wrap {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px;
min-height: 42px;
max-height: 160px;
overflow-y: auto;
}
.tags-wrap input {
background: transparent;
border: 0;
padding: 3px 6px;
min-width: 140px;
width: auto;
color: var(--text);
}
.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; }
.record-controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.meter {
flex: 1;
min-width: 140px;
height: 10px;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 5px;
overflow: hidden;
}
.meter > span {
display: block;
height: 100%;
width: 0;
background: linear-gradient(90deg, #22c55e, #f59e0b, #ef4444);
transition: width 0.06s linear;
}
.muted { color: var(--muted); }
.err { color: var(--danger); }
.ok { color: var(--accent-2); }
.warn { color: var(--warn); }
.small { font-size: 12px; }
.grow { flex: 1; }
audio { width: 100%; margin-top: 8px; }
.pending-box { background: var(--panel-2); border-radius: 6px; padding: 10px; margin-top: 8px; }
.option-row { display: flex; justify-content: space-between; padding: 2px 0; }
@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> 5 个接口;本地麦克风录音后生成 WAV 上传语音确认接口。</p>
<div class="row" style="margin-top:10px">
<div>
<label>服务端 Base URL</label>
<input id="base-url" type="url" value="http://localhost:38080" />
</div>
<div>
<label>手术号 surgery_id6 位数字)</label>
<input id="surgery-id" type="text" inputmode="numeric" pattern="\d{6}" maxlength="6" value="123456" />
</div>
</div>
<p class="small muted" style="margin-top:10px">
<label style="display:inline-flex;align-items:flex-start;gap:8px;cursor:pointer;max-width:52rem">
<input type="checkbox" id="toggle-browser-voice-ui" style="margin-top:2px" />
<span>显示<strong> §4.4 / §4.5</strong>(浏览器待确认与录音上传;默认关闭,主流程请用桌面语音客户端)</span>
</label>
</p>
<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 路(与一键联调 / 无真摄像头)</h2>
<p class="callout-ok small">
在下方选好各路视频、第 4.1 节勾选「一键联调」后点「开始手术」即可;服务端会起假 RTSP 并合并写入 <code>OR_SITE_CONFIG_JSON_FILE</code><code>video_rtsp_urls</code>。无法使用一键时,请按 <code>scripts/demo_client/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">1 路</option>
<option value="2" selected>2 路</option>
<option value="3">3 路</option>
<option value="4">4 路</option>
</select>
</div>
</div>
<h3>各路视频(为第 4.1 节一键选文件;每路 <code>RTSP_PATH</code> 须不同,<code>camera_id</code> 须与开录时一致)</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">
<input type="file" id="debug-vfile-1" accept="video/*" hidden />
<button type="button" class="secondary" id="btn-dbg-pick-1">选择…</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-01" />
</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">
<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">
<input type="file" id="debug-vfile-2" accept="video/*" hidden />
<button type="button" class="secondary" id="btn-dbg-pick-2">选择…</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">
<input type="file" id="debug-vfile-3" accept="video/*" hidden />
<button type="button" class="secondary" id="btn-dbg-pick-3">选择…</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-03" />
</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">
<input type="file" id="debug-vfile-4" accept="video/*" hidden />
<button type="button" class="secondary" id="btn-dbg-pick-4">选择…</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-04" />
</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 写进开录的 camera_ids">将 camera_id 填到开始手术</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-01,or-cam-02" />
</div>
<div>
<label>candidate_consumables<span id="labels-hint" class="badge">loading…</span></label>
<div class="tags-wrap" id="tags">
<input id="tag-input" type="text" placeholder="输入后回车添加" />
</div>
</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>选好的视频14 路),由监控服务在<strong>能执行 docker+ffmpeg 的环境</strong>里自动起假 RTSP、更新 <code>OR_SITE_CONFIG_JSON_FILE</code> 并开录(需 <code>DEMO_ORCHESTRATOR_ENABLED=true</code> 且该文件为可写挂载;详见 README。不勾选时仍为普通开录需自行先起假流并保证站点 JSON 中 RTSP 映射正确)。</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="result-render"></div>
</section>
<div id="browser-voice-sections" hidden>
<section class="card">
<h2>§4.4 待确认耗材</h2>
<div class="actions">
<button id="btn-pending" class="secondary">拉一条待确认</button>
<label class="small" style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input id="auto-poll" type="checkbox" checked /> 自动轮询10s
</label>
<label class="small" style="display:flex;align-items:center;gap:6px;cursor:pointer" title="拉取到待确认时朗读 prompt百度 TTS 或浏览器)">
<input id="tts-pending" type="checkbox" checked /> 有待确认时 TTS
</label>
<span id="voice-status" class="small muted"></span>
</div>
<p id="voice-pipeline-hint" class="small muted" style="margin:6px 0 0">默认策略:<strong>Top1 置信度 &lt; 0.9</strong> 且达语音下沿时多会<strong>入队待确认</strong>;≥ <code>VIDEO_AUTO_CONFIRM_CONFIDENCE</code>(默认 0.9)且标签在 <code>candidate_consumables</code> 内则<strong>直接记 vision</strong>,拉取待确认为 404。可在环境变量中调整 <code>VIDEO_AUTO_CONFIRM_CONFIDENCE</code><strong>§4.1 开录返回 200 后本页会自动排入一次 §4.4 拉取</strong>§4.5 上传成功后也会串行拉取下一条,多条待确认按服务端 FIFO 逐条处理。若在轮询 GET 尚未返回时已提交 §4.5,本页会丢弃过期响应并自动再拉一次,避免旧 <code>confirmation_id</code> 盖住新队首。</p>
<div id="pending-render" class="pending-box" hidden></div>
</section>
<section class="card">
<h2>§4.5 语音确认(录音 → WAV → 上传)</h2>
<div class="row">
<div>
<label>confirmation_id从 §4.4 自动填入,也可手动修改)</label>
<input id="confirmation-id" type="text" />
</div>
</div>
<h3>录音</h3>
<div class="record-controls">
<button id="btn-rec-start">开始录音</button>
<button id="btn-rec-stop" class="danger" disabled>停止</button>
<div class="meter"><span id="meter-bar"></span></div>
<span id="rec-info" class="muted small">就绪</span>
</div>
<audio id="audio-preview" controls hidden></audio>
<div class="actions">
<button id="btn-resolve" disabled>上传并确认</button>
<a id="btn-download" class="small muted" href="#" download="voice.wav" style="display:none">下载 WAV调试</a>
</div>
</section>
</div>
</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();
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());
};
const browserVoiceSections = $("browser-voice-sections");
const toggleBrowserVoiceUi = $("toggle-browser-voice-ui");
function syncBrowserVoiceUiVisibility() {
if (!browserVoiceSections || !toggleBrowserVoiceUi) return;
browserVoiceSections.hidden = !toggleBrowserVoiceUi.checked;
}
if (toggleBrowserVoiceUi) {
toggleBrowserVoiceUi.addEventListener("change", syncBrowserVoiceUiVisibility);
syncBrowserVoiceUiVisibility();
}
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 };
}
// ============================================================
// Surgery ID validation
// ============================================================
function ensureSurgeryId() {
const sid = surgeryId();
if (!/^\d{6}$/.test(sid)) {
alert("surgery_id 必须是 6 位数字");
return null;
}
return sid;
}
// ============================================================
// Tags for candidate_consumables
// ============================================================
const tagsEl = $("tags");
const tagInput = $("tag-input");
let tags = [];
function renderTags() {
[...tagsEl.querySelectorAll(".pill")].forEach(n => n.remove());
tags.forEach((t, idx) => {
const pill = document.createElement("span");
pill.className = "pill";
pill.innerHTML = `<span></span><button title="移除" aria-label="移除">×</button>`;
pill.firstChild.textContent = t;
pill.querySelector("button").onclick = () => {
tags.splice(idx, 1);
renderTags();
};
tagsEl.insertBefore(pill, tagInput);
});
}
function addTag(name) {
const v = name.trim();
if (!v) return;
if (tags.includes(v)) return;
tags.push(v);
renderTags();
}
tagInput.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addTag(tagInput.value);
tagInput.value = "";
} else if (e.key === "Backspace" && !tagInput.value && tags.length) {
tags.pop();
renderTags();
}
});
tagsEl.addEventListener("click", (e) => {
if (e.target === tagsEl) tagInput.focus();
});
async function loadLabels() {
const hint = $("labels-hint");
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 : [];
tags = labels.slice(0, 5);
renderTags();
hint.textContent = `${labels.length} 个标签`;
hint.classList.add("on");
window.__ALL_LABELS__ = labels;
} catch (e) {
hint.textContent = "labels.json 加载失败";
console.warn(e);
}
}
$("btn-load-all-labels").onclick = () => {
const all = window.__ALL_LABELS__ || [];
tags = [...all];
renderTags();
};
$("btn-clear-labels").onclick = () => { tags = []; renderTags(); };
// ============================================================
// 联调状态(不依赖一键开关,用于诊断 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;
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 missing = files.findIndex((x) => !x);
if (missing >= 0) {
alert("请先在上方「调试」里为 路 " + (missing + 1) + " 「选择…」一个视频文件(当前为 " + n + " 路)。");
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);
const defCams = ["or-cam-01", "or-cam-02", "or-cam-03", "or-cam-04"];
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([...tags]));
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;
}
// 开录成功后立即排入 §4.4;并使此前进行中的 pending GET 失效(避免旧 id 覆盖)
_pendingSyncSeq++;
fetchPendingOnce();
return;
}
const camera_ids = $("camera-ids").value.split(",").map(s => s.trim()).filter(Boolean);
if (camera_ids.length === 0) { alert("camera_ids 至少要 1 个"); return; }
const { res } = await apiJson("POST", "/client/surgeries/start", {
surgery_id: sid,
camera_ids,
candidate_consumables: [...tags],
});
if (res.ok) {
_pendingSyncSeq++;
fetchPendingOnce();
}
};
// ============================================================
// §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;
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);
};
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: "doctor" },
]);
renderTable("汇总 summary[]", summary, [
{ key: "item_id", label: "item_id" },
{ key: "item_name", label: "item_name" },
{ key: "total_quantity", label: "total" },
]);
};
// ============================================================
// §4.4 pending-confirmation响应内带 Base64 MP3+ 可选自动播报
// §4.5 依赖本节的 confirmation_id避免并发拉取竞态与「已无待确认仍保留旧 id」
// ============================================================
let pollTimer = null;
/**
* 自动播报去重:在「开始排程」时同步写入当前 confirmation_id避免串行 GET 在首段尚未播完时
* 再次命中 !== 判断而启动第二遍播报_pendingSyncSeq 重拉、开录后立即拉取等会紧挨着第二次 fetch
* 仅当播放失败时在 catch 中清除,便于轮询/手动重试。
*/
let lastSpokenConfirmationId = null;
let lastPendingPayload = null;
/**
* 与「进行中的 GET pending」比对§4.5 resolve 成功、手术 id 变更、重新开录时递增。
* 解决竞态GET 已发出后用户才提交 resolve晚到的响应会带着旧 confirmation_id不得写回 UI。
*/
let _pendingSyncSeq = 0;
/** 串行化 GET pending链式 Promise支持 await fetchPendingOnce()§4.5 成功后拉取下一条) */
let _pendingFetchChain = Promise.resolve();
/** 方案1首次用户手势内播放极短静音解锁自动播放之后待确认 MP3 复用同一 Audio */
const SILENT_UNLOCK_DATA_URL =
"data:audio/wav;base64,UklGRigAAABXQVZFZm10IBIAAAABAAEARKwAAIhYAQACABAAAABkYXRhAgAAAAE=";
let sharedPromptAudio = null;
let audioPlaybackUnlocked = false;
let lastPromptBlobUrl = null;
function getSharedPromptAudio() {
if (!sharedPromptAudio) {
sharedPromptAudio = new Audio();
sharedPromptAudio.preload = "auto";
sharedPromptAudio.volume = 1;
}
return sharedPromptAudio;
}
document.addEventListener(
"pointerdown",
async () => {
if (audioPlaybackUnlocked) return;
try {
const a = getSharedPromptAudio();
if (lastPromptBlobUrl) {
URL.revokeObjectURL(lastPromptBlobUrl);
lastPromptBlobUrl = null;
}
a.src = SILENT_UNLOCK_DATA_URL;
await a.play();
a.pause();
a.currentTime = 0;
audioPlaybackUnlocked = true;
} catch (e) {
console.warn("[demo-client] 音频自动播放未解锁(可点「播放话术」)", e);
}
},
{ once: true, capture: true, passive: true },
);
function pickZhTtsVoice() {
if (!window.speechSynthesis) return null;
const vs = window.speechSynthesis.getVoices() || [];
return (
vs.find((v) => /^zh/i.test((v.lang || "") + (v.voiceURI || ""))) ||
vs.find((v) => (v.lang || "").startsWith("zh")) ||
null
);
}
function speakTextPromise(text) {
return new Promise((resolve, reject) => {
if (!text || !window.speechSynthesis) {
resolve();
return;
}
try {
window.speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text);
u.lang = "zh-CN";
const v = pickZhTtsVoice();
if (v) u.voice = v;
u.rate = 0.95;
u.onend = () => resolve();
u.onerror = (ev) => reject(ev.error || new Error("tts"));
window.speechSynthesis.speak(u);
} catch (e) {
reject(e);
}
});
}
/** 解码 GET pending 的 prompt_audio_mp3_base64优先用解锁后的单例 Audio失败则回退 speechSynthesis */
async function playPromptAudioBase64(b64, textFallback) {
const t = (textFallback || "").trim();
const raw = typeof b64 === "string" ? b64.replace(/\s+/g, "") : "";
if (raw) {
try {
const bin = atob(raw);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
const blob = new Blob([bytes], { type: "audio/mpeg" });
const o = URL.createObjectURL(blob);
const a = getSharedPromptAudio();
if (lastPromptBlobUrl) {
URL.revokeObjectURL(lastPromptBlobUrl);
lastPromptBlobUrl = null;
}
lastPromptBlobUrl = o;
a.pause();
a.currentTime = 0;
a.src = o;
try {
await new Promise((resolve, reject) => {
const cleanupBlob = () => {
if (lastPromptBlobUrl === o) {
URL.revokeObjectURL(o);
lastPromptBlobUrl = null;
}
};
a.onended = () => {
cleanupBlob();
resolve();
};
a.onerror = () => {
cleanupBlob();
reject(new Error("Audio 元素解码/播放失败"));
};
const p = a.play();
if (p && typeof p.catch === "function") {
p.catch((err) => {
cleanupBlob();
reject(err);
});
}
});
return;
} catch (playErr) {
if (lastPromptBlobUrl === o) {
URL.revokeObjectURL(o);
lastPromptBlobUrl = null;
}
console.warn("[demo-client] MP3 play() 被拒或失败,尝试浏览器朗读", playErr);
}
} catch (e) {
console.warn("[demo-client] Base64 MP3 解码失败,尝试浏览器朗读", e);
}
}
if (t) await speakTextPromise(t);
}
if (window.speechSynthesis) {
window.speechSynthesis.addEventListener("voiceschanged", () => {});
}
$("surgery-id").addEventListener("input", () => {
lastSpokenConfirmationId = null;
lastPendingPayload = null;
_pendingSyncSeq++;
});
async function playLastPendingManually() {
const p = lastPendingPayload;
if (!p || !p.confirmation_id) return;
const pt = (p.prompt_text || "").trim();
const cid = p.confirmation_id;
try {
lastSpokenConfirmationId = cid;
await playPromptAudioBase64(p.prompt_audio_mp3_base64, pt);
} catch (e) {
console.warn("[demo-client] 手动播放失败", e);
if (lastSpokenConfirmationId === cid) lastSpokenConfirmationId = null;
}
}
async function runFetchPendingOnce() {
const sid = surgeryId();
if (!/^\d{6}$/.test(sid)) return;
const startSeq = _pendingSyncSeq;
const path = `/client/surgeries/${sid}/pending-confirmation`;
const url = baseUrl() + path;
let res;
try {
res = await fetch(url);
} catch (e) {
addLog("GET", url, "NETWORK", String(e), { error: true });
return;
}
if (startSeq !== _pendingSyncSeq) {
fetchPendingOnce();
return;
}
const raw = await res.text();
let body;
try {
body = raw ? JSON.parse(raw) : null;
} catch {
body = raw;
}
if (startSeq !== _pendingSyncSeq) {
fetchPendingOnce();
return;
}
if (res.status === 404) {
// 无待确认为常态,不写入右侧「响应日志」,减少刷屏
} else {
addLog("GET", url, res.status, body);
}
const box = $("pending-render");
if (res.status === 200 && body && body.confirmation_id) {
const prevId = lastPendingPayload && lastPendingPayload.confirmation_id;
if (prevId && prevId !== body.confirmation_id) {
recordingWav = null;
$("btn-resolve").disabled = true;
$("audio-preview").hidden = true;
$("btn-download").style.display = "none";
$("rec-info").textContent = "新待确认已入队,请重新录音后上传";
$("rec-info").className = "warn small";
}
box.hidden = false;
lastPendingPayload = body;
$("confirmation-id").value = body.confirmation_id;
const opts = (body.options || [])
.map(o => `<div class="option-row"><span>${o.label}</span><span class="muted">${(o.confidence * 100).toFixed(1)}%</span></div>`)
.join("");
box.innerHTML = `
<div><strong>confirmation_id:</strong> <span class="kv">${body.confirmation_id}</span></div>
<div style="margin-top:4px"><strong>prompt_text:</strong> ${body.prompt_text || ""}</div>
<div style="margin-top:4px"><strong>Top1:</strong> ${body.model_top1_label} <span class="muted">(${(body.model_top1_confidence * 100).toFixed(1)}%)</span></div>
<div style="margin-top:6px"><strong>options:</strong>${opts || '<div class="muted">(无)</div>'}</div>
<div style="margin-top:10px">
<button type="button" class="secondary" id="btn-play-pending">▶ 播放话术MP3 或浏览器朗读)</button>
<span class="small muted" style="margin-left:8px">首次在页面任意处点按可解锁自动播报;仍失败时点此处</span>
</div>`;
const btnPlay = $("btn-play-pending");
if (btnPlay) btnPlay.onclick = () => void playLastPendingManually();
const pt = (body.prompt_text || "").trim();
const ttsOn = $("tts-pending") && $("tts-pending").checked;
const cidForTts = body.confirmation_id;
if (ttsOn && pt && cidForTts !== lastSpokenConfirmationId) {
lastSpokenConfirmationId = cidForTts;
void (async () => {
try {
await playPromptAudioBase64(body.prompt_audio_mp3_base64, pt);
} catch (e) {
console.warn("[demo-client] 自动播报未完成(可点「播放话术」)", e);
if (lastSpokenConfirmationId === cidForTts) lastSpokenConfirmationId = null;
}
})();
}
} else if (res.status === 404) {
lastPendingPayload = null;
lastSpokenConfirmationId = null;
box.hidden = false;
box.innerHTML = '<span class="muted">暂无待确认项。请先 §4.4 拉取到待确认后再 §4.5 录音上传。</span>';
$("confirmation-id").value = "";
$("btn-resolve").disabled = true;
recordingWav = null;
$("audio-preview").hidden = true;
$("btn-download").style.display = "none";
$("rec-info").textContent = "无待确认:无需录音;有新任务时会自动填入 confirmation_id";
$("rec-info").className = "muted small";
} else {
box.hidden = false;
box.innerHTML = `<span class="err">HTTP ${res.status}</span>`;
}
}
/** 对外入口:重叠调用自动排队;返回的 Promise 在整段链完成后 settle便于 §4.5 await */
function fetchPendingOnce() {
const run = _pendingFetchChain.then(
() => runFetchPendingOnce(),
() => runFetchPendingOnce(),
);
_pendingFetchChain = run.catch((e) => {
console.warn("[demo-client] pending fetch", e);
});
return run;
}
$("btn-pending").onclick = fetchPendingOnce;
function applyAutoPoll() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
if ($("auto-poll") && $("auto-poll").checked) {
$("voice-status").textContent = "自动轮询中…";
pollTimer = setInterval(fetchPendingOnce, 10000);
fetchPendingOnce();
} else {
$("voice-status").textContent = "";
}
}
$("auto-poll").onchange = applyAutoPoll;
if ($("auto-poll") && $("auto-poll").checked) {
applyAutoPoll();
}
// ============================================================
// §4.5 Recording (mic → WAV 16kHz mono PCM)
// ============================================================
let audioCtx = null;
let mediaStream = null;
let sourceNode = null;
let processorNode = null;
let pcmChunks = [];
let inputSampleRate = 48000;
let recStartAt = 0;
let recordingWav = null;
function floatTo16BitPCM(view, offset, input) {
for (let i = 0; i < input.length; i++, offset += 2) {
const s = Math.max(-1, Math.min(1, input[i]));
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
}
}
function writeString(view, offset, str) {
for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
}
function downsampleTo16k(inputBuffer, inputRate) {
const target = 16000;
if (inputRate === target) return inputBuffer;
const ratio = inputRate / target;
const newLen = Math.round(inputBuffer.length / ratio);
const out = new Float32Array(newLen);
let offsetOut = 0;
let offsetIn = 0;
while (offsetOut < newLen) {
const nextIn = Math.round((offsetOut + 1) * ratio);
let accum = 0, count = 0;
for (let i = offsetIn; i < nextIn && i < inputBuffer.length; i++) {
accum += inputBuffer[i]; count++;
}
out[offsetOut] = count > 0 ? accum / count : 0;
offsetOut++;
offsetIn = nextIn;
}
return out;
}
function encodeWav(samples, sampleRate) {
const buffer = new ArrayBuffer(44 + samples.length * 2);
const view = new DataView(buffer);
writeString(view, 0, "RIFF");
view.setUint32(4, 36 + samples.length * 2, true);
writeString(view, 8, "WAVE");
writeString(view, 12, "fmt ");
view.setUint32(16, 16, true); // PCM subchunk size
view.setUint16(20, 1, true); // format = PCM
view.setUint16(22, 1, true); // channels = 1
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 2, true); // byte rate
view.setUint16(32, 2, true); // block align
view.setUint16(34, 16, true); // bits per sample
writeString(view, 36, "data");
view.setUint32(40, samples.length * 2, true);
floatTo16BitPCM(view, 44, samples);
return new Blob([view], { type: "audio/wav" });
}
function concatFloat32(chunks) {
let total = 0;
chunks.forEach(c => { total += c.length; });
const out = new Float32Array(total);
let offset = 0;
chunks.forEach(c => { out.set(c, offset); offset += c.length; });
return out;
}
async function startRecording() {
try {
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
} catch (e) {
alert("无法获取麦克风:" + e.message + "\n请确保使用 http://localhost 或 https 访问,并授权麦克风。");
return;
}
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
inputSampleRate = audioCtx.sampleRate;
sourceNode = audioCtx.createMediaStreamSource(mediaStream);
processorNode = audioCtx.createScriptProcessor(4096, 1, 1);
pcmChunks = [];
let peak = 0;
processorNode.onaudioprocess = (ev) => {
const input = ev.inputBuffer.getChannelData(0);
pcmChunks.push(new Float32Array(input));
let localPeak = 0;
for (let i = 0; i < input.length; i++) {
const v = Math.abs(input[i]);
if (v > localPeak) localPeak = v;
}
peak = Math.max(peak * 0.85, localPeak);
$("meter-bar").style.width = Math.min(100, peak * 140) + "%";
};
sourceNode.connect(processorNode);
processorNode.connect(audioCtx.destination);
recStartAt = performance.now();
$("btn-rec-start").disabled = true;
$("btn-rec-stop").disabled = false;
$("btn-resolve").disabled = true;
$("audio-preview").hidden = true;
$("btn-download").style.display = "none";
$("rec-info").textContent = `录音中 @ ${inputSampleRate} Hz…`;
$("rec-info").className = "warn small";
}
async function stopRecording() {
if (processorNode) { processorNode.disconnect(); processorNode.onaudioprocess = null; }
if (sourceNode) sourceNode.disconnect();
if (mediaStream) mediaStream.getTracks().forEach(t => t.stop());
if (audioCtx) await audioCtx.close();
const durationMs = performance.now() - recStartAt;
const samples = concatFloat32(pcmChunks);
const downsampled = downsampleTo16k(samples, inputSampleRate);
recordingWav = encodeWav(downsampled, 16000);
const url = URL.createObjectURL(recordingWav);
const audio = $("audio-preview");
audio.src = url;
audio.hidden = false;
const dl = $("btn-download");
dl.href = url;
dl.style.display = "inline-block";
$("btn-rec-start").disabled = false;
$("btn-rec-stop").disabled = true;
$("btn-resolve").disabled = false;
$("rec-info").textContent = `录音完成:${(durationMs / 1000).toFixed(1)}s · ${(recordingWav.size / 1024).toFixed(1)} KB · 16 kHz mono`;
$("rec-info").className = "ok small";
$("meter-bar").style.width = "0";
pcmChunks = [];
}
$("btn-rec-start").onclick = startRecording;
$("btn-rec-stop").onclick = stopRecording;
$("btn-resolve").onclick = async () => {
const sid = ensureSurgeryId();
if (!sid) return;
const cid = $("confirmation-id").value.trim();
if (!cid) { alert("请先获取 confirmation_id"); return; }
if (!recordingWav) { alert("请先录制音频"); return; }
const url = baseUrl() + `/client/surgeries/${sid}/pending-confirmation/${encodeURIComponent(cid)}/resolve`;
const fd = new FormData();
fd.append("audio", recordingWav, "voice.wav");
let res;
try {
res = await fetch(url, { method: "POST", body: fd });
} catch (e) {
addLog("POST", url, "NETWORK", String(e), { error: true });
return;
}
const text = await res.text();
let parsed;
try { parsed = text ? JSON.parse(text) : null; } catch { parsed = text; }
addLog("POST (multipart)", url, res.status, parsed);
if (res.ok) {
recordingWav = null;
$("btn-resolve").disabled = true;
$("audio-preview").hidden = true;
$("btn-download").style.display = "none";
lastSpokenConfirmationId = null;
_pendingSyncSeq++;
$("rec-info").textContent = "已提交,正在拉取下一条待确认…";
$("rec-info").className = "ok small";
await fetchPendingOnce();
if ($("auto-poll") && $("auto-poll").checked) {
$("voice-status").textContent = "自动轮询中…";
}
} else if (res.status === 422 && parsed && parsed.detail && typeof parsed.detail === "object") {
const d = parsed.detail;
if (d.message) {
let line = "解析未通过:" + d.message;
if (typeof d.retry_remaining === "number") {
line += "retry_remaining=" + d.retry_remaining + "";
}
$("rec-info").textContent = line;
$("rec-info").className = "warn small";
}
}
};
// ============================================================
// Debug: 14 streams for one-click upload
// ============================================================
function getDebugStreamCount() {
const sel = $("debug-stream-count");
const v = sel ? parseInt(sel.value, 10) : 2;
if (v === 1 || v === 2 || v === 3 || v === 4) return v;
return 2;
}
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";
}
}
if ($("debug-stream-count")) {
$("debug-stream-count").addEventListener("change", () => {
applyDebugStreamVisibility();
});
applyDebugStreamVisibility();
}
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;
});
}
}
$("btn-debug-apply-cams").onclick = () => {
const defCams = ["or-cam-01", "or-cam-02", "or-cam-03", "or-cam-04"];
const n = getDebugStreamCount();
const parts = [];
for (let i = 1; i <= n; i++) {
const a = ($("debug-cam-" + i).value || defCams[i - 1]).trim() || defCams[i - 1];
parts.push(a);
}
$("camera-ids").value = parts.join(",");
};
(function setupDebugVideoDrop() {
function bindStreamCard(el, vpathId, hintId) {
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)$/i.test(f.name || ""));
if (!looksVideo) {
$(hintId).textContent = "请拖入视频文件";
return;
}
$(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);
}
})();
// ============================================================
// Boot
// ============================================================
loadLabels();
$("base-url").addEventListener("change", () => { refreshOrchStatus(); });
refreshOrchStatus();
</script>
</body>
</html>