import "./styles.css"; const zoneIds = ["r1c1", "r1c2", "r1c3", "r1c4", "r2c1", "r2c2", "r2c3", "r2c4"]; const allRegions = [...zoneIds, "trash"]; const palette = { r1c1: "#d92d20", r1c2: "#b54708", r1c3: "#4e5ba6", r1c4: "#008a5a", r2c1: "#0077a3", r2c2: "#155eef", r2c3: "#7f56d9", r2c4: "#c11574", trash: "#111827", }; const state = { config: null, summary: null, events: [], activeTab: "calibration", activeRegion: "r1c1", polygons: Object.fromEntries(allRegions.map((id) => [id, []])), image: null, imageUrl: null, status: "正在连接后端...", }; const app = document.querySelector("#app"); app.innerHTML = `
CD
冷藏展示柜管理
标定、配置、事件数据
`; const els = { statusText: document.querySelector("#statusText"), canvas: document.querySelector("#canvas"), regionList: document.querySelector("#regionList"), rtspUrl: document.querySelector("#rtspUrl"), cameraId: document.querySelector("#cameraId"), timezone: document.querySelector("#timezone"), maxDwell: document.querySelector("#maxDwell"), trashWindow: document.querySelector("#trashWindow"), configPreview: document.querySelector("#configPreview"), regionSummary: document.querySelector("#regionSummary"), metrics: document.querySelector("#metrics"), eventsTable: document.querySelector("#eventsTable"), }; const ctx = els.canvas.getContext("2d"); function boot() { wireEvents(); renderRegionList(); refreshAll(); } function wireEvents() { document.querySelectorAll(".tabs button").forEach((button) => { button.addEventListener("click", () => setTab(button.dataset.tab)); }); document.querySelector("#refreshAll").addEventListener("click", refreshAll); document.querySelector("#saveConfig").addEventListener("click", saveConfig); 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); } async function refreshAll() { try { setStatus("正在读取配置和事件..."); const [config, summary, events] = await Promise.all([ apiJson("/api/manage/config"), apiJson("/api/manage/summary"), apiJson("/api/manage/events?limit=200"), ]); state.config = config; state.summary = summary; state.events = events.items || []; fillForm(); loadPolygonsFromConfig(false); render(); setStatus("已连接后端 19080"); } 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.rtspUrl.value.trim(), thresholds: { max_dwell_seconds: Number(els.maxDwell.value), trash_confirmation_seconds: Number(els.trashWindow.value), }, }; state.config = await apiJson("/api/manage/config", {method: "PUT", body: payload}); const calibrationSaved = await persistCalibration({requireAny: false}); fillForm(); renderConfigPreview(); setStatus(calibrationSaved ? "配置和标定已保存" : "配置已保存;当前没有可保存的标定点"); } 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) { render(); setStatus("标定已保存到项目配置"); } } catch (error) { setStatus(`保存标定失败:${error.message}`); } } async function persistCalibration({requireAny}) { const zones = zoneIds .map((id) => ({id, polygon: 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 = trashPolygon; } if (!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 || {}; els.rtspUrl.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.trashWindow.value = config.thresholds?.trash_confirmation_seconds || 120; } function loadPolygonsFromConfig(updateStatus = true) { if (!state.config) { 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})); } render(); if (updateStatus) { setStatus("已载入当前配置区域"); } } function render() { renderRegionList(); drawCanvas(); renderRegionSummary(); renderMetrics(); renderEvents(); renderConfigPreview(); setTab(state.activeTab); } function renderRegionList() { els.regionList.innerHTML = ""; for (const id of allRegions) { const button = document.createElement("button"); button.type = "button"; button.textContent = `${id}${state.polygons[id].length >= 3 ? " ✓" : ""}`; button.className = id === state.activeRegion ? "active" : ""; button.addEventListener("click", () => { state.activeRegion = id; render(); }); els.regionList.appendChild(button); } } function addPoint(event) { if (!state.image) { setStatus("请先从 RTSP 抓取一帧"); return; } const rect = els.canvas.getBoundingClientRect(); const x = clamp((event.clientX - rect.left) / rect.width); const y = clamp((event.clientY - rect.top) / rect.height); state.polygons[state.activeRegion].push({x: round(x), y: round(y)}); render(); } function undoPoint() { state.polygons[state.activeRegion].pop(); render(); } function clearRegion() { state.polygons[state.activeRegion] = []; render(); } 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 allRegions) { drawPolygon(id, state.polygons[id]); } } function drawPolygon(id, points) { if (!points.length) { return; } const color = palette[id] || "#ffffff"; 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(id, first.x * els.canvas.width + 8, first.y * els.canvas.height + 18); ctx.restore(); } function renderRegionSummary() { els.regionSummary.innerHTML = allRegions .map((id) => { const count = state.polygons[id].length; return `
${id}${count >= 3 ? `${count} 点,已标定` : `${count} 点`}
`; }) .join(""); } function renderMetrics() { const metrics = state.summary?.metrics || {}; const cards = [ ["事件总数", metrics.event_count ?? 0], ["违规事件", metrics.violation_count ?? 0], ["最新报警", metrics.latest_alert_time || "-"], ["事件文件", metrics.events_path || "-"], ]; els.metrics.innerHTML = cards.map(([label, value]) => `
${label}${value}
`).join(""); } function renderEvents() { if (!state.events.length) { els.eventsTable.innerHTML = `
还没有事件数据
`; return; } els.eventsTable.innerHTML = ` ${state.events .slice() .reverse() .map((event) => ` `) .join("")}
时间事件区域批次停留秒数
${escapeHtml(event.ts || "")} ${escapeHtml(event.event || "")} ${escapeHtml(event.zone_id || "")} ${escapeHtml(event.batch_id || "")} ${escapeHtml(String(event.dwell_seconds ?? ""))}
`; } function renderConfigPreview() { els.configPreview.textContent = JSON.stringify(state.config || {}, 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; } function clamp(value) { return Math.min(1, Math.max(0, value)); } function round(value) { return Math.round(value * 1000000) / 1000000; } function escapeHtml(value) { return value.replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[char]); } boot();