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:
8
frontend/modules/api.js
Normal file
8
frontend/modules/api.js
Normal 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;
|
||||
}
|
||||
30
frontend/modules/charts.js
Normal file
30
frontend/modules/charts.js
Normal 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
57
frontend/modules/dom.js
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
6
frontend/modules/store.js
Normal file
6
frontend/modules/store.js
Normal 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
36
frontend/modules/ui.js
Normal 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",
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user