const API_ENDPOINT = "/api/detect"; const VIEW_LABELS = { overview: "系统总览", inspection: "视频巡检", events: "告警中心", robots: "消息机器人", risks: "风险台账", settings: "系统设置", }; const STATUS_LABELS = { pending: "待处置", acknowledged: "已确认", resolved: "已解决", }; const elements = Object.fromEntries( [ "ackEventsCount", "alertStatus", "allEventsCount", "clearButton", "confirmFramesValue", "cooldownValue", "currentClock", "detectionList", "emptyState", "eventTableBody", "eventTableMeta", "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", "deviceOnlineRate", "eventClosureRate", "fireRiskRatio", "notificationCoverage", "onlineRobotCount", "overviewDeliveryCount", "overviewFeishuState", "overviewPolicyText", "overviewWechatState", "refreshRobotsButton", "riskDonut", "riskTotal", "robotCardGrid", "robotConfiguredCount", "robotDeliveredCount", "robotFailedCount", "robotNavCount", "robotPolicyConfirm", "robotPolicyCooldown", "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 = ""; 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") loadRobots(); if (["overview", "risks", "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 robotPayload = data.robots || { robots: [], summary: {} }; const robotSummary = robotPayload.summary || {}; elements.onlineRobotCount.textContent = String(robotSummary.configured || 0); elements.robotSummary.textContent = `${robotSummary.configured || 0} / ${robotSummary.total || 2} 已配置 · 成功投递 ${robotSummary.delivered || 0} 次`; 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); 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, robotSummary); renderRobots(robotPayload); } function updateCommandVisuals(events, summary, channels, robotSummary = {}) { const totalRobots = robotSummary.total || 2; const configuredRobots = robotSummary.configured || 0; const total = summary.total || 0; const closureRate = total ? Math.round(((summary.resolved || 0) / total) * 100) : 100; const notificationRate = Math.round((configuredRobots / totalRobots) * 100); const safetyScore = Math.max(42, Math.min(98, Math.round(notificationRate * 0.35 + closureRate * 0.35 + Math.max(0, 100 - (summary.pending || 0) * 8) * 0.3))); elements.deviceOnlineRate.textContent = `${notificationRate}%`; elements.eventClosureRate.textContent = `${closureRate}%`; elements.notificationCoverage.textContent = `${notificationRate}%`; elements.safetyScore.textContent = String(safetyScore); elements.safetyGauge.style.setProperty("--score", safetyScore); elements.overviewWechatState.textContent = channels.wechat ? "已配置" : "未配置"; elements.overviewFeishuState.textContent = channels.feishu ? "已配置" : "未配置"; elements.overviewPolicyText.textContent = `${dashboardData?.detection?.confirm_frames || 3} 帧确认`; elements.overviewDeliveryCount.textContent = `累计成功 ${robotSummary.delivered || 0} 次`; 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%)`; } async function loadRobots() { try { const response = await fetch("/api/robots"); const result = await response.json(); if (!response.ok) throw new Error(result.detail || `API ${response.status}`); renderRobots(result); setServiceStatus(true); } catch (error) { setServiceStatus(false); showToast(`无法读取机器人状态:${error.message}`, "error"); } } function renderRobots(payload = { robots: [], summary: {} }) { const robots = payload.robots || []; const summary = payload.summary || {}; elements.robotTotalCount.textContent = String(summary.total || robots.length); elements.robotConfiguredCount.textContent = String(summary.configured || 0); elements.robotDeliveredCount.textContent = String(summary.delivered || 0); elements.robotFailedCount.textContent = String(summary.failed || 0); elements.robotNavCount.textContent = String(summary.configured || 0); elements.robotPolicyConfirm.textContent = `${dashboardData?.detection?.confirm_frames || 3} 帧连续命中后发送`; elements.robotPolicyCooldown.textContent = `${dashboardData?.detection?.cooldown_seconds || 60} 秒内同类不重复发送`; elements.robotCardGrid.innerHTML = robots.length ? robots.map(robotCardTemplate).join("") : '
暂无机器人通道数据
'; } function robotCardTemplate(robot) { const platformClass = robot.id === "wechat" ? "wechat" : "feishu"; const platformMark = robot.id === "wechat" ? "微" : "飞"; const statusLabel = robot.configured ? "已配置" : "未配置"; const lastActivity = robot.last_test_at || robot.last_delivery_at; const activityText = lastActivity ? formatDate(lastActivity) : "尚无发送记录"; const statusText = robot.last_status === "success" ? "最近发送成功" : robot.last_status === "failed" ? "最近发送失败" : "等待首次发送"; const credential = robot.id === "wechat" ? "WECHAT_WEBHOOK_URL" : "FEISHU_WEBHOOK_URL"; return `
${robot.id.toUpperCase()} ROBOT

${robot.name}群机器人

${robot.id === "wechat" ? "群聊 Markdown 与告警截图" : "群聊交互卡片与签名校验"}

${statusLabel}
${robot.capabilities.map((capability) => `${capability}`).join("")}
成功投递${robot.delivered}
失败${robot.failed}
连接测试${robot.tests}
${statusText}${activityText}
服务端配置${credential}${robot.id === "feishu" ? " / FEISHU_SECRET" : ""}
${robot.last_error ? `

${escapeHtml(robot.last_error)}

` : ""}
`; } async function testRobot(channel, button) { const originalText = button.textContent; button.disabled = true; button.textContent = "正在发送"; try { const response = await fetch(`/api/robots/${encodeURIComponent(channel)}/test`, { method: "POST" }); const result = await response.json(); if (!response.ok) throw new Error(result.detail || `API ${response.status}`); showToast(`${result.robot.name}测试消息发送成功`, "success"); await Promise.all([loadRobots(), loadDashboard()]); } catch (error) { showToast(`测试消息发送失败:${error.message}`, "error"); } finally { button.disabled = false; button.textContent = originalText; } } function escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function setChannelReady(element, enabled) { element.textContent = enabled ? "已启用" : "未配置"; element.className = enabled ? "ready" : "optional"; } 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 = "

暂无告警事件。开始视频巡检后,满足连续帧条件的告警会显示在这里。

"; return; } elements.recentEventList.innerHTML = events.map((event) => { const primary = event.classes.includes("fire") ? "fire" : "smoke"; const label = event.classes.map(classLabel).join("、"); return `
${primary === "fire" ? "火" : "烟"}
${label}检测告警${formatDate(event.created_at)} · ${event.id}
${Math.round(event.max_confidence * 100)}%${STATUS_LABELS[event.status]}
`; }).join(""); } function renderEventTable(events) { elements.eventTableMeta.textContent = `${events.length} 条记录`; if (!events.length) { elements.eventTableBody.innerHTML = '当前筛选条件下暂无告警事件'; return; } elements.eventTableBody.innerHTML = events.map((event) => { const classes = event.classes.map((name) => `${classLabel(name)}`).join(" "); const channels = enabledChannelNames(event.notification_channels).join(" + ") || "未发送"; return `${event.id}${formatDate(event.created_at)}${classes}${Math.round(event.max_confidence * 100)}%${channels}${STATUS_LABELS[event.status]}
${event.status === "pending" ? `` : ""}${event.status !== "resolved" ? `` : ""}
`; }).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 = `最近七天火焰与烟雾告警趋势${gridValues.map((value) => `${value}`).join("")}${days.map((day, index) => `${day.label}`).join("")}`; } function clearDetectionResults() { elements.smokeCount.textContent = "0"; elements.fireCount.textContent = "0"; elements.maxConfidence.textContent = "--"; elements.riskStatus.textContent = "待机"; elements.detectionList.innerHTML = '

暂无检测结果

'; 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) => `
${classLabel(item.class)}${Math.round(item.confidence * 100)}%
`).join("") : '

未发现烟雾或火焰目标

'; 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(); }); }); elements.refreshRobotsButton.addEventListener("click", loadRobots); elements.robotCardGrid.addEventListener("click", (event) => { const button = event.target.closest("[data-test-robot]"); if (button) testRobot(button.dataset.testRobot, button); }); 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()]);