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 = `
CG
COLD DISPLAY GUARD
冷藏展示柜管理
区域标定 / 运行监控 / 合规事件

CALIBRATION

冷柜区域标定

抓取一帧后,在画面中依次点击每个格口和垃圾桶 ROI 的边界点。

FRAME INSPECTION 区域 1
`; 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 = ` ${escapeHtml(getRegionLabel(id))} ${id === TRASH_REGION_ID ? "ROI" : escapeHtml(id)} ${(state.polygons[id] || []).length} `; 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 `
${escapeHtml(getRegionLabel(id))} ${complete ? `${count} 点 / 已标定` : `${count} 点 / 待完成`}
`; }) .join(""); els.activeRegionBadge.textContent = getRegionLabel(state.activeRegion); } function renderRuntimeOverview(model) { const labels = [ model.hasSummary ? "运行摘要来自后端" : "暂无运行摘要", model.progressRows.length ? "计时进度来自事件" : "暂无计时进度", model.hasEvents ? "事件表来自后端" : "暂无事件数据", ]; els.runtimeOverview.innerHTML = `
LIVE DATA ${model.hasSummary || model.hasEvents ? "实时态:正在显示后端返回的运行数据" : "实时态:暂无真实运行数据"}

${escapeHtml(labels.join(" / "))}${model.demoReason ? `;${escapeHtml(model.demoReason)}` : ""}

`; } 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 ? `
${escapeHtml(metricLabel("最新区域状态"))}${Object.entries(zoneCounts) .map(([zoneId, count]) => escapeHtml(`${zoneId}:${count}`)) .join(" ")}
` : ""; els.metrics.innerHTML = cards .map((card) => `
${escapeHtml(card.label)} ${escapeHtml(String(card.value))}
`) .join("") + zoneSummary; } function renderRuntimeProgress(model) { if (!model.progressRows.length) { els.runtimeProgress.innerHTML = `
暂无可显示的计时进度
`; return; } els.runtimeProgress.innerHTML = model.progressRows .map((row) => { const statusLabel = row.status === "warning" ? "警告" : row.status === "alarm" ? "报警" : "正常"; return `
${escapeHtml(String(row.zoneIndex))} ${escapeHtml(row.zoneLabel)}
${escapeHtml(formatDuration(row.dwellSeconds))} ${escapeHtml(statusLabel)}
`; }) .join(""); } function renderEvents(model) { const events = model.displayEvents || model.events; if (!events.length) { els.eventsTable.innerHTML = `
还没有事件数据
`; return; } els.eventsTable.innerHTML = ` ${events .slice() .reverse() .map((event) => { const eventName = event.event || ""; const meta = classifyEvent(event); return ` `; }) .join("")}
时间来源级别事件区域序号区域批次停留秒数
${escapeHtml(event.ts || "")} 真实 ${escapeHtml(meta.severity)} ${escapeHtml(eventName)} ${escapeHtml(meta.zoneIndex ? String(meta.zoneIndex) : "")} ${escapeHtml(meta.zoneLabel || "")} ${escapeHtml(event.batch_id || "")} ${escapeHtml(String(event.displayDwellSeconds ?? event.dwell_seconds ?? ""))}
`; } function renderCases(model) { if (!model.rows.length) { els.casesTable.innerHTML = `
还没有处置单数据
`; return; } els.casesTable.innerHTML = ` ${model.rows .map((row) => ` `) .join("")}
处置单类型状态区域批次更新时间处理来源操作
${escapeHtml(row.caseId)} ${escapeHtml(row.typeLabel)} ${escapeHtml(row.statusLabel)} ${escapeHtml(row.zone_label || "")} ${escapeHtml(row.batch_id || "")} ${escapeHtml(row.updated_at || "")} ${escapeHtml(row.handledSourceLabel || "-")} ${row.case_status === "open" ? `` : `已完成`}
`; } 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); }