Files

1045 lines
35 KiB
JavaScript
Raw Permalink 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.

import "./styles.css";
import {
TRASH_REGION_ID,
alarmMinutesToSeconds,
buildCalibrationPayload,
buildCaseDisplayModel,
buildManualHandlePayload,
buildPolygonMap,
buildRuntimeDisplayModel,
clampZoneCount,
classifyEvent,
deriveFoodZones,
escapeHtml,
getRegionColor,
getRegionLabel,
secondsToAlarmMinutes,
} from "./zone-state.js";
const draftStorageKey = "cold-display-guard.calibrationDraft.v2";
const defaultFoodZones = deriveFoodZones({layout: {zone_count: 8}});
const runtimeClockMs = 1000;
const runtimePollMs = 5000;
window.addEventListener("error", (event) => {
showFatalError(event.error || event.message);
});
window.addEventListener("unhandledrejection", (event) => {
showFatalError(event.reason);
});
const state = {
config: null,
summary: null,
events: [],
cases: [],
caseSummary: null,
activeTab: "events",
activeRegion: "1",
foodZones: defaultFoodZones,
foodZoneCount: defaultFoodZones.length,
polygons: buildPolygonMap(defaultFoodZones),
image: null,
imageUrl: null,
status: "正在连接后端...",
runtimeDemoReason: "正在读取后端运行数据",
configDirty: false,
calibrationDirty: false,
};
const app = document.querySelector("#app");
let runtimeRefreshInFlight = false;
app.innerHTML = `
<div class="shell">
<header class="console-header">
<div class="brand">
<div class="brand-mark">CG</div>
<div class="brand-copy">
<div class="brand-kicker">COLD DISPLAY GUARD</div>
<div class="brand-title">冷藏展示柜管理</div>
<div class="brand-subtitle">区域标定 / 运行监控 / 合规事件</div>
</div>
</div>
<nav class="tabs">
<button data-tab="calibration">区域标定</button>
<button data-tab="events">事件数据</button>
<button data-tab="settings">运行配置</button>
</nav>
<div class="header-actions">
<div class="status-pill" id="statusPill">
<span class="status-dot"></span>
<span id="statusText"></span>
</div>
<button id="refreshRuntimeData" class="secondary-action" type="button">刷新运行数据</button>
</div>
</header>
<main class="main">
<section id="calibrationView" class="view">
<section class="view-head">
<div>
<p class="view-kicker">CALIBRATION</p>
<h1>冷柜区域标定</h1>
</div>
<p class="view-note">抓取一帧后,在画面中依次点击每个格口和垃圾桶 ROI 的边界点。</p>
</section>
<section class="command-bar">
<label class="field rtsp-field">
<span>RTSP 地址(用于抓帧)</span>
<input id="rtspUrl" type="text" placeholder="rtsp://user:password@camera-ip:554/stream">
</label>
<div class="command-actions">
<button id="captureSnapshot" class="primary-action" type="button">抓取一帧</button>
<button id="saveCalibration" class="success-action" type="button">保存标定</button>
</div>
</section>
<section class="calibration-layout">
<aside class="panel zone-panel">
<div class="panel-meta">ZONE MATRIX</div>
<div class="panel-title">区域选择</div>
<label class="field zone-count-field">
<span>食品区域数量1-10</span>
<input id="foodZoneCount" type="number" min="1" max="10" step="1">
</label>
<div id="regionList" class="region-list"></div>
<div class="tool-stack">
<button id="undoPoint" type="button">撤销点</button>
<button id="clearRegion" type="button">清空当前区域</button>
<button id="loadConfigPolygons" type="button">载入已保存标定</button>
</div>
</aside>
<section class="canvas-panel">
<div class="canvas-toolbar">
<span>FRAME INSPECTION</span>
<strong id="activeRegionBadge">区域 1</strong>
</div>
<canvas id="canvas" width="1280" height="720"></canvas>
</section>
<aside class="panel inspection-panel">
<div class="panel-meta">POLYGON STATUS</div>
<div class="panel-title">标定结果</div>
<div id="regionSummary" class="region-summary"></div>
</aside>
</section>
</section>
<section id="eventsView" class="view hidden">
<section class="view-head">
<div>
<p class="view-kicker">RUNTIME</p>
<h1>事件与运行状态</h1>
</div>
<p class="view-note">从运行进程写入的事件和诊断数据中读取最近状态。</p>
</section>
<section id="runtimeOverview" class="runtime-overview"></section>
<section class="metrics" id="metrics"></section>
<section class="panel progress-panel">
<div class="panel-meta">DWELL TIMER</div>
<div class="panel-title">计时进度</div>
<div id="runtimeProgress" class="runtime-progress"></div>
</section>
<section class="panel event-panel">
<div class="panel-meta">EVENT LOG</div>
<div class="panel-title">最近事件</div>
<div id="eventsTable" class="events-table"></div>
</section>
<section class="panel case-panel">
<div class="panel-meta">CASE WORKFLOW</div>
<div class="panel-title">处置单</div>
<div id="casesTable" class="events-table"></div>
</section>
</section>
<section id="settingsView" class="view hidden">
<section class="view-head">
<div>
<p class="view-kicker">CONFIGURATION</p>
<h1>运行配置</h1>
</div>
<p class="view-note">保存后写入后端配置;运行进程需要按配置重新读取。</p>
</section>
<section class="settings-layout">
<section class="panel settings-panel">
<div class="panel-meta">CAMERA INPUT</div>
<div class="settings-grid">
<label class="field">
<span>RTSP 地址</span>
<input id="settingsRtspUrl" type="text" placeholder="rtsp://user:password@camera-ip:554/stream">
</label>
<label class="field">
<span>Camera ID</span>
<input id="cameraId" type="text">
</label>
<label class="field">
<span>时区</span>
<input id="timezone" type="text">
</label>
<label class="field">
<span>报警阈值(分钟)</span>
<input id="maxDwell" type="number" min="1" step="1">
</label>
<label class="field">
<span>垃圾桶确认秒数</span>
<input id="trashWindow" type="number" min="1">
</label>
</div>
<div class="settings-actions">
<button id="saveConfig" class="success-action" type="button">保存运行配置</button>
<button id="reloadConfig" type="button">重新载入后端配置</button>
</div>
</section>
<section class="panel preview-panel">
<div class="panel-meta">LIVE CONFIG PREVIEW</div>
<pre id="configPreview" class="config-preview"></pre>
</section>
</section>
</section>
</main>
</div>
`;
const els = {
statusText: document.querySelector("#statusText"),
canvas: document.querySelector("#canvas"),
regionList: document.querySelector("#regionList"),
foodZoneCount: document.querySelector("#foodZoneCount"),
rtspUrl: document.querySelector("#rtspUrl"),
settingsRtspUrl: document.querySelector("#settingsRtspUrl"),
cameraId: document.querySelector("#cameraId"),
timezone: document.querySelector("#timezone"),
maxDwell: document.querySelector("#maxDwell"),
trashWindow: document.querySelector("#trashWindow"),
configPreview: document.querySelector("#configPreview"),
regionSummary: document.querySelector("#regionSummary"),
runtimeOverview: document.querySelector("#runtimeOverview"),
runtimeProgress: document.querySelector("#runtimeProgress"),
metrics: document.querySelector("#metrics"),
eventsTable: document.querySelector("#eventsTable"),
casesTable: document.querySelector("#casesTable"),
statusPill: document.querySelector("#statusPill"),
activeRegionBadge: document.querySelector("#activeRegionBadge"),
};
const ctx = els.canvas.getContext("2d");
function boot() {
wireEvents();
render();
loadInitialData().finally(startRuntimeTimers);
}
function startRuntimeTimers() {
window.setInterval(renderRuntimeSections, runtimeClockMs);
window.setInterval(refreshRuntimeDataSilently, runtimePollMs);
}
function wireEvents() {
document.querySelectorAll(".tabs button").forEach((button) => {
button.addEventListener("click", () => setTab(button.dataset.tab));
});
document.querySelector("#refreshRuntimeData").addEventListener("click", refreshRuntimeData);
document.querySelector("#saveConfig").addEventListener("click", saveConfig);
document.querySelector("#reloadConfig").addEventListener("click", reloadConfig);
document.querySelector("#captureSnapshot").addEventListener("click", captureSnapshot);
document.querySelector("#saveCalibration").addEventListener("click", saveCalibration);
document.querySelector("#undoPoint").addEventListener("click", undoPoint);
document.querySelector("#clearRegion").addEventListener("click", clearRegion);
document.querySelector("#loadConfigPolygons").addEventListener("click", loadPolygonsFromConfig);
els.canvas.addEventListener("click", addPoint);
els.casesTable.addEventListener("click", handleCaseTableClick);
window.addEventListener("resize", drawCanvas);
els.foodZoneCount.addEventListener("input", () => updateFoodZoneCount(els.foodZoneCount.value));
[els.rtspUrl, els.settingsRtspUrl, els.cameraId, els.timezone, els.maxDwell, els.trashWindow].forEach((input) => {
input.addEventListener("input", () => {
state.configDirty = true;
if (input === els.rtspUrl) {
els.settingsRtspUrl.value = els.rtspUrl.value;
}
if (input === els.settingsRtspUrl) {
els.rtspUrl.value = els.settingsRtspUrl.value;
}
renderConfigPreview();
});
});
}
async function loadInitialData() {
try {
setStatus("正在读取配置和运行数据...");
const config = await apiJson("/api/manage/config");
state.config = config;
applyConfigRegions(config, {useDraft: true});
await loadRuntimeData();
fillForm();
state.configDirty = false;
render();
setStatus("已连接后端 19080");
} catch (error) {
state.runtimeDemoReason = `后端连接失败:${error.message}`;
render();
setStatus(`连接失败:${error.message}`);
}
}
async function refreshRuntimeData() {
try {
setStatus("正在刷新运行数据...");
await loadRuntimeData();
render();
setStatus(state.runtimeDemoReason ? `运行数据已刷新,部分接口失败:${state.runtimeDemoReason}` : "运行数据已刷新");
} catch (error) {
state.runtimeDemoReason = `运行数据刷新失败:${error.message}`;
render();
setStatus(`刷新运行数据失败:${error.message}`);
}
}
async function refreshRuntimeDataSilently() {
if (runtimeRefreshInFlight) {
return;
}
runtimeRefreshInFlight = true;
try {
await loadRuntimeData();
renderRuntimeSections();
} catch (error) {
state.runtimeDemoReason = `运行数据刷新失败:${error.message}`;
renderRuntimeSections();
} finally {
runtimeRefreshInFlight = false;
}
}
async function loadRuntimeData() {
const [summaryResult, eventsResult, casesResult, caseSummaryResult] = await Promise.allSettled([
apiJson("/api/manage/summary"),
apiJson("/api/manage/events?limit=1000"),
apiJson("/api/manage/cases?limit=1000"),
apiJson("/api/manage/cases/summary"),
]);
const errors = [];
if (summaryResult.status === "fulfilled") {
state.summary = summaryResult.value;
} else {
state.summary = null;
errors.push(`summary ${errorMessage(summaryResult.reason)}`);
}
if (eventsResult.status === "fulfilled") {
state.events = eventsResult.value.items || [];
} else {
state.events = [];
errors.push(`events ${errorMessage(eventsResult.reason)}`);
}
if (casesResult.status === "fulfilled") {
state.cases = casesResult.value.items || [];
} else {
state.cases = [];
errors.push(`cases ${errorMessage(casesResult.reason)}`);
}
if (caseSummaryResult.status === "fulfilled") {
state.caseSummary = caseSummaryResult.value;
} else {
state.caseSummary = null;
errors.push(`case summary ${errorMessage(caseSummaryResult.reason)}`);
}
state.runtimeDemoReason = errors.length ? errors.join("") : "";
}
async function reloadConfig() {
if (state.configDirty && !window.confirm("当前运行配置有未保存修改。确认放弃修改并重新载入后端配置?")) {
return;
}
try {
setStatus("正在重新载入后端配置...");
state.config = await apiJson("/api/manage/config");
applyConfigRegions(state.config, {useDraft: false});
fillForm();
state.configDirty = false;
render();
setStatus("后端配置已重新载入");
} catch (error) {
setStatus(`重新载入配置失败:${error.message}`);
}
}
async function saveConfig() {
try {
const payload = {
camera_id: els.cameraId.value.trim(),
timezone: els.timezone.value.trim(),
rtsp_url: els.settingsRtspUrl.value.trim(),
thresholds: {
max_dwell_seconds: alarmMinutesToSeconds(els.maxDwell.value),
trash_confirmation_seconds: Number(els.trashWindow.value),
},
};
state.config = await apiJson("/api/manage/config", {method: "PUT", body: payload});
state.configDirty = false;
fillForm();
renderConfigPreview();
setStatus("运行配置已保存");
} catch (error) {
setStatus(`保存配置失败:${error.message}`);
}
}
async function captureSnapshot() {
try {
setStatus("正在从 RTSP 抓取一帧...");
const response = await fetch("/api/manage/snapshot", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({rtsp_url: els.rtspUrl.value.trim(), timeout_seconds: 12}),
});
if (!response.ok) {
const payload = await response.json();
throw new Error(payload.error || `HTTP ${response.status}`);
}
const blob = await response.blob();
if (state.imageUrl) {
URL.revokeObjectURL(state.imageUrl);
}
state.imageUrl = URL.createObjectURL(blob);
const image = new Image();
image.onload = () => {
state.image = image;
els.canvas.width = image.naturalWidth;
els.canvas.height = image.naturalHeight;
drawCanvas();
setStatus(`已抓取 ${image.naturalWidth}x${image.naturalHeight}`);
};
image.src = state.imageUrl;
} catch (error) {
setStatus(`抓帧失败:${error.message}`);
}
}
async function saveCalibration() {
try {
const saved = await persistCalibration({requireAny: true});
if (saved) {
state.calibrationDirty = false;
saveDraftPolygons();
render();
setStatus("标定已保存到项目配置");
}
} catch (error) {
setStatus(`保存标定失败:${error.message}`);
}
}
async function persistCalibration({requireAny}) {
const payload = buildCalibrationPayload(state.foodZones, state.polygons);
if (!payload.zones.length && !payload.trash.roi) {
if (requireAny) {
setStatus("当前没有可保存的标定点;每个区域至少需要 3 个点");
}
return false;
}
state.config = await apiJson("/api/manage/calibration", {
method: "PUT",
body: payload,
});
return true;
}
function setTab(tab) {
state.activeTab = tab;
document.querySelectorAll(".tabs button").forEach((button) => {
button.classList.toggle("active", button.dataset.tab === tab);
});
document.querySelector("#calibrationView").classList.toggle("hidden", tab !== "calibration");
document.querySelector("#eventsView").classList.toggle("hidden", tab !== "events");
document.querySelector("#settingsView").classList.toggle("hidden", tab !== "settings");
}
function fillForm() {
const config = state.config || {};
const alarmSeconds = config.thresholds?.max_dwell_seconds || 10800;
els.rtspUrl.value = config.stream?.rtsp_url || "";
els.settingsRtspUrl.value = config.stream?.rtsp_url || "";
els.cameraId.value = config.camera_id || "";
els.timezone.value = config.timezone || "";
els.maxDwell.value = secondsToAlarmMinutes(alarmSeconds);
els.trashWindow.value = config.thresholds?.trash_confirmation_seconds || 120;
els.foodZoneCount.value = String(state.foodZoneCount);
}
function applyConfigRegions(config, {useDraft}) {
const foodZones = deriveFoodZones(config);
const draft = useDraft ? readDraftPolygons() : {};
state.foodZones = foodZones;
state.foodZoneCount = foodZones.length;
state.polygons = buildPolygonMap(foodZones, draft, config?.trash?.roi || []);
if (!allRegionIds().includes(state.activeRegion)) {
state.activeRegion = foodZones[0]?.id || TRASH_REGION_ID;
}
}
function updateFoodZoneCount(value) {
const nextCount = clampZoneCount(value);
if (nextCount === state.foodZoneCount) {
els.foodZoneCount.value = String(nextCount);
return;
}
state.foodZoneCount = nextCount;
const existingPolygons = state.polygons;
state.foodZones = deriveFoodZones({layout: {zone_count: nextCount}});
state.polygons = buildPolygonMap(state.foodZones, existingPolygons, existingPolygons[TRASH_REGION_ID]);
if (!allRegionIds().includes(state.activeRegion)) {
state.activeRegion = state.foodZones.at(-1)?.id || TRASH_REGION_ID;
}
state.calibrationDirty = true;
els.foodZoneCount.value = String(nextCount);
saveDraftPolygons();
render();
}
function loadPolygonsFromConfig(updateStatus = true) {
if (!state.config) {
return;
}
if (updateStatus && state.calibrationDirty && !window.confirm("当前标定有未保存草稿。确认放弃草稿并载入已保存标定?")) {
return;
}
applyConfigRegions(state.config, {useDraft: false});
state.calibrationDirty = false;
saveDraftPolygons();
render();
if (updateStatus) {
setStatus("已载入当前配置区域");
}
}
function render() {
renderRegionList();
drawCanvas();
renderRegionSummary();
renderRuntimeSections();
renderConfigPreview();
setTab(state.activeTab);
}
function buildRuntimeModel() {
return buildRuntimeDisplayModel({
summary: state.summary,
events: state.events,
config: state.config,
foodZones: state.foodZones,
demoReason: state.runtimeDemoReason,
now: new Date(),
});
}
function buildCaseModel() {
return buildCaseDisplayModel({
summary: state.caseSummary,
cases: state.cases,
});
}
function renderRuntimeSections() {
const runtimeModel = buildRuntimeModel();
const caseModel = buildCaseModel();
renderRuntimeOverview(runtimeModel);
renderMetrics(runtimeModel, caseModel);
renderRuntimeProgress(runtimeModel);
renderEvents(runtimeModel);
renderCases(caseModel);
}
function renderRegionList() {
els.regionList.innerHTML = "";
for (const id of allRegionIds()) {
const button = document.createElement("button");
const complete = (state.polygons[id] || []).length >= 3;
button.type = "button";
button.className = [
"region-button",
id === state.activeRegion ? "active" : "",
complete ? "complete" : "",
].filter(Boolean).join(" ");
button.style.setProperty("--region-color", getRegionColor(id));
button.innerHTML = `
<span class="region-swatch"></span>
<span class="region-name">${escapeHtml(getRegionLabel(id))}</span>
<span class="region-code">${id === TRASH_REGION_ID ? "ROI" : escapeHtml(id)}</span>
<span class="region-points">${(state.polygons[id] || []).length}</span>
`;
button.addEventListener("click", () => {
state.activeRegion = id;
render();
});
els.regionList.appendChild(button);
}
}
function addPoint(event) {
if (!state.image) {
setStatus("请先从 RTSP 抓取一帧");
return;
}
const imageRect = getCanvasImageRect();
const rawX = event.clientX - imageRect.left;
const rawY = event.clientY - imageRect.top;
if (rawX < 0 || rawY < 0 || rawX > imageRect.width || rawY > imageRect.height) {
setStatus("请点击截图实际显示区域内");
return;
}
const x = clamp(rawX / imageRect.width);
const y = clamp(rawY / imageRect.height);
if (!state.polygons[state.activeRegion]) {
state.polygons[state.activeRegion] = [];
}
state.polygons[state.activeRegion].push({x: round(x), y: round(y)});
state.calibrationDirty = true;
saveDraftPolygons();
render();
}
function getCanvasImageRect() {
const rect = els.canvas.getBoundingClientRect();
const canvasRatio = els.canvas.width / els.canvas.height;
const elementRatio = rect.width / rect.height;
if (elementRatio > canvasRatio) {
const width = rect.height * canvasRatio;
return {
left: rect.left + (rect.width - width) / 2,
top: rect.top,
width,
height: rect.height,
};
}
const height = rect.width / canvasRatio;
return {
left: rect.left,
top: rect.top + (rect.height - height) / 2,
width: rect.width,
height,
};
}
function undoPoint() {
(state.polygons[state.activeRegion] || []).pop();
state.calibrationDirty = true;
saveDraftPolygons();
render();
}
function clearRegion() {
state.polygons[state.activeRegion] = [];
state.calibrationDirty = true;
saveDraftPolygons();
render();
}
function hasAnyPolygon() {
return allRegionIds().some((id) => (state.polygons[id] || []).length > 0);
}
function saveDraftPolygons() {
localStorage.setItem(draftStorageKey, JSON.stringify({
zone_count: state.foodZoneCount,
polygons: state.polygons,
}));
}
function readDraftPolygons() {
const raw = localStorage.getItem(draftStorageKey);
if (!raw) {
return {};
}
try {
const draft = JSON.parse(raw);
const polygons = draft.polygons && typeof draft.polygons === "object" ? draft.polygons : draft;
const normalized = {};
for (const id of Object.keys(polygons)) {
if (!Array.isArray(polygons[id])) {
continue;
}
normalized[id] = polygons[id]
.filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y))
.map((point) => ({x: clamp(point.x), y: clamp(point.y)}));
}
return normalized;
} catch {
localStorage.removeItem(draftStorageKey);
return {};
}
}
function drawCanvas() {
ctx.clearRect(0, 0, els.canvas.width, els.canvas.height);
if (state.image) {
ctx.drawImage(state.image, 0, 0, els.canvas.width, els.canvas.height);
} else {
ctx.fillStyle = "#121826";
ctx.fillRect(0, 0, els.canvas.width, els.canvas.height);
ctx.fillStyle = "#dbe3ea";
ctx.font = "22px system-ui";
ctx.textAlign = "center";
ctx.fillText("输入 RTSP 地址并抓取一帧", els.canvas.width / 2, els.canvas.height / 2);
}
for (const id of allRegionIds()) {
drawPolygon(id, state.polygons[id] || []);
}
}
function drawPolygon(id, points) {
if (!points.length) {
return;
}
const color = getRegionColor(id);
ctx.save();
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.lineWidth = id === state.activeRegion ? 4 : 2;
ctx.beginPath();
points.forEach((point, index) => {
const x = point.x * els.canvas.width;
const y = point.y * els.canvas.height;
if (index === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
if (points.length >= 3) {
ctx.closePath();
ctx.globalAlpha = 0.22;
ctx.fill();
ctx.globalAlpha = 1;
}
ctx.stroke();
points.forEach((point, index) => {
const x = point.x * els.canvas.width;
const y = point.y * els.canvas.height;
ctx.beginPath();
ctx.arc(x, y, 5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#ffffff";
ctx.font = "12px system-ui";
ctx.textAlign = "center";
ctx.fillText(String(index + 1), x, y - 9);
ctx.fillStyle = color;
});
const first = points[0];
ctx.font = id === state.activeRegion ? "bold 18px system-ui" : "14px system-ui";
ctx.textAlign = "left";
ctx.fillText(getRegionLabel(id), first.x * els.canvas.width + 8, first.y * els.canvas.height + 18);
ctx.restore();
}
function renderRegionSummary() {
els.regionSummary.innerHTML = allRegionIds()
.map((id) => {
const count = (state.polygons[id] || []).length;
const complete = count >= 3;
return `
<div class="summary-row ${complete ? "complete" : "pending"}">
<span class="summary-dot" style="--region-color:${getRegionColor(id)}"></span>
<strong>${escapeHtml(getRegionLabel(id))}</strong>
<span>${complete ? `${count} 点 / 已标定` : `${count} 点 / 待完成`}</span>
</div>
`;
})
.join("");
els.activeRegionBadge.textContent = getRegionLabel(state.activeRegion);
}
function renderRuntimeOverview(model) {
const labels = [
model.hasSummary ? "运行摘要来自后端" : "暂无运行摘要",
model.progressRows.length ? "计时进度来自事件" : "暂无计时进度",
model.hasEvents ? "事件表来自后端" : "暂无事件数据",
];
els.runtimeOverview.innerHTML = `
<div class="runtime-banner real">
<div>
<span>LIVE DATA</span>
<strong>${model.hasSummary || model.hasEvents ? "实时态:正在显示后端返回的运行数据" : "实时态:暂无真实运行数据"}</strong>
</div>
<p>${escapeHtml(labels.join(" / "))}${model.demoReason ? `${escapeHtml(model.demoReason)}` : ""}</p>
</div>
`;
}
function renderMetrics(model, caseModel) {
const metrics = model.summary?.metrics || {};
const alertCount = metrics.alert_count ?? 0;
const warningCount = metrics.warning_count ?? 0;
const violationCount = metrics.violation_count ?? 0;
const baselineReady = Boolean(metrics.baseline_ready);
const caseMetrics = caseModel?.metrics || {};
const metricLabel = (label) => label;
const cards = [
{label: metricLabel("事件总数"), value: metrics.event_count ?? 0, tone: "neutral"},
{label: metricLabel("时间报警"), value: alertCount, tone: alertCount > 0 ? "alarm" : "good"},
{label: metricLabel("升级警告"), value: warningCount, tone: warningCount > 0 ? "warning" : "good"},
{label: metricLabel("违规事件"), value: violationCount, tone: violationCount > 0 ? "danger" : "good"},
{label: metricLabel("诊断帧数"), value: metrics.diagnostics_count ?? 0, tone: "neutral"},
{label: metricLabel("基线状态"), value: baselineReady ? "ready" : "learning", tone: baselineReady ? "good" : "warning"},
{label: metricLabel("最新报警"), value: metrics.latest_alert_time || "-", tone: metrics.latest_alert_time ? "danger" : "neutral"},
{label: metricLabel("待处理处置单"), value: caseMetrics.openCaseCount ?? 0, tone: (caseMetrics.openCaseCount ?? 0) > 0 ? "warning" : "good"},
{label: metricLabel("已处理处置单"), value: caseMetrics.handledCaseCount ?? 0, tone: "good"},
{label: metricLabel("超时报警单"), value: caseMetrics.timeAlarmCaseCount ?? 0, tone: "neutral"},
{label: metricLabel("待丢弃确认单"), value: caseMetrics.pendingDisposalCaseCount ?? 0, tone: "neutral"},
{label: metricLabel("升级警告单"), value: caseMetrics.warningEscalatedCaseCount ?? 0, tone: (caseMetrics.warningEscalatedCaseCount ?? 0) > 0 ? "danger" : "good"},
{label: metricLabel("事件文件"), value: metrics.events_path || "-", tone: "path"},
];
const zoneCounts = metrics.latest_zone_counts || {};
const zoneSummary = Object.keys(zoneCounts).length
? `<div class="metric wide zone-state"><span>${escapeHtml(metricLabel("最新区域状态"))}</span><strong>${Object.entries(zoneCounts)
.map(([zoneId, count]) => escapeHtml(`${zoneId}:${count}`))
.join(" ")}</strong></div>`
: "";
els.metrics.innerHTML = cards
.map((card) => `
<div class="metric ${card.tone}">
<span>${escapeHtml(card.label)}</span>
<strong>${escapeHtml(String(card.value))}</strong>
</div>
`)
.join("") + zoneSummary;
}
function renderRuntimeProgress(model) {
if (!model.progressRows.length) {
els.runtimeProgress.innerHTML = `<div class="empty">暂无可显示的计时进度</div>`;
return;
}
els.runtimeProgress.innerHTML = model.progressRows
.map((row) => {
const statusLabel = row.status === "warning" ? "警告" : row.status === "alarm" ? "报警" : "正常";
return `
<div class="progress-row ${row.status}">
<div class="progress-zone">
<span class="zone-number">${escapeHtml(String(row.zoneIndex))}</span>
<strong>${escapeHtml(row.zoneLabel)}</strong>
</div>
<div class="progress-track" aria-label="${escapeHtml(`${row.zoneLabel} 停留 ${row.dwellSeconds}`)}">
<span style="width:${row.progressPct}%"></span>
</div>
<div class="progress-meta">
<strong>${escapeHtml(formatDuration(row.dwellSeconds))}</strong>
<span>${escapeHtml(statusLabel)}</span>
</div>
</div>
`;
})
.join("");
}
function renderEvents(model) {
const events = model.displayEvents || model.events;
if (!events.length) {
els.eventsTable.innerHTML = `<div class="empty">还没有事件数据</div>`;
return;
}
els.eventsTable.innerHTML = `
<table>
<thead><tr><th>时间</th><th>来源</th><th>级别</th><th>事件</th><th>区域序号</th><th>区域</th><th>批次</th><th>停留秒数</th></tr></thead>
<tbody>
${events
.slice()
.reverse()
.map((event) => {
const eventName = event.event || "";
const meta = classifyEvent(event);
return `
<tr class="event-row ${meta.tone}">
<td>${escapeHtml(event.ts || "")}</td>
<td><span class="event-source real">真实</span></td>
<td><span class="event-severity ${meta.tone}">${escapeHtml(meta.severity)}</span></td>
<td><span class="event-name ${meta.tone}">${escapeHtml(eventName)}</span></td>
<td>${escapeHtml(meta.zoneIndex ? String(meta.zoneIndex) : "")}</td>
<td>${escapeHtml(meta.zoneLabel || "")}</td>
<td>${escapeHtml(event.batch_id || "")}</td>
<td>${escapeHtml(String(event.displayDwellSeconds ?? event.dwell_seconds ?? ""))}</td>
</tr>
`;
})
.join("")}
</tbody>
</table>
`;
}
function renderCases(model) {
if (!model.rows.length) {
els.casesTable.innerHTML = `<div class="empty">还没有处置单数据</div>`;
return;
}
els.casesTable.innerHTML = `
<table>
<thead><tr><th>处置单</th><th>类型</th><th>状态</th><th>区域</th><th>批次</th><th>更新时间</th><th>处理来源</th><th>操作</th></tr></thead>
<tbody>
${model.rows
.map((row) => `
<tr class="event-row ${row.tone}">
<td>${escapeHtml(row.caseId)}</td>
<td>${escapeHtml(row.typeLabel)}</td>
<td><span class="event-severity ${row.tone}">${escapeHtml(row.statusLabel)}</span></td>
<td>${escapeHtml(row.zone_label || "")}</td>
<td>${escapeHtml(row.batch_id || "")}</td>
<td>${escapeHtml(row.updated_at || "")}</td>
<td>${escapeHtml(row.handledSourceLabel || "-")}</td>
<td>${row.case_status === "open"
? `<button type="button" class="secondary-action" data-handle-case="${escapeHtml(row.caseId)}">标记已处理</button>`
: `<span class="event-source real">已完成</span>`}</td>
</tr>
`)
.join("")}
</tbody>
</table>
`;
}
function formatDuration(seconds) {
const value = Number(seconds);
if (!Number.isFinite(value) || value <= 0) {
return "0s";
}
if (value < 60) {
return `${Math.round(value)}s`;
}
const minutes = Math.floor(value / 60);
const rest = Math.round(value % 60);
return rest ? `${minutes}m ${rest}s` : `${minutes}m`;
}
function errorMessage(reason) {
return reason?.message || String(reason || "unknown error");
}
function renderConfigPreview() {
const preview = {
...(state.config || {}),
stream: {
...((state.config || {}).stream || {}),
rtsp_url: els.settingsRtspUrl.value,
},
camera_id: els.cameraId.value,
timezone: els.timezone.value,
thresholds: {
max_dwell_seconds: alarmMinutesToSeconds(els.maxDwell.value || 0),
trash_confirmation_seconds: Number(els.trashWindow.value || 0),
},
layout: {
zone_count: state.foodZoneCount,
zone_ids: state.foodZones.map((zone) => zone.id),
},
zones: state.foodZones.map((zone) => ({
id: zone.id,
label: zone.label,
polygon: state.polygons[zone.id] || [],
})),
trash: {
roi: state.polygons[TRASH_REGION_ID] || [],
},
ui_state: {
config_dirty: state.configDirty,
calibration_dirty: state.calibrationDirty,
},
};
els.configPreview.textContent = JSON.stringify(preview, null, 2);
}
async function apiJson(path, options = {}) {
const request = {...options};
if (request.body && typeof request.body !== "string") {
request.headers = {"Content-Type": "application/json", ...(request.headers || {})};
request.body = JSON.stringify(request.body);
}
const response = await fetch(path, request);
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`);
}
return payload;
}
function handleCaseTableClick(event) {
const button = event.target.closest("[data-handle-case]");
if (!button) {
return;
}
handleCase(button.dataset.handleCase);
}
async function handleCase(caseId) {
const handledBy = window.prompt("请输入处理人");
if (handledBy === null) {
return;
}
const trimmedHandledBy = handledBy.trim();
if (!trimmedHandledBy) {
setStatus("处理人不能为空");
return;
}
const note = window.prompt("请输入处理备注(可选)") || "";
try {
setStatus("正在更新处置单状态...");
await apiJson(`/api/manage/cases/${encodeURIComponent(caseId)}/handle`, {
method: "POST",
body: buildManualHandlePayload(trimmedHandledBy, note),
});
await loadRuntimeData();
renderRuntimeSections();
setStatus("处置单已标记为已处理");
} catch (error) {
setStatus(`更新处置单失败:${error.message}`);
}
}
function setStatus(message) {
state.status = message;
els.statusText.textContent = message;
const tone = message.includes("失败") || message.includes("错误")
? "error"
: message.includes("正在")
? "busy"
: "ready";
els.statusPill.className = `status-pill ${tone}`;
}
function clamp(value) {
return Math.min(1, Math.max(0, value));
}
function round(value) {
return Math.round(value * 1000000) / 1000000;
}
function allRegionIds() {
return [...state.foodZones.map((zone) => zone.id), TRASH_REGION_ID];
}
try {
boot();
} catch (error) {
showFatalError(error);
}
function showFatalError(error) {
const message = error?.message || String(error || "unknown error");
console.error(error);
const target = document.querySelector("#app");
if (!target || target.querySelector(".fatal-error")) {
return;
}
const banner = document.createElement("div");
banner.className = "fatal-error";
banner.textContent = `前端初始化失败:${message}`;
target.prepend(banner);
}