Files
operating-room-monitor-server/scripts/demo_client/index.html
Kevin 42720f81cf feat: demo CORS, demo client, openpyxl catalog load
- Load consumable catalog XLSX with openpyxl and drop the pandas dependency.
- Add optional demo CORS settings and FastAPI CORSMiddleware for browser clients.
- Add scripts/demo_client static page and local server for API smoke tests.

Made-with: Cursor
2026-04-22 17:00:56 +08:00

718 lines
26 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-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); }
.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; }
}
</style>
</head>
<body>
<div class="layout">
<main>
<section class="card">
<h1>Operation Room Monitor · Demo Client</h1>
<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>
<div class="actions">
<button id="btn-health" class="secondary">GET /health</button>
<span id="health-status" class="small muted"></span>
</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" />
</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>
<div class="actions">
<button id="btn-start">POST /client/surgeries/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>
<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" /> 自动轮询2s
</label>
<span id="voice-status" class="small muted"></span>
</div>
<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>
</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 } = {}) {
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);
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 };
}
// ============================================================
// 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(); };
// ============================================================
// §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");
};
// ============================================================
// §4.1 start
// ============================================================
$("btn-start").onclick = async () => {
const sid = ensureSurgeryId();
if (!sid) return;
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: [...tags],
});
};
// ============================================================
// §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: "quantity", label: "qty" },
{ key: "doctor_id", label: "doctor" },
{ key: "source", label: "source" },
]);
renderTable("汇总 summary[]", summary, [
{ key: "item_id", label: "item_id" },
{ key: "item_name", label: "item_name" },
{ key: "total_quantity", label: "total" },
]);
};
// ============================================================
// §4.4 pending-confirmation
// ============================================================
let pollTimer = null;
async function fetchPendingOnce() {
const sid = surgeryId();
if (!/^\d{6}$/.test(sid)) return;
const { res, body } = await apiJson("GET", `/client/surgeries/${sid}/pending-confirmation`);
const box = $("pending-render");
if (res.status === 200 && body && body.confirmation_id) {
box.hidden = false;
$("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>`;
} else if (res.status === 404) {
box.hidden = false;
box.innerHTML = '<span class="muted">暂无待确认项。</span>';
} else {
box.hidden = false;
box.innerHTML = `<span class="err">HTTP ${res.status}</span>`;
}
}
$("btn-pending").onclick = fetchPendingOnce;
$("auto-poll").onchange = (e) => {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
if (e.target.checked) {
$("voice-status").textContent = "自动轮询中…";
pollTimer = setInterval(fetchPendingOnce, 2000);
fetchPendingOnce();
} else {
$("voice-status").textContent = "";
}
};
// ============================================================
// §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);
};
// ============================================================
// Boot
// ============================================================
loadLabels();
</script>
</body>
</html>