feat: add management web console
This commit is contained in:
485
web/src/main.js
Normal file
485
web/src/main.js
Normal file
@@ -0,0 +1,485 @@
|
||||
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});
|
||||
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 zones = zoneIds
|
||||
.map((id) => ({id, polygon: state.polygons[id]}))
|
||||
.filter((zone) => zone.polygon.length >= 3);
|
||||
const trashPolygon = state.polygons.trash;
|
||||
if (zones.length !== zoneIds.length) {
|
||||
setStatus("8 个格口都标定后才能保存");
|
||||
return;
|
||||
}
|
||||
if (trashPolygon.length < 3) {
|
||||
setStatus("垃圾桶区域至少需要 3 个点");
|
||||
return;
|
||||
}
|
||||
state.config = await apiJson("/api/manage/calibration", {
|
||||
method: "PUT",
|
||||
body: {zones, trash: {roi: trashPolygon}},
|
||||
});
|
||||
render();
|
||||
setStatus("标定已保存到项目配置");
|
||||
} catch (error) {
|
||||
setStatus(`保存标定失败:${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
Reference in New Issue
Block a user