feat: add webhook case management
This commit is contained in:
109
web/src/main.js
109
web/src/main.js
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user