Files
cold_display_guard/web/src/main.js
2026-04-27 11:35:31 +08:00

496 lines
15 KiB
JavaScript

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 = `
<div class="shell">
<header class="header">
<div class="brand">
<div class="brand-mark">CD</div>
<div>
<div class="brand-title">冷藏展示柜管理</div>
<div class="brand-subtitle">标定、配置、事件数据</div>
</div>
</div>
<nav class="tabs">
<button data-tab="calibration">区域标定</button>
<button data-tab="events">事件数据</button>
<button data-tab="settings">运行配置</button>
</nav>
</header>
<main class="main">
<section class="status-line">
<span id="statusText"></span>
<button id="refreshAll" type="button">刷新</button>
</section>
<section id="calibrationView" class="view">
<section class="toolbar">
<label>
RTSP 地址
<input id="rtspUrl" type="text" placeholder="rtsp://user:password@camera-ip:554/stream">
</label>
<button id="saveConfig" type="button">保存配置和标定</button>
<button id="captureSnapshot" type="button">抓取一帧</button>
<button id="saveCalibration" type="button">仅保存标定</button>
</section>
<section class="calibration-grid">
<aside class="panel">
<div class="panel-title">区域</div>
<div id="regionList" class="region-list"></div>
<div class="button-stack">
<button id="undoPoint" type="button">撤销点</button>
<button id="clearRegion" type="button">清空当前区域</button>
<button id="loadConfigPolygons" type="button">载入当前配置区域</button>
</div>
</aside>
<section class="canvas-panel">
<canvas id="canvas" width="1280" height="720"></canvas>
</section>
<aside class="panel">
<div class="panel-title">标定结果</div>
<div id="regionSummary" class="region-summary"></div>
</aside>
</section>
</section>
<section id="eventsView" class="view hidden">
<section class="metrics" id="metrics"></section>
<section class="panel">
<div class="panel-title">最近事件</div>
<div id="eventsTable" class="events-table"></div>
</section>
</section>
<section id="settingsView" class="view hidden">
<section class="settings-grid">
<label>
Camera ID
<input id="cameraId" type="text">
</label>
<label>
时区
<input id="timezone" type="text">
</label>
<label>
最大放置秒数
<input id="maxDwell" type="number" min="1">
</label>
<label>
垃圾桶确认秒数
<input id="trashWindow" type="number" min="1">
</label>
</section>
<pre id="configPreview" class="config-preview"></pre>
</section>
</main>
</div>
`;
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 `<div><strong>${id}</strong><span>${count >= 3 ? `${count} 点,已标定` : `${count}`}</span></div>`;
})
.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]) => `<div class="metric"><span>${label}</span><strong>${value}</strong></div>`).join("");
}
function renderEvents() {
if (!state.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>
<tbody>
${state.events
.slice()
.reverse()
.map((event) => `
<tr>
<td>${escapeHtml(event.ts || "")}</td>
<td>${escapeHtml(event.event || "")}</td>
<td>${escapeHtml(event.zone_id || "")}</td>
<td>${escapeHtml(event.batch_id || "")}</td>
<td>${escapeHtml(String(event.dwell_seconds ?? ""))}</td>
</tr>
`)
.join("")}
</tbody>
</table>
`;
}
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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[char]);
}
boot();