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

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);
}