fix: separate runtime refresh from config reload
This commit is contained in:
109
web/src/main.js
109
web/src/main.js
@@ -25,6 +25,8 @@ const state = {
|
|||||||
image: null,
|
image: null,
|
||||||
imageUrl: null,
|
imageUrl: null,
|
||||||
status: "正在连接后端...",
|
status: "正在连接后端...",
|
||||||
|
configDirty: false,
|
||||||
|
calibrationDirty: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const app = document.querySelector("#app");
|
const app = document.querySelector("#app");
|
||||||
@@ -49,18 +51,17 @@ app.innerHTML = `
|
|||||||
<main class="main">
|
<main class="main">
|
||||||
<section class="status-line">
|
<section class="status-line">
|
||||||
<span id="statusText"></span>
|
<span id="statusText"></span>
|
||||||
<button id="refreshAll" type="button">刷新</button>
|
<button id="refreshRuntimeData" type="button">刷新运行数据</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="calibrationView" class="view">
|
<section id="calibrationView" class="view">
|
||||||
<section class="toolbar">
|
<section class="toolbar">
|
||||||
<label>
|
<label>
|
||||||
RTSP 地址
|
RTSP 地址(用于抓帧)
|
||||||
<input id="rtspUrl" type="text" placeholder="rtsp://user:password@camera-ip:554/stream">
|
<input id="rtspUrl" type="text" placeholder="rtsp://user:password@camera-ip:554/stream">
|
||||||
</label>
|
</label>
|
||||||
<button id="saveConfig" type="button">保存配置和标定</button>
|
|
||||||
<button id="captureSnapshot" type="button">抓取一帧</button>
|
<button id="captureSnapshot" type="button">抓取一帧</button>
|
||||||
<button id="saveCalibration" type="button">仅保存标定</button>
|
<button id="saveCalibration" type="button">保存标定</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="calibration-grid">
|
<section class="calibration-grid">
|
||||||
@@ -70,7 +71,7 @@ app.innerHTML = `
|
|||||||
<div class="button-stack">
|
<div class="button-stack">
|
||||||
<button id="undoPoint" type="button">撤销点</button>
|
<button id="undoPoint" type="button">撤销点</button>
|
||||||
<button id="clearRegion" type="button">清空当前区域</button>
|
<button id="clearRegion" type="button">清空当前区域</button>
|
||||||
<button id="loadConfigPolygons" type="button">载入当前配置区域</button>
|
<button id="loadConfigPolygons" type="button">放弃草稿并载入已保存标定</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<section class="canvas-panel">
|
<section class="canvas-panel">
|
||||||
@@ -93,6 +94,10 @@ app.innerHTML = `
|
|||||||
|
|
||||||
<section id="settingsView" class="view hidden">
|
<section id="settingsView" class="view hidden">
|
||||||
<section class="settings-grid">
|
<section class="settings-grid">
|
||||||
|
<label>
|
||||||
|
RTSP 地址
|
||||||
|
<input id="settingsRtspUrl" type="text" placeholder="rtsp://user:password@camera-ip:554/stream">
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Camera ID
|
Camera ID
|
||||||
<input id="cameraId" type="text">
|
<input id="cameraId" type="text">
|
||||||
@@ -110,6 +115,10 @@ app.innerHTML = `
|
|||||||
<input id="trashWindow" type="number" min="1">
|
<input id="trashWindow" type="number" min="1">
|
||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="settings-actions">
|
||||||
|
<button id="saveConfig" type="button">保存运行配置</button>
|
||||||
|
<button id="reloadConfig" type="button">重新载入后端配置</button>
|
||||||
|
</section>
|
||||||
<pre id="configPreview" class="config-preview"></pre>
|
<pre id="configPreview" class="config-preview"></pre>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -121,6 +130,7 @@ const els = {
|
|||||||
canvas: document.querySelector("#canvas"),
|
canvas: document.querySelector("#canvas"),
|
||||||
regionList: document.querySelector("#regionList"),
|
regionList: document.querySelector("#regionList"),
|
||||||
rtspUrl: document.querySelector("#rtspUrl"),
|
rtspUrl: document.querySelector("#rtspUrl"),
|
||||||
|
settingsRtspUrl: document.querySelector("#settingsRtspUrl"),
|
||||||
cameraId: document.querySelector("#cameraId"),
|
cameraId: document.querySelector("#cameraId"),
|
||||||
timezone: document.querySelector("#timezone"),
|
timezone: document.querySelector("#timezone"),
|
||||||
maxDwell: document.querySelector("#maxDwell"),
|
maxDwell: document.querySelector("#maxDwell"),
|
||||||
@@ -136,15 +146,16 @@ function boot() {
|
|||||||
wireEvents();
|
wireEvents();
|
||||||
loadDraftPolygons();
|
loadDraftPolygons();
|
||||||
renderRegionList();
|
renderRegionList();
|
||||||
refreshAll();
|
loadInitialData();
|
||||||
}
|
}
|
||||||
|
|
||||||
function wireEvents() {
|
function wireEvents() {
|
||||||
document.querySelectorAll(".tabs button").forEach((button) => {
|
document.querySelectorAll(".tabs button").forEach((button) => {
|
||||||
button.addEventListener("click", () => setTab(button.dataset.tab));
|
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("#saveConfig").addEventListener("click", saveConfig);
|
||||||
|
document.querySelector("#reloadConfig").addEventListener("click", reloadConfig);
|
||||||
document.querySelector("#captureSnapshot").addEventListener("click", captureSnapshot);
|
document.querySelector("#captureSnapshot").addEventListener("click", captureSnapshot);
|
||||||
document.querySelector("#saveCalibration").addEventListener("click", saveCalibration);
|
document.querySelector("#saveCalibration").addEventListener("click", saveCalibration);
|
||||||
document.querySelector("#undoPoint").addEventListener("click", undoPoint);
|
document.querySelector("#undoPoint").addEventListener("click", undoPoint);
|
||||||
@@ -152,11 +163,23 @@ function wireEvents() {
|
|||||||
document.querySelector("#loadConfigPolygons").addEventListener("click", loadPolygonsFromConfig);
|
document.querySelector("#loadConfigPolygons").addEventListener("click", loadPolygonsFromConfig);
|
||||||
els.canvas.addEventListener("click", addPoint);
|
els.canvas.addEventListener("click", addPoint);
|
||||||
window.addEventListener("resize", drawCanvas);
|
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 {
|
try {
|
||||||
setStatus("正在读取配置和事件...");
|
setStatus("正在读取配置和运行数据...");
|
||||||
const [config, summary, events] = await Promise.all([
|
const [config, summary, events] = await Promise.all([
|
||||||
apiJson("/api/manage/config"),
|
apiJson("/api/manage/config"),
|
||||||
apiJson("/api/manage/summary"),
|
apiJson("/api/manage/summary"),
|
||||||
@@ -166,6 +189,7 @@ async function refreshAll() {
|
|||||||
state.summary = summary;
|
state.summary = summary;
|
||||||
state.events = events.items || [];
|
state.events = events.items || [];
|
||||||
fillForm();
|
fillForm();
|
||||||
|
state.configDirty = false;
|
||||||
if (!hasAnyPolygon()) {
|
if (!hasAnyPolygon()) {
|
||||||
loadPolygonsFromConfig(false);
|
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() {
|
async function saveConfig() {
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
camera_id: els.cameraId.value.trim(),
|
camera_id: els.cameraId.value.trim(),
|
||||||
timezone: els.timezone.value.trim(),
|
timezone: els.timezone.value.trim(),
|
||||||
rtsp_url: els.rtspUrl.value.trim(),
|
rtsp_url: els.settingsRtspUrl.value.trim(),
|
||||||
thresholds: {
|
thresholds: {
|
||||||
max_dwell_seconds: Number(els.maxDwell.value),
|
max_dwell_seconds: Number(els.maxDwell.value),
|
||||||
trash_confirmation_seconds: Number(els.trashWindow.value),
|
trash_confirmation_seconds: Number(els.trashWindow.value),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
state.config = await apiJson("/api/manage/config", {method: "PUT", body: payload});
|
state.config = await apiJson("/api/manage/config", {method: "PUT", body: payload});
|
||||||
const calibrationSaved = await persistCalibration({requireAny: false});
|
state.configDirty = false;
|
||||||
saveDraftPolygons();
|
|
||||||
fillForm();
|
fillForm();
|
||||||
renderConfigPreview();
|
renderConfigPreview();
|
||||||
setStatus(calibrationSaved ? "配置和标定已保存" : "配置已保存;当前没有可保存的标定点");
|
setStatus("运行配置已保存");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(`保存配置失败:${error.message}`);
|
setStatus(`保存配置失败:${error.message}`);
|
||||||
}
|
}
|
||||||
@@ -233,6 +288,7 @@ async function saveCalibration() {
|
|||||||
try {
|
try {
|
||||||
const saved = await persistCalibration({requireAny: true});
|
const saved = await persistCalibration({requireAny: true});
|
||||||
if (saved) {
|
if (saved) {
|
||||||
|
state.calibrationDirty = false;
|
||||||
saveDraftPolygons();
|
saveDraftPolygons();
|
||||||
render();
|
render();
|
||||||
setStatus("标定已保存到项目配置");
|
setStatus("标定已保存到项目配置");
|
||||||
@@ -277,6 +333,7 @@ function setTab(tab) {
|
|||||||
function fillForm() {
|
function fillForm() {
|
||||||
const config = state.config || {};
|
const config = state.config || {};
|
||||||
els.rtspUrl.value = config.stream?.rtsp_url || "";
|
els.rtspUrl.value = config.stream?.rtsp_url || "";
|
||||||
|
els.settingsRtspUrl.value = config.stream?.rtsp_url || "";
|
||||||
els.cameraId.value = config.camera_id || "";
|
els.cameraId.value = config.camera_id || "";
|
||||||
els.timezone.value = config.timezone || "";
|
els.timezone.value = config.timezone || "";
|
||||||
els.maxDwell.value = config.thresholds?.max_dwell_seconds || 10800;
|
els.maxDwell.value = config.thresholds?.max_dwell_seconds || 10800;
|
||||||
@@ -287,6 +344,9 @@ function loadPolygonsFromConfig(updateStatus = true) {
|
|||||||
if (!state.config) {
|
if (!state.config) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (updateStatus && state.calibrationDirty && !window.confirm("当前标定有未保存草稿。确认放弃草稿并载入已保存标定?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (const zone of state.config.zones || []) {
|
for (const zone of state.config.zones || []) {
|
||||||
if (zone.id && Array.isArray(zone.polygon)) {
|
if (zone.id && Array.isArray(zone.polygon)) {
|
||||||
state.polygons[zone.id] = zone.polygon.map(([x, y]) => ({x, y}));
|
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)) {
|
if (Array.isArray(state.config.trash?.roi)) {
|
||||||
state.polygons.trash = state.config.trash.roi.map(([x, y]) => ({x, y}));
|
state.polygons.trash = state.config.trash.roi.map(([x, y]) => ({x, y}));
|
||||||
}
|
}
|
||||||
|
state.calibrationDirty = false;
|
||||||
saveDraftPolygons();
|
saveDraftPolygons();
|
||||||
render();
|
render();
|
||||||
if (updateStatus) {
|
if (updateStatus) {
|
||||||
@@ -342,6 +403,7 @@ function addPoint(event) {
|
|||||||
const x = clamp(rawX / imageRect.width);
|
const x = clamp(rawX / imageRect.width);
|
||||||
const y = clamp(rawY / imageRect.height);
|
const y = clamp(rawY / imageRect.height);
|
||||||
state.polygons[state.activeRegion].push({x: round(x), y: round(y)});
|
state.polygons[state.activeRegion].push({x: round(x), y: round(y)});
|
||||||
|
state.calibrationDirty = true;
|
||||||
saveDraftPolygons();
|
saveDraftPolygons();
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
@@ -370,12 +432,14 @@ function getCanvasImageRect() {
|
|||||||
|
|
||||||
function undoPoint() {
|
function undoPoint() {
|
||||||
state.polygons[state.activeRegion].pop();
|
state.polygons[state.activeRegion].pop();
|
||||||
|
state.calibrationDirty = true;
|
||||||
saveDraftPolygons();
|
saveDraftPolygons();
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearRegion() {
|
function clearRegion() {
|
||||||
state.polygons[state.activeRegion] = [];
|
state.polygons[state.activeRegion] = [];
|
||||||
|
state.calibrationDirty = true;
|
||||||
saveDraftPolygons();
|
saveDraftPolygons();
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
@@ -526,7 +590,24 @@ function renderEvents() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderConfigPreview() {
|
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 = {}) {
|
async function apiJson(path, options = {}) {
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ label {
|
|||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(360px, 1fr) auto auto auto;
|
grid-template-columns: minmax(360px, 1fr) auto auto;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
@@ -263,6 +263,17 @@ th {
|
|||||||
border-radius: 8px;
|
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 {
|
.config-preview {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
|||||||
Reference in New Issue
Block a user