feat: SQLite persistence, non-blocking inference, modular frontend
Backend:
- Persist alert events and robot delivery counters in SQLite (data/app.db,
YOLO_DB_PATH override); EventStore keeps its interface, DeliveryStatsStore
uses read/write-through UPSERT; in-memory fallback remains for standalone
AlertManager use
- Serve /api/detect from a sync endpoint and serialize YOLO inference with a
lock, so concurrent requests no longer block the event loop
- Unify single-image and session detection: requests without session_id
share the fixed single-image session and run full frame confirmation
- Store UTC Z-suffixed timestamps for sortable string comparison; close
executor and database on shutdown via FastAPI lifespan
Frontend:
- Split app.js into ES modules (main.js + modules/{dom,api,ui,charts,store}
+ views/{dashboard,inspection,events,robots,robotCards}), no build step
- Escape all server data interpolated into innerHTML; guard missing
event.classes; replace lazy element ID list with memoized qs()
- Remove hardcoded fake stats (trend badge, device donut segment)
Docs:
- Rewrite README: accurate weight policy (only trained best.pt committed,
no Git LFS), SQLite persistence, single-worker note, data asset inventory
- Align models/pretrained/README.md with actual files; add YOLO_DB_PATH to
.env.example; add persistence unit tests; ruff clean
This commit is contained in:
104
frontend/views/dashboard.js
Normal file
104
frontend/views/dashboard.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import { qs, escapeHtml, formatDate, STATUS_LABELS, classLabel, enabledChannelNames } from "../modules/dom.js";
|
||||
import { apiFetch } from "../modules/api.js";
|
||||
import { showToast, setServiceStatus, setChannelReady } from "../modules/ui.js";
|
||||
import { renderTrend } from "../modules/charts.js";
|
||||
import { renderRobots } from "./robotCards.js";
|
||||
import { store } from "../modules/store.js";
|
||||
|
||||
export async function loadDashboard() {
|
||||
try {
|
||||
const data = await apiFetch("/api/dashboard");
|
||||
setServiceStatus(true);
|
||||
updateDashboard(data);
|
||||
} catch (error) {
|
||||
setServiceStatus(false);
|
||||
showToast(`无法读取管理数据:${error.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function updateDashboard(data) {
|
||||
store.dashboardData = data;
|
||||
const { summary, system, detection } = data;
|
||||
const channels = system.alert_channels || {};
|
||||
const channelNames = enabledChannelNames(channels);
|
||||
qs("#todayEventCount").textContent = String(summary.today);
|
||||
qs("#pendingEventCount").textContent = String(summary.pending);
|
||||
qs("#pendingNavCount").textContent = String(summary.pending);
|
||||
qs("#notificationDot").hidden = summary.pending === 0;
|
||||
qs("#modelStatusValue").textContent = system.weights_available ? "运行正常" : "权重缺失";
|
||||
qs("#modelStatusDetail").textContent = system.weights_available ? "模型文件已就绪" : "请检查 YOLO_WEIGHTS";
|
||||
qs("#modelStatusValue").className = "text-value";
|
||||
const robotPayload = data.robots || { robots: [], summary: {} };
|
||||
const robotSummary = robotPayload.summary || {};
|
||||
qs("#onlineRobotCount").textContent = String(robotSummary.configured || 0);
|
||||
qs("#robotSummary").textContent = `${robotSummary.configured || 0} / ${robotSummary.total || 2} 已配置 · 成功投递 ${robotSummary.delivered || 0} 次`;
|
||||
qs("#overviewMessage").textContent = summary.pending
|
||||
? `当前有 ${summary.pending} 条告警事件等待处置,请尽快进入告警中心确认。`
|
||||
: "当前无待处置事件,模型与视频巡检服务保持监测状态。";
|
||||
qs("#readyWeights").textContent = system.weights_available ? "已就绪" : "缺失";
|
||||
qs("#readyWeights").className = system.weights_available ? "ready" : "not-ready";
|
||||
setChannelReady(qs("#readyWechat"), channels.wechat);
|
||||
setChannelReady(qs("#readyFeishu"), channels.feishu);
|
||||
qs("#riskPendingValue").textContent = String(summary.pending);
|
||||
qs("#riskChannelValue").textContent = `${channelNames.length} / 2`;
|
||||
qs("#riskConfidenceValue").textContent = `${Math.round(detection.confidence * 100)}%`;
|
||||
qs("#confirmFramesValue").textContent = `${detection.confirm_frames} 帧`;
|
||||
qs("#cooldownValue").textContent = `${detection.cooldown_seconds} 秒`;
|
||||
qs("#settingWeights").textContent = system.weights;
|
||||
qs("#settingImageSize").textContent = `${detection.image_size} px`;
|
||||
qs("#settingConfidence").textContent = detection.confidence.toFixed(2);
|
||||
qs("#settingIou").textContent = detection.iou.toFixed(2);
|
||||
qs("#settingConfirmFrames").textContent = `${detection.confirm_frames} 帧`;
|
||||
qs("#settingCooldown").textContent = `${detection.cooldown_seconds} 秒`;
|
||||
renderRecentEvents(data.recent_events || []);
|
||||
const visualEvents = store.eventData.length ? store.eventData : data.recent_events || [];
|
||||
renderTrend(visualEvents);
|
||||
updateCommandVisuals(visualEvents, summary, channels, robotSummary);
|
||||
renderRobots(robotPayload, detection);
|
||||
}
|
||||
|
||||
function updateCommandVisuals(events, summary, channels, robotSummary = {}) {
|
||||
const totalRobots = robotSummary.total || 2;
|
||||
const configuredRobots = robotSummary.configured || 0;
|
||||
const total = summary.total || 0;
|
||||
const closureRate = total ? Math.round(((summary.resolved || 0) / total) * 100) : 100;
|
||||
const notificationRate = Math.round((configuredRobots / totalRobots) * 100);
|
||||
const safetyScore = Math.max(42, Math.min(98, Math.round(notificationRate * 0.35 + closureRate * 0.35 + Math.max(0, 100 - (summary.pending || 0) * 8) * 0.3)));
|
||||
qs("#deviceOnlineRate").textContent = `${notificationRate}%`;
|
||||
qs("#eventClosureRate").textContent = `${closureRate}%`;
|
||||
qs("#notificationCoverage").textContent = `${notificationRate}%`;
|
||||
qs("#safetyScore").textContent = String(safetyScore);
|
||||
qs("#safetyGauge").style.setProperty("--score", safetyScore);
|
||||
qs("#overviewWechatState").textContent = channels.wechat ? "已配置" : "未配置";
|
||||
qs("#overviewFeishuState").textContent = channels.feishu ? "已配置" : "未配置";
|
||||
qs("#overviewPolicyText").textContent = `${store.dashboardData?.detection?.confirm_frames || 3} 帧确认`;
|
||||
qs("#overviewDeliveryCount").textContent = `累计成功 ${robotSummary.delivered || 0} 次`;
|
||||
|
||||
let fireEvents = 0;
|
||||
let smokeEvents = 0;
|
||||
events.forEach((event) => {
|
||||
const classes = event.classes || [];
|
||||
if (classes.includes("fire")) fireEvents += 1;
|
||||
if (classes.includes("smoke")) smokeEvents += 1;
|
||||
});
|
||||
const detectedTotal = fireEvents + smokeEvents;
|
||||
const fireArc = detectedTotal ? Math.round((fireEvents / detectedTotal) * 100) : 0;
|
||||
const smokeArc = detectedTotal ? 100 - fireArc : 0;
|
||||
qs("#riskTotal").textContent = String(detectedTotal);
|
||||
qs("#fireRiskRatio").textContent = `${detectedTotal ? Math.round((fireEvents / detectedTotal) * 100) : 0}%`;
|
||||
qs("#smokeRiskRatio").textContent = `${detectedTotal ? Math.round((smokeEvents / detectedTotal) * 100) : 0}%`;
|
||||
qs("#riskDonut").style.background = `conic-gradient(var(--red) 0 ${fireArc}%, var(--cyan) ${fireArc}% ${fireArc + smokeArc}%, var(--line) ${fireArc + smokeArc}% 100%)`;
|
||||
}
|
||||
|
||||
function renderRecentEvents(events) {
|
||||
if (!events.length) {
|
||||
qs("#recentEventList").innerHTML = "<p>暂无告警事件。开始视频巡检后,满足连续帧条件的告警会显示在这里。</p>";
|
||||
return;
|
||||
}
|
||||
qs("#recentEventList").innerHTML = events.map((event) => {
|
||||
const classes = event.classes || [];
|
||||
const primary = classes.includes("fire") ? "fire" : "smoke";
|
||||
const label = classes.map(classLabel).join("、");
|
||||
return `<div class="event-list-item"><span class="event-type-icon ${primary}">${primary === "fire" ? "火" : "烟"}</span><div class="event-description"><strong>${label}检测告警</strong><span>${formatDate(event.created_at)} · ${escapeHtml(event.id)}</span></div><strong class="event-confidence">${Math.round(event.max_confidence * 100)}%</strong><span class="event-status status-${escapeHtml(event.status)}">${STATUS_LABELS[event.status] || escapeHtml(event.status)}</span></div>`;
|
||||
}).join("");
|
||||
}
|
||||
Reference in New Issue
Block a user