diff --git a/README.md b/README.md index c565f3e..e7c4ce1 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,8 @@ uv sync uv run uvicorn backend.main:app --host 127.0.0.1 --port 8000 ``` -Open `http://127.0.0.1:8000`, select a local video, and start continuous detection. The browser plays the video locally and sends sequential JPEG frames to `POST /api/detect`; requests do not overlap. +Open `http://127.0.0.1:8000` to use the fire prevention management platform. It provides a system overview, local-video inspection, alert event handling, a risk register, robot channel status, and effective model settings. The browser plays selected videos locally and sends sequential JPEG frames to `POST /api/detect`; requests do not overlap. + +Management endpoints include `GET /api/dashboard`, `GET /api/events`, and `PATCH /api/events/{event_id}`. Alert events can be marked as pending, acknowledged, or resolved. The current implementation retains the most recent 500 events in process memory, so events are cleared when the API service restarts. The local `.env` file contains optional robot settings. Set `WECHAT_WEBHOOK_URL` for an Enterprise WeChat group robot, `FEISHU_WEBHOOK_URL` for a Feishu custom group robot, or both. If Feishu signature verification is enabled, also set `FEISHU_SECRET`. Alerts require three consecutive positive frames by default and use separate 60-second cooldowns for fire and smoke. `ALERT_CONFIRM_FRAMES` and `ALERT_COOLDOWN_SECONDS` override these settings. Without a webhook, video detection still works and the UI reports that notifications are disabled. diff --git a/backend/events.py b/backend/events.py new file mode 100644 index 0000000..b1df80b --- /dev/null +++ b/backend/events.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import threading +import uuid +from collections import deque +from datetime import datetime +from typing import Any + +EVENT_STATUSES = {"pending", "acknowledged", "resolved"} + + +class EventStore: + def __init__(self, max_events: int = 500) -> None: + self._events: deque[dict[str, Any]] = deque(maxlen=max_events) + self._lock = threading.Lock() + + def create( + self, + session_id: str, + classes: list[str], + detections: list[dict[str, Any]], + notification_channels: dict[str, bool], + ) -> dict[str, Any]: + now = datetime.now().astimezone() + confidences = [ + float(detection.get("confidence", 0.0)) + for detection in detections + if detection.get("class") in classes + ] + event = { + "id": uuid.uuid4().hex[:12], + "created_at": now.isoformat(timespec="seconds"), + "session_id": session_id, + "classes": classes, + "max_confidence": round(max(confidences, default=0.0), 6), + "detection_count": len(detections), + "status": "pending", + "notification_channels": notification_channels, + "handled_at": None, + } + with self._lock: + self._events.appendleft(event) + return dict(event) + + def list( + self, + limit: int = 100, + status: str | None = None, + ) -> list[dict[str, Any]]: + with self._lock: + events = [dict(event) for event in self._events] + if status: + events = [event for event in events if event["status"] == status] + return events[: max(1, min(limit, 500))] + + def update(self, event_id: str, status: str) -> dict[str, Any] | None: + if status not in EVENT_STATUSES: + raise ValueError(f"Unsupported event status: {status}") + with self._lock: + for event in self._events: + if event["id"] != event_id: + continue + event["status"] = status + event["handled_at"] = ( + None + if status == "pending" + else datetime.now().astimezone().isoformat( + timespec="seconds" + ) + ) + return dict(event) + return None + + def summary(self) -> dict[str, int]: + today = datetime.now().astimezone().date() + with self._lock: + events = [dict(event) for event in self._events] + today_events = [ + event + for event in events + if datetime.fromisoformat(event["created_at"]).date() == today + ] + return { + "total": len(events), + "today": len(today_events), + "pending": sum( + event["status"] == "pending" for event in events + ), + "acknowledged": sum( + event["status"] == "acknowledged" for event in events + ), + "resolved": sum( + event["status"] == "resolved" for event in events + ), + "fire": sum("fire" in event["classes"] for event in events), + "smoke": sum("smoke" in event["classes"] for event in events), + } diff --git a/backend/main.py b/backend/main.py index ea95a15..385fe39 100644 --- a/backend/main.py +++ b/backend/main.py @@ -12,9 +12,11 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from PIL import Image, UnidentifiedImageError from dotenv import load_dotenv +from pydantic import BaseModel from ultralytics import YOLO from .alerting import AlertManager +from .events import EVENT_STATUSES, EventStore PROJECT_ROOT = Path(__file__).resolve().parents[1] FRONTEND_DIR = PROJECT_ROOT / "frontend" @@ -32,6 +34,11 @@ ALERT_MANAGER = AlertManager( confirm_frames=int(os.getenv("ALERT_CONFIRM_FRAMES", "3")), cooldown_seconds=float(os.getenv("ALERT_COOLDOWN_SECONDS", "60")), ) +EVENT_STORE = EventStore() + + +class EventStatusUpdate(BaseModel): + status: str app = FastAPI(title="Smoke Fire Detector API", version="0.1.0") app.add_middleware( @@ -132,6 +139,16 @@ async def detect( "notification_channels": ALERT_MANAGER.channels, } ) + if result["alert"]["triggered"]: + result["event"] = EVENT_STORE.create( + session_id=session_id or "single-image", + classes=result["alert"]["classes"], + detections=result["detections"], + notification_channels=result["alert"].get( + "notification_channels", + ALERT_MANAGER.channels, + ), + ) return result @@ -141,6 +158,49 @@ def reset_detection_session(session_id: str) -> dict[str, str]: return {"status": "reset"} +@app.get("/api/events") +def list_events( + limit: int = 100, + status: str | None = None, +) -> dict[str, Any]: + if status and status not in EVENT_STATUSES: + raise HTTPException(status_code=400, detail="Invalid event status") + return { + "events": EVENT_STORE.list(limit=limit, status=status), + "summary": EVENT_STORE.summary(), + } + + +@app.patch("/api/events/{event_id}") +def update_event( + event_id: str, + update: EventStatusUpdate, +) -> dict[str, Any]: + try: + event = EVENT_STORE.update(event_id, update.status) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + if event is None: + raise HTTPException(status_code=404, detail="Event not found") + return event + + +@app.get("/api/dashboard") +def dashboard() -> dict[str, Any]: + return { + "summary": EVENT_STORE.summary(), + "recent_events": EVENT_STORE.list(limit=6), + "system": health(), + "detection": { + "confidence": CONFIDENCE, + "iou": IOU, + "image_size": IMAGE_SIZE, + "confirm_frames": ALERT_MANAGER.confirm_frames, + "cooldown_seconds": ALERT_MANAGER.cooldown_seconds, + }, + } + + @app.get("/") def frontend() -> FileResponse: return FileResponse(FRONTEND_DIR / "index.html") diff --git a/frontend/app.js b/frontend/app.js index 2483f28..4e802d2 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1,24 +1,38 @@ const API_ENDPOINT = "/api/detect"; - -const elements = { - alertStatus: document.querySelector("#alertStatus"), - clearButton: document.querySelector("#clearButton"), - connectionStatus: document.querySelector("#connectionStatus"), - detectionList: document.querySelector("#detectionList"), - emptyState: document.querySelector("#emptyState"), - fireCount: document.querySelector("#fireCount"), - intervalSelect: document.querySelector("#intervalSelect"), - lastUpdated: document.querySelector("#lastUpdated"), - loadingState: document.querySelector("#loadingState"), - maxConfidence: document.querySelector("#maxConfidence"), - overlayCanvas: document.querySelector("#overlayCanvas"), - riskStatus: document.querySelector("#riskStatus"), - runDetectionButton: document.querySelector("#runDetectionButton"), - smokeCount: document.querySelector("#smokeCount"), - sourceLabel: document.querySelector("#sourceLabel"), - videoInput: document.querySelector("#videoInput"), - videoPreview: document.querySelector("#videoPreview"), +const VIEW_LABELS = { + overview: "系统总览", + inspection: "视频巡检", + events: "告警中心", + risks: "风险台账", + channels: "通知通道", + settings: "系统设置", }; +const STATUS_LABELS = { + pending: "待处置", + acknowledged: "已确认", + resolved: "已解决", +}; + +const elements = Object.fromEntries( + [ + "ackEventsCount", "alertStatus", "allEventsCount", "channelCount", + "channelSummary", "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", + ].map((id) => [id, document.querySelector(`#${id}`)]) +); const frameCanvas = document.createElement("canvas"); let videoUrl = null; @@ -26,10 +40,40 @@ let sessionId = null; let detectionActive = false; let requestInFlight = false; let lastDetectionAt = 0; +let dashboardData = null; +let eventData = []; +let eventFilter = ""; -function setConnectionStatus(label, state = "idle") { - elements.connectionStatus.textContent = label; - elements.connectionStatus.className = `status-pill status-${state}`; +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 (["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 = {}) { @@ -39,12 +83,189 @@ function enabledChannelNames(channels = {}) { return names; } -function clearResults() { +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"; + elements.channelCount.textContent = String(channelNames.length); + elements.channelSummary.textContent = channelNames.length ? channelNames.join(" + ") : "尚未启用机器人"; + 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 || []); + renderTrend(eventData.length ? eventData : data.recent_events || []); +} + +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 = "

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

"; + 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 = '

暂无检测结果

'; + elements.detectionList.innerHTML = '

暂无检测结果

'; const context = elements.overlayCanvas.getContext("2d"); context.clearRect(0, 0, elements.overlayCanvas.width, elements.overlayCanvas.height); } @@ -59,7 +280,7 @@ function drawDetections(detections = []) { context.clearRect(0, 0, canvas.width, canvas.height); detections.forEach((detection) => { const [x1, y1, x2, y2] = detection.box || []; - const color = detection.class === "fire" ? "#ff786b" : "#55d5c2"; + 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); @@ -69,7 +290,7 @@ function drawDetections(detections = []) { }); } -function updateResults(result) { +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; @@ -77,35 +298,26 @@ function updateResults(result) { elements.smokeCount.textContent = String(smoke); elements.fireCount.textContent = String(fire); elements.maxConfidence.textContent = max ? `${Math.round(max * 100)}%` : "--"; - elements.riskStatus.textContent = fire ? "火焰告警" : smoke ? "烟雾告警" : "正常"; + elements.riskStatus.textContent = fire ? "高风险" : smoke ? "需关注" : "正常"; elements.detectionList.innerHTML = detections.length - ? detections.map((item) => `
${item.class === "fire" ? "火焰" : "烟雾"}${Math.round(item.confidence * 100)}%
`).join("") - : '

未发现目标

'; + ? 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((name) => name === "fire" ? "火焰" : "烟雾").join("、"); - const channelNames = enabledChannelNames(alert.notification_channels); - elements.alertStatus.textContent = channelNames.length - ? `已触发 ${channelNames.join(" + ")} 告警:${labels}` - : `已满足告警条件:${labels}(未配置机器人 Webhook)`; + 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; - const channelNames = enabledChannelNames(alert.notification_channels); - elements.alertStatus.textContent = channelNames.length - ? `${channelNames.join(" + ")} 告警已启用 · 连续帧 火焰 ${fireFrames} / 烟雾 ${smokeFrames}` - : "机器人告警未配置,检测功能正常"; + elements.alertStatus.textContent = channelNames.length ? `${channelNames.join(" + ")}已启用 · 连续帧 火焰 ${fireFrames} / 烟雾 ${smokeFrames}` : `机器人未配置 · 连续帧 火焰 ${fireFrames} / 烟雾 ${smokeFrames}`; elements.alertStatus.className = "alert-status"; } drawDetections(detections); - elements.lastUpdated.textContent = `视频 ${formatTime(elements.videoPreview.currentTime)} · 推理 ${result.inference_ms ?? "--"} ms`; -} - -function formatTime(seconds) { - const minutes = Math.floor(seconds / 60); - const remaining = Math.floor(seconds % 60); - return `${String(minutes).padStart(2, "0")}:${String(remaining).padStart(2, "0")}`; + elements.lastUpdated.textContent = `推理 ${result.inference_ms ?? "--"} ms`; } function captureFrame() { @@ -117,13 +329,7 @@ function captureFrame() { } async function detectCurrentFrame(timestamp) { - if ( - !detectionActive - || requestInFlight - || !sessionId - || elements.videoPreview.paused - || elements.videoPreview.ended - ) return; + if (!detectionActive || requestInFlight || !sessionId || elements.videoPreview.paused || elements.videoPreview.ended) return; const interval = Number(elements.intervalSelect.value); if (timestamp - lastDetectionAt < interval) return; lastDetectionAt = timestamp; @@ -136,11 +342,14 @@ async function detectCurrentFrame(timestamp) { 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}`); - updateResults(await response.json()); - setConnectionStatus("检测服务已连接", "ready"); + updateDetectionResults(await response.json()); + elements.inspectionStatus.textContent = "检测运行中"; + elements.inspectionStatus.className = "status-chip status-online"; + setServiceStatus(true); } catch (error) { - setConnectionStatus("检测服务异常", "alert"); - elements.detectionList.innerHTML = `

${error.message}

`; + elements.inspectionStatus.textContent = "检测异常"; + elements.inspectionStatus.className = "status-chip status-offline"; + showToast(`视频检测失败:${error.message}`, "error"); } finally { requestInFlight = false; elements.loadingState.hidden = true; @@ -160,42 +369,50 @@ async function startDetection() { elements.intervalSelect.disabled = true; try { await elements.videoPreview.play(); + requestAnimationFrame(scheduleDetection); } catch { - setConnectionStatus("请允许视频播放", "alert"); + stopDetection(); + showToast("浏览器未允许视频播放,请手动点击播放后重试。", "error"); } - requestAnimationFrame(scheduleDetection); } 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 previousSession = sessionId; + const previous = sessionId; sessionId = crypto.randomUUID(); - if (previousSession) { - fetch(`/api/sessions/${encodeURIComponent(previousSession)}`, { method: "DELETE" }).catch(() => {}); - } -} - -async function checkHealth() { - try { - const response = await fetch("/api/health"); - if (!response.ok) throw new Error(); - const health = await response.json(); - setConnectionStatus("检测服务已连接", "ready"); - const channelNames = enabledChannelNames(health.alert_channels); - elements.alertStatus.textContent = channelNames.length - ? `${channelNames.join(" + ")} 告警已启用` - : "机器人告警未配置,检测功能正常"; - } catch { - setConnectionStatus("检测服务未连接", "alert"); - elements.alertStatus.textContent = "无法读取机器人告警状态"; - } + 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.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; @@ -207,22 +424,13 @@ elements.videoInput.addEventListener("change", async () => { 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(); - clearResults(); + clearDetectionResults(); }); - -elements.runDetectionButton.addEventListener("click", () => { - if (detectionActive) stopDetection(); else startDetection(); -}); - -elements.videoPreview.addEventListener("ended", stopDetection); -elements.videoPreview.addEventListener("seeked", async () => { - drawDetections([]); - await resetSession(); -}); -elements.videoPreview.addEventListener("resize", () => drawDetections([])); - elements.clearButton.addEventListener("click", async () => { stopDetection(); elements.videoPreview.pause(); @@ -232,12 +440,15 @@ elements.clearButton.addEventListener("click", async () => { if (videoUrl) URL.revokeObjectURL(videoUrl); videoUrl = null; elements.videoInput.value = ""; - elements.sourceLabel.textContent = "等待选择视频"; + elements.sourceLabel.textContent = "未选择视频"; + elements.videoMeta.textContent = "支持 MP4、WebM 等浏览器可播放格式"; elements.emptyState.hidden = false; elements.runDetectionButton.disabled = true; await resetSession(); - clearResults(); + clearDetectionResults(); }); +updateClock(); +setInterval(updateClock, 1000); resetSession(); -checkHealth(); +Promise.all([loadDashboard(), loadEvents()]); diff --git a/frontend/index.html b/frontend/index.html index f8ba920..50d9fbb 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,88 +3,177 @@ - 烟火视频检测 + + 燧安 · 火灾预防管理平台 -
-
-
-

YOLO11S / VIDEO DETECTION

-

烟火视频检测

-
- 正在检查服务 -
- -
-
-
-
-

VIDEO VIEW

-

检测画面

-
- 等待选择视频 -
-
- -
-
+
- 选择本地视频开始检测 - 视频仅在浏览器本地播放,发送的是抽取帧 -
- - -
-
-
+ +
+
今日告警!
0系统产生的有效告警事件
+
待处置事件
0需要值班人员确认处理
+
模型状态
检查中正在读取权重状态
+
通知通道
0尚未启用机器人
+
+ +
+
+

ALERT TREND

近期告警趋势

火焰烟雾
+ +
+
+

SYSTEM READINESS

系统就绪度

+
+
模型权重检查中
+
检测服务检查中
+
企业微信未配置
+
飞书机器人未配置
+
+
+
+ +
+

RECENT EVENTS

最近告警事件

+

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

+
+ + +
+

VIDEO INSPECTION

智能视频巡检

选择本地监控视频,系统将按设定间隔抽帧并进行烟雾、火焰识别。

等待视频
+
+
+
未选择视频支持 MP4、WebM 等浏览器可播放格式
00:00 / 00:00
+
+ +
导入监控视频视频在浏览器本地播放,仅将抽取帧发送至检测服务
+ + +
+
+ + + +
+
+ +
+
+

LIVE RESULT

实时检测摘要

尚未检测
+
+
烟雾目标0
+
火焰目标0
+
最高置信度--
+
风险等级待机
+
+
机器人告警状态:检查中
+

暂无检测结果

+
+
+

STRATEGY

巡检策略

+ +
连续确认帧3 帧
+
告警冷却60 秒
+
连续检测到目标后才会生成告警事件,并按火焰、烟雾分别执行冷却。
+
+
+
+
+ +
+

ALERT CENTER

告警事件管理

集中查看、确认和关闭视频巡检产生的火灾风险事件。

+
全部事件0
待处置0
已确认0
已解决0
+
+
0 条记录
+
事件编号时间类型置信度通知通道状态操作
暂无告警事件
+
+
+ +
+

RISK REGISTER

风险台账

从模型阈值、告警策略和处置状态评估当前风险治理情况。

+
+
高风险

未处置告警事件

0

待值班人员确认的火焰或烟雾事件。

+
策略风险

告警通知覆盖

0 / 2

建议至少启用一个机器人通知通道。

+
模型策略

检测置信度阈值

--

阈值过低会增加误报,过高可能遗漏早期烟雾。

+
+

PREVENTION CHECKLIST

火灾预防工作清单

+
+ +
+

NOTIFICATION CHANNELS

通知通道

管理告警触达方式。敏感凭据保存在服务端本地环境文件中。

+
+

企业微信群机器人

未配置

告警时发送 Markdown 消息与带检测框截图。

WECHAT_WEBHOOK_URL
+

飞书自定义机器人

未配置

告警时发送红色交互卡片,支持签名校验。

FEISHU_WEBHOOK_URL / FEISHU_SECRET
+
+

CONFIGURATION

配置说明

  1. 在企业微信或飞书群中添加自定义机器人并复制 Webhook。
  2. 将 Webhook 填入项目根目录 .env 对应字段。
  3. 飞书开启签名校验时,同时填写 FEISHU_SECRET
  4. 重启后端服务,平台将自动显示通道启用状态。
+
+ +
+

SYSTEM SETTINGS

系统设置

查看当前生效的模型和告警参数。配置修改后需重启服务。

+
+

MODEL

模型配置

权重文件
--
输入尺寸
--
置信度阈值
--
IOU 阈值
--
+

ALERT POLICY

告警策略

连续确认
--
分类冷却
--
事件存储
进程内最近 500 条
视频处理
浏览器本地抽帧
+
+

ENVIRONMENT

环境变量参考

YOLO_WEIGHTS=...
+YOLO_DEVICE=0
+YOLO_IMGSZ=768
+YOLO_CONF=0.40
+YOLO_IOU=0.45
+ALERT_CONFIRM_FRAMES=3
+ALERT_COOLDOWN_SECONDS=60
+
+ + + +
diff --git a/frontend/styles.css b/frontend/styles.css index dc2ce35..b049807 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -1,75 +1,193 @@ :root { color-scheme: dark; - --bg: #101417; - --surface: #171d21; - --surface-raised: #1e272c; - --line: #2c373d; - --text: #edf3f2; - --muted: #93a2a5; - --cyan: #55d5c2; - --cyan-deep: #183d3b; - --orange: #ffb454; - --red: #ff786b; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --bg: #071017; + --sidebar: #0a141c; + --surface: #0e1a23; + --surface-2: #13232e; + --surface-3: #192d39; + --line: #203540; + --line-soft: #182b35; + --text: #edf4f5; + --muted: #8498a1; + --cyan: #41d6c3; + --cyan-soft: #153a39; + --red: #ff665e; + --red-soft: #402321; + --orange: #ffad4d; + --orange-soft: #40301e; + --green: #5fd28a; + --green-soft: #173828; + --blue: #5da8ff; + --blue-soft: #18334d; + font-family: Inter, "Segoe UI", "Microsoft YaHei", system-ui, sans-serif; } * { box-sizing: border-box; } -body { margin: 0; min-width: 320px; background: var(--bg); color: var(--text); } +html { min-width: 320px; background: var(--bg); } +body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--bg); color: var(--text); } button, input, select { font: inherit; } button { cursor: pointer; } -.app-shell { width: min(1380px, calc(100% - 40px)); margin: 0 auto; padding: 28px 0 40px; } -.topbar, .panel-heading, .stage-footer { display: flex; align-items: center; justify-content: space-between; gap: 20px; } -.topbar { border-bottom: 1px solid var(--line); padding-bottom: 24px; } -.eyebrow { margin: 0 0 7px; color: var(--cyan); font-size: 11px; font-weight: 800; letter-spacing: 0.12em; } -h1, h2, p { margin-top: 0; } -h1 { margin-bottom: 0; font-size: clamp(24px, 4vw, 38px); letter-spacing: 0; } -h2 { margin-bottom: 0; font-size: 17px; } -.status-pill { border: 1px solid var(--line); border-radius: 999px; padding: 8px 12px; color: var(--muted); font-size: 12px; white-space: nowrap; } -.status-ready { border-color: #286f67; background: var(--cyan-deep); color: var(--cyan); } -.status-alert { border-color: #81473f; background: #392321; color: var(--red); } -.workspace { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 18px; margin-top: 22px; } -.stage-panel, .control-panel { border: 1px solid var(--line); background: var(--surface); } -.stage-panel { min-width: 0; padding: 20px; } -.control-panel { padding: 20px; } -.muted { color: var(--muted); font-size: 13px; } -.media-stage { position: relative; display: grid; place-items: center; min-height: min(66vh, 680px); margin: 20px 0 16px; overflow: hidden; background: #0a0d0e; border: 1px solid var(--line); } -.media-stage img, .media-stage video { display: block; width: 100%; height: 100%; max-height: min(66vh, 680px); object-fit: contain; } +button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; } +h1, h2, h3, p { margin-top: 0; } +h1, h2, h3, strong { font-weight: 700; } +code, pre { font-family: "Cascadia Code", Consolas, monospace; } + +.platform-shell { display: grid; grid-template-columns: 248px minmax(0, 1fr); min-height: 100vh; } +.sidebar { position: sticky; top: 0; z-index: 20; display: flex; height: 100vh; flex-direction: column; border-right: 1px solid var(--line); background: var(--sidebar); } +.brand { display: flex; min-height: 82px; align-items: center; gap: 12px; border-bottom: 1px solid var(--line); padding: 0 22px; } +.brand-mark { display: grid; width: 39px; height: 39px; place-items: center; border: 1px solid #2d7069; background: var(--cyan-soft); color: var(--cyan); font-size: 20px; font-weight: 800; } +.brand div:last-child { display: grid; gap: 3px; } +.brand strong { font-size: 19px; letter-spacing: .12em; } +.brand span { color: var(--muted); font-size: 11px; } +.main-nav { display: grid; gap: 4px; padding: 22px 14px; } +.nav-item { position: relative; display: grid; min-height: 46px; grid-template-columns: 26px 1fr auto; align-items: center; gap: 8px; border: 0; border-left: 2px solid transparent; padding: 0 13px; background: transparent; color: var(--muted); text-align: left; } +.nav-item:hover { background: #10202a; color: var(--text); } +.nav-item.is-active { border-left-color: var(--cyan); background: linear-gradient(90deg, #163632, #10212a 74%); color: var(--cyan); } +.nav-icon { display: grid; width: 22px; height: 22px; place-items: center; font-size: 15px; } +.nav-count { display: grid; min-width: 22px; height: 20px; place-items: center; padding: 0 6px; border-radius: 999px; background: var(--red-soft); color: var(--red); font-size: 11px; } +.sidebar-footer { display: grid; gap: 16px; margin-top: auto; border-top: 1px solid var(--line); padding: 20px; } +.system-indicator { display: flex; align-items: flex-start; gap: 10px; } +.system-indicator div { display: grid; gap: 4px; } +.system-indicator strong { font-size: 12px; } +.system-indicator span:not(.status-dot) { color: var(--muted); font-size: 10px; } +.status-dot { width: 8px; height: 8px; margin-top: 4px; border-radius: 50%; background: var(--orange); box-shadow: 0 0 0 4px #ffad4d18; } +.status-dot.is-online { background: var(--green); box-shadow: 0 0 0 4px #5fd28a18; } +.status-dot.is-offline { background: var(--red); box-shadow: 0 0 0 4px #ff665e18; } +.version { color: #5b7079; font-size: 10px; letter-spacing: .08em; } + +.main-area { min-width: 0; } +.topbar { position: sticky; top: 0; z-index: 15; display: flex; min-height: 82px; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--line); padding: 0 30px; background: #071017e8; backdrop-filter: blur(14px); } +.topbar-left, .topbar-actions { display: flex; align-items: center; gap: 18px; } +.breadcrumb { margin-bottom: 5px; color: var(--muted); font-size: 11px; } +.topbar h1 { margin-bottom: 0; font-size: 20px; } +.clock-block { display: grid; justify-items: end; gap: 3px; padding-right: 18px; border-right: 1px solid var(--line); } +.clock-block span { color: var(--muted); font-size: 10px; } +.clock-block strong { font-size: 13px; font-variant-numeric: tabular-nums; } +.icon-button { position: relative; display: grid; width: 38px; height: 38px; place-items: center; border: 1px solid var(--line); background: var(--surface); color: var(--muted); } +.icon-button:hover { border-color: #3d655f; color: var(--cyan); } +.menu-button { display: none; } +.notification-dot { position: absolute; top: 7px; right: 7px; width: 7px; height: 7px; border: 1px solid var(--bg); border-radius: 50%; background: var(--red); } +.status-chip { display: inline-flex; min-height: 28px; align-items: center; border: 1px solid var(--line); padding: 0 10px; color: var(--muted); font-size: 11px; white-space: nowrap; } +.status-online { border-color: #29664b; background: var(--green-soft); color: var(--green); } +.status-offline { border-color: #7b3632; background: var(--red-soft); color: var(--red); } +.status-checking { border-color: #69532c; background: var(--orange-soft); color: var(--orange); } +.status-idle { background: var(--surface); } + +.content-area { padding: 28px 30px 50px; } +.view { display: none; } +.view.is-active { display: block; animation: view-enter .18s ease-out; } +@keyframes view-enter { from { opacity: .4; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } +.section-kicker { margin-bottom: 7px; color: var(--cyan); font-size: 10px; font-weight: 800; letter-spacing: .14em; } +.hero-strip, .page-intro { display: flex; align-items: flex-end; justify-content: space-between; gap: 22px; margin-bottom: 22px; } +.hero-strip h2, .page-intro h2 { margin-bottom: 8px; font-size: clamp(23px, 3vw, 32px); } +.hero-strip p:last-child, .page-intro p:last-child { max-width: 720px; margin-bottom: 0; color: var(--muted); font-size: 13px; line-height: 1.7; } +.primary-button, .secondary-button, .text-button, .file-button { display: inline-flex; min-height: 40px; align-items: center; justify-content: center; border: 1px solid transparent; padding: 0 16px; font-weight: 700; text-decoration: none; } +.primary-button { background: var(--cyan); color: #071715; } +.primary-button:hover { background: #66e4d4; } +.primary-button:disabled { cursor: not-allowed; opacity: .4; } +.secondary-button, .file-button { border-color: var(--line); background: var(--surface-2); color: var(--text); } +.secondary-button:hover, .file-button:hover { border-color: var(--cyan); color: var(--cyan); } +.text-button { min-height: 30px; border: 0; padding: 0; background: transparent; color: var(--cyan); } + +.stat-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; } +.stat-card, .panel { border: 1px solid var(--line); background: var(--surface); } +.stat-card { display: grid; min-height: 145px; align-content: space-between; gap: 9px; padding: 18px; } +.stat-head { display: flex; align-items: center; justify-content: space-between; color: var(--muted); font-size: 12px; } +.stat-symbol { display: grid; width: 27px; height: 27px; place-items: center; border: 1px solid currentColor; font-size: 12px; } +.stat-symbol.warning { color: var(--orange); }.stat-symbol.danger { color: var(--red); }.stat-symbol.success { color: var(--green); }.stat-symbol.info { color: var(--blue); } +.stat-card > strong { font-size: 32px; font-variant-numeric: tabular-nums; } +.stat-card > strong.text-value { font-size: 22px; } +.stat-card small { color: var(--muted); font-size: 10px; line-height: 1.5; } +.overview-grid { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(280px, .7fr); gap: 14px; margin-top: 14px; } +.panel { padding: 20px; } +.panel-header { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-bottom: 18px; } +.panel-header h3 { margin-bottom: 0; font-size: 16px; } +.legend { display: flex; gap: 14px; color: var(--muted); font-size: 10px; } +.legend span { display: flex; align-items: center; gap: 5px; } +.legend i { width: 14px; height: 2px; }.legend-fire { background: var(--red); }.legend-smoke { background: var(--cyan); } +.trend-chart { min-height: 225px; } +.trend-chart svg { display: block; width: 100%; height: 225px; overflow: visible; } +.chart-grid { stroke: var(--line-soft); stroke-width: 1; }.chart-axis-label { fill: var(--muted); font-size: 9px; }.chart-fire { fill: none; stroke: var(--red); stroke-width: 2; }.chart-smoke { fill: none; stroke: var(--cyan); stroke-width: 2; }.chart-point-fire { fill: var(--red); }.chart-point-smoke { fill: var(--cyan); } +.readiness-list { display: grid; } +.readiness-item { display: flex; min-height: 47px; align-items: center; justify-content: space-between; gap: 14px; border-bottom: 1px solid var(--line-soft); } +.readiness-item:last-child { border-bottom: 0; } +.readiness-item span { color: var(--muted); font-size: 12px; } +.readiness-item strong { font-size: 11px; } +.ready { color: var(--green); }.not-ready { color: var(--red); }.optional { color: var(--orange); } +.recent-panel { margin-top: 14px; } +.event-list { display: grid; } +.event-list > p { margin: 0; padding: 28px 0; color: var(--muted); text-align: center; font-size: 12px; } +.event-list-item { display: grid; grid-template-columns: auto 1fr auto auto; align-items: center; gap: 12px; border-top: 1px solid var(--line-soft); padding: 13px 0; } +.event-list-item:first-child { border-top: 0; } +.event-type-icon { display: grid; width: 33px; height: 33px; place-items: center; border: 1px solid currentColor; }.event-type-icon.fire { color: var(--red); background: var(--red-soft); }.event-type-icon.smoke { color: var(--cyan); background: var(--cyan-soft); } +.event-description { display: grid; gap: 4px; }.event-description strong { font-size: 12px; }.event-description span { color: var(--muted); font-size: 10px; } +.event-confidence { color: var(--orange); font-size: 12px; } + +.inspection-grid { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(320px, .65fr); gap: 16px; } +.video-panel { min-width: 0; } +.video-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 14px; } +.video-toolbar > div { display: grid; gap: 4px; min-width: 0; }.video-toolbar strong { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }.video-toolbar span { color: var(--muted); font-size: 10px; } +.media-stage { position: relative; display: grid; min-height: min(58vh, 600px); place-items: center; overflow: hidden; border: 1px solid var(--line); background: #03080c; } +.media-stage video { display: block; width: 100%; max-height: min(58vh, 600px); object-fit: contain; } .media-stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; } -.empty-state { display: grid; justify-items: center; gap: 8px; color: var(--muted); text-align: center; } -.empty-icon { display: grid; place-items: center; width: 44px; height: 44px; border: 1px solid var(--line); border-radius: 50%; color: var(--cyan); font-size: 26px; } -.loading-state { position: absolute; inset: auto 16px 16px auto; padding: 10px 12px; background: #0e1718e8; border: 1px solid #286f67; color: var(--cyan); font-size: 12px; } -.stage-footer { color: var(--muted); font-size: 12px; } -.control-stack { display: grid; gap: 10px; margin-top: 22px; } -.button { display: inline-flex; min-height: 42px; align-items: center; justify-content: center; border: 1px solid transparent; border-radius: 6px; padding: 0 15px; font-weight: 700; } -.button:disabled { cursor: not-allowed; opacity: 0.45; } -.button-primary { background: var(--cyan); color: #0b1918; } -.button-secondary { border-color: #3b5658; background: var(--surface-raised); color: var(--text); } -.button-secondary:hover, .button-quiet:hover { border-color: var(--cyan); color: var(--cyan); } -.button-quiet { border-color: var(--line); background: transparent; color: var(--muted); } -.upload-control { display: grid; gap: 8px; } -.upload-control input { position: absolute; width: 1px; height: 1px; opacity: 0; } -.upload-control small { color: var(--muted); font-size: 11px; } -.setting-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; color: var(--muted); font-size: 13px; } -.setting-row select { min-height: 38px; border: 1px solid var(--line); border-radius: 6px; padding: 0 10px; background: var(--surface-raised); color: var(--text); } -.alert-status { margin-top: 14px; border: 1px solid var(--line); padding: 10px 12px; color: var(--muted); font-size: 12px; line-height: 1.5; } -.alert-triggered { border-color: #81473f; background: #392321; color: var(--red); } -.divider { height: 1px; margin: 24px 0; background: var(--line); } -.compact-heading { margin-bottom: 14px; } -.metrics-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } -.metric-card { display: grid; gap: 8px; min-height: 78px; padding: 12px; border: 1px solid var(--line); background: var(--surface-raised); } -.metric-card span { color: var(--muted); font-size: 12px; } -.metric-card strong { font-size: 20px; } -.detection-list { display: grid; gap: 8px; margin-top: 14px; } -.detection-row { display: flex; justify-content: space-between; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line); font-size: 13px; } -.detection-row strong { color: var(--orange); } +.empty-state { display: grid; justify-items: center; gap: 9px; padding: 30px; color: var(--muted); text-align: center; } +.empty-state strong { color: var(--text); font-size: 14px; }.empty-state span { max-width: 420px; font-size: 11px; line-height: 1.6; } +.upload-emblem { display: grid; width: 52px; height: 52px; place-items: center; border: 1px dashed #3f635f; color: var(--cyan); font-size: 26px; } +.loading-state { position: absolute; right: 14px; bottom: 14px; display: flex; align-items: center; gap: 8px; border: 1px solid #2d7069; padding: 8px 10px; background: #0d1b1ee8; color: var(--cyan); font-size: 10px; }.loading-state[hidden] { display: none; } +.spinner { width: 12px; height: 12px; border: 2px solid #28625d; border-top-color: var(--cyan); border-radius: 50%; animation: spin .8s linear infinite; }@keyframes spin { to { transform: rotate(360deg); } } +.video-actions { display: flex; flex-wrap: wrap; gap: 9px; margin-top: 14px; }.file-button input { position: absolute; width: 1px; height: 1px; opacity: 0; } +.inspection-side { display: grid; align-content: start; gap: 16px; } +.micro-copy { color: var(--muted); font-size: 9px; } +.detection-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.detection-metrics > div { display: grid; min-height: 78px; align-content: space-between; gap: 7px; border: 1px solid var(--line-soft); padding: 12px; background: var(--surface-2); } +.detection-metrics span { color: var(--muted); font-size: 10px; }.detection-metrics strong { font-size: 20px; } +.alert-status { margin-top: 12px; border-left: 2px solid var(--cyan); padding: 10px 11px; background: #10242b; color: var(--muted); font-size: 10px; line-height: 1.6; } +.alert-status.alert-triggered { border-color: var(--red); background: var(--red-soft); color: var(--red); } +.detection-list { display: grid; margin-top: 10px; }.detection-row { display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line-soft); padding: 9px 0; font-size: 11px; }.detection-row strong { color: var(--orange); } +.empty-copy { margin: 0; padding: 12px 0; color: var(--muted); font-size: 11px; text-align: center; } +.field-row, .strategy-row { display: flex; min-height: 43px; align-items: center; justify-content: space-between; gap: 14px; border-bottom: 1px solid var(--line-soft); color: var(--muted); font-size: 11px; }.field-row select { min-height: 32px; border: 1px solid var(--line); padding: 0 9px; background: var(--surface-2); color: var(--text); }.strategy-row strong { color: var(--text); }.strategy-note { margin-top: 13px; color: var(--muted); font-size: 10px; line-height: 1.7; } + +.event-stat-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; margin-bottom: 16px; border: 1px solid var(--line); background: var(--line); }.event-stat-row > div { display: grid; gap: 7px; padding: 15px 18px; background: var(--surface); }.event-stat-row span { color: var(--muted); font-size: 10px; }.event-stat-row strong { font-size: 22px; } +.event-table-panel { padding-bottom: 8px; }.table-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 12px; }.filter-group { display: flex; flex-wrap: wrap; gap: 6px; }.filter-button { min-height: 30px; border: 1px solid var(--line); padding: 0 11px; background: transparent; color: var(--muted); font-size: 10px; }.filter-button.is-active { border-color: #2f7069; background: var(--cyan-soft); color: var(--cyan); }.table-toolbar > span { color: var(--muted); font-size: 10px; } +.table-wrap { overflow-x: auto; }.event-table { width: 100%; border-collapse: collapse; font-size: 11px; }.event-table th { border-bottom: 1px solid var(--line); padding: 11px 10px; color: var(--muted); font-size: 9px; text-align: left; white-space: nowrap; }.event-table td { border-bottom: 1px solid var(--line-soft); padding: 12px 10px; vertical-align: middle; }.event-table tbody tr:hover { background: #11232c; }.empty-cell { height: 140px; color: var(--muted); text-align: center; } +.type-tag, .event-status { display: inline-flex; min-height: 24px; align-items: center; border: 1px solid currentColor; padding: 0 8px; font-size: 9px; }.type-fire { color: var(--red); background: var(--red-soft); }.type-smoke { color: var(--cyan); background: var(--cyan-soft); }.status-pending { color: var(--red); background: var(--red-soft); }.status-acknowledged { color: var(--orange); background: var(--orange-soft); }.status-resolved { color: var(--green); background: var(--green-soft); } +.table-actions { display: flex; gap: 6px; }.table-action { min-height: 28px; border: 1px solid var(--line); padding: 0 8px; background: var(--surface-2); color: var(--muted); font-size: 9px; }.table-action:hover { border-color: var(--cyan); color: var(--cyan); } + +.risk-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }.risk-card { display: grid; align-content: start; gap: 10px; }.risk-card h3 { margin: 0; font-size: 14px; }.risk-card > strong { font-size: 30px; }.risk-card p { min-height: 42px; margin-bottom: 0; color: var(--muted); font-size: 11px; line-height: 1.6; }.risk-level { width: fit-content; border-left: 2px solid currentColor; padding: 4px 8px; font-size: 9px; }.risk-level.high { background: var(--red-soft); color: var(--red); }.risk-level.medium { background: var(--orange-soft); color: var(--orange); }.risk-level.low { background: var(--blue-soft); color: var(--blue); } +.checklist-panel { margin-top: 14px; }.checklist { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; border: 1px solid var(--line-soft); background: var(--line-soft); }.checklist label { display: flex; min-height: 50px; align-items: center; gap: 10px; padding: 0 14px; background: var(--surface-2); color: var(--muted); font-size: 11px; }.checklist input { accent-color: var(--cyan); } + +.channel-grid, .settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }.channel-card { display: flex; gap: 16px; }.channel-logo { display: grid; flex: 0 0 50px; height: 50px; place-items: center; font-size: 18px; font-weight: 800; }.wechat-logo { background: var(--green-soft); color: var(--green); }.feishu-logo { background: var(--blue-soft); color: var(--blue); }.channel-content { display: grid; min-width: 0; gap: 10px; }.channel-content > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; }.channel-content h3 { margin: 0; font-size: 14px; }.channel-content p { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.6; }.channel-content code { overflow-wrap: anywhere; color: var(--cyan); font-size: 9px; }.channel-badge { border: 1px solid var(--line); padding: 4px 7px; color: var(--muted); font-size: 9px; white-space: nowrap; }.channel-badge.enabled { border-color: #29664b; background: var(--green-soft); color: var(--green); } +.config-guide { margin-top: 14px; }.config-guide ol { margin: 0; padding-left: 18px; color: var(--muted); font-size: 11px; line-height: 2; }.config-guide code { color: var(--cyan); } +.settings-card dl { margin: 0; }.settings-card dl div { display: grid; grid-template-columns: 130px 1fr; gap: 16px; border-top: 1px solid var(--line-soft); padding: 12px 0; }.settings-card dl div:first-child { border-top: 0; }.settings-card dt { color: var(--muted); font-size: 10px; }.settings-card dd { margin: 0; overflow-wrap: anywhere; font-size: 11px; text-align: right; }.env-panel { margin-top: 14px; }.env-panel pre { margin: 0; overflow-x: auto; border-left: 2px solid var(--cyan); padding: 14px; background: #071016; color: #a9c2c6; font-size: 11px; line-height: 1.7; } +.toast-region { position: fixed; right: 20px; bottom: 20px; z-index: 100; display: grid; gap: 8px; }.toast { min-width: 260px; max-width: 380px; border: 1px solid var(--line); border-left: 3px solid var(--cyan); padding: 12px 14px; background: var(--surface-2); box-shadow: 0 10px 30px #0008; font-size: 11px; line-height: 1.5; }.toast.error { border-left-color: var(--red); }.toast.success { border-left-color: var(--green); } + +@media (max-width: 1120px) { + .stat-grid { grid-template-columns: 1fr 1fr; } + .inspection-grid { grid-template-columns: 1fr; } + .inspection-side { grid-template-columns: 1fr 1fr; } + .risk-grid { grid-template-columns: 1fr; } +} @media (max-width: 860px) { - .app-shell { width: min(100% - 24px, 680px); padding-top: 18px; } - .workspace { grid-template-columns: 1fr; } - .media-stage { min-height: 48vh; } + .platform-shell { grid-template-columns: 1fr; } + .sidebar { position: fixed; left: 0; transform: translateX(-101%); width: 248px; transition: transform .2s ease; box-shadow: 18px 0 40px #0008; } + .sidebar.is-open { transform: translateX(0); } + .menu-button { display: grid; } + .topbar { padding: 0 18px; } + .content-area { padding: 22px 18px 40px; } + .clock-block { display: none; } + .overview-grid, .channel-grid, .settings-grid { grid-template-columns: 1fr; } } -@media (max-width: 480px) { - .topbar { align-items: flex-start; flex-direction: column; } - .stage-panel, .control-panel { padding: 14px; } - .stage-footer { align-items: flex-end; flex-direction: column; } - .stage-footer .button { width: 100%; } +@media (max-width: 620px) { + .topbar-actions .status-chip { display: none; } + .hero-strip, .page-intro { align-items: flex-start; flex-direction: column; } + .hero-strip .primary-button, .page-intro .secondary-button { width: 100%; } + .stat-grid, .event-stat-row, .inspection-side, .detection-metrics, .checklist { grid-template-columns: 1fr; } + .event-stat-row { gap: 1px; } + .panel, .stat-card { padding: 15px; } + .media-stage { min-height: 330px; } + .video-actions > * { flex: 1; } + .event-list-item { grid-template-columns: auto 1fr; }.event-confidence, .event-list-item .event-status { grid-column: 2; } + .settings-card dl div { grid-template-columns: 1fr; gap: 6px; }.settings-card dd { text-align: left; } + .channel-card { flex-direction: column; } + .toast-region { right: 12px; bottom: 12px; left: 12px; }.toast { min-width: 0; max-width: none; } }