feat: stabilize cold display runtime deployment

This commit is contained in:
Yoilun
2026-05-29 14:48:01 +08:00
parent ea5f9b1b07
commit 8b5bbff364
32 changed files with 5050 additions and 241 deletions

3
web/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
.DS_Store

29
web/Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/node:20-alpine AS builder
ENV TZ=Asia/Shanghai
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
WORKDIR /source
RUN npm install -g pnpm@10.30.3
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/nginx:1.29.4-alpine
ENV TZ=Asia/Shanghai
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \
apk add --no-cache tzdata
COPY --from=builder /source/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

20
web/nginx.conf Normal file
View File

@@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://cold-display-guard-api:19080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -1,35 +1,51 @@
import "./styles.css";
import {
TRASH_REGION_ID,
alarmMinutesToSeconds,
buildCalibrationPayload,
buildPolygonMap,
buildRuntimeDisplayModel,
clampZoneCount,
classifyEvent,
deriveFoodZones,
escapeHtml,
getRegionColor,
getRegionLabel,
secondsToAlarmMinutes,
} from "./zone-state.js";
const zoneIds = ["r1c1", "r1c2", "r1c3", "r1c4", "r2c1", "r2c2", "r2c3", "r2c4"];
const allRegions = [...zoneIds, "trash"];
const draftStorageKey = "cold-display-guard.calibrationDraft.v1";
const palette = {
r1c1: "#d92d20",
r1c2: "#b54708",
r1c3: "#4e5ba6",
r1c4: "#008a5a",
r2c1: "#0077a3",
r2c2: "#155eef",
r2c3: "#7f56d9",
r2c4: "#c11574",
trash: "#111827",
};
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: [],
activeTab: "calibration",
activeRegion: "r1c1",
polygons: Object.fromEntries(allRegions.map((id) => [id, []])),
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">
@@ -83,6 +99,10 @@ app.innerHTML = `
<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>
@@ -94,7 +114,7 @@ app.innerHTML = `
<section class="canvas-panel">
<div class="canvas-toolbar">
<span>FRAME INSPECTION</span>
<strong id="activeRegionBadge">r1c1</strong>
<strong id="activeRegionBadge">区域 1</strong>
</div>
<canvas id="canvas" width="1280" height="720"></canvas>
</section>
@@ -115,7 +135,13 @@ app.innerHTML = `
</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>
@@ -149,8 +175,8 @@ app.innerHTML = `
<input id="timezone" type="text">
</label>
<label class="field">
<span>最大放置秒数</span>
<input id="maxDwell" type="number" min="1">
<span>报警阈值(分钟)</span>
<input id="maxDwell" type="number" min="1" step="1">
</label>
<label class="field">
<span>垃圾桶确认秒数</span>
@@ -177,6 +203,7 @@ 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"),
@@ -185,6 +212,8 @@ const els = {
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"),
statusPill: document.querySelector("#statusPill"),
@@ -194,9 +223,13 @@ const ctx = els.canvas.getContext("2d");
function boot() {
wireEvents();
loadDraftPolygons();
renderRegionList();
loadInitialData();
render();
loadInitialData().finally(startRuntimeTimers);
}
function startRuntimeTimers() {
window.setInterval(renderRuntimeSections, runtimeClockMs);
window.setInterval(refreshRuntimeDataSilently, runtimePollMs);
}
function wireEvents() {
@@ -213,6 +246,7 @@ function wireEvents() {
document.querySelector("#loadConfigPolygons").addEventListener("click", loadPolygonsFromConfig);
els.canvas.addEventListener("click", addPoint);
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;
@@ -230,22 +264,17 @@ function wireEvents() {
async function loadInitialData() {
try {
setStatus("正在读取配置和运行数据...");
const [config, summary, events] = await Promise.all([
apiJson("/api/manage/config"),
apiJson("/api/manage/summary"),
apiJson("/api/manage/events?limit=200"),
]);
const config = await apiJson("/api/manage/config");
state.config = config;
state.summary = summary;
state.events = events.items || [];
applyConfigRegions(config, {useDraft: true});
await loadRuntimeData();
fillForm();
state.configDirty = false;
if (!hasAnyPolygon()) {
loadPolygonsFromConfig(false);
}
render();
setStatus("已连接后端 19080");
} catch (error) {
state.runtimeDemoReason = `后端连接失败:${error.message}`;
render();
setStatus(`连接失败:${error.message}`);
}
}
@@ -253,19 +282,53 @@ async function loadInitialData() {
async function refreshRuntimeData() {
try {
setStatus("正在刷新运行数据...");
const [summary, events] = await Promise.all([
apiJson("/api/manage/summary"),
apiJson("/api/manage/events?limit=200"),
]);
state.summary = summary;
state.events = events.items || [];
await loadRuntimeData();
render();
setStatus("运行数据已刷新");
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] = await Promise.allSettled([
apiJson("/api/manage/summary"),
apiJson("/api/manage/events?limit=1000"),
]);
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)}`);
}
state.runtimeDemoReason = errors.length ? errors.join("") : "";
}
async function reloadConfig() {
if (state.configDirty && !window.confirm("当前运行配置有未保存修改。确认放弃修改并重新载入后端配置?")) {
return;
@@ -273,6 +336,7 @@ async function reloadConfig() {
try {
setStatus("正在重新载入后端配置...");
state.config = await apiJson("/api/manage/config");
applyConfigRegions(state.config, {useDraft: false});
fillForm();
state.configDirty = false;
render();
@@ -289,7 +353,7 @@ async function saveConfig() {
timezone: els.timezone.value.trim(),
rtsp_url: els.settingsRtspUrl.value.trim(),
thresholds: {
max_dwell_seconds: Number(els.maxDwell.value),
max_dwell_seconds: alarmMinutesToSeconds(els.maxDwell.value),
trash_confirmation_seconds: Number(els.trashWindow.value),
},
};
@@ -349,15 +413,8 @@ async function saveCalibration() {
}
async function persistCalibration({requireAny}) {
const zones = zoneIds
.map((id) => ({id, polygon: serializePolygon(state.polygons[id])}))
.filter((zone) => zone.polygon.length >= 3);
const trashPolygon = state.polygons.trash;
const payload = {zones, trash: {}};
if (trashPolygon.length >= 3) {
payload.trash.roi = serializePolygon(trashPolygon);
}
if (!zones.length && !payload.trash.roi) {
const payload = buildCalibrationPayload(state.foodZones, state.polygons);
if (!payload.zones.length && !payload.trash.roi) {
if (requireAny) {
setStatus("当前没有可保存的标定点;每个区域至少需要 3 个点");
}
@@ -370,10 +427,6 @@ async function persistCalibration({requireAny}) {
return true;
}
function serializePolygon(points) {
return points.map((point) => [point.x, point.y]);
}
function setTab(tab) {
state.activeTab = tab;
document.querySelectorAll(".tabs button").forEach((button) => {
@@ -386,12 +439,44 @@ function setTab(tab) {
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 = config.thresholds?.max_dwell_seconds || 10800;
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) {
@@ -401,14 +486,7 @@ function loadPolygonsFromConfig(updateStatus = true) {
if (updateStatus && state.calibrationDirty && !window.confirm("当前标定有未保存草稿。确认放弃草稿并载入已保存标定?")) {
return;
}
for (const zone of state.config.zones || []) {
if (zone.id && Array.isArray(zone.polygon)) {
state.polygons[zone.id] = zone.polygon.map(([x, y]) => ({x, y}));
}
}
if (Array.isArray(state.config.trash?.roi)) {
state.polygons.trash = state.config.trash.roi.map(([x, y]) => ({x, y}));
}
applyConfigRegions(state.config, {useDraft: false});
state.calibrationDirty = false;
saveDraftPolygons();
render();
@@ -421,29 +499,47 @@ function render() {
renderRegionList();
drawCanvas();
renderRegionSummary();
renderMetrics();
renderEvents();
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 renderRuntimeSections() {
const runtimeModel = buildRuntimeModel();
renderRuntimeOverview(runtimeModel);
renderMetrics(runtimeModel);
renderRuntimeProgress(runtimeModel);
renderEvents(runtimeModel);
}
function renderRegionList() {
els.regionList.innerHTML = "";
for (const id of allRegions) {
for (const id of allRegionIds()) {
const button = document.createElement("button");
const complete = state.polygons[id].length >= 3;
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", palette[id] || "#ffffff");
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">${escapeHtml(id)}</span>
<span class="region-points">${state.polygons[id].length}</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;
@@ -467,6 +563,9 @@ function addPoint(event) {
}
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();
@@ -496,7 +595,7 @@ function getCanvasImageRect() {
}
function undoPoint() {
state.polygons[state.activeRegion].pop();
(state.polygons[state.activeRegion] || []).pop();
state.calibrationDirty = true;
saveDraftPolygons();
render();
@@ -510,30 +609,37 @@ function clearRegion() {
}
function hasAnyPolygon() {
return allRegions.some((id) => state.polygons[id].length > 0);
return allRegionIds().some((id) => (state.polygons[id] || []).length > 0);
}
function saveDraftPolygons() {
localStorage.setItem(draftStorageKey, JSON.stringify(state.polygons));
localStorage.setItem(draftStorageKey, JSON.stringify({
zone_count: state.foodZoneCount,
polygons: state.polygons,
}));
}
function loadDraftPolygons() {
function readDraftPolygons() {
const raw = localStorage.getItem(draftStorageKey);
if (!raw) {
return;
return {};
}
try {
const draft = JSON.parse(raw);
for (const id of allRegions) {
if (!Array.isArray(draft[id])) {
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;
}
state.polygons[id] = draft[id]
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 {};
}
}
@@ -549,8 +655,8 @@ function drawCanvas() {
ctx.textAlign = "center";
ctx.fillText("输入 RTSP 地址并抓取一帧", els.canvas.width / 2, els.canvas.height / 2);
}
for (const id of allRegions) {
drawPolygon(id, state.polygons[id]);
for (const id of allRegionIds()) {
drawPolygon(id, state.polygons[id] || []);
}
}
@@ -558,7 +664,7 @@ function drawPolygon(id, points) {
if (!points.length) {
return;
}
const color = palette[id] || "#ffffff";
const color = getRegionColor(id);
ctx.save();
ctx.strokeStyle = color;
ctx.fillStyle = color;
@@ -595,43 +701,65 @@ function drawPolygon(id, points) {
const first = points[0];
ctx.font = id === state.activeRegion ? "bold 18px system-ui" : "14px system-ui";
ctx.textAlign = "left";
ctx.fillText(id, first.x * els.canvas.width + 8, first.y * els.canvas.height + 18);
ctx.fillText(getRegionLabel(id), first.x * els.canvas.width + 8, first.y * els.canvas.height + 18);
ctx.restore();
}
function renderRegionSummary() {
els.regionSummary.innerHTML = allRegions
els.regionSummary.innerHTML = allRegionIds()
.map((id) => {
const count = state.polygons[id].length;
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:${palette[id] || "#ffffff"}"></span>
<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 = state.activeRegion;
els.activeRegionBadge.textContent = getRegionLabel(state.activeRegion);
}
function renderMetrics() {
const metrics = state.summary?.metrics || {};
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) {
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 metricLabel = (label) => label;
const cards = [
{label: "事件总数", value: metrics.event_count ?? 0, tone: "neutral"},
{label: "违规事件", value: violationCount, tone: violationCount > 0 ? "danger" : "good"},
{label: "诊断帧数", value: metrics.diagnostics_count ?? 0, tone: "neutral"},
{label: "基线状态", value: baselineReady ? "ready" : "learning", tone: baselineReady ? "good" : "warning"},
{label: "最新报警", value: metrics.latest_alert_time || "-", tone: metrics.latest_alert_time ? "danger" : "neutral"},
{label: "事件文件", value: metrics.events_path || "-", tone: "path"},
{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: metrics.events_path || "-", tone: "path"},
];
const zoneCounts = metrics.latest_zone_counts || {};
const zoneSummary = Object.keys(zoneCounts).length
? `<div class="metric wide zone-state"><span>最新区域状态</span><strong>${Object.entries(zoneCounts)
.map(([zoneId, count]) => `${zoneId}:${count}`)
? `<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
@@ -644,28 +772,59 @@ function renderMetrics() {
.join("") + zoneSummary;
}
function renderEvents() {
if (!state.events.length) {
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></tr></thead>
<thead><tr><th>时间</th><th>来源</th><th>级别</th><th>事件</th><th>区域序号</th><th>区域</th><th>批次</th><th>停留秒数</th></tr></thead>
<tbody>
${state.events
${events
.slice()
.reverse()
.map((event) => {
const eventName = event.event || "";
const isViolation = eventName.includes("violation");
const meta = classifyEvent(event);
return `
<tr class="${isViolation ? "violation-row" : ""}">
<tr class="event-row ${meta.tone}">
<td>${escapeHtml(event.ts || "")}</td>
<td><span class="event-name ${isViolation ? "danger" : ""}">${escapeHtml(eventName)}</span></td>
<td>${escapeHtml(event.zone_id || "")}</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.dwell_seconds ?? ""))}</td>
<td>${escapeHtml(String(event.displayDwellSeconds ?? event.dwell_seconds ?? ""))}</td>
</tr>
`;
})
@@ -675,6 +834,23 @@ function renderEvents() {
`;
}
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 || {}),
@@ -685,9 +861,21 @@ function renderConfigPreview() {
camera_id: els.cameraId.value,
timezone: els.timezone.value,
thresholds: {
max_dwell_seconds: Number(els.maxDwell.value || 0),
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,
@@ -721,17 +909,6 @@ function setStatus(message) {
els.statusPill.className = `status-pill ${tone}`;
}
function getRegionLabel(id) {
if (id === "trash") {
return "垃圾桶";
}
const match = id.match(/^r(\d)c(\d)$/);
if (!match) {
return id;
}
return `${match[1]}${match[2]}`;
}
function clamp(value) {
return Math.min(1, Math.max(0, value));
}
@@ -740,14 +917,25 @@ function round(value) {
return Math.round(value * 1000000) / 1000000;
}
function escapeHtml(value) {
return value.replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[char]);
function allRegionIds() {
return [...state.foodZones.map((zone) => zone.id), TRASH_REGION_ID];
}
boot();
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);
}

View File

@@ -32,6 +32,18 @@ body {
var(--paper);
}
.fatal-error {
position: sticky;
top: 0;
z-index: 20;
padding: 10px 14px;
border-bottom: 1px solid rgba(201, 50, 50, 0.28);
background: #fdecea;
color: var(--red);
font-size: 13px;
font-weight: 900;
}
button,
input {
font: inherit;
@@ -357,6 +369,10 @@ input::placeholder {
gap: 7px;
}
.zone-count-field {
margin-bottom: 12px;
}
.region-button {
display: grid;
grid-template-columns: 12px minmax(0, 1fr) auto 28px;
@@ -507,6 +523,52 @@ canvas {
gap: 12px;
}
.runtime-overview {
min-width: 0;
}
.runtime-banner {
display: grid;
grid-template-columns: minmax(260px, auto) minmax(0, 1fr);
align-items: center;
gap: 16px;
padding: 13px 14px;
border: 1px solid var(--line);
border-left: 5px solid var(--green);
border-radius: 8px;
background: #f8fbfa;
box-shadow: var(--shadow);
}
.runtime-banner.demo {
border-left-color: var(--amber);
background: #fffaf0;
}
.runtime-banner span {
display: block;
color: var(--muted);
font-size: 11px;
font-weight: 900;
}
.runtime-banner strong {
display: block;
margin-top: 3px;
color: var(--ink);
font-size: 15px;
line-height: 1.25;
}
.runtime-banner p {
margin: 0;
color: var(--muted);
font-size: 13px;
font-weight: 700;
line-height: 1.55;
text-align: right;
}
.metric {
display: grid;
gap: 8px;
@@ -541,6 +603,10 @@ canvas {
border-top-color: var(--amber);
}
.metric.alarm {
border-top-color: var(--blue);
}
.metric.danger {
border-top-color: var(--red);
}
@@ -560,6 +626,107 @@ canvas {
overflow: hidden;
}
.progress-panel {
overflow: hidden;
}
.runtime-progress {
display: grid;
gap: 9px;
}
.progress-row {
display: grid;
grid-template-columns: minmax(130px, 0.4fr) minmax(180px, 1fr) minmax(88px, auto);
align-items: center;
gap: 12px;
min-height: 48px;
padding: 9px 10px;
border: 1px solid #e2e8ee;
border-radius: 8px;
background: #fbfcfd;
}
.progress-zone {
display: flex;
align-items: center;
min-width: 0;
gap: 8px;
}
.zone-number {
display: grid;
flex: 0 0 30px;
width: 30px;
height: 30px;
place-items: center;
border: 1px solid #d7dee5;
border-radius: 999px;
background: #ffffff;
color: var(--ink);
font-family: "DIN Alternate", "Avenir Next Condensed", sans-serif;
font-weight: 900;
}
.progress-zone strong {
overflow: hidden;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress-track {
height: 12px;
overflow: hidden;
border: 1px solid #d5dde5;
border-radius: 999px;
background: #eef2f5;
}
.progress-track span {
display: block;
width: 0;
height: 100%;
min-width: 3px;
border-radius: inherit;
background: var(--green);
}
.progress-row.alarm .progress-track span {
background: var(--blue);
}
.progress-row.warning .progress-track span {
background: var(--amber);
}
.progress-meta {
display: grid;
gap: 2px;
justify-items: end;
white-space: nowrap;
}
.progress-meta strong {
font-family: "DIN Alternate", "Avenir Next Condensed", "PingFang SC", sans-serif;
font-size: 15px;
font-weight: 900;
}
.progress-meta span {
color: var(--muted);
font-size: 12px;
font-weight: 900;
}
.progress-row.alarm .progress-meta span {
color: var(--blue);
}
.progress-row.warning .progress-meta span {
color: var(--amber);
}
.events-table {
overflow: auto;
margin: 0 -14px -14px;
@@ -593,11 +760,18 @@ td {
font-size: 13px;
}
.event-row.warning,
.violation-row {
background: #fff6f5;
}
.event-name {
.event-row.alarm {
background: #f0f7ff;
}
.event-name,
.event-severity,
.event-source {
display: inline-flex;
align-items: center;
min-height: 26px;
@@ -610,12 +784,37 @@ td {
font-weight: 800;
}
.event-severity {
text-transform: uppercase;
}
.event-source.real {
border-color: rgba(15, 143, 97, 0.24);
background: #e9f8f1;
color: var(--green);
}
.event-source.demo {
border-color: rgba(183, 110, 0, 0.24);
background: #fff4df;
color: var(--amber);
}
.event-name.warning,
.event-severity.warning,
.event-name.danger {
border-color: rgba(201, 50, 50, 0.24);
background: #fdecea;
color: var(--red);
}
.event-name.alarm,
.event-severity.alarm {
border-color: rgba(35, 95, 159, 0.24);
background: #e7f1fd;
color: var(--blue);
}
.settings-layout {
display: grid;
grid-template-columns: minmax(360px, 0.9fr) minmax(420px, 1.1fr);
@@ -719,10 +918,20 @@ td {
.calibration-layout,
.settings-layout,
.settings-grid,
.metrics {
.metrics,
.runtime-banner,
.progress-row {
grid-template-columns: 1fr;
}
.runtime-banner p {
text-align: left;
}
.progress-meta {
justify-items: start;
}
.canvas-panel {
min-height: 420px;
}

502
web/src/zone-state.js Normal file
View File

@@ -0,0 +1,502 @@
export const TRASH_REGION_ID = "trash";
export const MIN_FOOD_ZONE_COUNT = 1;
export const MAX_FOOD_ZONE_COUNT = 10;
export const DEFAULT_FOOD_ZONE_COUNT = 8;
const DEFAULT_RUNTIME_THRESHOLD_SECONDS = 1200;
const zonePalette = [
"#d92d20",
"#b54708",
"#4e5ba6",
"#008a5a",
"#0077a3",
"#155eef",
"#7f56d9",
"#c11574",
"#4f7f1f",
"#8c5a00",
];
export function deriveFoodZones(config = {}) {
const layout = config.layout || {};
const sourceZones = config.zones || [];
const configuredIds = normalizeZoneIds(layout.zone_ids);
const numericIds = configuredIds.filter(isNumericId);
const sourceZonesById = new Map(sourceZones.map((zone) => [String(zone.id || ""), zone]));
const count = deriveZoneCount(layout, configuredIds, sourceZones);
const legacyIds = deriveLegacySourceIds(layout, configuredIds, sourceZones, count);
return numericZoneIds(count).map((id, index) => {
const legacySourceId = legacyIds[index];
const numericSourceId = numericIds.includes(id) ? id : "";
const source = sourceZonesById.get(numericSourceId) || sourceZonesById.get(legacySourceId) || {};
return {
id,
label: `区域 ${id}`,
sourceId: String(source.id || numericSourceId || legacySourceId || id),
polygon: normalizePolygon(source.polygon),
};
});
}
export function deriveZoneCount(layout = {}, configuredIds = normalizeZoneIds(layout.zone_ids), zones = []) {
if (configuredIds.length) {
return clampZoneCount(configuredIds.length);
}
if (layout.zone_count !== undefined) {
return clampZoneCount(layout.zone_count);
}
const rows = Number(layout.rows);
const cols = Number(layout.cols);
if (Number.isFinite(rows) && Number.isFinite(cols) && rows > 0 && cols > 0) {
return clampZoneCount(rows * cols);
}
if (Array.isArray(zones) && zones.length) {
return clampZoneCount(zones.length);
}
return DEFAULT_FOOD_ZONE_COUNT;
}
export function clampZoneCount(value, fallback = DEFAULT_FOOD_ZONE_COUNT) {
const parsed = Number(value);
const count = Number.isFinite(parsed) ? Math.trunc(parsed) : fallback;
return Math.min(MAX_FOOD_ZONE_COUNT, Math.max(MIN_FOOD_ZONE_COUNT, count));
}
export function numericZoneIds(count) {
return Array.from({length: clampZoneCount(count)}, (_, index) => String(index + 1));
}
export function createEmptyPolygonMap(foodZones) {
return Object.fromEntries([...foodZones.map((zone) => [zone.id, []]), [TRASH_REGION_ID, []]]);
}
export function buildPolygonMap(foodZones, existing = {}, trashRoi = []) {
const polygons = createEmptyPolygonMap(foodZones);
for (const zone of foodZones) {
const existingPolygon = normalizePolygon(existing[zone.id]);
polygons[zone.id] = existingPolygon.length ? existingPolygon : normalizePolygon(zone.polygon);
}
const existingTrash = normalizePolygon(existing[TRASH_REGION_ID]);
polygons[TRASH_REGION_ID] = existingTrash.length ? existingTrash : normalizePolygon(trashRoi);
return polygons;
}
export function buildCalibrationPayload(foodZones, polygons) {
const zones = foodZones
.map((zone) => ({
id: zone.id,
label: getRegionLabel(zone.id),
polygon: serializePolygon(polygons[zone.id]),
}))
.filter((zone) => zone.polygon.length >= 3);
const trashPolygon = serializePolygon(polygons[TRASH_REGION_ID]);
return {
layout: {
zone_count: foodZones.length,
zone_ids: foodZones.map((zone) => zone.id),
},
zones,
trash: trashPolygon.length >= 3 ? {roi: trashPolygon} : {},
};
}
export function classifyEvent(event = {}) {
const eventName = String(event.event || "");
const severity = String(event.severity || defaultSeverity(eventName)).toLowerCase();
const zoneIndex = deriveEventZoneIndex(event);
const zoneLabel = String(event.zone_label || (zoneIndex ? `区域 ${zoneIndex}` : event.zone_id || ""));
const isAlert = severity === "alarm" || eventName === "time_alarm";
const isWarning = severity === "warning" || eventName === "warning_escalated" || eventName.endsWith("_violation");
const isViolation = eventName === "warning_escalated" || eventName.endsWith("_violation") || event.state === "warning";
return {
severity,
tone: isWarning ? "warning" : isAlert ? "alarm" : "info",
zoneIndex,
zoneLabel,
isAlert,
isWarning,
isViolation,
};
}
export function buildRuntimeDisplayModel({
summary = null,
events = [],
config = {},
foodZones = deriveFoodZones(config),
demoReason = "",
now = new Date(),
} = {}) {
const safeConfig = config || {};
const realEvents = (Array.isArray(events) ? events : []).filter((event) => !isDemoRuntimeEvent(event));
const hasEvents = realEvents.length > 0;
const hasSummary = hasRuntimeSummary(summary) && !isDemoRuntimeSummary(summary);
const thresholdSeconds = runtimeThresholdSeconds(safeConfig, realEvents);
const displaySummary = hasSummary ? summary : createEmptyRuntimeSummary(thresholdSeconds);
const displayEvents = buildDisplayEvents(realEvents, now);
const latestZoneCounts = displaySummary?.metrics?.latest_zone_counts || {};
const configuredZoneIndexes = new Set(foodZones.map((zone) => Number(zone.id)).filter((id) => Number.isFinite(id)));
const progressRows = hasEvents
? buildProgressRowsFromEvents(realEvents, thresholdSeconds, now)
.filter((row) => configuredZoneIndexes.size === 0 || configuredZoneIndexes.has(row.zoneIndex))
.filter((row) => zoneCurrentlyOccupied(latestZoneCounts, row.zoneIndex))
: [];
return {
isDemo: false,
summaryIsDemo: false,
eventsAreDemo: false,
progressIsDemo: false,
hasSummary,
hasEvents,
demoReason,
summary: displaySummary,
events: realEvents,
displayEvents,
progressRows,
};
}
export function getRegionColor(id) {
if (id === TRASH_REGION_ID) {
return "#111827";
}
const index = Number(id) - 1;
return zonePalette[index] || "#667085";
}
export function getRegionLabel(id) {
if (id === TRASH_REGION_ID) {
return "垃圾桶";
}
if (isNumericId(id)) {
return `区域 ${id}`;
}
const match = String(id).match(/^r(\d)c(\d)$/);
return match ? `${match[1]}${match[2]}` : String(id);
}
export function secondsToAlarmMinutes(seconds) {
const parsed = Number(seconds);
if (!Number.isFinite(parsed) || parsed <= 0) {
return 1;
}
return Math.max(1, Math.round(parsed / 60));
}
export function alarmMinutesToSeconds(minutes) {
const parsed = Number(minutes);
if (!Number.isFinite(parsed) || parsed <= 0) {
return 60;
}
return Math.max(60, Math.round(parsed * 60));
}
export function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[char]);
}
function hasRuntimeSummary(summary) {
const metrics = summary?.metrics;
return Boolean(metrics && typeof metrics === "object");
}
function isDemoRuntimeSummary(summary) {
return containsDemoMarker(summary?.result_type) || containsDemoMarker(summary?.headline);
}
function isDemoRuntimeEvent(event) {
return event?.demo === true || containsDemoMarker(event?.camera_id) || containsDemoMarker(event?.batch_id);
}
function containsDemoMarker(value) {
const text = String(value || "").toLowerCase();
return text.includes("demo") || text.includes("演示");
}
function createEmptyRuntimeSummary(thresholdSeconds) {
return {
result_type: "cold_display_guard",
headline: "暂无事件数据",
last_result_time: "",
metrics: {
event_counts: {},
event_count: 0,
alert_count: 0,
warning_count: 0,
violation_count: 0,
latest_alert_time: "",
events_path: "-",
diagnostics_path: "-",
diagnostics_count: 0,
latest_zone_counts: {},
baseline_ready: false,
max_dwell_seconds: thresholdSeconds,
},
};
}
function buildDisplayEvents(events, now) {
const liveEventOrdersByBatch = latestLiveEventOrdersByBatch(events);
return events.map((event, order) => ({
...event,
displayDwellSeconds: displayEventDwellSeconds(event, order, liveEventOrdersByBatch, now),
}));
}
function displayEventDwellSeconds(event, order, liveEventOrdersByBatch, now) {
const fallbackSeconds = normalizeSeconds(event.dwell_seconds);
const batchId = String(event.batch_id || "");
if (!batchId || liveEventOrdersByBatch.get(batchId) !== order) {
return fallbackSeconds;
}
return liveDwellSeconds(event, fallbackSeconds, now);
}
function latestLiveEventOrdersByBatch(events) {
const latestByBatch = new Map();
events.forEach((event, order) => {
const batchId = String(event.batch_id || "");
if (!batchId) {
return;
}
const candidate = {
event,
eventTime: eventTimestamp(event),
order,
};
const existing = latestByBatch.get(batchId);
if (!existing || isNewerEventCandidate(candidate, existing)) {
latestByBatch.set(batchId, candidate);
}
});
const liveOrders = new Map();
latestByBatch.forEach((candidate, batchId) => {
if (isLiveBatchEvent(candidate.event)) {
liveOrders.set(batchId, candidate.order);
}
});
return liveOrders;
}
function zoneCurrentlyOccupied(latestZoneCounts, zoneIndex) {
if (!latestZoneCounts || typeof latestZoneCounts !== "object") {
return true;
}
if (Object.keys(latestZoneCounts).length === 0) {
return true;
}
const count = latestZoneCounts[String(zoneIndex)];
if (count === undefined) {
return false;
}
return Number(count) > 0;
}
function buildProgressRowsFromEvents(events, thresholdSeconds, now) {
const candidatesByZone = new Map();
events.forEach((event, order) => {
const meta = classifyEvent(event);
if (!meta.zoneIndex) {
return;
}
const dwellSeconds = liveDwellSeconds(event, normalizeSeconds(event.dwell_seconds), now);
const threshold = normalizeSeconds(event.max_dwell_seconds) || thresholdSeconds;
const existing = candidatesByZone.get(meta.zoneIndex);
const row = {
zoneIndex: meta.zoneIndex,
zoneLabel: meta.zoneLabel || `区域 ${meta.zoneIndex}`,
dwellSeconds,
thresholdSeconds: threshold,
progressPct: progressPct(dwellSeconds, threshold),
status: progressStatus(event, dwellSeconds, threshold),
source: "real",
};
const candidate = {
row,
eventTime: eventTimestamp(event),
order,
};
if (!existing || isNewerEventCandidate(candidate, existing)) {
candidatesByZone.set(meta.zoneIndex, candidate);
}
});
return [...candidatesByZone.values()].map((candidate) => candidate.row).sort((a, b) => a.zoneIndex - b.zoneIndex);
}
function runtimeThresholdSeconds(config = {}, events = []) {
const fromConfig = normalizeSeconds(config.thresholds?.max_dwell_seconds);
if (fromConfig > 0) {
return fromConfig;
}
const fromEvent = events.map((event) => normalizeSeconds(event.max_dwell_seconds)).find((seconds) => seconds > 0);
return fromEvent || DEFAULT_RUNTIME_THRESHOLD_SECONDS;
}
function progressStatus(event, dwellSeconds, thresholdSeconds) {
const meta = classifyEvent(event);
if (meta.isWarning || meta.isViolation) {
return "warning";
}
if (meta.isAlert || dwellSeconds >= thresholdSeconds) {
return "alarm";
}
return "normal";
}
function progressPct(dwellSeconds, thresholdSeconds) {
if (!thresholdSeconds) {
return 0;
}
return Math.min(100, Math.round((dwellSeconds / thresholdSeconds) * 100));
}
function normalizeSeconds(value) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0;
}
function eventTimestamp(event) {
const parsed = timestampMillis(event.ts);
return Number.isFinite(parsed) ? parsed : null;
}
function liveDwellSeconds(event, fallbackSeconds, now) {
if (!isLiveBatchEvent(event)) {
return fallbackSeconds;
}
const startedAt = timestampMillis(event.started_at);
const nowAt = timestampMillis(now);
if (!Number.isFinite(startedAt) || !Number.isFinite(nowAt) || nowAt < startedAt) {
return fallbackSeconds;
}
return Math.max(fallbackSeconds, Math.round((nowAt - startedAt) / 1000));
}
function isLiveBatchEvent(event = {}) {
const terminalEvents = new Set([
"batch_consumed",
"batch_pending_disposal",
"batch_discarded",
"warning_escalated",
"overdue_return_violation",
]);
const terminalStates = new Set(["consumed", "pending_disposal", "discarded", "warning"]);
const eventName = String(event.event || "");
const state = String(event.state || "").toLowerCase();
return Boolean(event.started_at)
&& !event.ended_at
&& !terminalEvents.has(eventName)
&& !terminalStates.has(state);
}
function timestampMillis(value) {
if (value instanceof Date) {
return value.getTime();
}
const parsed = Date.parse(String(value || ""));
return Number.isFinite(parsed) ? parsed : Number.NaN;
}
function isNewerEventCandidate(next, existing) {
if (next.eventTime !== null && existing.eventTime !== null && next.eventTime !== existing.eventTime) {
return next.eventTime > existing.eventTime;
}
return next.order > existing.order;
}
export function normalizePolygon(value) {
if (!Array.isArray(value)) {
return [];
}
return value
.filter((point) => Array.isArray(point) || (point && typeof point === "object"))
.map((point) => {
const x = Array.isArray(point) ? point[0] : point.x;
const y = Array.isArray(point) ? point[1] : point.y;
return {x: round(clamp(Number(x))), y: round(clamp(Number(y)))};
})
.filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y));
}
function serializePolygon(points) {
return normalizePolygon(points).map((point) => [point.x, point.y]);
}
function normalizeZoneIds(value) {
if (!Array.isArray(value)) {
return [];
}
return value.map((id) => String(id).trim()).filter(Boolean);
}
function deriveLegacySourceIds(layout, configuredIds, zones, count) {
const configuredLegacyIds = configuredIds.filter((id) => !isNumericId(id));
if (configuredLegacyIds.length) {
return configuredLegacyIds;
}
if (!configuredIds.length) {
const rowColIds = rowColumnZoneIds(layout).slice(0, count);
if (rowColIds.length) {
return rowColIds;
}
}
return zones.map((zone) => String(zone.id || "")).filter((id) => id && !isNumericId(id));
}
function rowColumnZoneIds(layout) {
const rows = Number(layout.rows);
const cols = Number(layout.cols);
if (!Number.isFinite(rows) || !Number.isFinite(cols) || rows <= 0 || cols <= 0) {
return [];
}
const ids = [];
for (let row = 1; row <= Math.trunc(rows); row += 1) {
for (let col = 1; col <= Math.trunc(cols); col += 1) {
ids.push(`r${row}c${col}`);
}
}
return ids;
}
function isNumericId(id) {
return /^\d+$/.test(String(id));
}
function deriveEventZoneIndex(event) {
const explicit = Number(event.zone_index);
if (Number.isInteger(explicit) && explicit > 0) {
return explicit;
}
const zoneId = String(event.zone_id || "");
if (isNumericId(zoneId)) {
return Number(zoneId);
}
return null;
}
function defaultSeverity(eventName) {
if (eventName === "time_alarm") {
return "alarm";
}
if (eventName === "warning_escalated" || eventName.endsWith("_violation")) {
return "warning";
}
return "info";
}
function clamp(value) {
return Math.min(1, Math.max(0, value));
}
function round(value) {
return Math.round(value * 1000000) / 1000000;
}

630
web/test/zone-state.test.js Normal file
View File

@@ -0,0 +1,630 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
TRASH_REGION_ID,
alarmMinutesToSeconds,
buildCalibrationPayload,
buildPolygonMap,
buildRuntimeDisplayModel,
classifyEvent,
deriveFoodZones,
escapeHtml,
secondsToAlarmMinutes,
} from "../src/zone-state.js";
test("deriveFoodZones creates numeric zones from legacy grid config", () => {
const zones = deriveFoodZones({
layout: {zone_ids: ["r1c1", "r1c2"]},
zones: [
{id: "r1c1", label: "1排1列", polygon: [[0, 0], [0.4, 0], [0.4, 0.4]]},
{id: "r1c2", polygon: [[0.4, 0], [0.8, 0], [0.8, 0.4]]},
],
});
assert.deepEqual(zones.map((zone) => zone.id), ["1", "2"]);
assert.deepEqual(zones.map((zone) => zone.label), ["区域 1", "区域 2"]);
assert.deepEqual(zones[1].polygon, [
{x: 0.4, y: 0},
{x: 0.8, y: 0},
{x: 0.8, y: 0.4},
]);
});
test("deriveFoodZones maps legacy rows and columns without explicit zone ids", () => {
const zones = deriveFoodZones({
layout: {rows: 1, cols: 2},
zones: [
{id: "r1c1", polygon: [[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]},
{id: "r1c2", polygon: [[0.4, 0.1], [0.6, 0.1], [0.6, 0.3]]},
],
});
assert.deepEqual(zones.map((zone) => zone.id), ["1", "2"]);
assert.deepEqual(zones.map((zone) => zone.label), ["区域 1", "区域 2"]);
assert.deepEqual(zones[0].polygon, [
{x: 0.1, y: 0.1},
{x: 0.3, y: 0.1},
{x: 0.3, y: 0.3},
]);
assert.deepEqual(zones[1].polygon, [
{x: 0.4, y: 0.1},
{x: 0.6, y: 0.1},
{x: 0.6, y: 0.3},
]);
});
test("deriveFoodZones honors numeric zone count and clamps to ten", () => {
const zones = deriveFoodZones({
layout: {
zone_count: 11,
zone_ids: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"],
},
});
assert.equal(zones.length, 10);
assert.equal(zones.at(-1).id, "10");
});
test("buildCalibrationPayload keeps trash roi separate from food zones", () => {
const payload = buildCalibrationPayload(
[
{id: "1", label: "1排1列"},
{id: "2", label: "区域 2"},
],
{
1: [{x: 0, y: 0}, {x: 0.2, y: 0}, {x: 0.2, y: 0.2}],
2: [{x: 0.2, y: 0}, {x: 0.4, y: 0}],
[TRASH_REGION_ID]: [{x: 0.8, y: 0.8}, {x: 1, y: 0.8}, {x: 1, y: 1}],
},
);
assert.deepEqual(payload.zones, [
{
id: "1",
label: "区域 1",
polygon: [[0, 0], [0.2, 0], [0.2, 0.2]],
},
]);
assert.deepEqual(payload.layout, {zone_count: 2, zone_ids: ["1", "2"]});
assert.deepEqual(payload.trash.roi, [[0.8, 0.8], [1, 0.8], [1, 1]]);
assert.equal(payload.zones.some((zone) => zone.id === TRASH_REGION_ID), false);
});
test("buildPolygonMap keeps saved config polygons when draft entries are empty", () => {
const foodZones = deriveFoodZones({
layout: {zone_count: 1, zone_ids: ["1"]},
zones: [{id: "1", polygon: [[0, 0], [0.5, 0], [0.5, 0.5]]}],
trash: {roi: [[0.8, 0.8], [1, 0.8], [1, 1]]},
});
const polygons = buildPolygonMap(foodZones, {1: [], [TRASH_REGION_ID]: []}, [[0.8, 0.8], [1, 0.8], [1, 1]]);
assert.deepEqual(polygons["1"], [
{x: 0, y: 0},
{x: 0.5, y: 0},
{x: 0.5, y: 0.5},
]);
assert.deepEqual(polygons[TRASH_REGION_ID], [
{x: 0.8, y: 0.8},
{x: 1, y: 0.8},
{x: 1, y: 1},
]);
});
test("classifyEvent exposes alarm and warning event display data", () => {
assert.deepEqual(classifyEvent({event: "time_alarm", zone_id: "2"}), {
severity: "alarm",
tone: "alarm",
zoneIndex: 2,
zoneLabel: "区域 2",
isAlert: true,
isWarning: false,
isViolation: false,
});
assert.deepEqual(classifyEvent({event: "warning_escalated", severity: "warning", zone_index: 3}), {
severity: "warning",
tone: "warning",
zoneIndex: 3,
zoneLabel: "区域 3",
isAlert: false,
isWarning: true,
isViolation: true,
});
});
test("alarm minute helpers round trip to backend seconds", () => {
assert.equal(secondsToAlarmMinutes(1200), 20);
assert.equal(secondsToAlarmMinutes(10800), 180);
assert.equal(alarmMinutesToSeconds(20), 1200);
});
test("escapeHtml neutralizes dynamic HTML before innerHTML rendering", () => {
assert.equal(
escapeHtml('<img src=x onerror=alert(1)> & "zone"'),
"&lt;img src=x onerror=alert(1)&gt; &amp; &quot;zone&quot;",
);
});
test("buildRuntimeDisplayModel does not synthesize demo runtime data", () => {
const model = buildRuntimeDisplayModel({
summary: null,
events: [],
config: {thresholds: {max_dwell_seconds: 1200}},
foodZones: deriveFoodZones({layout: {zone_count: 8}}),
demoReason: "接口不可用",
});
assert.equal(model.isDemo, false);
assert.equal(model.summaryIsDemo, false);
assert.equal(model.eventsAreDemo, false);
assert.equal(model.progressIsDemo, false);
assert.equal(model.demoReason, "接口不可用");
assert.equal(model.summary.metrics.event_count, 0);
assert.deepEqual(model.events, []);
assert.deepEqual(model.progressRows, []);
});
test("buildRuntimeDisplayModel tolerates null config before backend config loads", () => {
const model = buildRuntimeDisplayModel({
summary: null,
events: [],
config: null,
foodZones: deriveFoodZones({layout: {zone_count: 2}}),
});
assert.equal(model.isDemo, false);
assert.deepEqual(model.events, []);
assert.deepEqual(model.progressRows, []);
assert.equal(model.summary.metrics.max_dwell_seconds, 1200);
});
test("buildRuntimeDisplayModel keeps diagnostics-only runtime data without demo fallback", () => {
const summary = {
metrics: {
event_count: 0,
alert_count: 0,
warning_count: 0,
violation_count: 0,
diagnostics_count: 8,
latest_zone_counts: {},
},
};
const model = buildRuntimeDisplayModel({
summary,
events: [],
config: {thresholds: {max_dwell_seconds: 1200}},
foodZones: deriveFoodZones({layout: {zone_count: 3}}),
});
assert.equal(model.summaryIsDemo, false);
assert.equal(model.eventsAreDemo, false);
assert.equal(model.progressIsDemo, false);
assert.equal(model.summary, summary);
assert.deepEqual(model.events, []);
assert.deepEqual(model.progressRows, []);
});
test("buildRuntimeDisplayModel filters legacy demo events and summaries", () => {
const model = buildRuntimeDisplayModel({
summary: {
result_type: "cold_display_guard_demo",
metrics: {
event_count: 4,
alert_count: 1,
warning_count: 2,
violation_count: 1,
},
},
events: [
{
demo: true,
event: "time_alarm",
zone_id: "1",
zone_index: 1,
zone_label: "区域 1",
dwell_seconds: 1200,
},
{
event: "batch_started",
zone_id: "2",
zone_index: 2,
zone_label: "区域 2",
dwell_seconds: 0,
},
],
config: {thresholds: {max_dwell_seconds: 1200}},
foodZones: deriveFoodZones({layout: {zone_count: 3}}),
});
assert.equal(model.hasSummary, false);
assert.equal(model.summary.metrics.event_count, 0);
assert.deepEqual(model.events.map((event) => event.zone_id), ["2"]);
assert.deepEqual(model.progressRows.map((row) => row.zoneIndex), [2]);
});
test("buildRuntimeDisplayModel keeps real summary and events ahead of demo data", () => {
const realSummary = {
metrics: {
event_count: 1,
alert_count: 1,
warning_count: 0,
violation_count: 0,
diagnostics_count: 2,
baseline_ready: true,
latest_alert_time: "2026-05-26T14:40:00+08:00",
},
};
const realEvents = [{
event: "time_alarm",
severity: "alarm",
ts: "2026-05-26T14:40:00+08:00",
zone_id: "2",
zone_index: 2,
zone_label: "区域 2",
batch_id: "batch_real",
dwell_seconds: 1300,
max_dwell_seconds: 1200,
}];
const model = buildRuntimeDisplayModel({
summary: realSummary,
events: realEvents,
config: {thresholds: {max_dwell_seconds: 1200}},
foodZones: deriveFoodZones({layout: {zone_count: 4}}),
});
assert.equal(model.isDemo, false);
assert.equal(model.summaryIsDemo, false);
assert.equal(model.eventsAreDemo, false);
assert.equal(model.progressIsDemo, false);
assert.equal(model.summary, realSummary);
assert.deepEqual(model.events, realEvents);
assert.deepEqual(model.progressRows, [{
zoneIndex: 2,
zoneLabel: "区域 2",
dwellSeconds: 1300,
thresholdSeconds: 1200,
progressPct: 100,
status: "alarm",
source: "real",
}]);
});
test("buildRuntimeDisplayModel uses latest real event for zone progress", () => {
const model = buildRuntimeDisplayModel({
summary: {
metrics: {
event_count: 2,
alert_count: 1,
warning_count: 0,
violation_count: 0,
},
},
events: [
{
event: "time_alarm",
severity: "alarm",
ts: "2026-05-26T14:40:00+08:00",
zone_id: "2",
zone_index: 2,
zone_label: "区域 2",
batch_id: "old_batch",
dwell_seconds: 1300,
max_dwell_seconds: 1200,
},
{
event: "batch_started",
severity: "info",
ts: "2026-05-26T14:50:00+08:00",
zone_id: "2",
zone_index: 2,
zone_label: "区域 2",
batch_id: "new_batch",
current_count: 2,
dwell_seconds: 0,
max_dwell_seconds: 1200,
},
],
config: {thresholds: {max_dwell_seconds: 1200}},
foodZones: deriveFoodZones({layout: {zone_count: 4}}),
});
assert.deepEqual(model.progressRows, [{
zoneIndex: 2,
zoneLabel: "区域 2",
dwellSeconds: 0,
thresholdSeconds: 1200,
progressPct: 0,
status: "normal",
source: "real",
}]);
});
test("buildRuntimeDisplayModel keeps active dwell timer moving from started_at", () => {
const model = buildRuntimeDisplayModel({
summary: {metrics: {event_count: 1, alert_count: 1}},
events: [
{
event: "time_alarm",
severity: "alarm",
ts: "2026-05-27T09:43:48+08:00",
zone_id: "1",
zone_index: 1,
zone_label: "区域 1",
batch_id: "batch_active",
state: "alerted",
started_at: "2026-05-27T09:23:43+08:00",
alerted_at: "2026-05-27T09:43:48+08:00",
dwell_seconds: 1205,
max_dwell_seconds: 1200,
},
],
config: {thresholds: {max_dwell_seconds: 1200}},
foodZones: deriveFoodZones({layout: {zone_count: 2}}),
now: "2026-05-27T09:50:00+08:00",
});
assert.equal(model.progressRows[0].dwellSeconds, 1577);
assert.equal(model.progressRows[0].progressPct, 100);
assert.equal(model.progressRows[0].status, "alarm");
});
test("buildRuntimeDisplayModel exposes live dwell seconds for event table rows", () => {
const model = buildRuntimeDisplayModel({
summary: {metrics: {event_count: 1, alert_count: 1}},
events: [
{
event: "time_alarm",
severity: "alarm",
ts: "2026-05-27T09:43:48+08:00",
zone_id: "6",
zone_index: 6,
zone_label: "区域 6",
batch_id: "batch_active",
state: "alerted",
started_at: "2026-05-27T09:23:49+08:00",
alerted_at: "2026-05-27T09:43:54+08:00",
dwell_seconds: 1204,
max_dwell_seconds: 1200,
},
],
config: {thresholds: {max_dwell_seconds: 1200}},
foodZones: deriveFoodZones({layout: {zone_count: 8}}),
now: "2026-05-27T11:03:49+08:00",
});
assert.equal(model.events[0].dwell_seconds, 1204);
assert.equal(model.displayEvents[0].displayDwellSeconds, 6000);
});
test("buildRuntimeDisplayModel does not keep batch_started row ticking after removal", () => {
const model = buildRuntimeDisplayModel({
summary: {metrics: {event_count: 2, latest_zone_counts: {"1": 0}}},
events: [
{
event: "batch_started",
severity: "info",
ts: "2026-05-29T09:59:49+08:00",
zone_id: "1",
zone_index: 1,
zone_label: "区域 1",
batch_id: "batch_done",
state: "active",
started_at: "2026-05-29T09:59:49+08:00",
dwell_seconds: 0,
max_dwell_seconds: 300,
},
{
event: "batch_consumed",
severity: "info",
ts: "2026-05-29T10:00:53+08:00",
zone_id: "1",
zone_index: 1,
zone_label: "区域 1",
batch_id: "batch_done",
state: "consumed",
started_at: "2026-05-29T09:59:49+08:00",
ended_at: "2026-05-29T10:00:53+08:00",
dwell_seconds: 64,
max_dwell_seconds: 300,
},
],
config: {thresholds: {max_dwell_seconds: 300}},
foodZones: deriveFoodZones({layout: {zone_count: 8}}),
now: "2026-05-29T10:05:00+08:00",
});
assert.equal(model.displayEvents[0].displayDwellSeconds, 0);
assert.equal(model.displayEvents[1].displayDwellSeconds, 64);
});
test("buildRuntimeDisplayModel hides live progress for zones currently empty in diagnostics", () => {
const model = buildRuntimeDisplayModel({
summary: {metrics: {event_count: 2, alert_count: 2, latest_zone_counts: {"1": 1, "3": 0}}},
events: [
{
event: "time_alarm",
severity: "alarm",
ts: "2026-05-27T09:43:48+08:00",
zone_id: "1",
zone_index: 1,
zone_label: "区域 1",
batch_id: "batch_real",
state: "alerted",
started_at: "2026-05-27T09:23:43+08:00",
dwell_seconds: 1204,
max_dwell_seconds: 1200,
},
{
event: "time_alarm",
severity: "alarm",
ts: "2026-05-27T10:13:55+08:00",
zone_id: "3",
zone_index: 3,
zone_label: "区域 3",
batch_id: "batch_reflection",
state: "alerted",
started_at: "2026-05-27T09:53:51+08:00",
dwell_seconds: 1204,
max_dwell_seconds: 1200,
},
],
config: {thresholds: {max_dwell_seconds: 1200}},
foodZones: deriveFoodZones({layout: {zone_count: 4}}),
now: "2026-05-27T11:03:51+08:00",
});
assert.deepEqual(model.progressRows.map((row) => row.zoneIndex), [1]);
});
test("buildRuntimeDisplayModel hides historical zones outside current configuration", () => {
const model = buildRuntimeDisplayModel({
summary: {metrics: {event_count: 3, latest_zone_counts: {"4": 1}}},
events: [
{
event: "time_alarm",
severity: "alarm",
ts: "2026-05-29T09:50:00+08:00",
zone_id: "4",
zone_index: 4,
zone_label: "区域 4",
batch_id: "batch_current",
started_at: "2026-05-29T09:45:00+08:00",
dwell_seconds: 300,
max_dwell_seconds: 300,
},
{
event: "batch_consumed",
severity: "info",
ts: "2026-05-28T08:31:53+08:00",
zone_id: "9",
zone_index: 9,
zone_label: "区域 9",
batch_id: "batch_old_9",
started_at: "2026-05-28T08:13:48+08:00",
ended_at: "2026-05-28T08:31:53+08:00",
dwell_seconds: 1085,
max_dwell_seconds: 1200,
},
{
event: "batch_consumed",
severity: "info",
ts: "2026-05-28T08:31:53+08:00",
zone_id: "10",
zone_index: 10,
zone_label: "区域 10",
batch_id: "batch_old_10",
started_at: "2026-05-28T08:13:48+08:00",
ended_at: "2026-05-28T08:31:53+08:00",
dwell_seconds: 1085,
max_dwell_seconds: 1200,
},
],
config: {layout: {zone_count: 8, zone_ids: ["1", "2", "3", "4", "5", "6", "7", "8"]}},
foodZones: deriveFoodZones({layout: {zone_count: 8, zone_ids: ["1", "2", "3", "4", "5", "6", "7", "8"]}}),
});
assert.deepEqual(model.progressRows.map((row) => row.zoneIndex), [4]);
});
test("buildRuntimeDisplayModel does not advance ended batch dwell timer", () => {
const model = buildRuntimeDisplayModel({
summary: {metrics: {event_count: 1}},
events: [
{
event: "batch_consumed",
severity: "info",
ts: "2026-05-27T09:25:00+08:00",
zone_id: "1",
zone_index: 1,
zone_label: "区域 1",
batch_id: "batch_done",
state: "consumed",
started_at: "2026-05-27T09:23:43+08:00",
ended_at: "2026-05-27T09:25:00+08:00",
dwell_seconds: 77,
max_dwell_seconds: 1200,
},
],
config: {thresholds: {max_dwell_seconds: 1200}},
foodZones: deriveFoodZones({layout: {zone_count: 2}}),
now: "2026-05-27T09:50:00+08:00",
});
assert.equal(model.progressRows[0].dwellSeconds, 77);
assert.equal(model.progressRows[0].progressPct, 6);
assert.equal(model.progressRows[0].status, "normal");
});
test("buildRuntimeDisplayModel falls back to event order when latest event has no timestamp", () => {
const model = buildRuntimeDisplayModel({
summary: {metrics: {event_count: 2, alert_count: 1}},
events: [
{
event: "time_alarm",
severity: "alarm",
ts: "2026-05-26T14:40:00+08:00",
zone_id: "2",
zone_index: 2,
zone_label: "区域 2",
batch_id: "old_batch",
dwell_seconds: 1300,
max_dwell_seconds: 1200,
},
{
event: "batch_started",
severity: "info",
zone_id: "2",
zone_index: 2,
zone_label: "区域 2",
batch_id: "new_batch",
current_count: 2,
dwell_seconds: 0,
},
],
config: {thresholds: {max_dwell_seconds: 1200}},
foodZones: deriveFoodZones({layout: {zone_count: 4}}),
});
assert.deepEqual(model.progressRows, [{
zoneIndex: 2,
zoneLabel: "区域 2",
dwellSeconds: 0,
thresholdSeconds: 1200,
progressPct: 0,
status: "normal",
source: "real",
}]);
});
test("buildRuntimeDisplayModel uses config threshold when event omits threshold", () => {
const model = buildRuntimeDisplayModel({
summary: {metrics: {event_count: 1}},
events: [
{
event: "batch_count_changed",
severity: "info",
ts: "2026-05-26T14:40:00+08:00",
zone_id: "1",
zone_index: 1,
zone_label: "区域 1",
batch_id: "batch_1",
dwell_seconds: 700,
},
],
config: {thresholds: {max_dwell_seconds: 600}},
foodZones: deriveFoodZones({layout: {zone_count: 4}}),
});
assert.deepEqual(model.progressRows, [{
zoneIndex: 1,
zoneLabel: "区域 1",
dwellSeconds: 700,
thresholdSeconds: 600,
progressPct: 100,
status: "alarm",
source: "real",
}]);
});