import "./styles.css";
import {
TRASH_REGION_ID,
alarmMinutesToSeconds,
buildCalibrationPayload,
buildPolygonMap,
buildRuntimeDisplayModel,
clampZoneCount,
classifyEvent,
deriveFoodZones,
escapeHtml,
getRegionColor,
getRegionLabel,
secondsToAlarmMinutes,
} from "./zone-state.js";
const draftStorageKey = "cold-display-guard.calibrationDraft.v2";
const defaultFoodZones = deriveFoodZones({layout: {zone_count: 8}});
const runtimeClockMs = 1000;
const runtimePollMs = 5000;
window.addEventListener("error", (event) => {
showFatalError(event.error || event.message);
});
window.addEventListener("unhandledrejection", (event) => {
showFatalError(event.reason);
});
const state = {
config: null,
summary: null,
events: [],
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 = `
抓取一帧后,在画面中依次点击每个格口和垃圾桶 ROI 的边界点。
`;
const els = {
statusText: document.querySelector("#statusText"),
canvas: document.querySelector("#canvas"),
regionList: document.querySelector("#regionList"),
foodZoneCount: document.querySelector("#foodZoneCount"),
rtspUrl: document.querySelector("#rtspUrl"),
settingsRtspUrl: document.querySelector("#settingsRtspUrl"),
cameraId: document.querySelector("#cameraId"),
timezone: document.querySelector("#timezone"),
maxDwell: document.querySelector("#maxDwell"),
trashWindow: document.querySelector("#trashWindow"),
configPreview: document.querySelector("#configPreview"),
regionSummary: document.querySelector("#regionSummary"),
runtimeOverview: document.querySelector("#runtimeOverview"),
runtimeProgress: document.querySelector("#runtimeProgress"),
metrics: document.querySelector("#metrics"),
eventsTable: document.querySelector("#eventsTable"),
statusPill: document.querySelector("#statusPill"),
activeRegionBadge: document.querySelector("#activeRegionBadge"),
};
const ctx = els.canvas.getContext("2d");
function boot() {
wireEvents();
render();
loadInitialData().finally(startRuntimeTimers);
}
function startRuntimeTimers() {
window.setInterval(renderRuntimeSections, runtimeClockMs);
window.setInterval(refreshRuntimeDataSilently, runtimePollMs);
}
function wireEvents() {
document.querySelectorAll(".tabs button").forEach((button) => {
button.addEventListener("click", () => setTab(button.dataset.tab));
});
document.querySelector("#refreshRuntimeData").addEventListener("click", refreshRuntimeData);
document.querySelector("#saveConfig").addEventListener("click", saveConfig);
document.querySelector("#reloadConfig").addEventListener("click", reloadConfig);
document.querySelector("#captureSnapshot").addEventListener("click", captureSnapshot);
document.querySelector("#saveCalibration").addEventListener("click", saveCalibration);
document.querySelector("#undoPoint").addEventListener("click", undoPoint);
document.querySelector("#clearRegion").addEventListener("click", clearRegion);
document.querySelector("#loadConfigPolygons").addEventListener("click", loadPolygonsFromConfig);
els.canvas.addEventListener("click", addPoint);
window.addEventListener("resize", drawCanvas);
els.foodZoneCount.addEventListener("input", () => updateFoodZoneCount(els.foodZoneCount.value));
[els.rtspUrl, els.settingsRtspUrl, els.cameraId, els.timezone, els.maxDwell, els.trashWindow].forEach((input) => {
input.addEventListener("input", () => {
state.configDirty = true;
if (input === els.rtspUrl) {
els.settingsRtspUrl.value = els.rtspUrl.value;
}
if (input === els.settingsRtspUrl) {
els.rtspUrl.value = els.settingsRtspUrl.value;
}
renderConfigPreview();
});
});
}
async function loadInitialData() {
try {
setStatus("正在读取配置和运行数据...");
const config = await apiJson("/api/manage/config");
state.config = config;
applyConfigRegions(config, {useDraft: true});
await loadRuntimeData();
fillForm();
state.configDirty = false;
render();
setStatus("已连接后端 19080");
} catch (error) {
state.runtimeDemoReason = `后端连接失败:${error.message}`;
render();
setStatus(`连接失败:${error.message}`);
}
}
async function refreshRuntimeData() {
try {
setStatus("正在刷新运行数据...");
await loadRuntimeData();
render();
setStatus(state.runtimeDemoReason ? `运行数据已刷新,部分接口失败:${state.runtimeDemoReason}` : "运行数据已刷新");
} catch (error) {
state.runtimeDemoReason = `运行数据刷新失败:${error.message}`;
render();
setStatus(`刷新运行数据失败:${error.message}`);
}
}
async function refreshRuntimeDataSilently() {
if (runtimeRefreshInFlight) {
return;
}
runtimeRefreshInFlight = true;
try {
await loadRuntimeData();
renderRuntimeSections();
} catch (error) {
state.runtimeDemoReason = `运行数据刷新失败:${error.message}`;
renderRuntimeSections();
} finally {
runtimeRefreshInFlight = false;
}
}
async function loadRuntimeData() {
const [summaryResult, eventsResult] = 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;
}
try {
setStatus("正在重新载入后端配置...");
state.config = await apiJson("/api/manage/config");
applyConfigRegions(state.config, {useDraft: false});
fillForm();
state.configDirty = false;
render();
setStatus("后端配置已重新载入");
} catch (error) {
setStatus(`重新载入配置失败:${error.message}`);
}
}
async function saveConfig() {
try {
const payload = {
camera_id: els.cameraId.value.trim(),
timezone: els.timezone.value.trim(),
rtsp_url: els.settingsRtspUrl.value.trim(),
thresholds: {
max_dwell_seconds: alarmMinutesToSeconds(els.maxDwell.value),
trash_confirmation_seconds: Number(els.trashWindow.value),
},
};
state.config = await apiJson("/api/manage/config", {method: "PUT", body: payload});
state.configDirty = false;
fillForm();
renderConfigPreview();
setStatus("运行配置已保存");
} catch (error) {
setStatus(`保存配置失败:${error.message}`);
}
}
async function captureSnapshot() {
try {
setStatus("正在从 RTSP 抓取一帧...");
const response = await fetch("/api/manage/snapshot", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({rtsp_url: els.rtspUrl.value.trim(), timeout_seconds: 12}),
});
if (!response.ok) {
const payload = await response.json();
throw new Error(payload.error || `HTTP ${response.status}`);
}
const blob = await response.blob();
if (state.imageUrl) {
URL.revokeObjectURL(state.imageUrl);
}
state.imageUrl = URL.createObjectURL(blob);
const image = new Image();
image.onload = () => {
state.image = image;
els.canvas.width = image.naturalWidth;
els.canvas.height = image.naturalHeight;
drawCanvas();
setStatus(`已抓取 ${image.naturalWidth}x${image.naturalHeight}`);
};
image.src = state.imageUrl;
} catch (error) {
setStatus(`抓帧失败:${error.message}`);
}
}
async function saveCalibration() {
try {
const saved = await persistCalibration({requireAny: true});
if (saved) {
state.calibrationDirty = false;
saveDraftPolygons();
render();
setStatus("标定已保存到项目配置");
}
} catch (error) {
setStatus(`保存标定失败:${error.message}`);
}
}
async function persistCalibration({requireAny}) {
const payload = buildCalibrationPayload(state.foodZones, state.polygons);
if (!payload.zones.length && !payload.trash.roi) {
if (requireAny) {
setStatus("当前没有可保存的标定点;每个区域至少需要 3 个点");
}
return false;
}
state.config = await apiJson("/api/manage/calibration", {
method: "PUT",
body: payload,
});
return true;
}
function setTab(tab) {
state.activeTab = tab;
document.querySelectorAll(".tabs button").forEach((button) => {
button.classList.toggle("active", button.dataset.tab === tab);
});
document.querySelector("#calibrationView").classList.toggle("hidden", tab !== "calibration");
document.querySelector("#eventsView").classList.toggle("hidden", tab !== "events");
document.querySelector("#settingsView").classList.toggle("hidden", tab !== "settings");
}
function fillForm() {
const config = state.config || {};
const alarmSeconds = config.thresholds?.max_dwell_seconds || 10800;
els.rtspUrl.value = config.stream?.rtsp_url || "";
els.settingsRtspUrl.value = config.stream?.rtsp_url || "";
els.cameraId.value = config.camera_id || "";
els.timezone.value = config.timezone || "";
els.maxDwell.value = secondsToAlarmMinutes(alarmSeconds);
els.trashWindow.value = config.thresholds?.trash_confirmation_seconds || 120;
els.foodZoneCount.value = String(state.foodZoneCount);
}
function applyConfigRegions(config, {useDraft}) {
const foodZones = deriveFoodZones(config);
const draft = useDraft ? readDraftPolygons() : {};
state.foodZones = foodZones;
state.foodZoneCount = foodZones.length;
state.polygons = buildPolygonMap(foodZones, draft, config?.trash?.roi || []);
if (!allRegionIds().includes(state.activeRegion)) {
state.activeRegion = foodZones[0]?.id || TRASH_REGION_ID;
}
}
function updateFoodZoneCount(value) {
const nextCount = clampZoneCount(value);
if (nextCount === state.foodZoneCount) {
els.foodZoneCount.value = String(nextCount);
return;
}
state.foodZoneCount = nextCount;
const existingPolygons = state.polygons;
state.foodZones = deriveFoodZones({layout: {zone_count: nextCount}});
state.polygons = buildPolygonMap(state.foodZones, existingPolygons, existingPolygons[TRASH_REGION_ID]);
if (!allRegionIds().includes(state.activeRegion)) {
state.activeRegion = state.foodZones.at(-1)?.id || TRASH_REGION_ID;
}
state.calibrationDirty = true;
els.foodZoneCount.value = String(nextCount);
saveDraftPolygons();
render();
}
function loadPolygonsFromConfig(updateStatus = true) {
if (!state.config) {
return;
}
if (updateStatus && state.calibrationDirty && !window.confirm("当前标定有未保存草稿。确认放弃草稿并载入已保存标定?")) {
return;
}
applyConfigRegions(state.config, {useDraft: false});
state.calibrationDirty = false;
saveDraftPolygons();
render();
if (updateStatus) {
setStatus("已载入当前配置区域");
}
}
function render() {
renderRegionList();
drawCanvas();
renderRegionSummary();
renderRuntimeSections();
renderConfigPreview();
setTab(state.activeTab);
}
function buildRuntimeModel() {
return buildRuntimeDisplayModel({
summary: state.summary,
events: state.events,
config: state.config,
foodZones: state.foodZones,
demoReason: state.runtimeDemoReason,
now: new Date(),
});
}
function renderRuntimeSections() {
const runtimeModel = buildRuntimeModel();
renderRuntimeOverview(runtimeModel);
renderMetrics(runtimeModel);
renderRuntimeProgress(runtimeModel);
renderEvents(runtimeModel);
}
function renderRegionList() {
els.regionList.innerHTML = "";
for (const id of allRegionIds()) {
const button = document.createElement("button");
const complete = (state.polygons[id] || []).length >= 3;
button.type = "button";
button.className = [
"region-button",
id === state.activeRegion ? "active" : "",
complete ? "complete" : "",
].filter(Boolean).join(" ");
button.style.setProperty("--region-color", getRegionColor(id));
button.innerHTML = `
${escapeHtml(getRegionLabel(id))}
${id === TRASH_REGION_ID ? "ROI" : escapeHtml(id)}
${(state.polygons[id] || []).length}
`;
button.addEventListener("click", () => {
state.activeRegion = id;
render();
});
els.regionList.appendChild(button);
}
}
function addPoint(event) {
if (!state.image) {
setStatus("请先从 RTSP 抓取一帧");
return;
}
const imageRect = getCanvasImageRect();
const rawX = event.clientX - imageRect.left;
const rawY = event.clientY - imageRect.top;
if (rawX < 0 || rawY < 0 || rawX > imageRect.width || rawY > imageRect.height) {
setStatus("请点击截图实际显示区域内");
return;
}
const x = clamp(rawX / imageRect.width);
const y = clamp(rawY / imageRect.height);
if (!state.polygons[state.activeRegion]) {
state.polygons[state.activeRegion] = [];
}
state.polygons[state.activeRegion].push({x: round(x), y: round(y)});
state.calibrationDirty = true;
saveDraftPolygons();
render();
}
function getCanvasImageRect() {
const rect = els.canvas.getBoundingClientRect();
const canvasRatio = els.canvas.width / els.canvas.height;
const elementRatio = rect.width / rect.height;
if (elementRatio > canvasRatio) {
const width = rect.height * canvasRatio;
return {
left: rect.left + (rect.width - width) / 2,
top: rect.top,
width,
height: rect.height,
};
}
const height = rect.width / canvasRatio;
return {
left: rect.left,
top: rect.top + (rect.height - height) / 2,
width: rect.width,
height,
};
}
function undoPoint() {
(state.polygons[state.activeRegion] || []).pop();
state.calibrationDirty = true;
saveDraftPolygons();
render();
}
function clearRegion() {
state.polygons[state.activeRegion] = [];
state.calibrationDirty = true;
saveDraftPolygons();
render();
}
function hasAnyPolygon() {
return allRegionIds().some((id) => (state.polygons[id] || []).length > 0);
}
function saveDraftPolygons() {
localStorage.setItem(draftStorageKey, JSON.stringify({
zone_count: state.foodZoneCount,
polygons: state.polygons,
}));
}
function readDraftPolygons() {
const raw = localStorage.getItem(draftStorageKey);
if (!raw) {
return {};
}
try {
const draft = JSON.parse(raw);
const polygons = draft.polygons && typeof draft.polygons === "object" ? draft.polygons : draft;
const normalized = {};
for (const id of Object.keys(polygons)) {
if (!Array.isArray(polygons[id])) {
continue;
}
normalized[id] = polygons[id]
.filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y))
.map((point) => ({x: clamp(point.x), y: clamp(point.y)}));
}
return normalized;
} catch {
localStorage.removeItem(draftStorageKey);
return {};
}
}
function drawCanvas() {
ctx.clearRect(0, 0, els.canvas.width, els.canvas.height);
if (state.image) {
ctx.drawImage(state.image, 0, 0, els.canvas.width, els.canvas.height);
} else {
ctx.fillStyle = "#121826";
ctx.fillRect(0, 0, els.canvas.width, els.canvas.height);
ctx.fillStyle = "#dbe3ea";
ctx.font = "22px system-ui";
ctx.textAlign = "center";
ctx.fillText("输入 RTSP 地址并抓取一帧", els.canvas.width / 2, els.canvas.height / 2);
}
for (const id of allRegionIds()) {
drawPolygon(id, state.polygons[id] || []);
}
}
function drawPolygon(id, points) {
if (!points.length) {
return;
}
const color = getRegionColor(id);
ctx.save();
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.lineWidth = id === state.activeRegion ? 4 : 2;
ctx.beginPath();
points.forEach((point, index) => {
const x = point.x * els.canvas.width;
const y = point.y * els.canvas.height;
if (index === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
if (points.length >= 3) {
ctx.closePath();
ctx.globalAlpha = 0.22;
ctx.fill();
ctx.globalAlpha = 1;
}
ctx.stroke();
points.forEach((point, index) => {
const x = point.x * els.canvas.width;
const y = point.y * els.canvas.height;
ctx.beginPath();
ctx.arc(x, y, 5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#ffffff";
ctx.font = "12px system-ui";
ctx.textAlign = "center";
ctx.fillText(String(index + 1), x, y - 9);
ctx.fillStyle = color;
});
const first = points[0];
ctx.font = id === state.activeRegion ? "bold 18px system-ui" : "14px system-ui";
ctx.textAlign = "left";
ctx.fillText(getRegionLabel(id), first.x * els.canvas.width + 8, first.y * els.canvas.height + 18);
ctx.restore();
}
function renderRegionSummary() {
els.regionSummary.innerHTML = allRegionIds()
.map((id) => {
const count = (state.polygons[id] || []).length;
const complete = count >= 3;
return `
${escapeHtml(getRegionLabel(id))}
${complete ? `${count} 点 / 已标定` : `${count} 点 / 待完成`}
`;
})
.join("");
els.activeRegionBadge.textContent = getRegionLabel(state.activeRegion);
}
function renderRuntimeOverview(model) {
const labels = [
model.hasSummary ? "运行摘要来自后端" : "暂无运行摘要",
model.progressRows.length ? "计时进度来自事件" : "暂无计时进度",
model.hasEvents ? "事件表来自后端" : "暂无事件数据",
];
els.runtimeOverview.innerHTML = `
LIVE DATA
${model.hasSummary || model.hasEvents ? "实时态:正在显示后端返回的运行数据" : "实时态:暂无真实运行数据"}
${escapeHtml(labels.join(" / "))}${model.demoReason ? `;${escapeHtml(model.demoReason)}` : ""}
`;
}
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: 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
? `${escapeHtml(metricLabel("最新区域状态"))}${Object.entries(zoneCounts)
.map(([zoneId, count]) => escapeHtml(`${zoneId}:${count}`))
.join(" ")}
`
: "";
els.metrics.innerHTML = cards
.map((card) => `
${escapeHtml(card.label)}
${escapeHtml(String(card.value))}
`)
.join("") + zoneSummary;
}
function renderRuntimeProgress(model) {
if (!model.progressRows.length) {
els.runtimeProgress.innerHTML = `暂无可显示的计时进度
`;
return;
}
els.runtimeProgress.innerHTML = model.progressRows
.map((row) => {
const statusLabel = row.status === "warning" ? "警告" : row.status === "alarm" ? "报警" : "正常";
return `
${escapeHtml(String(row.zoneIndex))}
${escapeHtml(row.zoneLabel)}
${escapeHtml(formatDuration(row.dwellSeconds))}
${escapeHtml(statusLabel)}
`;
})
.join("");
}
function renderEvents(model) {
const events = model.displayEvents || model.events;
if (!events.length) {
els.eventsTable.innerHTML = `还没有事件数据
`;
return;
}
els.eventsTable.innerHTML = `
| 时间 | 来源 | 级别 | 事件 | 区域序号 | 区域 | 批次 | 停留秒数 |
${events
.slice()
.reverse()
.map((event) => {
const eventName = event.event || "";
const meta = classifyEvent(event);
return `
| ${escapeHtml(event.ts || "")} |
真实 |
${escapeHtml(meta.severity)} |
${escapeHtml(eventName)} |
${escapeHtml(meta.zoneIndex ? String(meta.zoneIndex) : "")} |
${escapeHtml(meta.zoneLabel || "")} |
${escapeHtml(event.batch_id || "")} |
${escapeHtml(String(event.displayDwellSeconds ?? event.dwell_seconds ?? ""))} |
`;
})
.join("")}
`;
}
function formatDuration(seconds) {
const value = Number(seconds);
if (!Number.isFinite(value) || value <= 0) {
return "0s";
}
if (value < 60) {
return `${Math.round(value)}s`;
}
const minutes = Math.floor(value / 60);
const rest = Math.round(value % 60);
return rest ? `${minutes}m ${rest}s` : `${minutes}m`;
}
function errorMessage(reason) {
return reason?.message || String(reason || "unknown error");
}
function renderConfigPreview() {
const preview = {
...(state.config || {}),
stream: {
...((state.config || {}).stream || {}),
rtsp_url: els.settingsRtspUrl.value,
},
camera_id: els.cameraId.value,
timezone: els.timezone.value,
thresholds: {
max_dwell_seconds: alarmMinutesToSeconds(els.maxDwell.value || 0),
trash_confirmation_seconds: Number(els.trashWindow.value || 0),
},
layout: {
zone_count: state.foodZoneCount,
zone_ids: state.foodZones.map((zone) => zone.id),
},
zones: state.foodZones.map((zone) => ({
id: zone.id,
label: zone.label,
polygon: state.polygons[zone.id] || [],
})),
trash: {
roi: state.polygons[TRASH_REGION_ID] || [],
},
ui_state: {
config_dirty: state.configDirty,
calibration_dirty: state.calibrationDirty,
},
};
els.configPreview.textContent = JSON.stringify(preview, null, 2);
}
async function apiJson(path, options = {}) {
const request = {...options};
if (request.body && typeof request.body !== "string") {
request.headers = {"Content-Type": "application/json", ...(request.headers || {})};
request.body = JSON.stringify(request.body);
}
const response = await fetch(path, request);
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`);
}
return payload;
}
function setStatus(message) {
state.status = message;
els.statusText.textContent = message;
const tone = message.includes("失败") || message.includes("错误")
? "error"
: message.includes("正在")
? "busy"
: "ready";
els.statusPill.className = `status-pill ${tone}`;
}
function clamp(value) {
return Math.min(1, Math.max(0, value));
}
function round(value) {
return Math.round(value * 1000000) / 1000000;
}
function allRegionIds() {
return [...state.foodZones.map((zone) => zone.id), TRASH_REGION_ID];
}
try {
boot();
} catch (error) {
showFatalError(error);
}
function showFatalError(error) {
const message = error?.message || String(error || "unknown error");
console.error(error);
const target = document.querySelector("#app");
if (!target || target.querySelector(".fatal-error")) {
return;
}
const banner = document.createElement("div");
banner.className = "fatal-error";
banner.textContent = `前端初始化失败:${message}`;
target.prepend(banner);
}