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

View File

@@ -0,0 +1,196 @@
import { qs, classLabel, formatTime, enabledChannelNames } from "../modules/dom.js";
import { apiFetch } from "../modules/api.js";
import { showToast, setServiceStatus } from "../modules/ui.js";
import { loadDashboard } from "./dashboard.js";
const API_ENDPOINT = "/api/detect";
const frameCanvas = document.createElement("canvas");
let videoUrl = null;
let sessionId = null;
let detectionActive = false;
let requestInFlight = false;
let lastDetectionAt = 0;
function setInspectionStatus(text, className) {
const status = qs("#inspectionStatus");
status.textContent = text;
status.className = className;
}
function clearDetectionResults() {
qs("#smokeCount").textContent = "0";
qs("#fireCount").textContent = "0";
qs("#maxConfidence").textContent = "--";
qs("#riskStatus").textContent = "待机";
qs("#detectionList").innerHTML = '<p class="empty-copy">暂无检测结果</p>';
const context = qs("#overlayCanvas").getContext("2d");
context.clearRect(0, 0, qs("#overlayCanvas").width, qs("#overlayCanvas").height);
}
function drawDetections(detections = []) {
const video = qs("#videoPreview");
if (!video.videoWidth || !video.videoHeight) return;
const canvas = qs("#overlayCanvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const context = canvas.getContext("2d");
context.clearRect(0, 0, canvas.width, canvas.height);
detections.forEach((detection) => {
const [x1, y1, x2, y2] = detection.box || [];
const color = detection.class === "fire" ? "#ff665e" : "#41d6c3";
context.strokeStyle = color;
context.lineWidth = Math.max(2, canvas.width / 320);
context.strokeRect(x1, y1, x2 - x1, y2 - y1);
context.fillStyle = color;
context.font = `${Math.max(13, canvas.width / 60)}px sans-serif`;
context.fillText(`${detection.class} ${Math.round(detection.confidence * 100)}%`, x1 + 4, Math.max(18, y1 - 6));
});
}
function updateDetectionResults(result) {
const detections = result.detections || [];
const smoke = detections.filter((item) => item.class === "smoke").length;
const fire = detections.filter((item) => item.class === "fire").length;
const max = detections.reduce((highest, item) => Math.max(highest, item.confidence || 0), 0);
qs("#smokeCount").textContent = String(smoke);
qs("#fireCount").textContent = String(fire);
qs("#maxConfidence").textContent = max ? `${Math.round(max * 100)}%` : "--";
qs("#riskStatus").textContent = fire ? "高风险" : smoke ? "需关注" : "正常";
qs("#detectionList").innerHTML = detections.length
? detections.map((item) => `<div class="detection-row"><span>${classLabel(item.class)}</span><strong>${Math.round(item.confidence * 100)}%</strong></div>`).join("")
: '<p class="empty-copy">未发现烟雾或火焰目标</p>';
const alert = result.alert || {};
const channelNames = enabledChannelNames(alert.notification_channels);
if (alert.triggered) {
const labels = alert.classes.map(classLabel).join("、");
const alertStatus = qs("#alertStatus");
alertStatus.textContent = channelNames.length ? `已触发 ${channelNames.join(" + ")} 告警:${labels}` : `已生成告警事件:${labels}(机器人未配置)`;
alertStatus.className = "alert-status alert-triggered";
showToast(`检测到${labels},已生成告警事件`, "error");
loadDashboard();
} else {
const fireFrames = alert.consecutive?.fire || 0;
const smokeFrames = alert.consecutive?.smoke || 0;
const alertStatus = qs("#alertStatus");
alertStatus.textContent = channelNames.length ? `${channelNames.join(" + ")}已启用 · 连续帧 火焰 ${fireFrames} / 烟雾 ${smokeFrames}` : `机器人未配置 · 连续帧 火焰 ${fireFrames} / 烟雾 ${smokeFrames}`;
alertStatus.className = "alert-status";
}
drawDetections(detections);
qs("#lastUpdated").textContent = `推理 ${result.inference_ms ?? "--"} ms`;
}
function captureFrame() {
const video = qs("#videoPreview");
frameCanvas.width = video.videoWidth;
frameCanvas.height = video.videoHeight;
frameCanvas.getContext("2d").drawImage(video, 0, 0);
return new Promise((resolve) => frameCanvas.toBlob(resolve, "image/jpeg", 0.88));
}
async function detectCurrentFrame(timestamp) {
const video = qs("#videoPreview");
if (!detectionActive || requestInFlight || !sessionId || video.paused || video.ended) return;
const interval = Number(qs("#intervalSelect").value);
if (timestamp - lastDetectionAt < interval) return;
lastDetectionAt = timestamp;
requestInFlight = true;
qs("#loadingState").hidden = false;
try {
const frame = await captureFrame();
if (!frame) throw new Error("无法截取视频帧");
const form = new FormData();
form.append("file", frame, "video-frame.jpg");
const result = await apiFetch(`${API_ENDPOINT}?session_id=${encodeURIComponent(sessionId)}`, { method: "POST", body: form });
updateDetectionResults(result);
setInspectionStatus("检测运行中", "status-chip status-online");
setServiceStatus(true);
} catch (error) {
setInspectionStatus("检测异常", "status-chip status-offline");
showToast(`视频检测失败:${error.message}`, "error");
} finally {
requestInFlight = false;
qs("#loadingState").hidden = true;
}
}
function scheduleDetection(timestamp) {
detectCurrentFrame(timestamp);
if (detectionActive) requestAnimationFrame(scheduleDetection);
}
async function startDetection() {
const video = qs("#videoPreview");
if (!video.src) return;
detectionActive = true;
lastDetectionAt = -Infinity;
qs("#runDetectionButton").textContent = "停止检测";
qs("#intervalSelect").disabled = true;
try {
await video.play();
requestAnimationFrame(scheduleDetection);
} catch {
stopDetection();
showToast("浏览器未允许视频播放,请手动点击播放后重试。", "error");
}
}
function stopDetection() {
detectionActive = false;
qs("#runDetectionButton").textContent = "开始连续检测";
qs("#intervalSelect").disabled = false;
const video = qs("#videoPreview");
setInspectionStatus(video.src ? "检测已暂停" : "等待视频", "status-chip status-idle");
}
export async function resetSession() {
const previous = sessionId;
sessionId = crypto.randomUUID();
if (previous) fetch(`/api/sessions/${encodeURIComponent(previous)}`, { method: "DELETE" }).catch(() => {});
}
export function initInspection() {
const video = qs("#videoPreview");
qs("#runDetectionButton").addEventListener("click", () => detectionActive ? stopDetection() : startDetection());
video.addEventListener("ended", stopDetection);
video.addEventListener("timeupdate", () => {
qs("#videoClock").textContent = `${formatTime(video.currentTime)} / ${formatTime(video.duration)}`;
});
video.addEventListener("seeked", async () => {
drawDetections([]);
await resetSession();
});
video.addEventListener("resize", () => drawDetections([]));
qs("#videoInput").addEventListener("change", async () => {
const [file] = qs("#videoInput").files;
if (!file) return;
stopDetection();
video.pause();
if (videoUrl) URL.revokeObjectURL(videoUrl);
videoUrl = URL.createObjectURL(file);
video.src = videoUrl;
video.hidden = false;
qs("#emptyState").hidden = true;
qs("#sourceLabel").textContent = file.name;
qs("#videoMeta").textContent = `${(file.size / 1024 / 1024).toFixed(1)} MB · 本地视频`;
qs("#runDetectionButton").disabled = false;
setInspectionStatus("视频已就绪", "status-chip status-online");
await resetSession();
clearDetectionResults();
});
qs("#clearButton").addEventListener("click", async () => {
stopDetection();
video.pause();
video.removeAttribute("src");
video.load();
video.hidden = true;
if (videoUrl) URL.revokeObjectURL(videoUrl);
videoUrl = null;
qs("#videoInput").value = "";
qs("#sourceLabel").textContent = "未选择视频";
qs("#videoMeta").textContent = "支持 MP4、WebM 等浏览器可播放格式";
qs("#emptyState").hidden = false;
qs("#runDetectionButton").disabled = true;
await resetSession();
clearDetectionResults();
});
}