将 Demo 录制收敛为三条独立链路,并重做联调台 UI。
移除 demo_orch 统一编排,改为 recording_demo 与 live/simulated 服务;客户端拆分为静态资源,以模式卡片与 chip 耗材覆盖三链路联调,并同步测试与文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,43 +1,55 @@
|
||||
# Demo Client
|
||||
# Demo Client · 联调台
|
||||
|
||||
独立浏览器联调页,用于手动触发监控 API 的部分 `/client/*` 接口:开始/结束手术、查询结果等。语音待确认、TTS 与麦克风录音请使用同级的 [`../voice-confirmation/`](../voice-confirmation/) 或其它专用客户端。
|
||||
浏览器联调页,覆盖三条录制链路。语音待确认请使用 [`../voice-confirmation/`](../voice-confirmation/)(默认 :8080)。
|
||||
|
||||
## 结构
|
||||
## 三条链路
|
||||
|
||||
```
|
||||
clients/demo-client/
|
||||
start.sh
|
||||
server.py
|
||||
index.html
|
||||
labels.yaml # 耗材类名快照(与 backend/app/resources/consumable_classifier_labels.yaml 同步)
|
||||
fake_rtsp_from_file.py
|
||||
```
|
||||
| 模式 | 操作 | API | 语音 | 结束手术 |
|
||||
|------|------|-----|------|----------|
|
||||
| **链路 1 · 真摄像头** | 填 camera_id → 开始手术 | `POST /client/surgeries/start` | 需要 | 需要 |
|
||||
| **链路 2 · 模拟实时** | 选满 N 路视频 → 开始模拟开录 | `POST /internal/demo/simulated-start` | 需要 | 需要 |
|
||||
| **链路 3 · 离线精确** | 选 MP4 → 上传并处理 | `POST /internal/demo/offline-batch` | 无 | 不需要 |
|
||||
|
||||
**`labels.yaml`**:本目录自带副本,与后端解耦。后端类名变更时,请同步更新此文件。
|
||||
链路 2/3 需 `DEMO_ORCHESTRATOR_ENABLED=true`;链路 2 还需可写 `OR_SITE_CONFIG_JSON_FILE`。页顶「刷新状态」可查看 API 与 Demo 模式是否就绪。
|
||||
|
||||
## 界面说明
|
||||
|
||||
- **模式卡片**:点选切换,只显示当前模式相关配置
|
||||
- **耗材**:标签 chip 多选;「高级」可编辑 JSON
|
||||
- **链路 2**:默认只需上传视频;RTSP 路径与 camera_id 在「高级」折叠
|
||||
- **链路 3**:独立 MP4 上传区,可选生成标注视频
|
||||
- **开发者日志**:右侧折叠,记录完整 HTTP 请求/响应
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
# 1) 启动 Docker 后端
|
||||
cd backend && docker compose up -d --build
|
||||
|
||||
# 2) 在本目录启动 Demo 页
|
||||
cd ../clients/demo-client && ./start.sh
|
||||
|
||||
# 3) 浏览器访问
|
||||
open http://127.0.0.1:38081/
|
||||
```
|
||||
|
||||
页面「服务端 Base URL」默认指向同主机 `:38080`;后端在其他机器时手动改为 `http://<GPU服务器IP>:38080`。
|
||||
「API 地址」默认 `http://127.0.0.1:38080`;从局域网访问 demo 页时会自动改用当前主机 IP。
|
||||
|
||||
## 调试:无真实摄像头,用录好的视频模拟 RTSP
|
||||
## 文件
|
||||
|
||||
```bash
|
||||
python3 fake_rtsp_from_file.py /path/to/recording.mp4 --port 18554 --path demo
|
||||
```
|
||||
clients/demo-client/
|
||||
index.html # 页面骨架
|
||||
styles.css # 样式
|
||||
app.js # 逻辑
|
||||
server.py # 静态服务 + GET /labels.json
|
||||
labels.yaml # 耗材标签(与后端同步)
|
||||
start.sh
|
||||
```
|
||||
|
||||
容器内 API 访问宿主机 RTSP 应使用 `host.docker.internal`。详见 [`../../docs/video-backends.md`](../../docs/video-backends.md)。
|
||||
## 手跑假 RTSP(链路 2 高级)
|
||||
|
||||
```bash
|
||||
python3 fake_rtsp_from_file.py /path/to/video.mp4 --port 18554 --path demo
|
||||
```
|
||||
|
||||
详见 [`../../docs/video-backends.md`](../../docs/video-backends.md)。
|
||||
|
||||
## CORS
|
||||
|
||||
跨域访问 API 时,后端 `backend/.env` 需 `DEMO_CORS_ENABLED=true`;生产环境收窄 `DEMO_CORS_ORIGINS`。
|
||||
跨域访问 API 时设置 `DEMO_CORS_ENABLED=true`。
|
||||
|
||||
789
clients/demo-client/app.js
Normal file
789
clients/demo-client/app.js
Normal file
@@ -0,0 +1,789 @@
|
||||
/**
|
||||
* Operation Room Monitor — Demo 联调台
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const DEF_CAMS = ["or-cam-03", "or-cam-02", "or-cam-04", "or-cam-01"];
|
||||
const DEF_RP = ["demo1", "demo2", "demo3", "demo4"];
|
||||
|
||||
let activeMode = "live-rtsp";
|
||||
let allLabels = [];
|
||||
let selectedConsumables = new Set();
|
||||
let lastVideoBatchDoctorDisplay = "";
|
||||
const webcamSlotState = {};
|
||||
|
||||
const baseUrl = () => $("base-url").value.trim().replace(/\/+$/, "");
|
||||
const surgeryId = () => $("surgery-id").value.trim();
|
||||
|
||||
function inferDefaultApiBase() {
|
||||
try {
|
||||
const host = location.hostname || "127.0.0.1";
|
||||
const scheme = location.protocol === "https:" ? "https" : "http";
|
||||
return `${scheme}://${host}:38080`;
|
||||
} catch {
|
||||
return "http://127.0.0.1:38080";
|
||||
}
|
||||
}
|
||||
|
||||
function isLoopbackOrWildcardHost(hostname) {
|
||||
const h = String(hostname || "").trim().toLowerCase();
|
||||
return h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "0.0.0.0";
|
||||
}
|
||||
|
||||
function applyLanDefaultApiBase() {
|
||||
const input = $("base-url");
|
||||
const pageHost = location.hostname || "";
|
||||
try {
|
||||
const current = new URL(input.value);
|
||||
if (!isLoopbackOrWildcardHost(pageHost) && isLoopbackOrWildcardHost(current.hostname)) {
|
||||
input.value = inferDefaultApiBase();
|
||||
}
|
||||
} catch {
|
||||
input.value = inferDefaultApiBase();
|
||||
}
|
||||
}
|
||||
|
||||
function getRunMode() {
|
||||
return activeMode;
|
||||
}
|
||||
|
||||
function showBanner(msg, type) {
|
||||
const el = $("banner");
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.className = "banner show " + (type || "info");
|
||||
}
|
||||
|
||||
function setLoading(btn, on) {
|
||||
if (!btn) return;
|
||||
btn.disabled = on;
|
||||
btn.classList.toggle("loading", on);
|
||||
}
|
||||
|
||||
function formatDetail(body) {
|
||||
if (body == null) return "错误";
|
||||
const detail = body.detail !== undefined ? body.detail : body;
|
||||
if (typeof detail === "object" && detail !== null) {
|
||||
return JSON.stringify(detail, null, 2);
|
||||
}
|
||||
return String(detail || body);
|
||||
}
|
||||
|
||||
const logEl = $("log-scroll");
|
||||
function addLog(method, url, status, body, { error = false, hint = "" } = {}) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "log-item";
|
||||
const time = new Date().toLocaleTimeString();
|
||||
const statusClass = error || (typeof status === "number" && status >= 400) ? "err" : "ok";
|
||||
const statusText = typeof status === "number" ? status : status || (error ? "ERROR" : "—");
|
||||
item.innerHTML = `
|
||||
<div class="log-head">
|
||||
<span><span class="log-method">${method}</span> <span>${url}</span></span>
|
||||
<span class="log-status ${statusClass}">${statusText} <span>${time}</span></span>
|
||||
</div>`;
|
||||
const bodyEl = document.createElement("div");
|
||||
bodyEl.className = "log-body";
|
||||
if (body == null || body === "") bodyEl.textContent = "(empty)";
|
||||
else if (typeof body === "string") bodyEl.textContent = body;
|
||||
else {
|
||||
try {
|
||||
bodyEl.textContent = JSON.stringify(body, null, 2);
|
||||
} catch {
|
||||
bodyEl.textContent = String(body);
|
||||
}
|
||||
}
|
||||
item.appendChild(bodyEl);
|
||||
if (hint) {
|
||||
const h = document.createElement("div");
|
||||
h.className = "log-hint";
|
||||
h.textContent = hint;
|
||||
item.appendChild(h);
|
||||
}
|
||||
if (logEl) logEl.insertBefore(item, logEl.firstChild);
|
||||
}
|
||||
|
||||
async function apiJson(method, path, payload) {
|
||||
const url = baseUrl() + path;
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method,
|
||||
headers: payload ? { "Content-Type": "application/json" } : undefined,
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
} catch (e) {
|
||||
addLog(method, url, "NETWORK", String(e), { error: true });
|
||||
throw e;
|
||||
}
|
||||
const text = await res.text();
|
||||
let parsed;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
parsed = text;
|
||||
}
|
||||
addLog(method, url, res.status, parsed, { error: !res.ok });
|
||||
return { res, body: parsed };
|
||||
}
|
||||
|
||||
async function apiMultipart(path, formData, logLabel) {
|
||||
const url = baseUrl() + path;
|
||||
const label = logLabel || "multipart";
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, { method: "POST", body: formData });
|
||||
} catch (e) {
|
||||
const hint = "无法连接 API,请确认 Base URL 指向主服务(默认 :38080)。";
|
||||
addLog("POST (" + label + ")", url, "NETWORK", String(e), { error: true, hint });
|
||||
throw e;
|
||||
}
|
||||
const text = await res.text();
|
||||
let parsed;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
parsed = text;
|
||||
}
|
||||
let hint = "";
|
||||
if (res.status === 404) {
|
||||
hint = "Demo 录制模式未注册,请设 DEMO_ORCHESTRATOR_ENABLED=true 并重启。";
|
||||
} else if (
|
||||
res.status === 400 &&
|
||||
parsed &&
|
||||
String(parsed.detail || "").indexOf("OR_SITE_CONFIG") >= 0
|
||||
) {
|
||||
hint = "需配置可写的 OR_SITE_CONFIG_JSON_FILE。";
|
||||
}
|
||||
addLog("POST (" + label + ")", url, res.status, parsed, { error: !res.ok, hint });
|
||||
return { res, body: parsed };
|
||||
}
|
||||
|
||||
async function apiOfflineBatch(formData) {
|
||||
return apiMultipart("/internal/demo/offline-batch", formData, "offline-batch");
|
||||
}
|
||||
|
||||
function ensureSurgeryId() {
|
||||
const sid = surgeryId();
|
||||
if (!/^\d{6}$/.test(sid)) {
|
||||
showBanner("手术号必须是 6 位数字", "err");
|
||||
return null;
|
||||
}
|
||||
return sid;
|
||||
}
|
||||
|
||||
function formatConsumablesJson(arr) {
|
||||
return JSON.stringify(arr, null, 2);
|
||||
}
|
||||
|
||||
function parseConsumablesFromJsonText(raw) {
|
||||
if (!raw.trim()) return [];
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
throw new Error("JSON 格式错误:" + (e.message || String(e)));
|
||||
}
|
||||
if (!Array.isArray(parsed)) throw new Error("必须是 JSON 数组");
|
||||
const out = [];
|
||||
for (let i = 0; i < parsed.length; i++) {
|
||||
const x = parsed[i];
|
||||
if (typeof x === "string") {
|
||||
const s = x.trim();
|
||||
if (s) out.push(s);
|
||||
continue;
|
||||
}
|
||||
if (x !== null && typeof x === "object" && !Array.isArray(x)) {
|
||||
const name = x["名称"] != null ? x["名称"] : x["name"];
|
||||
if (typeof name === "string") {
|
||||
const s = name.trim();
|
||||
if (s) out.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function getSelectedConsumables() {
|
||||
const ta = $("candidate-consumables-json");
|
||||
const advanced = ta?.closest("details.advanced");
|
||||
if (ta && (advanced?.open || document.activeElement === ta)) {
|
||||
return parseConsumablesFromJsonText(ta.value);
|
||||
}
|
||||
return [...selectedConsumables];
|
||||
}
|
||||
|
||||
function syncJsonFromChips() {
|
||||
const ta = $("candidate-consumables-json");
|
||||
if (ta) ta.value = formatConsumablesJson(getSelectedConsumables());
|
||||
}
|
||||
|
||||
function syncChipsFromJson() {
|
||||
try {
|
||||
const arr = parseConsumablesFromJsonText($("candidate-consumables-json").value);
|
||||
selectedConsumables = new Set(arr);
|
||||
renderConsumableChips();
|
||||
} catch {
|
||||
/* ignore while typing invalid json */
|
||||
}
|
||||
}
|
||||
|
||||
function renderConsumableChips() {
|
||||
const container = $("chips");
|
||||
const q = ($("chip-search")?.value || "").trim().toLowerCase();
|
||||
if (!container) return;
|
||||
container.innerHTML = "";
|
||||
const filtered = allLabels.filter((l) => !q || l.toLowerCase().includes(q));
|
||||
filtered.forEach((label) => {
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "chip" + (selectedConsumables.has(label) ? " selected" : "");
|
||||
chip.textContent = label;
|
||||
chip.onclick = () => {
|
||||
if (selectedConsumables.has(label)) selectedConsumables.delete(label);
|
||||
else selectedConsumables.add(label);
|
||||
renderConsumableChips();
|
||||
syncJsonFromChips();
|
||||
};
|
||||
container.appendChild(chip);
|
||||
});
|
||||
const meta = $("labels-meta");
|
||||
if (meta) {
|
||||
meta.textContent = `已选 ${selectedConsumables.size} / 共 ${allLabels.length} 个标签`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLabels() {
|
||||
try {
|
||||
const res = await fetch("/labels.json");
|
||||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||
const data = await res.json();
|
||||
allLabels = Array.isArray(data.labels) ? data.labels : [];
|
||||
selectedConsumables = new Set(allLabels.slice(0, 5));
|
||||
renderConsumableChips();
|
||||
syncJsonFromChips();
|
||||
} catch (e) {
|
||||
allLabels = [];
|
||||
selectedConsumables = new Set();
|
||||
renderConsumableChips();
|
||||
$("labels-meta").textContent = "标签加载失败";
|
||||
console.warn(e);
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveMode(mode) {
|
||||
activeMode = mode;
|
||||
document.querySelectorAll(".mode-card").forEach((card) => {
|
||||
card.classList.toggle("active", card.dataset.mode === mode);
|
||||
});
|
||||
document.querySelectorAll(".mode-panel").forEach((panel) => {
|
||||
panel.classList.toggle("active", panel.dataset.mode === mode);
|
||||
});
|
||||
const voice = $("voice-callout");
|
||||
if (voice) voice.classList.toggle("hidden", mode === "offline-batch");
|
||||
const btnStart = $("btn-start");
|
||||
const btnEnd = $("btn-end");
|
||||
if (btnStart) {
|
||||
btnStart.textContent =
|
||||
mode === "offline-batch"
|
||||
? "上传并处理"
|
||||
: mode === "live-simulated"
|
||||
? "开始模拟开录"
|
||||
: "开始手术";
|
||||
}
|
||||
if (btnEnd) btnEnd.disabled = mode === "offline-batch";
|
||||
$("pill-mode").textContent =
|
||||
mode === "live-rtsp"
|
||||
? "链路 1 · 真摄像头"
|
||||
: mode === "live-simulated"
|
||||
? "链路 2 · 模拟实时"
|
||||
: "链路 3 · 离线精确";
|
||||
refreshRecordingModesStatus();
|
||||
}
|
||||
|
||||
async function refreshHealth() {
|
||||
const pill = $("pill-health");
|
||||
try {
|
||||
const { res } = await apiJson("GET", "/health");
|
||||
if (pill) {
|
||||
pill.textContent = res.ok ? "API 正常" : "API 异常 " + res.status;
|
||||
pill.className = "pill " + (res.ok ? "ok" : "err");
|
||||
}
|
||||
} catch {
|
||||
if (pill) {
|
||||
pill.textContent = "API 不可达";
|
||||
pill.className = "pill err";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRecordingModesStatus() {
|
||||
const pill = $("pill-demo-modes");
|
||||
if (activeMode === "live-rtsp") {
|
||||
if (pill) {
|
||||
pill.textContent = "链路 2/3 未检测";
|
||||
pill.className = "pill";
|
||||
}
|
||||
return;
|
||||
}
|
||||
const url = baseUrl() + "/internal/demo/recording-modes-status";
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
addLog("GET", url, res.status, data, { error: !res.ok });
|
||||
if (!pill) return;
|
||||
const on = data.demo_recording_modes_enabled === true || data.orchestrator_enabled === true;
|
||||
const fset = data.or_site_config_json_file_set === true;
|
||||
if (!res.ok) {
|
||||
pill.textContent = "状态拉取失败";
|
||||
pill.className = "pill err";
|
||||
return;
|
||||
}
|
||||
if (on && fset) {
|
||||
pill.textContent = "链路 2/3 已就绪";
|
||||
pill.className = "pill ok";
|
||||
} else if (on) {
|
||||
pill.textContent = "缺 OR_SITE_CONFIG";
|
||||
pill.className = "pill warn";
|
||||
} else {
|
||||
pill.textContent = "DEMO 未开启";
|
||||
pill.className = "pill err";
|
||||
}
|
||||
} catch (e) {
|
||||
if (pill) {
|
||||
pill.textContent = "状态失败";
|
||||
pill.className = "pill err";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hideVideoBatchVisualization() {
|
||||
const wrap = $("video-batch-vis");
|
||||
const player = $("video-batch-vis-player");
|
||||
if (wrap) wrap.classList.add("hidden");
|
||||
if (player) {
|
||||
player.removeAttribute("src");
|
||||
player.load();
|
||||
}
|
||||
}
|
||||
|
||||
function showVideoBatchVisualization(sid, urlPath, doctorDisplay) {
|
||||
const wrap = $("video-batch-vis");
|
||||
const player = $("video-batch-vis-player");
|
||||
const hint = $("video-batch-vis-hint");
|
||||
if (!wrap || !player) return;
|
||||
const path = urlPath || `/internal/demo/offline-batch/${sid}/visualization`;
|
||||
const src = baseUrl() + path + "?t=" + Date.now();
|
||||
wrap.classList.remove("hidden");
|
||||
const docEl = $("video-batch-doctor-info");
|
||||
const text = (doctorDisplay || lastVideoBatchDoctorDisplay || "").trim();
|
||||
if (docEl) {
|
||||
docEl.textContent = text ? "识别医生:" + text : "";
|
||||
docEl.style.display = text ? "block" : "none";
|
||||
}
|
||||
player.onerror = () => {
|
||||
if (hint) hint.textContent = "视频加载失败,请稍后重试或新标签页打开链接。";
|
||||
};
|
||||
player.onloadeddata = () => {
|
||||
if (hint) hint.textContent = "标注视频已加载";
|
||||
};
|
||||
player.src = src;
|
||||
player.load();
|
||||
if (hint) hint.textContent = "正在加载…";
|
||||
}
|
||||
|
||||
function getDebugStreamCount() {
|
||||
const sel = $("debug-stream-count");
|
||||
const v = sel ? parseInt(sel.value, 10) : 1;
|
||||
return v >= 1 && v <= 4 ? v : 1;
|
||||
}
|
||||
|
||||
function applyDebugStreamVisibility() {
|
||||
const n = getDebugStreamCount();
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const el = $("sim-stream-" + i);
|
||||
if (el) el.classList.toggle("hidden", i > n);
|
||||
}
|
||||
}
|
||||
|
||||
function assignFileToInput(inputEl, file) {
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
inputEl.files = dt.files;
|
||||
}
|
||||
|
||||
function setSlotFile(slot, file, source) {
|
||||
const vfile = $("sim-vfile-" + slot);
|
||||
const hint = $("sim-hint-" + slot);
|
||||
const fname = $("sim-fname-" + slot);
|
||||
if (vfile && file) assignFileToInput(vfile, file);
|
||||
if (fname) fname.textContent = file ? file.name : "";
|
||||
if (hint) hint.textContent = file ? "已选:" + file.name + (source ? " (" + source + ")" : "") : "";
|
||||
}
|
||||
|
||||
function setOfflineFile(file, source) {
|
||||
const vfile = $("offline-vfile");
|
||||
const fname = $("offline-fname");
|
||||
const hint = $("offline-hint");
|
||||
if (vfile && file) assignFileToInput(vfile, file);
|
||||
if (fname) fname.textContent = file ? file.name : "点击或拖放 MP4 到此处";
|
||||
if (hint) hint.textContent = file ? "已选:" + file.name + (source ? " (" + source + ")" : "") : "";
|
||||
}
|
||||
|
||||
function bindDropZone(zoneEl, onFile) {
|
||||
if (!zoneEl) return;
|
||||
zoneEl.addEventListener("dragover", (ev) => {
|
||||
ev.preventDefault();
|
||||
zoneEl.classList.add("drag-over");
|
||||
});
|
||||
zoneEl.addEventListener("dragleave", () => zoneEl.classList.remove("drag-over"));
|
||||
zoneEl.addEventListener("drop", (ev) => {
|
||||
ev.preventDefault();
|
||||
zoneEl.classList.remove("drag-over");
|
||||
const f = ev.dataTransfer?.files?.[0];
|
||||
if (f && looksLikeVideo(f)) onFile(f, "拖放");
|
||||
else showBanner("请拖入视频文件(MP4 等)", "warn");
|
||||
});
|
||||
}
|
||||
|
||||
function looksLikeVideo(f) {
|
||||
return (
|
||||
/^video\//.test(f.type || "") ||
|
||||
/\.(mp4|mov|mkv|avi|webm|m4v)$/i.test(f.name || "")
|
||||
);
|
||||
}
|
||||
|
||||
function pickMediaRecorderMime() {
|
||||
if (typeof MediaRecorder === "undefined" || !MediaRecorder.isTypeSupported) return "";
|
||||
for (const m of [
|
||||
"video/webm;codecs=vp9,opus",
|
||||
"video/webm;codecs=vp8,opus",
|
||||
"video/webm",
|
||||
"video/mp4",
|
||||
]) {
|
||||
if (MediaRecorder.isTypeSupported(m)) return m;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function toggleWebcamSlot(slot) {
|
||||
const st = webcamSlotState[slot] || (webcamSlotState[slot] = {});
|
||||
const btn = $("sim-webcam-" + slot);
|
||||
const hint = $("sim-hint-" + slot);
|
||||
if (!st.recording) {
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
showBanner("需要 HTTPS 或 localhost 才能使用摄像头", "warn");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: "user" },
|
||||
audio: true,
|
||||
});
|
||||
const mime = pickMediaRecorderMime();
|
||||
st.chunks = [];
|
||||
const rec = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream);
|
||||
rec.ondataavailable = (ev) => {
|
||||
if (ev.data?.size) st.chunks.push(ev.data);
|
||||
};
|
||||
rec.start(250);
|
||||
st.recording = true;
|
||||
st.stream = stream;
|
||||
st.recorder = rec;
|
||||
if (btn) {
|
||||
btn.textContent = "停止录制";
|
||||
btn.classList.add("warn");
|
||||
}
|
||||
if (hint) hint.textContent = "录制中…";
|
||||
} catch (e) {
|
||||
showBanner("无法打开摄像头:" + (e.message || String(e)), "err");
|
||||
}
|
||||
return;
|
||||
}
|
||||
const rec = st.recorder;
|
||||
const stream = st.stream;
|
||||
st.recording = false;
|
||||
st.recorder = null;
|
||||
st.stream = null;
|
||||
if (btn) {
|
||||
btn.textContent = "摄像头";
|
||||
btn.classList.remove("warn");
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
rec.addEventListener("stop", resolve, { once: true });
|
||||
rec.stop();
|
||||
});
|
||||
stream?.getTracks().forEach((t) => t.stop());
|
||||
const blob = new Blob(st.chunks, { type: rec.mimeType || "video/webm" });
|
||||
st.chunks = [];
|
||||
if (!blob.size) {
|
||||
if (hint) hint.textContent = "未录到数据";
|
||||
return;
|
||||
}
|
||||
const ext = (rec.mimeType || "").includes("mp4") ? ".mp4" : ".webm";
|
||||
const file = new File([blob], "webcam-" + slot + "-" + Date.now() + ext, { type: blob.type });
|
||||
setSlotFile(slot, file, "摄像头");
|
||||
}
|
||||
|
||||
async function handleStart() {
|
||||
const sid = ensureSurgeryId();
|
||||
if (!sid) return;
|
||||
let candidateConsumables;
|
||||
try {
|
||||
candidateConsumables = getSelectedConsumables();
|
||||
} catch (e) {
|
||||
showBanner(e.message || String(e), "err");
|
||||
return;
|
||||
}
|
||||
const btn = $("btn-start");
|
||||
setLoading(btn, true);
|
||||
showBanner("请求进行中…", "info");
|
||||
try {
|
||||
const mode = getRunMode();
|
||||
if (mode === "offline-batch") {
|
||||
const f = $("offline-vfile")?.files?.[0];
|
||||
if (!f) {
|
||||
showBanner("请先选择完整 MP4 文件", "err");
|
||||
return;
|
||||
}
|
||||
const fd = new FormData();
|
||||
fd.append("surgery_id", sid);
|
||||
fd.append("video1", f, f.name);
|
||||
fd.append("candidate_consumables_json", JSON.stringify(candidateConsumables));
|
||||
fd.append(
|
||||
"include_visualization",
|
||||
$("offline-batch-include-vis")?.checked ? "true" : "false",
|
||||
);
|
||||
const { res, body } = await apiOfflineBatch(fd);
|
||||
if (!res.ok) {
|
||||
showBanner("离线处理失败:" + formatDetail(body), "err");
|
||||
return;
|
||||
}
|
||||
lastVideoBatchDoctorDisplay = body?.doctor_display || "";
|
||||
if ($("offline-batch-include-vis")?.checked && body?.visualization_url) {
|
||||
showVideoBatchVisualization(sid, body.visualization_url, lastVideoBatchDoctorDisplay);
|
||||
} else hideVideoBatchVisualization();
|
||||
showBanner("离线处理完成,正在查询结果…", "ok");
|
||||
await handleResult();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "live-simulated") {
|
||||
const n = getDebugStreamCount();
|
||||
const files = [];
|
||||
for (let i = 1; i <= n; i++) {
|
||||
files.push($("sim-vfile-" + i)?.files?.[0]);
|
||||
}
|
||||
if (files.length !== n || !files.every(Boolean)) {
|
||||
showBanner("请为路 1…" + n + " 全部选择视频文件", "err");
|
||||
return;
|
||||
}
|
||||
const fd = new FormData();
|
||||
fd.append("surgery_id", sid);
|
||||
fd.append("video1", files[0], files[0].name);
|
||||
if (n >= 2) fd.append("video2", files[1], files[1].name);
|
||||
if (n >= 3) fd.append("video3", files[2], files[2].name);
|
||||
if (n >= 4) fd.append("video4", files[3], files[3].name);
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
fd.append(
|
||||
"camera_" + i,
|
||||
($("adv-cam-" + i)?.value || DEF_CAMS[i - 1]).trim() || DEF_CAMS[i - 1],
|
||||
);
|
||||
fd.append(
|
||||
"rtsp_path_" + i,
|
||||
($("adv-rpath-" + i)?.value || DEF_RP[i - 1]).trim() || DEF_RP[i - 1],
|
||||
);
|
||||
}
|
||||
fd.append("candidate_consumables_json", JSON.stringify(candidateConsumables));
|
||||
const { res, body } = await apiMultipart(
|
||||
"/internal/demo/simulated-start",
|
||||
fd,
|
||||
"simulated-start",
|
||||
);
|
||||
if (!res.ok) {
|
||||
showBanner("模拟开录失败:" + formatDetail(body), "err");
|
||||
return;
|
||||
}
|
||||
showBanner("模拟开录已接受,请打开语音终端", "ok");
|
||||
return;
|
||||
}
|
||||
|
||||
const camera_ids = $("camera-ids")
|
||||
.value.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (!camera_ids.length) {
|
||||
showBanner("请填写至少一个 camera_id", "err");
|
||||
return;
|
||||
}
|
||||
const { res, body } = await apiJson("POST", "/client/surgeries/start", {
|
||||
surgery_id: sid,
|
||||
camera_ids,
|
||||
candidate_consumables: candidateConsumables,
|
||||
});
|
||||
if (!res.ok) {
|
||||
showBanner("开录失败:" + formatDetail(body), "err");
|
||||
return;
|
||||
}
|
||||
showBanner("开录已接受,请打开语音终端", "ok");
|
||||
} catch (e) {
|
||||
showBanner("网络错误:" + String(e), "err");
|
||||
} finally {
|
||||
setLoading(btn, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEnd() {
|
||||
const sid = ensureSurgeryId();
|
||||
if (!sid) return;
|
||||
const btn = $("btn-end");
|
||||
setLoading(btn, true);
|
||||
try {
|
||||
const { res, body } = await apiJson("POST", "/client/surgeries/end", { surgery_id: sid });
|
||||
showBanner(res.ok ? "手术已结束" : "结束失败:" + formatDetail(body), res.ok ? "ok" : "err");
|
||||
} finally {
|
||||
setLoading(btn, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResult() {
|
||||
const sid = ensureSurgeryId();
|
||||
if (!sid) return;
|
||||
if (getRunMode() === "offline-batch" && $("offline-batch-include-vis")?.checked) {
|
||||
showVideoBatchVisualization(sid, null);
|
||||
} else if (getRunMode() !== "offline-batch") {
|
||||
hideVideoBatchVisualization();
|
||||
}
|
||||
const { res, body } = await apiJson("GET", `/client/surgeries/${sid}/result`);
|
||||
const target = $("result-render");
|
||||
if (!target) return;
|
||||
target.innerHTML = "";
|
||||
if (!res.ok || !body || typeof body !== "object") {
|
||||
showBanner(res.ok ? "无结果数据" : "查询失败:" + formatDetail(body), "err");
|
||||
return;
|
||||
}
|
||||
const { details = [], summary = [] } = body;
|
||||
const renderTable = (title, rows, cols) => {
|
||||
const h = document.createElement("h3");
|
||||
h.textContent = title;
|
||||
target.appendChild(h);
|
||||
const t = document.createElement("table");
|
||||
t.innerHTML =
|
||||
"<thead><tr>" + cols.map((c) => `<th>${c.label}</th>`).join("") + "</tr></thead>";
|
||||
const tbody = document.createElement("tbody");
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML = `<tr><td colspan="${cols.length}">(空)</td></tr>`;
|
||||
} else {
|
||||
rows.forEach((row) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = cols.map((c) => `<td>${row[c.key] ?? ""}</td>`).join("");
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
t.appendChild(tbody);
|
||||
target.appendChild(t);
|
||||
};
|
||||
renderTable("消耗明细", details, [
|
||||
{ key: "timestamp", label: "时间" },
|
||||
{ key: "item_name", label: "耗材" },
|
||||
{ key: "qty", label: "数量" },
|
||||
{ key: "doctor_id", label: "医生" },
|
||||
]);
|
||||
renderTable("汇总", summary, [
|
||||
{ key: "item_name", label: "耗材" },
|
||||
{ key: "total_quantity", label: "合计" },
|
||||
]);
|
||||
showBanner("结果已加载", "ok");
|
||||
}
|
||||
|
||||
function initModeCards() {
|
||||
document.querySelectorAll(".mode-card").forEach((card) => {
|
||||
card.addEventListener("click", () => setActiveMode(card.dataset.mode));
|
||||
});
|
||||
setActiveMode("live-rtsp");
|
||||
}
|
||||
|
||||
function initSimulatedUploads() {
|
||||
$("debug-stream-count")?.addEventListener("change", applyDebugStreamVisibility);
|
||||
applyDebugStreamVisibility();
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const pick = $("sim-pick-" + i);
|
||||
const vfile = $("sim-vfile-" + i);
|
||||
const zone = $("sim-zone-" + i);
|
||||
if (pick && vfile) {
|
||||
pick.onclick = () => vfile.click();
|
||||
vfile.onchange = () => {
|
||||
const f = vfile.files?.[0];
|
||||
if (f) setSlotFile(i, f, "选择");
|
||||
};
|
||||
}
|
||||
bindDropZone(zone, (f) => setSlotFile(i, f, "拖放"));
|
||||
zone?.addEventListener("click", (ev) => {
|
||||
if (ev.target.closest("button")) return;
|
||||
vfile?.click();
|
||||
});
|
||||
$("sim-webcam-" + i)?.addEventListener("click", () => toggleWebcamSlot(i));
|
||||
}
|
||||
}
|
||||
|
||||
function initOfflineUpload() {
|
||||
const zone = $("offline-zone");
|
||||
const vfile = $("offline-vfile");
|
||||
$("offline-pick")?.addEventListener("click", () => vfile?.click());
|
||||
vfile?.addEventListener("change", () => {
|
||||
const f = vfile.files?.[0];
|
||||
if (f) setOfflineFile(f, "选择");
|
||||
});
|
||||
bindDropZone(zone, (f) => setOfflineFile(f, "拖放"));
|
||||
zone?.addEventListener("click", (ev) => {
|
||||
if (ev.target.closest("button")) return;
|
||||
vfile?.click();
|
||||
});
|
||||
}
|
||||
|
||||
function initConsumables() {
|
||||
$("chip-search")?.addEventListener("input", renderConsumableChips);
|
||||
$("btn-chips-all")?.addEventListener("click", () => {
|
||||
allLabels.forEach((l) => selectedConsumables.add(l));
|
||||
renderConsumableChips();
|
||||
syncJsonFromChips();
|
||||
});
|
||||
$("btn-chips-clear")?.addEventListener("click", () => {
|
||||
selectedConsumables.clear();
|
||||
renderConsumableChips();
|
||||
syncJsonFromChips();
|
||||
});
|
||||
$("candidate-consumables-json")?.addEventListener("change", syncChipsFromJson);
|
||||
$("candidate-consumables-json")?.addEventListener("blur", syncChipsFromJson);
|
||||
}
|
||||
|
||||
function init() {
|
||||
applyLanDefaultApiBase();
|
||||
initModeCards();
|
||||
initSimulatedUploads();
|
||||
initOfflineUpload();
|
||||
initConsumables();
|
||||
loadLabels();
|
||||
|
||||
$("btn-start")?.addEventListener("click", handleStart);
|
||||
$("btn-end")?.addEventListener("click", handleEnd);
|
||||
$("btn-result")?.addEventListener("click", handleResult);
|
||||
$("btn-refresh-status")?.addEventListener("click", () => {
|
||||
refreshHealth();
|
||||
refreshRecordingModesStatus();
|
||||
});
|
||||
$("btn-clear-log")?.addEventListener("click", () => {
|
||||
if (logEl) logEl.innerHTML = "";
|
||||
});
|
||||
$("base-url")?.addEventListener("change", refreshRecordingModesStatus);
|
||||
|
||||
refreshHealth();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
624
clients/demo-client/styles.css
Normal file
624
clients/demo-client/styles.css
Normal file
@@ -0,0 +1,624 @@
|
||||
:root {
|
||||
--bg: #0c1222;
|
||||
--bg-elevated: #111827;
|
||||
--panel: #151d2e;
|
||||
--panel-hover: #1a2438;
|
||||
--border: #2a3a52;
|
||||
--border-active: #38bdf8;
|
||||
--text: #e8eef7;
|
||||
--muted: #8b9cb3;
|
||||
--accent: #38bdf8;
|
||||
--accent-dim: rgba(56, 189, 248, 0.12);
|
||||
--success: #34d399;
|
||||
--success-dim: rgba(52, 211, 153, 0.12);
|
||||
--danger: #f87171;
|
||||
--danger-dim: rgba(248, 113, 113, 0.12);
|
||||
--warn: #fbbf24;
|
||||
--warn-dim: rgba(251, 191, 36, 0.12);
|
||||
--radius: 10px;
|
||||
--radius-sm: 6px;
|
||||
--shadow: 0 4px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 20px 40px;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 4px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.header .subtitle {
|
||||
color: var(--muted);
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.top-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.top-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="url"],
|
||||
input[type="number"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
font-size: 0.9375rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pill.ok {
|
||||
border-color: rgba(52, 211, 153, 0.4);
|
||||
color: var(--success);
|
||||
background: var(--success-dim);
|
||||
}
|
||||
|
||||
.pill.err {
|
||||
border-color: rgba(248, 113, 113, 0.4);
|
||||
color: var(--danger);
|
||||
background: var(--danger-dim);
|
||||
}
|
||||
|
||||
.pill.warn {
|
||||
border-color: rgba(251, 191, 36, 0.4);
|
||||
color: var(--warn);
|
||||
background: var(--warn-dim);
|
||||
}
|
||||
|
||||
.banner {
|
||||
margin-top: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.875rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.banner.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.banner.info {
|
||||
background: var(--accent-dim);
|
||||
border: 1px solid rgba(56, 189, 248, 0.35);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.banner.ok {
|
||||
background: var(--success-dim);
|
||||
border: 1px solid rgba(52, 211, 153, 0.35);
|
||||
}
|
||||
|
||||
.banner.err {
|
||||
background: var(--danger-dim);
|
||||
border: 1px solid rgba(248, 113, 113, 0.35);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.banner.warn {
|
||||
background: var(--warn-dim);
|
||||
border: 1px solid rgba(251, 191, 36, 0.35);
|
||||
}
|
||||
|
||||
.voice-callout {
|
||||
margin-top: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--success-dim);
|
||||
border: 1px solid rgba(52, 211, 153, 0.3);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.voice-callout.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.layout-main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(300px, 360px);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.layout-main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px 20px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.mode-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.mode-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.mode-card {
|
||||
text-align: left;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius);
|
||||
border: 2px solid var(--border);
|
||||
background: var(--bg-elevated);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.mode-card:hover {
|
||||
background: var(--panel-hover);
|
||||
border-color: #3d5168;
|
||||
}
|
||||
|
||||
.mode-card.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.mode-card .title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.mode-card .desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.mode-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mode-panel.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.chip-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chip-search {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--muted);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chip.selected {
|
||||
background: var(--accent-dim);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.labels-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
details.advanced {
|
||||
margin-top: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
details.advanced summary {
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--muted);
|
||||
background: var(--bg-elevated);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
details.advanced summary:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
details.advanced .advanced-body {
|
||||
padding: 12px 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
#candidate-consumables-json {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.75rem;
|
||||
min-height: 80px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.upload-zone {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.upload-zone:hover,
|
||||
.upload-zone.drag-over {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.upload-zone .icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 8px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.upload-zone .filename {
|
||||
margin-top: 10px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--accent);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.stream-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.stream-slot {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px;
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.stream-slot h4 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.stream-slot .upload-zone {
|
||||
padding: 16px 10px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.stream-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.stream-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
margin-top: 6px;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
.steps {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 18px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
button:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
color: #0a1628;
|
||||
}
|
||||
|
||||
button.primary.lg {
|
||||
padding: 12px 24px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
button.warn {
|
||||
background: var(--warn);
|
||||
color: #0a1628;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
button.loading {
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-row input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.result-area {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.result-area table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.result-area th,
|
||||
.result-area td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.result-area th {
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.result-area h3 {
|
||||
font-size: 0.875rem;
|
||||
margin: 16px 0 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.vis-block {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.vis-block video {
|
||||
width: 100%;
|
||||
max-height: 400px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.dev-log {
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
}
|
||||
|
||||
.dev-log details {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: #0a0f18;
|
||||
}
|
||||
|
||||
.dev-log summary {
|
||||
padding: 12px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.log-scroll {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 0 10px 10px;
|
||||
}
|
||||
|
||||
.log-item {
|
||||
border-bottom: 1px dashed var(--border);
|
||||
padding: 8px 4px;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.log-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.log-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.log-method {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.log-status.ok {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.log-status.err {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.log-body {
|
||||
background: var(--panel);
|
||||
border-radius: 4px;
|
||||
padding: 6px;
|
||||
margin-top: 4px;
|
||||
font-family: ui-monospace, monospace;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.log-hint {
|
||||
margin-top: 4px;
|
||||
padding: 4px 6px;
|
||||
background: var(--warn-dim);
|
||||
border-radius: 4px;
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
Reference in New Issue
Block a user