Files
yolo/frontend/app.js

604 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const API_ENDPOINT = "/api/detect";
const VIEW_LABELS = {
overview: "系统总览",
inspection: "视频巡检",
events: "告警中心",
robots: "机器人管理",
risks: "风险台账",
channels: "通知通道",
settings: "系统设置",
};
const ROBOTS = [
{ id: "FR-001", name: "巡检一号", type: "轮式热成像", zone: "A 仓储区", status: "active", battery: 86, signal: 92, temperature: 41, progress: 68, mission: "仓储区例行巡检", distance: 3.8, sensors: ["热成像", "烟雾", "可见光"] },
{ id: "FR-002", name: "巡检二号", type: "履带防爆型", zone: "B 生产区", status: "active", battery: 72, signal: 87, temperature: 38, progress: 43, mission: "生产线火点复核", distance: 4.6, sensors: ["热成像", "气体", "可见光"] },
{ id: "FR-003", name: "哨兵三号", type: "固定巡检站", zone: "C 能源站", status: "charging", battery: 34, signal: 96, temperature: 36, progress: 100, mission: "自动回充", distance: 1.9, sensors: ["热成像", "烟雾", "温湿度"] },
{ id: "FR-004", name: "云台四号", type: "升降云台型", zone: "D 装卸区", status: "offline", battery: 12, signal: 0, temperature: 29, progress: 0, mission: "等待维护", distance: 0.7, sensors: ["可见光", "烟雾"] },
];
const MISSIONS = [
{ time: "08:30", title: "仓储区例行巡检", robot: "巡检一号", status: "active", progress: 68 },
{ time: "09:15", title: "生产线火点复核", robot: "巡检二号", status: "active", progress: 43 },
{ time: "11:00", title: "能源站热源扫描", robot: "哨兵三号", status: "queued", progress: 0 },
{ time: "14:30", title: "装卸区消防通道检查", robot: "待分配", status: "queued", progress: 0 },
];
const ROBOT_STATUS = {
active: { label: "任务中", tone: "active" },
charging: { label: "充电中", tone: "charging" },
standby: { label: "待命", tone: "standby" },
offline: { label: "离线", tone: "offline" },
};
const STATUS_LABELS = {
pending: "待处置",
acknowledged: "已确认",
resolved: "已解决",
};
const elements = Object.fromEntries(
[
"ackEventsCount", "alertStatus", "allEventsCount", "clearButton", "confirmFramesValue", "cooldownValue",
"currentClock", "detectionList", "emptyState", "eventTableBody",
"eventTableMeta", "feishuChannelBadge", "fireCount", "globalStatus",
"inspectionStatus", "intervalSelect", "lastUpdated", "loadingState",
"maxConfidence", "menuButton", "modelStatusDetail", "modelStatusValue",
"notificationDot", "overlayCanvas", "overviewMessage", "pendingEventCount",
"pendingEventsCount", "pendingNavCount", "readyApi", "readyFeishu",
"readyWechat", "readyWeights", "recentEventList", "refreshEventsButton",
"resolvedEventsCount", "riskChannelValue", "riskConfidenceValue",
"riskPendingValue", "riskStatus", "runDetectionButton", "settingConfidence",
"settingConfirmFrames", "settingCooldown", "settingImageSize", "settingIou",
"settingWeights", "sidebar", "sidebarStatus", "sidebarStatusDot", "smokeCount",
"sourceLabel", "todayEventCount", "toastRegion", "trendChart", "videoClock",
"videoInput", "videoMeta", "videoPreview", "viewBreadcrumb", "viewTitle",
"wechatChannelBadge", "deviceOnlineRate", "dispatchRobotButton",
"eventClosureRate", "fireRiskRatio", "missionQueue", "missionQueueCount",
"notificationCoverage", "onlineRobotCount", "riskDonut", "riskTotal",
"robotAverageBattery", "robotCardGrid", "robotDistance", "robotMissionCount",
"robotNavCount", "robotSearchInput", "robotSummary", "robotTotalCount",
"safetyGauge", "safetyScore", "smokeRiskRatio",
].map((id) => [id, document.querySelector(`#${id}`)])
);
const frameCanvas = document.createElement("canvas");
let videoUrl = null;
let sessionId = null;
let detectionActive = false;
let requestInFlight = false;
let lastDetectionAt = 0;
let dashboardData = null;
let eventData = [];
let eventFilter = "";
let robotFilter = "all";
function showToast(message, type = "info") {
const toast = document.createElement("div");
toast.className = `toast ${type}`;
toast.textContent = message;
elements.toastRegion.appendChild(toast);
setTimeout(() => toast.remove(), 3600);
}
function switchView(viewName) {
if (!VIEW_LABELS[viewName]) return;
document.querySelectorAll("[data-view-panel]").forEach((panel) => {
panel.classList.toggle("is-active", panel.dataset.viewPanel === viewName);
});
document.querySelectorAll("[data-view]").forEach((button) => {
button.classList.toggle("is-active", button.dataset.view === viewName);
});
elements.viewTitle.textContent = VIEW_LABELS[viewName];
elements.viewBreadcrumb.textContent = VIEW_LABELS[viewName];
elements.sidebar.classList.remove("is-open");
if (viewName === "events") loadEvents();
if (viewName === "robots") renderRobots();
if (["overview", "risks", "channels", "settings"].includes(viewName)) loadDashboard();
}
function setServiceStatus(online) {
elements.globalStatus.textContent = online ? "服务运行正常" : "服务连接异常";
elements.globalStatus.className = `status-chip ${online ? "status-online" : "status-offline"}`;
elements.sidebarStatus.textContent = online ? "服务在线" : "服务离线";
elements.sidebarStatusDot.className = `status-dot ${online ? "is-online" : "is-offline"}`;
elements.readyApi.textContent = online ? "正常" : "异常";
elements.readyApi.className = online ? "ready" : "not-ready";
}
function enabledChannelNames(channels = {}) {
const names = [];
if (channels.wechat) names.push("企业微信");
if (channels.feishu) names.push("飞书");
return names;
}
function updateClock() {
elements.currentClock.textContent = new Date().toLocaleString("zh-CN", {
hour12: false,
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function formatDate(value) {
if (!value) return "--";
return new Date(value).toLocaleString("zh-CN", { hour12: false });
}
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")}`;
}
function updateDashboard(data) {
dashboardData = data;
const { summary, system, detection } = data;
const channels = system.alert_channels || {};
const channelNames = enabledChannelNames(channels);
elements.todayEventCount.textContent = String(summary.today);
elements.pendingEventCount.textContent = String(summary.pending);
elements.pendingNavCount.textContent = String(summary.pending);
elements.notificationDot.hidden = summary.pending === 0;
elements.modelStatusValue.textContent = system.weights_available ? "运行正常" : "权重缺失";
elements.modelStatusDetail.textContent = system.weights_available ? "模型文件已就绪" : "请检查 YOLO_WEIGHTS";
elements.modelStatusValue.className = "text-value";
const onlineRobots = ROBOTS.filter((robot) => robot.status !== "offline").length;
elements.onlineRobotCount.textContent = String(onlineRobots);
elements.robotSummary.textContent = `${onlineRobots} / ${ROBOTS.length} 在线 · ${ROBOTS.filter((robot) => robot.status === "active").length} 台任务中`;
elements.overviewMessage.textContent = summary.pending
? `当前有 ${summary.pending} 条告警事件等待处置,请尽快进入告警中心确认。`
: "当前无待处置事件,模型与视频巡检服务保持监测状态。";
elements.readyWeights.textContent = system.weights_available ? "已就绪" : "缺失";
elements.readyWeights.className = system.weights_available ? "ready" : "not-ready";
setChannelReady(elements.readyWechat, channels.wechat);
setChannelReady(elements.readyFeishu, channels.feishu);
setChannelBadge(elements.wechatChannelBadge, channels.wechat);
setChannelBadge(elements.feishuChannelBadge, channels.feishu);
elements.riskPendingValue.textContent = String(summary.pending);
elements.riskChannelValue.textContent = `${channelNames.length} / 2`;
elements.riskConfidenceValue.textContent = `${Math.round(detection.confidence * 100)}%`;
elements.confirmFramesValue.textContent = `${detection.confirm_frames}`;
elements.cooldownValue.textContent = `${detection.cooldown_seconds}`;
elements.settingWeights.textContent = system.weights;
elements.settingImageSize.textContent = `${detection.image_size} px`;
elements.settingConfidence.textContent = detection.confidence.toFixed(2);
elements.settingIou.textContent = detection.iou.toFixed(2);
elements.settingConfirmFrames.textContent = `${detection.confirm_frames}`;
elements.settingCooldown.textContent = `${detection.cooldown_seconds}`;
renderRecentEvents(data.recent_events || []);
const visualEvents = eventData.length ? eventData : data.recent_events || [];
renderTrend(visualEvents);
updateCommandVisuals(visualEvents, summary, channels);
renderRobots();
}
function updateCommandVisuals(events, summary, channels) {
const onlineRobots = ROBOTS.filter((robot) => robot.status !== "offline").length;
const total = summary.total || 0;
const closureRate = total ? Math.round(((summary.resolved || 0) / total) * 100) : 100;
const notificationRate = Math.round((enabledChannelNames(channels).length / 2) * 100);
const deviceRate = Math.round((onlineRobots / ROBOTS.length) * 100);
const safetyScore = Math.max(42, Math.min(98, Math.round(deviceRate * 0.35 + closureRate * 0.35 + Math.max(50, notificationRate) * 0.15 + Math.max(0, 100 - (summary.pending || 0) * 8) * 0.15)));
elements.deviceOnlineRate.textContent = `${deviceRate}%`;
elements.eventClosureRate.textContent = `${closureRate}%`;
elements.notificationCoverage.textContent = `${notificationRate}%`;
elements.safetyScore.textContent = String(safetyScore);
elements.safetyGauge.style.setProperty("--score", safetyScore);
let fireEvents = 0;
let smokeEvents = 0;
events.forEach((event) => {
if ((event.classes || []).includes("fire")) fireEvents += 1;
if ((event.classes || []).includes("smoke")) smokeEvents += 1;
});
const detectedTotal = fireEvents + smokeEvents;
const chartTotal = Math.max(detectedTotal, 1);
const fireArc = Math.round((fireEvents / chartTotal) * 88);
const smokeArc = detectedTotal ? 88 - fireArc : 0;
elements.riskTotal.textContent = String(detectedTotal);
elements.fireRiskRatio.textContent = `${detectedTotal ? Math.round((fireEvents / detectedTotal) * 100) : 0}%`;
elements.smokeRiskRatio.textContent = `${detectedTotal ? Math.round((smokeEvents / detectedTotal) * 100) : 0}%`;
elements.riskDonut.style.background = `conic-gradient(var(--red) 0 ${fireArc}%, var(--cyan) ${fireArc}% ${fireArc + smokeArc}%, var(--orange) ${fireArc + smokeArc}% 100%)`;
}
function renderRobots() {
const query = (elements.robotSearchInput?.value || "").trim().toLowerCase();
const filtered = ROBOTS.filter((robot) => {
const matchesStatus = robotFilter === "all" || robot.status === robotFilter;
const haystack = `${robot.id} ${robot.name} ${robot.zone} ${robot.type}`.toLowerCase();
return matchesStatus && haystack.includes(query);
});
const activeCount = ROBOTS.filter((robot) => robot.status === "active").length;
const onlineCount = ROBOTS.filter((robot) => robot.status !== "offline").length;
const averageBattery = Math.round(ROBOTS.reduce((sum, robot) => sum + robot.battery, 0) / ROBOTS.length);
elements.robotTotalCount.textContent = String(ROBOTS.length);
elements.robotMissionCount.textContent = String(activeCount);
elements.robotAverageBattery.textContent = `${averageBattery}%`;
elements.robotDistance.textContent = `${ROBOTS.reduce((sum, robot) => sum + robot.distance, 0).toFixed(1)} km`;
elements.robotNavCount.textContent = String(onlineCount);
elements.robotCardGrid.innerHTML = filtered.length ? filtered.map(robotCardTemplate).join("") : '<div class="panel robot-empty">没有匹配的机器人资产</div>';
renderMissionQueue();
}
function robotCardTemplate(robot) {
const status = ROBOT_STATUS[robot.status];
const batteryTone = robot.battery < 25 ? "danger" : robot.battery < 45 ? "warning" : "healthy";
const actionLabel = robot.status === "active" ? "暂停任务" : robot.status === "offline" ? "发起诊断" : "下发任务";
return `<article class="panel robot-card" data-robot-id="${robot.id}">
<div class="robot-card-head"><div class="robot-avatar"><span></span><i></i></div><div><span class="robot-id">${robot.id}</span><h3>${robot.name}</h3><p>${robot.type}</p></div><span class="robot-status ${status.tone}"><i></i>${status.label}</span></div>
<div class="robot-location"><span>当前位置</span><strong>${robot.zone}</strong></div>
<div class="robot-mission"><div><span>${robot.mission}</span><strong>${robot.progress}%</strong></div><div class="progress-track"><i style="width:${robot.progress}%"></i></div></div>
<div class="robot-telemetry"><div><span>电量</span><strong class="${batteryTone}">${robot.battery}%</strong></div><div><span>信号</span><strong>${robot.signal}%</strong></div><div><span>机身温度</span><strong>${robot.temperature}℃</strong></div></div>
<div class="sensor-tags">${robot.sensors.map((sensor) => `<span>${sensor}</span>`).join("")}</div>
<div class="robot-actions"><button class="secondary-button" type="button" data-robot-action="detail">查看详情</button><button class="primary-button" type="button" data-robot-action="toggle">${actionLabel}</button>${robot.status !== "offline" ? '<button class="icon-button" type="button" data-robot-action="return" title="返回充电桩">↩</button>' : ""}</div>
</article>`;
}
function renderMissionQueue() {
elements.missionQueueCount.textContent = String(MISSIONS.length);
elements.missionQueue.innerHTML = MISSIONS.map((mission, index) => `<div class="mission-item"><div class="mission-time"><strong>${mission.time}</strong><span>${index + 1}</span></div><div class="mission-copy"><strong>${mission.title}</strong><span>${mission.robot}</span><div class="progress-track"><i style="width:${mission.progress}%"></i></div></div><span class="mission-status ${mission.status}">${mission.status === "active" ? "执行中" : "待执行"}</span></div>`).join("");
}
function handleRobotAction(button) {
const card = button.closest("[data-robot-id]");
const robot = ROBOTS.find((item) => item.id === card?.dataset.robotId);
if (!robot) return;
const action = button.dataset.robotAction;
if (action === "detail") {
showToast(`${robot.name}${robot.zone},电量 ${robot.battery}%,信号 ${robot.signal}%`);
return;
}
if (action === "return") {
robot.status = "charging";
robot.mission = "返回充电桩";
robot.progress = 100;
showToast(`${robot.name} 已收到返航指令`, "success");
} else if (robot.status === "offline") {
showToast(`${robot.name} 离线,已创建远程诊断工单`, "error");
} else {
robot.status = robot.status === "active" ? "standby" : "active";
robot.mission = robot.status === "active" ? "临时重点区域巡检" : "待命中";
robot.progress = robot.status === "active" ? 8 : 0;
showToast(`${robot.name}${robot.status === "active" ? " 已开始新任务" : " 已暂停任务"}`, "success");
}
renderRobots();
if (dashboardData) updateCommandVisuals(eventData.length ? eventData : dashboardData.recent_events || [], dashboardData.summary, dashboardData.system.alert_channels || {});
}
function setChannelReady(element, enabled) {
element.textContent = enabled ? "已启用" : "未配置";
element.className = enabled ? "ready" : "optional";
}
function setChannelBadge(element, enabled) {
element.textContent = enabled ? "已启用" : "未配置";
element.className = `channel-badge ${enabled ? "enabled" : ""}`;
}
async function loadDashboard() {
try {
const response = await fetch("/api/dashboard");
if (!response.ok) throw new Error(`API ${response.status}`);
const data = await response.json();
setServiceStatus(true);
updateDashboard(data);
} catch (error) {
setServiceStatus(false);
showToast(`无法读取管理数据:${error.message}`, "error");
}
}
async function loadEvents() {
try {
const query = eventFilter ? `?status=${eventFilter}` : "";
const response = await fetch(`/api/events${query}`);
if (!response.ok) throw new Error(`API ${response.status}`);
const result = await response.json();
eventData = result.events || [];
renderEventTable(eventData);
updateEventSummary(result.summary);
renderTrend(eventData);
setServiceStatus(true);
} catch (error) {
setServiceStatus(false);
showToast(`无法读取告警事件:${error.message}`, "error");
}
}
function updateEventSummary(summary) {
elements.allEventsCount.textContent = String(summary.total);
elements.pendingEventsCount.textContent = String(summary.pending);
elements.ackEventsCount.textContent = String(summary.acknowledged);
elements.resolvedEventsCount.textContent = String(summary.resolved);
elements.pendingNavCount.textContent = String(summary.pending);
elements.notificationDot.hidden = summary.pending === 0;
}
function renderRecentEvents(events) {
if (!events.length) {
elements.recentEventList.innerHTML = "<p>暂无告警事件。开始视频巡检后,满足连续帧条件的告警会显示在这里。</p>";
return;
}
elements.recentEventList.innerHTML = events.map((event) => {
const primary = event.classes.includes("fire") ? "fire" : "smoke";
const label = event.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)} · ${event.id}</span></div><strong class="event-confidence">${Math.round(event.max_confidence * 100)}%</strong><span class="event-status status-${event.status}">${STATUS_LABELS[event.status]}</span></div>`;
}).join("");
}
function renderEventTable(events) {
elements.eventTableMeta.textContent = `${events.length} 条记录`;
if (!events.length) {
elements.eventTableBody.innerHTML = '<tr><td colspan="7" class="empty-cell">当前筛选条件下暂无告警事件</td></tr>';
return;
}
elements.eventTableBody.innerHTML = events.map((event) => {
const classes = event.classes.map((name) => `<span class="type-tag type-${name}">${classLabel(name)}</span>`).join(" ");
const channels = enabledChannelNames(event.notification_channels).join(" + ") || "未发送";
return `<tr><td>${event.id}</td><td>${formatDate(event.created_at)}</td><td>${classes}</td><td>${Math.round(event.max_confidence * 100)}%</td><td>${channels}</td><td><span class="event-status status-${event.status}">${STATUS_LABELS[event.status]}</span></td><td><div class="table-actions">${event.status === "pending" ? `<button class="table-action" data-event-id="${event.id}" data-event-status="acknowledged">确认</button>` : ""}${event.status !== "resolved" ? `<button class="table-action" data-event-id="${event.id}" data-event-status="resolved">解决</button>` : ""}</div></td></tr>`;
}).join("");
}
function classLabel(name) {
return name === "fire" ? "火焰" : "烟雾";
}
async function updateEventStatus(eventId, status) {
try {
const response = await fetch(`/api/events/${eventId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
});
if (!response.ok) throw new Error(`API ${response.status}`);
showToast(`事件 ${eventId} 已更新为${STATUS_LABELS[status]}`, "success");
await Promise.all([loadEvents(), loadDashboard()]);
} catch (error) {
showToast(`事件更新失败:${error.message}`, "error");
}
}
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 day = days.find((item) => item.key === String(event.created_at).slice(0, 10));
if (!day) return;
if (event.classes.includes("fire")) day.fire += 1;
if (event.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];
elements.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>`;
}
function clearDetectionResults() {
elements.smokeCount.textContent = "0";
elements.fireCount.textContent = "0";
elements.maxConfidence.textContent = "--";
elements.riskStatus.textContent = "待机";
elements.detectionList.innerHTML = '<p class="empty-copy">暂无检测结果</p>';
const context = elements.overlayCanvas.getContext("2d");
context.clearRect(0, 0, elements.overlayCanvas.width, elements.overlayCanvas.height);
}
function drawDetections(detections = []) {
const video = elements.videoPreview;
if (!video.videoWidth || !video.videoHeight) return;
const canvas = elements.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);
elements.smokeCount.textContent = String(smoke);
elements.fireCount.textContent = String(fire);
elements.maxConfidence.textContent = max ? `${Math.round(max * 100)}%` : "--";
elements.riskStatus.textContent = fire ? "高风险" : smoke ? "需关注" : "正常";
elements.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("、");
elements.alertStatus.textContent = channelNames.length ? `已触发 ${channelNames.join(" + ")} 告警:${labels}` : `已生成告警事件:${labels}(机器人未配置)`;
elements.alertStatus.className = "alert-status alert-triggered";
showToast(`检测到${labels},已生成告警事件`, "error");
loadDashboard();
} else {
const fireFrames = alert.consecutive?.fire || 0;
const smokeFrames = alert.consecutive?.smoke || 0;
elements.alertStatus.textContent = channelNames.length ? `${channelNames.join(" + ")}已启用 · 连续帧 火焰 ${fireFrames} / 烟雾 ${smokeFrames}` : `机器人未配置 · 连续帧 火焰 ${fireFrames} / 烟雾 ${smokeFrames}`;
elements.alertStatus.className = "alert-status";
}
drawDetections(detections);
elements.lastUpdated.textContent = `推理 ${result.inference_ms ?? "--"} ms`;
}
function captureFrame() {
const video = elements.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) {
if (!detectionActive || requestInFlight || !sessionId || elements.videoPreview.paused || elements.videoPreview.ended) return;
const interval = Number(elements.intervalSelect.value);
if (timestamp - lastDetectionAt < interval) return;
lastDetectionAt = timestamp;
requestInFlight = true;
elements.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 response = await fetch(`${API_ENDPOINT}?session_id=${encodeURIComponent(sessionId)}`, { method: "POST", body: form });
if (!response.ok) throw new Error(`API ${response.status}`);
updateDetectionResults(await response.json());
elements.inspectionStatus.textContent = "检测运行中";
elements.inspectionStatus.className = "status-chip status-online";
setServiceStatus(true);
} catch (error) {
elements.inspectionStatus.textContent = "检测异常";
elements.inspectionStatus.className = "status-chip status-offline";
showToast(`视频检测失败:${error.message}`, "error");
} finally {
requestInFlight = false;
elements.loadingState.hidden = true;
}
}
function scheduleDetection(timestamp) {
detectCurrentFrame(timestamp);
if (detectionActive) requestAnimationFrame(scheduleDetection);
}
async function startDetection() {
if (!elements.videoPreview.src) return;
detectionActive = true;
lastDetectionAt = -Infinity;
elements.runDetectionButton.textContent = "停止检测";
elements.intervalSelect.disabled = true;
try {
await elements.videoPreview.play();
requestAnimationFrame(scheduleDetection);
} catch {
stopDetection();
showToast("浏览器未允许视频播放,请手动点击播放后重试。", "error");
}
}
function stopDetection() {
detectionActive = false;
elements.runDetectionButton.textContent = "开始连续检测";
elements.intervalSelect.disabled = false;
elements.inspectionStatus.textContent = elements.videoPreview.src ? "检测已暂停" : "等待视频";
elements.inspectionStatus.className = "status-chip status-idle";
}
async function resetSession() {
const previous = sessionId;
sessionId = crypto.randomUUID();
if (previous) fetch(`/api/sessions/${encodeURIComponent(previous)}`, { method: "DELETE" }).catch(() => {});
}
document.querySelectorAll("[data-view], [data-view-jump]").forEach((control) => {
control.addEventListener("click", () => switchView(control.dataset.view || control.dataset.viewJump));
});
document.querySelectorAll("[data-event-filter]").forEach((button) => {
button.addEventListener("click", () => {
eventFilter = button.dataset.eventFilter;
document.querySelectorAll("[data-event-filter]").forEach((item) => item.classList.toggle("is-active", item === button));
loadEvents();
});
});
document.querySelectorAll("[data-robot-filter]").forEach((button) => {
button.addEventListener("click", () => {
robotFilter = button.dataset.robotFilter;
document.querySelectorAll("[data-robot-filter]").forEach((item) => item.classList.toggle("is-active", item === button));
renderRobots();
});
});
elements.robotSearchInput.addEventListener("input", renderRobots);
elements.robotCardGrid.addEventListener("click", (event) => {
const button = event.target.closest("[data-robot-action]");
if (button) handleRobotAction(button);
});
elements.dispatchRobotButton.addEventListener("click", () => {
const available = ROBOTS.find((robot) => ["standby", "charging"].includes(robot.status));
if (!available) {
showToast("当前没有可调度机器人,请先暂停现有任务。", "error");
return;
}
available.status = "active";
available.mission = "临时重点区域巡检";
available.progress = 5;
showToast(`任务已下发给 ${available.name}`, "success");
renderRobots();
});
document.querySelector(".mission-history-button")?.addEventListener("click", () => showToast("任务历史模块已就绪,可继续对接后端任务接口。"));
elements.eventTableBody.addEventListener("click", (event) => {
const button = event.target.closest("[data-event-id]");
if (button) updateEventStatus(button.dataset.eventId, button.dataset.eventStatus);
});
elements.menuButton.addEventListener("click", () => elements.sidebar.classList.toggle("is-open"));
elements.refreshEventsButton.addEventListener("click", loadEvents);
elements.runDetectionButton.addEventListener("click", () => detectionActive ? stopDetection() : startDetection());
elements.videoPreview.addEventListener("ended", stopDetection);
elements.videoPreview.addEventListener("timeupdate", () => {
elements.videoClock.textContent = `${formatTime(elements.videoPreview.currentTime)} / ${formatTime(elements.videoPreview.duration)}`;
});
elements.videoPreview.addEventListener("seeked", async () => { drawDetections([]); await resetSession(); });
elements.videoPreview.addEventListener("resize", () => drawDetections([]));
elements.videoInput.addEventListener("change", async () => {
const [file] = elements.videoInput.files;
if (!file) return;
stopDetection();
elements.videoPreview.pause();
if (videoUrl) URL.revokeObjectURL(videoUrl);
videoUrl = URL.createObjectURL(file);
elements.videoPreview.src = videoUrl;
elements.videoPreview.hidden = false;
elements.emptyState.hidden = true;
elements.sourceLabel.textContent = file.name;
elements.videoMeta.textContent = `${(file.size / 1024 / 1024).toFixed(1)} MB · 本地视频`;
elements.runDetectionButton.disabled = false;
elements.inspectionStatus.textContent = "视频已就绪";
elements.inspectionStatus.className = "status-chip status-online";
await resetSession();
clearDetectionResults();
});
elements.clearButton.addEventListener("click", async () => {
stopDetection();
elements.videoPreview.pause();
elements.videoPreview.removeAttribute("src");
elements.videoPreview.load();
elements.videoPreview.hidden = true;
if (videoUrl) URL.revokeObjectURL(videoUrl);
videoUrl = null;
elements.videoInput.value = "";
elements.sourceLabel.textContent = "未选择视频";
elements.videoMeta.textContent = "支持 MP4、WebM 等浏览器可播放格式";
elements.emptyState.hidden = false;
elements.runDetectionButton.disabled = true;
await resetSession();
clearDetectionResults();
});
updateClock();
setInterval(updateClock, 1000);
resetSession();
renderRobots();
Promise.all([loadDashboard(), loadEvents()]);