feat: add webhook case management

This commit is contained in:
2026-06-09 11:13:56 +08:00
parent 490b3089d2
commit 9d791be174
17 changed files with 1982 additions and 12 deletions

View File

@@ -3,6 +3,8 @@ import {
TRASH_REGION_ID,
alarmMinutesToSeconds,
buildCalibrationPayload,
buildCaseDisplayModel,
buildManualHandlePayload,
buildPolygonMap,
buildRuntimeDisplayModel,
clampZoneCount,
@@ -31,6 +33,8 @@ const state = {
config: null,
summary: null,
events: [],
cases: [],
caseSummary: null,
activeTab: "events",
activeRegion: "1",
foodZones: defaultFoodZones,
@@ -147,6 +151,11 @@ app.innerHTML = `
<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">
@@ -216,6 +225,7 @@ const els = {
runtimeProgress: document.querySelector("#runtimeProgress"),
metrics: document.querySelector("#metrics"),
eventsTable: document.querySelector("#eventsTable"),
casesTable: document.querySelector("#casesTable"),
statusPill: document.querySelector("#statusPill"),
activeRegionBadge: document.querySelector("#activeRegionBadge"),
};
@@ -245,6 +255,7 @@ function wireEvents() {
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) => {
@@ -309,9 +320,11 @@ async function refreshRuntimeDataSilently() {
}
async function loadRuntimeData() {
const [summaryResult, eventsResult] = await Promise.allSettled([
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") {
@@ -326,6 +339,18 @@ async function loadRuntimeData() {
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("") : "";
}
@@ -515,12 +540,21 @@ function buildRuntimeModel() {
});
}
function buildCaseModel() {
return buildCaseDisplayModel({
summary: state.caseSummary,
cases: state.cases,
});
}
function renderRuntimeSections() {
const runtimeModel = buildRuntimeModel();
const caseModel = buildCaseModel();
renderRuntimeOverview(runtimeModel);
renderMetrics(runtimeModel);
renderMetrics(runtimeModel, caseModel);
renderRuntimeProgress(runtimeModel);
renderEvents(runtimeModel);
renderCases(caseModel);
}
function renderRegionList() {
@@ -739,12 +773,13 @@ function renderRuntimeOverview(model) {
`;
}
function renderMetrics(model) {
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"},
@@ -754,6 +789,11 @@ function renderMetrics(model) {
{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 || {};
@@ -834,6 +874,36 @@ function renderEvents(model) {
`;
}
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) {
@@ -898,6 +968,39 @@ async function apiJson(path, options = {}) {
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;

View File

@@ -158,6 +158,38 @@ export function buildRuntimeDisplayModel({
};
}
export function buildCaseDisplayModel({summary = null, cases = []} = {}) {
const metrics = {
openCaseCount: Number(summary?.open_case_count || 0),
handledCaseCount: Number(summary?.handled_case_count || 0),
timeAlarmCaseCount: Number(summary?.time_alarm_case_count || 0),
pendingDisposalCaseCount: Number(summary?.pending_disposal_case_count || 0),
warningEscalatedCaseCount: Number(summary?.warning_escalated_case_count || 0),
};
const rows = (Array.isArray(cases) ? cases : [])
.map((item) => ({
...item,
caseId: String(item.case_id || ""),
typeLabel: caseTypeLabel(item.case_type),
statusLabel: String(item.case_status || "") === "handled" ? "已处理" : "待处理",
tone: String(item.case_status || "") === "handled" ? "good" : "warning",
handledSourceLabel: caseHandledSourceLabel(item.handled_source),
}))
.sort((left, right) => timestampMillis(right.updated_at) - timestampMillis(left.updated_at));
return {metrics, rows};
}
export function buildManualHandlePayload(handledBy, note = "") {
const payload = {
handled_by: String(handledBy || "").trim(),
};
const trimmedNote = String(note || "").trim();
if (trimmedNote) {
payload.note = trimmedNote;
}
return payload;
}
export function getRegionColor(id) {
if (id === TRASH_REGION_ID) {
return "#111827";
@@ -221,6 +253,32 @@ function containsDemoMarker(value) {
return text.includes("demo") || text.includes("演示");
}
function caseTypeLabel(caseType) {
if (caseType === "warning_escalated") {
return "升级警告";
}
if (caseType === "pending_disposal") {
return "待丢弃确认";
}
if (caseType === "time_alarm") {
return "超时报警";
}
return String(caseType || "");
}
function caseHandledSourceLabel(source) {
if (source === "manual") {
return "人工处理";
}
if (source === "webhook_callback") {
return "回调处理";
}
if (source === "auto_closed") {
return "自动关闭";
}
return "";
}
function createEmptyRuntimeSummary(thresholdSeconds) {
return {
result_type: "cold_display_guard",

View File

@@ -5,6 +5,8 @@ import {
TRASH_REGION_ID,
alarmMinutesToSeconds,
buildCalibrationPayload,
buildCaseDisplayModel,
buildManualHandlePayload,
buildPolygonMap,
buildRuntimeDisplayModel,
classifyEvent,
@@ -628,3 +630,58 @@ test("buildRuntimeDisplayModel uses config threshold when event omits threshold"
source: "real",
}]);
});
test("buildCaseDisplayModel normalizes case rows and summary metrics", () => {
const model = buildCaseDisplayModel({
summary: {
open_case_count: 1,
handled_case_count: 2,
time_alarm_case_count: 1,
pending_disposal_case_count: 1,
warning_escalated_case_count: 1,
},
cases: [
{
case_id: "case_batch_000001",
case_type: "warning_escalated",
case_status: "open",
zone_label: "区域 1",
batch_id: "batch_000001",
updated_at: "2026-06-09T09:10:00+08:00",
handled_source: "",
},
{
case_id: "case_batch_000002",
case_type: "time_alarm",
case_status: "handled",
zone_label: "区域 2",
batch_id: "batch_000002",
updated_at: "2026-06-09T09:12:00+08:00",
handled_source: "manual",
},
],
});
assert.deepEqual(model.metrics, {
openCaseCount: 1,
handledCaseCount: 2,
timeAlarmCaseCount: 1,
pendingDisposalCaseCount: 1,
warningEscalatedCaseCount: 1,
});
assert.equal(model.rows[0].caseId, "case_batch_000002");
assert.equal(model.rows[0].statusLabel, "已处理");
assert.equal(model.rows[0].tone, "good");
assert.equal(model.rows[1].typeLabel, "升级警告");
assert.equal(model.rows[1].statusLabel, "待处理");
});
test("buildManualHandlePayload trims handled_by and keeps optional note", () => {
assert.deepEqual(buildManualHandlePayload(" alice ", " checked "), {
handled_by: "alice",
note: "checked",
});
assert.deepEqual(buildManualHandlePayload("bob", ""), {
handled_by: "bob",
});
});