604 lines
31 KiB
JavaScript
604 lines
31 KiB
JavaScript
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()]);
|