feat: stabilize cold display runtime deployment
This commit is contained in:
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user