718 lines
26 KiB
HTML
718 lines
26 KiB
HTML
|
|
<!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_id(6 位数字)</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>
|