diff --git a/web/src/main.js b/web/src/main.js index 30c99bb..2ac5188 100644 --- a/web/src/main.js +++ b/web/src/main.js @@ -25,6 +25,8 @@ const state = { image: null, imageUrl: null, status: "正在连接后端...", + configDirty: false, + calibrationDirty: false, }; const app = document.querySelector("#app"); @@ -49,18 +51,17 @@ app.innerHTML = `
- +
- - +
@@ -70,7 +71,7 @@ app.innerHTML = `
- +
@@ -93,6 +94,10 @@ app.innerHTML = `
@@ -121,6 +130,7 @@ const els = { canvas: document.querySelector("#canvas"), regionList: document.querySelector("#regionList"), rtspUrl: document.querySelector("#rtspUrl"), + settingsRtspUrl: document.querySelector("#settingsRtspUrl"), cameraId: document.querySelector("#cameraId"), timezone: document.querySelector("#timezone"), maxDwell: document.querySelector("#maxDwell"), @@ -136,15 +146,16 @@ function boot() { wireEvents(); loadDraftPolygons(); renderRegionList(); - refreshAll(); + loadInitialData(); } function wireEvents() { document.querySelectorAll(".tabs button").forEach((button) => { button.addEventListener("click", () => setTab(button.dataset.tab)); }); - document.querySelector("#refreshAll").addEventListener("click", refreshAll); + 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); @@ -152,11 +163,23 @@ function wireEvents() { document.querySelector("#loadConfigPolygons").addEventListener("click", loadPolygonsFromConfig); els.canvas.addEventListener("click", addPoint); window.addEventListener("resize", drawCanvas); + [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 refreshAll() { +async function loadInitialData() { try { - setStatus("正在读取配置和事件..."); + setStatus("正在读取配置和运行数据..."); const [config, summary, events] = await Promise.all([ apiJson("/api/manage/config"), apiJson("/api/manage/summary"), @@ -166,6 +189,7 @@ async function refreshAll() { state.summary = summary; state.events = events.items || []; fillForm(); + state.configDirty = false; if (!hasAnyPolygon()) { loadPolygonsFromConfig(false); } @@ -176,23 +200,54 @@ async function refreshAll() { } } +async function refreshRuntimeData() { + try { + setStatus("正在刷新运行数据..."); + const [summary, events] = await Promise.all([ + apiJson("/api/manage/summary"), + apiJson("/api/manage/events?limit=200"), + ]); + state.summary = summary; + state.events = events.items || []; + render(); + setStatus("运行数据已刷新"); + } catch (error) { + setStatus(`刷新运行数据失败:${error.message}`); + } +} + +async function reloadConfig() { + if (state.configDirty && !window.confirm("当前运行配置有未保存修改。确认放弃修改并重新载入后端配置?")) { + return; + } + try { + setStatus("正在重新载入后端配置..."); + state.config = await apiJson("/api/manage/config"); + 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.rtspUrl.value.trim(), + rtsp_url: els.settingsRtspUrl.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}); - saveDraftPolygons(); + state.configDirty = false; fillForm(); renderConfigPreview(); - setStatus(calibrationSaved ? "配置和标定已保存" : "配置已保存;当前没有可保存的标定点"); + setStatus("运行配置已保存"); } catch (error) { setStatus(`保存配置失败:${error.message}`); } @@ -233,6 +288,7 @@ async function saveCalibration() { try { const saved = await persistCalibration({requireAny: true}); if (saved) { + state.calibrationDirty = false; saveDraftPolygons(); render(); setStatus("标定已保存到项目配置"); @@ -277,6 +333,7 @@ function setTab(tab) { function fillForm() { const config = state.config || {}; 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 = config.thresholds?.max_dwell_seconds || 10800; @@ -287,6 +344,9 @@ function loadPolygonsFromConfig(updateStatus = true) { if (!state.config) { return; } + if (updateStatus && state.calibrationDirty && !window.confirm("当前标定有未保存草稿。确认放弃草稿并载入已保存标定?")) { + 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})); @@ -295,6 +355,7 @@ function loadPolygonsFromConfig(updateStatus = true) { if (Array.isArray(state.config.trash?.roi)) { state.polygons.trash = state.config.trash.roi.map(([x, y]) => ({x, y})); } + state.calibrationDirty = false; saveDraftPolygons(); render(); if (updateStatus) { @@ -342,6 +403,7 @@ function addPoint(event) { const x = clamp(rawX / imageRect.width); const y = clamp(rawY / imageRect.height); state.polygons[state.activeRegion].push({x: round(x), y: round(y)}); + state.calibrationDirty = true; saveDraftPolygons(); render(); } @@ -370,12 +432,14 @@ function getCanvasImageRect() { function undoPoint() { state.polygons[state.activeRegion].pop(); + state.calibrationDirty = true; saveDraftPolygons(); render(); } function clearRegion() { state.polygons[state.activeRegion] = []; + state.calibrationDirty = true; saveDraftPolygons(); render(); } @@ -526,7 +590,24 @@ function renderEvents() { } function renderConfigPreview() { - els.configPreview.textContent = JSON.stringify(state.config || {}, null, 2); + 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: Number(els.maxDwell.value || 0), + trash_confirmation_seconds: Number(els.trashWindow.value || 0), + }, + ui_state: { + config_dirty: state.configDirty, + calibration_dirty: state.calibrationDirty, + }, + }; + els.configPreview.textContent = JSON.stringify(preview, null, 2); } async function apiJson(path, options = {}) { diff --git a/web/src/styles.css b/web/src/styles.css index a550a7d..84fc725 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -113,7 +113,7 @@ label { .toolbar { display: grid; - grid-template-columns: minmax(360px, 1fr) auto auto auto; + grid-template-columns: minmax(360px, 1fr) auto auto; gap: 12px; align-items: end; margin-bottom: 12px; @@ -263,6 +263,17 @@ th { border-radius: 8px; } +.settings-actions { + display: flex; + gap: 10px; + justify-content: flex-start; + margin-bottom: 12px; + padding: 12px; + background: #ffffff; + border: 1px solid #d8dee5; + border-radius: 8px; +} + .config-preview { margin: 0; padding: 12px;