496 lines
15 KiB
JavaScript
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) => ({
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
'"': """,
|
|
"'": "'",
|
|
})[char]);
|
|
}
|
|
|
|
boot();
|