feat: stabilize cold display runtime deployment
This commit is contained in:
3
web/.dockerignore
Normal file
3
web/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
29
web/Dockerfile
Normal file
29
web/Dockerfile
Normal 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
20
web/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
440
web/src/main.js
440
web/src/main.js
@@ -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) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
})[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);
|
||||
}
|
||||
|
||||
@@ -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
502
web/src/zone-state.js
Normal 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) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
})[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
630
web/test/zone-state.test.js
Normal 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"'),
|
||||
"<img src=x onerror=alert(1)> & "zone"",
|
||||
);
|
||||
});
|
||||
|
||||
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",
|
||||
}]);
|
||||
});
|
||||
Reference in New Issue
Block a user