1045 lines
35 KiB
JavaScript
1045 lines
35 KiB
JavaScript
import "./styles.css";
|
||
import {
|
||
TRASH_REGION_ID,
|
||
alarmMinutesToSeconds,
|
||
buildCalibrationPayload,
|
||
buildCaseDisplayModel,
|
||
buildManualHandlePayload,
|
||
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: [],
|
||
cases: [],
|
||
caseSummary: null,
|
||
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 = `
|
||
<div class="shell">
|
||
<header class="console-header">
|
||
<div class="brand">
|
||
<div class="brand-mark">CG</div>
|
||
<div class="brand-copy">
|
||
<div class="brand-kicker">COLD DISPLAY GUARD</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>
|
||
|
||
<div class="header-actions">
|
||
<div class="status-pill" id="statusPill">
|
||
<span class="status-dot"></span>
|
||
<span id="statusText"></span>
|
||
</div>
|
||
<button id="refreshRuntimeData" class="secondary-action" type="button">刷新运行数据</button>
|
||
</div>
|
||
</header>
|
||
|
||
<main class="main">
|
||
<section id="calibrationView" class="view">
|
||
<section class="view-head">
|
||
<div>
|
||
<p class="view-kicker">CALIBRATION</p>
|
||
<h1>冷柜区域标定</h1>
|
||
</div>
|
||
<p class="view-note">抓取一帧后,在画面中依次点击每个格口和垃圾桶 ROI 的边界点。</p>
|
||
</section>
|
||
|
||
<section class="command-bar">
|
||
<label class="field rtsp-field">
|
||
<span>RTSP 地址(用于抓帧)</span>
|
||
<input id="rtspUrl" type="text" placeholder="rtsp://user:password@camera-ip:554/stream">
|
||
</label>
|
||
<div class="command-actions">
|
||
<button id="captureSnapshot" class="primary-action" type="button">抓取一帧</button>
|
||
<button id="saveCalibration" class="success-action" type="button">保存标定</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="calibration-layout">
|
||
<aside class="panel zone-panel">
|
||
<div class="panel-meta">ZONE MATRIX</div>
|
||
<div class="panel-title">区域选择</div>
|
||
<label class="field zone-count-field">
|
||
<span>食品区域数量(1-10)</span>
|
||
<input id="foodZoneCount" type="number" min="1" max="10" step="1">
|
||
</label>
|
||
<div id="regionList" class="region-list"></div>
|
||
<div class="tool-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">
|
||
<div class="canvas-toolbar">
|
||
<span>FRAME INSPECTION</span>
|
||
<strong id="activeRegionBadge">区域 1</strong>
|
||
</div>
|
||
<canvas id="canvas" width="1280" height="720"></canvas>
|
||
</section>
|
||
|
||
<aside class="panel inspection-panel">
|
||
<div class="panel-meta">POLYGON STATUS</div>
|
||
<div class="panel-title">标定结果</div>
|
||
<div id="regionSummary" class="region-summary"></div>
|
||
</aside>
|
||
</section>
|
||
</section>
|
||
|
||
<section id="eventsView" class="view hidden">
|
||
<section class="view-head">
|
||
<div>
|
||
<p class="view-kicker">RUNTIME</p>
|
||
<h1>事件与运行状态</h1>
|
||
</div>
|
||
<p class="view-note">从运行进程写入的事件和诊断数据中读取最近状态。</p>
|
||
</section>
|
||
<section id="runtimeOverview" class="runtime-overview"></section>
|
||
<section class="metrics" id="metrics"></section>
|
||
<section class="panel progress-panel">
|
||
<div class="panel-meta">DWELL TIMER</div>
|
||
<div class="panel-title">计时进度</div>
|
||
<div id="runtimeProgress" class="runtime-progress"></div>
|
||
</section>
|
||
<section class="panel event-panel">
|
||
<div class="panel-meta">EVENT LOG</div>
|
||
<div class="panel-title">最近事件</div>
|
||
<div id="eventsTable" class="events-table"></div>
|
||
</section>
|
||
<section class="panel case-panel">
|
||
<div class="panel-meta">CASE WORKFLOW</div>
|
||
<div class="panel-title">处置单</div>
|
||
<div id="casesTable" class="events-table"></div>
|
||
</section>
|
||
</section>
|
||
|
||
<section id="settingsView" class="view hidden">
|
||
<section class="view-head">
|
||
<div>
|
||
<p class="view-kicker">CONFIGURATION</p>
|
||
<h1>运行配置</h1>
|
||
</div>
|
||
<p class="view-note">保存后写入后端配置;运行进程需要按配置重新读取。</p>
|
||
</section>
|
||
|
||
<section class="settings-layout">
|
||
<section class="panel settings-panel">
|
||
<div class="panel-meta">CAMERA INPUT</div>
|
||
<div class="settings-grid">
|
||
<label class="field">
|
||
<span>RTSP 地址</span>
|
||
<input id="settingsRtspUrl" type="text" placeholder="rtsp://user:password@camera-ip:554/stream">
|
||
</label>
|
||
<label class="field">
|
||
<span>Camera ID</span>
|
||
<input id="cameraId" type="text">
|
||
</label>
|
||
<label class="field">
|
||
<span>时区</span>
|
||
<input id="timezone" type="text">
|
||
</label>
|
||
<label class="field">
|
||
<span>报警阈值(分钟)</span>
|
||
<input id="maxDwell" type="number" min="1" step="1">
|
||
</label>
|
||
<label class="field">
|
||
<span>垃圾桶确认秒数</span>
|
||
<input id="trashWindow" type="number" min="1">
|
||
</label>
|
||
</div>
|
||
<div class="settings-actions">
|
||
<button id="saveConfig" class="success-action" type="button">保存运行配置</button>
|
||
<button id="reloadConfig" type="button">重新载入后端配置</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel preview-panel">
|
||
<div class="panel-meta">LIVE CONFIG PREVIEW</div>
|
||
<pre id="configPreview" class="config-preview"></pre>
|
||
</section>
|
||
</section>
|
||
</section>
|
||
</main>
|
||
</div>
|
||
`;
|
||
|
||
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"),
|
||
casesTable: document.querySelector("#casesTable"),
|
||
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);
|
||
els.casesTable.addEventListener("click", handleCaseTableClick);
|
||
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, casesResult, caseSummaryResult] = await Promise.allSettled([
|
||
apiJson("/api/manage/summary"),
|
||
apiJson("/api/manage/events?limit=1000"),
|
||
apiJson("/api/manage/cases?limit=1000"),
|
||
apiJson("/api/manage/cases/summary"),
|
||
]);
|
||
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)}`);
|
||
}
|
||
if (casesResult.status === "fulfilled") {
|
||
state.cases = casesResult.value.items || [];
|
||
} else {
|
||
state.cases = [];
|
||
errors.push(`cases ${errorMessage(casesResult.reason)}`);
|
||
}
|
||
if (caseSummaryResult.status === "fulfilled") {
|
||
state.caseSummary = caseSummaryResult.value;
|
||
} else {
|
||
state.caseSummary = null;
|
||
errors.push(`case summary ${errorMessage(caseSummaryResult.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 buildCaseModel() {
|
||
return buildCaseDisplayModel({
|
||
summary: state.caseSummary,
|
||
cases: state.cases,
|
||
});
|
||
}
|
||
|
||
function renderRuntimeSections() {
|
||
const runtimeModel = buildRuntimeModel();
|
||
const caseModel = buildCaseModel();
|
||
renderRuntimeOverview(runtimeModel);
|
||
renderMetrics(runtimeModel, caseModel);
|
||
renderRuntimeProgress(runtimeModel);
|
||
renderEvents(runtimeModel);
|
||
renderCases(caseModel);
|
||
}
|
||
|
||
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 = `
|
||
<span class="region-swatch"></span>
|
||
<span class="region-name">${escapeHtml(getRegionLabel(id))}</span>
|
||
<span class="region-code">${id === TRASH_REGION_ID ? "ROI" : escapeHtml(id)}</span>
|
||
<span class="region-points">${(state.polygons[id] || []).length}</span>
|
||
`;
|
||
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 `
|
||
<div class="summary-row ${complete ? "complete" : "pending"}">
|
||
<span class="summary-dot" style="--region-color:${getRegionColor(id)}"></span>
|
||
<strong>${escapeHtml(getRegionLabel(id))}</strong>
|
||
<span>${complete ? `${count} 点 / 已标定` : `${count} 点 / 待完成`}</span>
|
||
</div>
|
||
`;
|
||
})
|
||
.join("");
|
||
els.activeRegionBadge.textContent = getRegionLabel(state.activeRegion);
|
||
}
|
||
|
||
function renderRuntimeOverview(model) {
|
||
const labels = [
|
||
model.hasSummary ? "运行摘要来自后端" : "暂无运行摘要",
|
||
model.progressRows.length ? "计时进度来自事件" : "暂无计时进度",
|
||
model.hasEvents ? "事件表来自后端" : "暂无事件数据",
|
||
];
|
||
els.runtimeOverview.innerHTML = `
|
||
<div class="runtime-banner real">
|
||
<div>
|
||
<span>LIVE DATA</span>
|
||
<strong>${model.hasSummary || model.hasEvents ? "实时态:正在显示后端返回的运行数据" : "实时态:暂无真实运行数据"}</strong>
|
||
</div>
|
||
<p>${escapeHtml(labels.join(" / "))}${model.demoReason ? `;${escapeHtml(model.demoReason)}` : ""}</p>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderMetrics(model, caseModel) {
|
||
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 caseMetrics = caseModel?.metrics || {};
|
||
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: caseMetrics.openCaseCount ?? 0, tone: (caseMetrics.openCaseCount ?? 0) > 0 ? "warning" : "good"},
|
||
{label: metricLabel("已处理处置单"), value: caseMetrics.handledCaseCount ?? 0, tone: "good"},
|
||
{label: metricLabel("超时报警单"), value: caseMetrics.timeAlarmCaseCount ?? 0, tone: "neutral"},
|
||
{label: metricLabel("待丢弃确认单"), value: caseMetrics.pendingDisposalCaseCount ?? 0, tone: "neutral"},
|
||
{label: metricLabel("升级警告单"), value: caseMetrics.warningEscalatedCaseCount ?? 0, tone: (caseMetrics.warningEscalatedCaseCount ?? 0) > 0 ? "danger" : "good"},
|
||
{label: metricLabel("事件文件"), value: metrics.events_path || "-", tone: "path"},
|
||
];
|
||
const zoneCounts = metrics.latest_zone_counts || {};
|
||
const zoneSummary = Object.keys(zoneCounts).length
|
||
? `<div class="metric wide zone-state"><span>${escapeHtml(metricLabel("最新区域状态"))}</span><strong>${Object.entries(zoneCounts)
|
||
.map(([zoneId, count]) => escapeHtml(`${zoneId}:${count}`))
|
||
.join(" ")}</strong></div>`
|
||
: "";
|
||
els.metrics.innerHTML = cards
|
||
.map((card) => `
|
||
<div class="metric ${card.tone}">
|
||
<span>${escapeHtml(card.label)}</span>
|
||
<strong>${escapeHtml(String(card.value))}</strong>
|
||
</div>
|
||
`)
|
||
.join("") + zoneSummary;
|
||
}
|
||
|
||
function renderRuntimeProgress(model) {
|
||
if (!model.progressRows.length) {
|
||
els.runtimeProgress.innerHTML = `<div class="empty">暂无可显示的计时进度</div>`;
|
||
return;
|
||
}
|
||
els.runtimeProgress.innerHTML = model.progressRows
|
||
.map((row) => {
|
||
const statusLabel = row.status === "warning" ? "警告" : row.status === "alarm" ? "报警" : "正常";
|
||
return `
|
||
<div class="progress-row ${row.status}">
|
||
<div class="progress-zone">
|
||
<span class="zone-number">${escapeHtml(String(row.zoneIndex))}</span>
|
||
<strong>${escapeHtml(row.zoneLabel)}</strong>
|
||
</div>
|
||
<div class="progress-track" aria-label="${escapeHtml(`${row.zoneLabel} 停留 ${row.dwellSeconds} 秒`)}">
|
||
<span style="width:${row.progressPct}%"></span>
|
||
</div>
|
||
<div class="progress-meta">
|
||
<strong>${escapeHtml(formatDuration(row.dwellSeconds))}</strong>
|
||
<span>${escapeHtml(statusLabel)}</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
})
|
||
.join("");
|
||
}
|
||
|
||
function renderEvents(model) {
|
||
const events = model.displayEvents || model.events;
|
||
if (!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><th>区域</th><th>批次</th><th>停留秒数</th></tr></thead>
|
||
<tbody>
|
||
${events
|
||
.slice()
|
||
.reverse()
|
||
.map((event) => {
|
||
const eventName = event.event || "";
|
||
const meta = classifyEvent(event);
|
||
return `
|
||
<tr class="event-row ${meta.tone}">
|
||
<td>${escapeHtml(event.ts || "")}</td>
|
||
<td><span class="event-source real">真实</span></td>
|
||
<td><span class="event-severity ${meta.tone}">${escapeHtml(meta.severity)}</span></td>
|
||
<td><span class="event-name ${meta.tone}">${escapeHtml(eventName)}</span></td>
|
||
<td>${escapeHtml(meta.zoneIndex ? String(meta.zoneIndex) : "")}</td>
|
||
<td>${escapeHtml(meta.zoneLabel || "")}</td>
|
||
<td>${escapeHtml(event.batch_id || "")}</td>
|
||
<td>${escapeHtml(String(event.displayDwellSeconds ?? event.dwell_seconds ?? ""))}</td>
|
||
</tr>
|
||
`;
|
||
})
|
||
.join("")}
|
||
</tbody>
|
||
</table>
|
||
`;
|
||
}
|
||
|
||
function renderCases(model) {
|
||
if (!model.rows.length) {
|
||
els.casesTable.innerHTML = `<div class="empty">还没有处置单数据</div>`;
|
||
return;
|
||
}
|
||
els.casesTable.innerHTML = `
|
||
<table>
|
||
<thead><tr><th>处置单</th><th>类型</th><th>状态</th><th>区域</th><th>批次</th><th>更新时间</th><th>处理来源</th><th>操作</th></tr></thead>
|
||
<tbody>
|
||
${model.rows
|
||
.map((row) => `
|
||
<tr class="event-row ${row.tone}">
|
||
<td>${escapeHtml(row.caseId)}</td>
|
||
<td>${escapeHtml(row.typeLabel)}</td>
|
||
<td><span class="event-severity ${row.tone}">${escapeHtml(row.statusLabel)}</span></td>
|
||
<td>${escapeHtml(row.zone_label || "")}</td>
|
||
<td>${escapeHtml(row.batch_id || "")}</td>
|
||
<td>${escapeHtml(row.updated_at || "")}</td>
|
||
<td>${escapeHtml(row.handledSourceLabel || "-")}</td>
|
||
<td>${row.case_status === "open"
|
||
? `<button type="button" class="secondary-action" data-handle-case="${escapeHtml(row.caseId)}">标记已处理</button>`
|
||
: `<span class="event-source real">已完成</span>`}</td>
|
||
</tr>
|
||
`)
|
||
.join("")}
|
||
</tbody>
|
||
</table>
|
||
`;
|
||
}
|
||
|
||
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 handleCaseTableClick(event) {
|
||
const button = event.target.closest("[data-handle-case]");
|
||
if (!button) {
|
||
return;
|
||
}
|
||
handleCase(button.dataset.handleCase);
|
||
}
|
||
|
||
async function handleCase(caseId) {
|
||
const handledBy = window.prompt("请输入处理人");
|
||
if (handledBy === null) {
|
||
return;
|
||
}
|
||
const trimmedHandledBy = handledBy.trim();
|
||
if (!trimmedHandledBy) {
|
||
setStatus("处理人不能为空");
|
||
return;
|
||
}
|
||
const note = window.prompt("请输入处理备注(可选)") || "";
|
||
try {
|
||
setStatus("正在更新处置单状态...");
|
||
await apiJson(`/api/manage/cases/${encodeURIComponent(caseId)}/handle`, {
|
||
method: "POST",
|
||
body: buildManualHandlePayload(trimmedHandledBy, note),
|
||
});
|
||
await loadRuntimeData();
|
||
renderRuntimeSections();
|
||
setStatus("处置单已标记为已处理");
|
||
} catch (error) {
|
||
setStatus(`更新处置单失败:${error.message}`);
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|