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:
2026-08-13 16:50:23 +08:00
parent 4dee7f664d
commit 25fa0c5825
25 changed files with 1058 additions and 669 deletions

8
frontend/modules/api.js Normal file
View File

@@ -0,0 +1,8 @@
export async function apiFetch(url, options = {}) {
const response = await fetch(url, options);
const result = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(result.detail || `API ${response.status}`);
}
return result;
}

View File

@@ -0,0 +1,30 @@
import { qs } from "./dom.js";
export function renderTrend(events = []) {
const days = Array.from({ length: 7 }, (_, index) => {
const date = new Date();
date.setDate(date.getDate() - (6 - index));
return {
key: date.toISOString().slice(0, 10),
label: `${date.getMonth() + 1}/${date.getDate()}`,
fire: 0,
smoke: 0,
};
});
events.forEach((event) => {
const classes = event.classes || [];
const day = days.find((item) => item.key === String(event.created_at).slice(0, 10));
if (!day) return;
if (classes.includes("fire")) day.fire += 1;
if (classes.includes("smoke")) day.smoke += 1;
});
const width = 720;
const height = 225;
const padding = { left: 34, right: 12, top: 14, bottom: 28 };
const maxValue = Math.max(3, ...days.flatMap((day) => [day.fire, day.smoke]));
const x = (index) => padding.left + index * ((width - padding.left - padding.right) / 6);
const y = (value) => height - padding.bottom - value * ((height - padding.top - padding.bottom) / maxValue);
const path = (key) => days.map((day, index) => `${index ? "L" : "M"}${x(index)},${y(day[key])}`).join(" ");
const gridValues = [0, Math.ceil(maxValue / 2), maxValue];
qs("#trendChart").innerHTML = `<svg viewBox="0 0 ${width} ${height}" preserveAspectRatio="none"><title>最近七天火焰与烟雾告警趋势</title>${gridValues.map((value) => `<line class="chart-grid" x1="${padding.left}" x2="${width - padding.right}" y1="${y(value)}" y2="${y(value)}"></line><text class="chart-axis-label" x="4" y="${y(value) + 3}">${value}</text>`).join("")}<path class="chart-fire" d="${path("fire")}"></path><path class="chart-smoke" d="${path("smoke")}"></path>${days.map((day, index) => `<circle class="chart-point-fire" cx="${x(index)}" cy="${y(day.fire)}" r="3"></circle><circle class="chart-point-smoke" cx="${x(index)}" cy="${y(day.smoke)}" r="3"></circle><text class="chart-axis-label" text-anchor="middle" x="${x(index)}" y="${height - 8}">${day.label}</text>`).join("")}</svg>`;
}

57
frontend/modules/dom.js Normal file
View File

@@ -0,0 +1,57 @@
const elementCache = new Map();
export function qs(selector) {
if (!elementCache.has(selector)) {
elementCache.set(selector, document.querySelector(selector));
}
return elementCache.get(selector);
}
export function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
export const VIEW_LABELS = {
overview: "系统总览",
inspection: "视频巡检",
events: "告警中心",
robots: "消息机器人",
risks: "风险台账",
settings: "系统设置",
};
export const STATUS_LABELS = {
pending: "待处置",
acknowledged: "已确认",
resolved: "已解决",
};
const CLASS_LABELS = { fire: "火焰", smoke: "烟雾" };
export function classLabel(name) {
return CLASS_LABELS[name] || escapeHtml(String(name));
}
export function formatDate(value) {
if (!value) return "--";
return new Date(value).toLocaleString("zh-CN", { hour12: false });
}
export function formatTime(seconds) {
if (!Number.isFinite(seconds)) return "00:00";
const minutes = Math.floor(seconds / 60);
const remaining = Math.floor(seconds % 60);
return `${String(minutes).padStart(2, "0")}:${String(remaining).padStart(2, "0")}`;
}
export function enabledChannelNames(channels = {}) {
const names = [];
if (channels.wechat) names.push("企业微信");
if (channels.feishu) names.push("飞书");
return names;
}

View File

@@ -0,0 +1,6 @@
// Shared view state. Keeping it here (instead of inside one view module)
// lets views depend on each other without import cycles.
export const store = {
dashboardData: null,
eventData: [],
};

36
frontend/modules/ui.js Normal file
View File

@@ -0,0 +1,36 @@
import { qs } from "./dom.js";
export function showToast(message, type = "info") {
const toast = document.createElement("div");
toast.className = `toast ${type}`;
toast.textContent = message;
qs("#toastRegion").appendChild(toast);
setTimeout(() => toast.remove(), 3600);
}
export function setServiceStatus(online) {
const statusChip = qs("#globalStatus");
statusChip.textContent = online ? "服务运行正常" : "服务连接异常";
statusChip.className = `status-chip ${online ? "status-online" : "status-offline"}`;
qs("#sidebarStatus").textContent = online ? "服务在线" : "服务离线";
qs("#sidebarStatusDot").className = `status-dot ${online ? "is-online" : "is-offline"}`;
const readyApi = qs("#readyApi");
readyApi.textContent = online ? "正常" : "异常";
readyApi.className = online ? "ready" : "not-ready";
}
export function setChannelReady(element, enabled) {
element.textContent = enabled ? "已启用" : "未配置";
element.className = enabled ? "ready" : "optional";
}
export function updateClock() {
qs("#currentClock").textContent = new Date().toLocaleString("zh-CN", {
hour12: false,
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}