feat: add fire prevention management platform

This commit is contained in:
2026-08-12 17:35:03 +08:00
parent bb395e3d9a
commit 9a1788fe8f
6 changed files with 806 additions and 229 deletions

View File

@@ -78,6 +78,8 @@ uv sync
uv run uvicorn backend.main:app --host 127.0.0.1 --port 8000 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. 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.

97
backend/events.py Normal file
View File

@@ -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),
}

View File

@@ -12,9 +12,11 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from PIL import Image, UnidentifiedImageError from PIL import Image, UnidentifiedImageError
from dotenv import load_dotenv from dotenv import load_dotenv
from pydantic import BaseModel
from ultralytics import YOLO from ultralytics import YOLO
from .alerting import AlertManager from .alerting import AlertManager
from .events import EVENT_STATUSES, EventStore
PROJECT_ROOT = Path(__file__).resolve().parents[1] PROJECT_ROOT = Path(__file__).resolve().parents[1]
FRONTEND_DIR = PROJECT_ROOT / "frontend" FRONTEND_DIR = PROJECT_ROOT / "frontend"
@@ -32,6 +34,11 @@ ALERT_MANAGER = AlertManager(
confirm_frames=int(os.getenv("ALERT_CONFIRM_FRAMES", "3")), confirm_frames=int(os.getenv("ALERT_CONFIRM_FRAMES", "3")),
cooldown_seconds=float(os.getenv("ALERT_COOLDOWN_SECONDS", "60")), 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 = FastAPI(title="Smoke Fire Detector API", version="0.1.0")
app.add_middleware( app.add_middleware(
@@ -132,6 +139,16 @@ async def detect(
"notification_channels": ALERT_MANAGER.channels, "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 return result
@@ -141,6 +158,49 @@ def reset_detection_session(session_id: str) -> dict[str, str]:
return {"status": "reset"} 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("/") @app.get("/")
def frontend() -> FileResponse: def frontend() -> FileResponse:
return FileResponse(FRONTEND_DIR / "index.html") return FileResponse(FRONTEND_DIR / "index.html")

View File

@@ -1,24 +1,38 @@
const API_ENDPOINT = "/api/detect"; const API_ENDPOINT = "/api/detect";
const VIEW_LABELS = {
const elements = { overview: "系统总览",
alertStatus: document.querySelector("#alertStatus"), inspection: "视频巡检",
clearButton: document.querySelector("#clearButton"), events: "告警中心",
connectionStatus: document.querySelector("#connectionStatus"), risks: "风险台账",
detectionList: document.querySelector("#detectionList"), channels: "通知通道",
emptyState: document.querySelector("#emptyState"), settings: "系统设置",
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 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"); const frameCanvas = document.createElement("canvas");
let videoUrl = null; let videoUrl = null;
@@ -26,10 +40,40 @@ let sessionId = null;
let detectionActive = false; let detectionActive = false;
let requestInFlight = false; let requestInFlight = false;
let lastDetectionAt = 0; let lastDetectionAt = 0;
let dashboardData = null;
let eventData = [];
let eventFilter = "";
function setConnectionStatus(label, state = "idle") { function showToast(message, type = "info") {
elements.connectionStatus.textContent = label; const toast = document.createElement("div");
elements.connectionStatus.className = `status-pill status-${state}`; 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 = {}) { function enabledChannelNames(channels = {}) {
@@ -39,12 +83,189 @@ function enabledChannelNames(channels = {}) {
return names; 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 = "<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.smokeCount.textContent = "0";
elements.fireCount.textContent = "0"; elements.fireCount.textContent = "0";
elements.maxConfidence.textContent = "--"; elements.maxConfidence.textContent = "--";
elements.riskStatus.textContent = "待机"; elements.riskStatus.textContent = "待机";
elements.detectionList.innerHTML = '<p class="muted">暂无检测结果</p>'; elements.detectionList.innerHTML = '<p class="empty-copy">暂无检测结果</p>';
const context = elements.overlayCanvas.getContext("2d"); const context = elements.overlayCanvas.getContext("2d");
context.clearRect(0, 0, elements.overlayCanvas.width, elements.overlayCanvas.height); 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); context.clearRect(0, 0, canvas.width, canvas.height);
detections.forEach((detection) => { detections.forEach((detection) => {
const [x1, y1, x2, y2] = detection.box || []; 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.strokeStyle = color;
context.lineWidth = Math.max(2, canvas.width / 320); context.lineWidth = Math.max(2, canvas.width / 320);
context.strokeRect(x1, y1, x2 - x1, y2 - y1); 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 detections = result.detections || [];
const smoke = detections.filter((item) => item.class === "smoke").length; const smoke = detections.filter((item) => item.class === "smoke").length;
const fire = detections.filter((item) => item.class === "fire").length; const fire = detections.filter((item) => item.class === "fire").length;
@@ -77,35 +298,26 @@ function updateResults(result) {
elements.smokeCount.textContent = String(smoke); elements.smokeCount.textContent = String(smoke);
elements.fireCount.textContent = String(fire); elements.fireCount.textContent = String(fire);
elements.maxConfidence.textContent = max ? `${Math.round(max * 100)}%` : "--"; elements.maxConfidence.textContent = max ? `${Math.round(max * 100)}%` : "--";
elements.riskStatus.textContent = fire ? "火焰告警" : smoke ? "烟雾告警" : "正常"; elements.riskStatus.textContent = fire ? "高风险" : smoke ? "需关注" : "正常";
elements.detectionList.innerHTML = detections.length elements.detectionList.innerHTML = detections.length
? detections.map((item) => `<div class="detection-row"><span>${item.class === "fire" ? "火焰" : "烟雾"}</span><strong>${Math.round(item.confidence * 100)}%</strong></div>`).join("") ? detections.map((item) => `<div class="detection-row"><span>${classLabel(item.class)}</span><strong>${Math.round(item.confidence * 100)}%</strong></div>`).join("")
: '<p class="muted">未发现目标</p>'; : '<p class="empty-copy">未发现烟雾或火焰目标</p>';
const alert = result.alert || {}; const alert = result.alert || {};
if (alert.triggered) {
const labels = alert.classes.map((name) => name === "fire" ? "火焰" : "烟雾").join("、");
const channelNames = enabledChannelNames(alert.notification_channels); const channelNames = enabledChannelNames(alert.notification_channels);
elements.alertStatus.textContent = channelNames.length if (alert.triggered) {
? `已触发 ${channelNames.join(" + ")} 告警:${labels}` const labels = alert.classes.map(classLabel).join("");
: `满足告警件:${labels}(未配置机器人 Webhook`; elements.alertStatus.textContent = channelNames.length ? `已触发 ${channelNames.join(" + ")} 告警:${labels}` : `生成告警件:${labels}机器人未配置)`;
elements.alertStatus.className = "alert-status alert-triggered"; elements.alertStatus.className = "alert-status alert-triggered";
showToast(`检测到${labels},已生成告警事件`, "error");
loadDashboard();
} else { } else {
const fireFrames = alert.consecutive?.fire || 0; const fireFrames = alert.consecutive?.fire || 0;
const smokeFrames = alert.consecutive?.smoke || 0; const smokeFrames = alert.consecutive?.smoke || 0;
const channelNames = enabledChannelNames(alert.notification_channels); elements.alertStatus.textContent = channelNames.length ? `${channelNames.join(" + ")}已启用 · 连续帧 火焰 ${fireFrames} / 烟雾 ${smokeFrames}` : `机器人未配置 · 连续帧 火焰 ${fireFrames} / 烟雾 ${smokeFrames}`;
elements.alertStatus.textContent = channelNames.length
? `${channelNames.join(" + ")} 告警已启用 · 连续帧 火焰 ${fireFrames} / 烟雾 ${smokeFrames}`
: "机器人告警未配置,检测功能正常";
elements.alertStatus.className = "alert-status"; elements.alertStatus.className = "alert-status";
} }
drawDetections(detections); drawDetections(detections);
elements.lastUpdated.textContent = `视频 ${formatTime(elements.videoPreview.currentTime)} · 推理 ${result.inference_ms ?? "--"} ms`; elements.lastUpdated.textContent = `推理 ${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")}`;
} }
function captureFrame() { function captureFrame() {
@@ -117,13 +329,7 @@ function captureFrame() {
} }
async function detectCurrentFrame(timestamp) { async function detectCurrentFrame(timestamp) {
if ( if (!detectionActive || requestInFlight || !sessionId || elements.videoPreview.paused || elements.videoPreview.ended) return;
!detectionActive
|| requestInFlight
|| !sessionId
|| elements.videoPreview.paused
|| elements.videoPreview.ended
) return;
const interval = Number(elements.intervalSelect.value); const interval = Number(elements.intervalSelect.value);
if (timestamp - lastDetectionAt < interval) return; if (timestamp - lastDetectionAt < interval) return;
lastDetectionAt = timestamp; lastDetectionAt = timestamp;
@@ -136,11 +342,14 @@ async function detectCurrentFrame(timestamp) {
form.append("file", frame, "video-frame.jpg"); form.append("file", frame, "video-frame.jpg");
const response = await fetch(`${API_ENDPOINT}?session_id=${encodeURIComponent(sessionId)}`, { method: "POST", body: form }); const response = await fetch(`${API_ENDPOINT}?session_id=${encodeURIComponent(sessionId)}`, { method: "POST", body: form });
if (!response.ok) throw new Error(`API ${response.status}`); if (!response.ok) throw new Error(`API ${response.status}`);
updateResults(await response.json()); updateDetectionResults(await response.json());
setConnectionStatus("检测服务已连接", "ready"); elements.inspectionStatus.textContent = "检测运行中";
elements.inspectionStatus.className = "status-chip status-online";
setServiceStatus(true);
} catch (error) { } catch (error) {
setConnectionStatus("检测服务异常", "alert"); elements.inspectionStatus.textContent = "检测异常";
elements.detectionList.innerHTML = `<p class="muted">${error.message}</p>`; elements.inspectionStatus.className = "status-chip status-offline";
showToast(`视频检测失败:${error.message}`, "error");
} finally { } finally {
requestInFlight = false; requestInFlight = false;
elements.loadingState.hidden = true; elements.loadingState.hidden = true;
@@ -160,42 +369,50 @@ async function startDetection() {
elements.intervalSelect.disabled = true; elements.intervalSelect.disabled = true;
try { try {
await elements.videoPreview.play(); await elements.videoPreview.play();
} catch {
setConnectionStatus("请允许视频播放", "alert");
}
requestAnimationFrame(scheduleDetection); requestAnimationFrame(scheduleDetection);
} catch {
stopDetection();
showToast("浏览器未允许视频播放,请手动点击播放后重试。", "error");
}
} }
function stopDetection() { function stopDetection() {
detectionActive = false; detectionActive = false;
elements.runDetectionButton.textContent = "开始连续检测"; elements.runDetectionButton.textContent = "开始连续检测";
elements.intervalSelect.disabled = false; elements.intervalSelect.disabled = false;
elements.inspectionStatus.textContent = elements.videoPreview.src ? "检测已暂停" : "等待视频";
elements.inspectionStatus.className = "status-chip status-idle";
} }
async function resetSession() { async function resetSession() {
const previousSession = sessionId; const previous = sessionId;
sessionId = crypto.randomUUID(); sessionId = crypto.randomUUID();
if (previousSession) { if (previous) fetch(`/api/sessions/${encodeURIComponent(previous)}`, { method: "DELETE" }).catch(() => {});
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 = "无法读取机器人告警状态";
}
} }
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 () => { elements.videoInput.addEventListener("change", async () => {
const [file] = elements.videoInput.files; const [file] = elements.videoInput.files;
if (!file) return; if (!file) return;
@@ -207,22 +424,13 @@ elements.videoInput.addEventListener("change", async () => {
elements.videoPreview.hidden = false; elements.videoPreview.hidden = false;
elements.emptyState.hidden = true; elements.emptyState.hidden = true;
elements.sourceLabel.textContent = file.name; elements.sourceLabel.textContent = file.name;
elements.videoMeta.textContent = `${(file.size / 1024 / 1024).toFixed(1)} MB · 本地视频`;
elements.runDetectionButton.disabled = false; elements.runDetectionButton.disabled = false;
elements.inspectionStatus.textContent = "视频已就绪";
elements.inspectionStatus.className = "status-chip status-online";
await resetSession(); 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 () => { elements.clearButton.addEventListener("click", async () => {
stopDetection(); stopDetection();
elements.videoPreview.pause(); elements.videoPreview.pause();
@@ -232,12 +440,15 @@ elements.clearButton.addEventListener("click", async () => {
if (videoUrl) URL.revokeObjectURL(videoUrl); if (videoUrl) URL.revokeObjectURL(videoUrl);
videoUrl = null; videoUrl = null;
elements.videoInput.value = ""; elements.videoInput.value = "";
elements.sourceLabel.textContent = "等待选择视频"; elements.sourceLabel.textContent = "选择视频";
elements.videoMeta.textContent = "支持 MP4、WebM 等浏览器可播放格式";
elements.emptyState.hidden = false; elements.emptyState.hidden = false;
elements.runDetectionButton.disabled = true; elements.runDetectionButton.disabled = true;
await resetSession(); await resetSession();
clearResults(); clearDetectionResults();
}); });
updateClock();
setInterval(updateClock, 1000);
resetSession(); resetSession();
checkHealth(); Promise.all([loadDashboard(), loadEvents()]);

View File

@@ -3,88 +3,177 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>烟火视频检测</title> <meta name="theme-color" content="#0b1117" />
<title>燧安 · 火灾预防管理平台</title>
<link rel="stylesheet" href="./styles.css" /> <link rel="stylesheet" href="./styles.css" />
</head> </head>
<body> <body>
<main class="app-shell"> <div class="platform-shell">
<header class="topbar"> <aside class="sidebar" id="sidebar">
<div class="brand">
<div class="brand-mark" aria-hidden="true"></div>
<div> <div>
<p class="eyebrow">YOLO11S / VIDEO DETECTION</p> <strong>燧安</strong>
<h1>烟火视频检测</h1> <span>火灾预防管理平台</span>
</div>
<span id="connectionStatus" class="status-pill status-idle">正在检查服务</span>
</header>
<section class="workspace">
<div class="stage-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">VIDEO VIEW</p>
<h2>检测画面</h2>
</div>
<span id="sourceLabel" class="muted">等待选择视频</span>
</div>
<div class="media-stage">
<video id="videoPreview" controls muted playsinline hidden></video>
<div id="emptyState" class="empty-state">
<div class="empty-icon">+</div>
<strong>选择本地视频开始检测</strong>
<span>视频仅在浏览器本地播放,发送的是抽取帧</span>
</div>
<canvas id="overlayCanvas" aria-hidden="true"></canvas>
<div id="loadingState" class="loading-state" hidden>正在分析视频帧...</div>
</div>
<div class="stage-footer">
<span id="lastUpdated">尚未检测</span>
<button id="runDetectionButton" class="button button-primary" type="button" disabled>开始连续检测</button>
</div> </div>
</div> </div>
<nav class="main-nav" aria-label="主导航">
<aside class="control-panel"> <button class="nav-item is-active" type="button" data-view="overview"><span class="nav-icon"></span><span>系统总览</span></button>
<div class="panel-heading"> <button class="nav-item" type="button" data-view="inspection"><span class="nav-icon"></span><span>视频巡检</span></button>
<div> <button class="nav-item" type="button" data-view="events"><span class="nav-icon">!</span><span>告警中心</span><span class="nav-count" id="pendingNavCount">0</span></button>
<p class="eyebrow">INPUT</p> <button class="nav-item" type="button" data-view="risks"><span class="nav-icon"></span><span>风险台账</span></button>
<h2>视频源</h2> <button class="nav-item" type="button" data-view="channels"><span class="nav-icon"></span><span>通知通道</span></button>
</div> <button class="nav-item" type="button" data-view="settings"><span class="nav-icon"></span><span>系统设置</span></button>
</div> </nav>
<div class="control-stack"> <div class="sidebar-footer">
<label class="upload-control"> <div class="system-indicator"><span id="sidebarStatusDot" class="status-dot"></span><div><strong id="sidebarStatus">连接中</strong><span>智能视觉预警服务</span></div></div>
<span class="button button-secondary">选择视频</span> <span class="version">YOLO11S · v0.1.0</span>
<input id="videoInput" type="file" accept="video/*" />
<small>支持浏览器可播放的 MP4、WebM 等格式</small>
</label>
<label class="setting-row">
<span>检测间隔</span>
<select id="intervalSelect">
<option value="500">0.5 秒</option>
<option value="1000" selected>1 秒</option>
<option value="2000">2 秒</option>
</select>
</label>
<button id="clearButton" class="button button-quiet" type="button">清除视频</button>
</div>
<div class="divider"></div>
<div class="panel-heading compact-heading">
<div>
<p class="eyebrow">RESULTS</p>
<h2>检测摘要</h2>
</div>
</div>
<div class="metrics-grid">
<div class="metric-card"><span>烟雾</span><strong id="smokeCount">0</strong></div>
<div class="metric-card"><span>火焰</span><strong id="fireCount">0</strong></div>
<div class="metric-card"><span>最高置信度</span><strong id="maxConfidence">--</strong></div>
<div class="metric-card"><span>状态</span><strong id="riskStatus">待机</strong></div>
</div>
<div id="alertStatus" class="alert-status">机器人告警状态:检查中</div>
<div id="detectionList" class="detection-list">
<p class="muted">暂无检测结果</p>
</div> </div>
</aside> </aside>
<main class="main-area">
<header class="topbar">
<div class="topbar-left">
<button id="menuButton" class="icon-button menu-button" type="button" aria-label="展开导航"></button>
<div>
<p class="breadcrumb">消防安全 / <span id="viewBreadcrumb">系统总览</span></p>
<h1 id="viewTitle">系统总览</h1>
</div>
</div>
<div class="topbar-actions">
<div class="clock-block"><span>当前时间</span><strong id="currentClock">--:--:--</strong></div>
<span id="globalStatus" class="status-chip status-checking">服务检查中</span>
<button class="icon-button" type="button" data-view-jump="events" aria-label="查看告警"><span id="notificationDot" class="notification-dot" hidden></span></button>
</div>
</header>
<div class="content-area">
<section class="view is-active" id="view-overview" data-view-panel="overview">
<div class="hero-strip">
<div>
<p class="section-kicker">FIRE PREVENTION COMMAND CENTER</p>
<h2>今日消防安全态势</h2>
<p id="overviewMessage">正在汇总模型、告警与通知通道状态。</p>
</div>
<button class="primary-button" type="button" data-view-jump="inspection">开始视频巡检</button>
</div>
<div class="stat-grid">
<article class="stat-card"><div class="stat-head"><span>今日告警</span><span class="stat-symbol warning">!</span></div><strong id="todayEventCount">0</strong><small>系统产生的有效告警事件</small></article>
<article class="stat-card"><div class="stat-head"><span>待处置事件</span><span class="stat-symbol danger"></span></div><strong id="pendingEventCount">0</strong><small>需要值班人员确认处理</small></article>
<article class="stat-card"><div class="stat-head"><span>模型状态</span><span class="stat-symbol success"></span></div><strong id="modelStatusValue" class="text-value">检查中</strong><small id="modelStatusDetail">正在读取权重状态</small></article>
<article class="stat-card"><div class="stat-head"><span>通知通道</span><span class="stat-symbol info"></span></div><strong id="channelCount">0</strong><small id="channelSummary">尚未启用机器人</small></article>
</div>
<div class="overview-grid">
<article class="panel trend-panel">
<div class="panel-header"><div><p class="section-kicker">ALERT TREND</p><h3>近期告警趋势</h3></div><div class="legend"><span><i class="legend-fire"></i>火焰</span><span><i class="legend-smoke"></i>烟雾</span></div></div>
<div id="trendChart" class="trend-chart" role="img" aria-label="近期火焰和烟雾告警趋势"></div>
</article>
<article class="panel readiness-panel">
<div class="panel-header"><div><p class="section-kicker">SYSTEM READINESS</p><h3>系统就绪度</h3></div></div>
<div class="readiness-list">
<div class="readiness-item"><span>模型权重</span><strong id="readyWeights">检查中</strong></div>
<div class="readiness-item"><span>检测服务</span><strong id="readyApi">检查中</strong></div>
<div class="readiness-item"><span>企业微信</span><strong id="readyWechat">未配置</strong></div>
<div class="readiness-item"><span>飞书机器人</span><strong id="readyFeishu">未配置</strong></div>
</div>
</article>
</div>
<article class="panel recent-panel">
<div class="panel-header"><div><p class="section-kicker">RECENT EVENTS</p><h3>最近告警事件</h3></div><button class="text-button" type="button" data-view-jump="events">查看全部 →</button></div>
<div id="recentEventList" class="event-list empty-list"><p>暂无告警事件。开始视频巡检后,满足连续帧条件的告警会显示在这里。</p></div>
</article>
</section> </section>
<section class="view" id="view-inspection" data-view-panel="inspection">
<div class="page-intro"><div><p class="section-kicker">VIDEO INSPECTION</p><h2>智能视频巡检</h2><p>选择本地监控视频,系统将按设定间隔抽帧并进行烟雾、火焰识别。</p></div><span id="inspectionStatus" class="status-chip status-idle">等待视频</span></div>
<div class="inspection-grid">
<article class="panel video-panel">
<div class="video-toolbar"><div><strong id="sourceLabel">未选择视频</strong><span id="videoMeta">支持 MP4、WebM 等浏览器可播放格式</span></div><span id="videoClock">00:00 / 00:00</span></div>
<div class="media-stage">
<video id="videoPreview" controls muted playsinline hidden></video>
<div id="emptyState" class="empty-state"><div class="upload-emblem"></div><strong>导入监控视频</strong><span>视频在浏览器本地播放,仅将抽取帧发送至检测服务</span></div>
<canvas id="overlayCanvas" aria-hidden="true"></canvas>
<div id="loadingState" class="loading-state" hidden><span class="spinner"></span>正在分析视频帧</div>
</div>
<div class="video-actions">
<label class="file-button"><input id="videoInput" type="file" accept="video/*" /><span>选择视频</span></label>
<button id="runDetectionButton" class="primary-button" type="button" disabled>开始连续检测</button>
<button id="clearButton" class="secondary-button" type="button">清除</button>
</div>
</article>
<div class="inspection-side">
<article class="panel detection-summary">
<div class="panel-header"><div><p class="section-kicker">LIVE RESULT</p><h3>实时检测摘要</h3></div><span id="lastUpdated" class="micro-copy">尚未检测</span></div>
<div class="detection-metrics">
<div><span>烟雾目标</span><strong id="smokeCount">0</strong></div>
<div><span>火焰目标</span><strong id="fireCount">0</strong></div>
<div><span>最高置信度</span><strong id="maxConfidence">--</strong></div>
<div><span>风险等级</span><strong id="riskStatus">待机</strong></div>
</div>
<div id="alertStatus" class="alert-status">机器人告警状态:检查中</div>
<div id="detectionList" class="detection-list"><p class="empty-copy">暂无检测结果</p></div>
</article>
<article class="panel inspection-settings">
<div class="panel-header"><div><p class="section-kicker">STRATEGY</p><h3>巡检策略</h3></div></div>
<label class="field-row"><span>检测间隔</span><select id="intervalSelect"><option value="500">0.5 秒</option><option value="1000" selected>1 秒</option><option value="2000">2 秒</option></select></label>
<div class="strategy-row"><span>连续确认帧</span><strong id="confirmFramesValue">3 帧</strong></div>
<div class="strategy-row"><span>告警冷却</span><strong id="cooldownValue">60 秒</strong></div>
<div class="strategy-note">连续检测到目标后才会生成告警事件,并按火焰、烟雾分别执行冷却。</div>
</article>
</div>
</div>
</section>
<section class="view" id="view-events" data-view-panel="events">
<div class="page-intro"><div><p class="section-kicker">ALERT CENTER</p><h2>告警事件管理</h2><p>集中查看、确认和关闭视频巡检产生的火灾风险事件。</p></div><button id="refreshEventsButton" class="secondary-button" type="button">刷新事件</button></div>
<div class="event-stat-row"><div><span>全部事件</span><strong id="allEventsCount">0</strong></div><div><span>待处置</span><strong id="pendingEventsCount">0</strong></div><div><span>已确认</span><strong id="ackEventsCount">0</strong></div><div><span>已解决</span><strong id="resolvedEventsCount">0</strong></div></div>
<article class="panel event-table-panel">
<div class="table-toolbar"><div class="filter-group" role="group" aria-label="事件状态筛选"><button type="button" class="filter-button is-active" data-event-filter="">全部</button><button type="button" class="filter-button" data-event-filter="pending">待处置</button><button type="button" class="filter-button" data-event-filter="acknowledged">已确认</button><button type="button" class="filter-button" data-event-filter="resolved">已解决</button></div><span id="eventTableMeta">0 条记录</span></div>
<div class="table-wrap"><table class="event-table"><thead><tr><th>事件编号</th><th>时间</th><th>类型</th><th>置信度</th><th>通知通道</th><th>状态</th><th>操作</th></tr></thead><tbody id="eventTableBody"><tr><td colspan="7" class="empty-cell">暂无告警事件</td></tr></tbody></table></div>
</article>
</section>
<section class="view" id="view-risks" data-view-panel="risks">
<div class="page-intro"><div><p class="section-kicker">RISK REGISTER</p><h2>风险台账</h2><p>从模型阈值、告警策略和处置状态评估当前风险治理情况。</p></div></div>
<div class="risk-grid">
<article class="panel risk-card"><span class="risk-level high">高风险</span><h3>未处置告警事件</h3><strong id="riskPendingValue">0</strong><p>待值班人员确认的火焰或烟雾事件。</p><button class="text-button" type="button" data-view-jump="events">进入告警中心 →</button></article>
<article class="panel risk-card"><span class="risk-level medium">策略风险</span><h3>告警通知覆盖</h3><strong id="riskChannelValue">0 / 2</strong><p>建议至少启用一个机器人通知通道。</p><button class="text-button" type="button" data-view-jump="channels">检查通知通道 →</button></article>
<article class="panel risk-card"><span class="risk-level low">模型策略</span><h3>检测置信度阈值</h3><strong id="riskConfidenceValue">--</strong><p>阈值过低会增加误报,过高可能遗漏早期烟雾。</p><button class="text-button" type="button" data-view-jump="settings">查看系统参数 →</button></article>
</div>
<article class="panel checklist-panel"><div class="panel-header"><div><p class="section-kicker">PREVENTION CHECKLIST</p><h3>火灾预防工作清单</h3></div></div><div class="checklist"><label><input type="checkbox" />视频监控覆盖重点区域</label><label><input type="checkbox" />告警机器人可正常接收消息</label><label><input type="checkbox" />值班人员明确事件处置流程</label><label><input type="checkbox" />模型权重与阈值定期复核</label><label><input type="checkbox" />消防通道和设备状态已巡查</label><label><input type="checkbox" />告警事件完成闭环记录</label></div></article>
</section>
<section class="view" id="view-channels" data-view-panel="channels">
<div class="page-intro"><div><p class="section-kicker">NOTIFICATION CHANNELS</p><h2>通知通道</h2><p>管理告警触达方式。敏感凭据保存在服务端本地环境文件中。</p></div></div>
<div class="channel-grid">
<article class="panel channel-card"><div class="channel-logo wechat-logo"></div><div class="channel-content"><div><h3>企业微信群机器人</h3><span id="wechatChannelBadge" class="channel-badge">未配置</span></div><p>告警时发送 Markdown 消息与带检测框截图。</p><code>WECHAT_WEBHOOK_URL</code></div></article>
<article class="panel channel-card"><div class="channel-logo feishu-logo"></div><div class="channel-content"><div><h3>飞书自定义机器人</h3><span id="feishuChannelBadge" class="channel-badge">未配置</span></div><p>告警时发送红色交互卡片,支持签名校验。</p><code>FEISHU_WEBHOOK_URL / FEISHU_SECRET</code></div></article>
</div>
<article class="panel config-guide"><div class="panel-header"><div><p class="section-kicker">CONFIGURATION</p><h3>配置说明</h3></div></div><ol><li>在企业微信或飞书群中添加自定义机器人并复制 Webhook。</li><li>将 Webhook 填入项目根目录 <code>.env</code> 对应字段。</li><li>飞书开启签名校验时,同时填写 <code>FEISHU_SECRET</code></li><li>重启后端服务,平台将自动显示通道启用状态。</li></ol></article>
</section>
<section class="view" id="view-settings" data-view-panel="settings">
<div class="page-intro"><div><p class="section-kicker">SYSTEM SETTINGS</p><h2>系统设置</h2><p>查看当前生效的模型和告警参数。配置修改后需重启服务。</p></div></div>
<div class="settings-grid">
<article class="panel settings-card"><div class="panel-header"><div><p class="section-kicker">MODEL</p><h3>模型配置</h3></div></div><dl><div><dt>权重文件</dt><dd id="settingWeights">--</dd></div><div><dt>输入尺寸</dt><dd id="settingImageSize">--</dd></div><div><dt>置信度阈值</dt><dd id="settingConfidence">--</dd></div><div><dt>IOU 阈值</dt><dd id="settingIou">--</dd></div></dl></article>
<article class="panel settings-card"><div class="panel-header"><div><p class="section-kicker">ALERT POLICY</p><h3>告警策略</h3></div></div><dl><div><dt>连续确认</dt><dd id="settingConfirmFrames">--</dd></div><div><dt>分类冷却</dt><dd id="settingCooldown">--</dd></div><div><dt>事件存储</dt><dd>进程内最近 500 条</dd></div><div><dt>视频处理</dt><dd>浏览器本地抽帧</dd></div></dl></article>
</div>
<article class="panel env-panel"><div class="panel-header"><div><p class="section-kicker">ENVIRONMENT</p><h3>环境变量参考</h3></div></div><pre><code>YOLO_WEIGHTS=...
YOLO_DEVICE=0
YOLO_IMGSZ=768
YOLO_CONF=0.40
YOLO_IOU=0.45
ALERT_CONFIRM_FRAMES=3
ALERT_COOLDOWN_SECONDS=60</code></pre></article>
</section>
</div>
</main> </main>
</div>
<div id="toastRegion" class="toast-region" aria-live="polite"></div>
<script src="./app.js" type="module"></script> <script src="./app.js" type="module"></script>
</body> </body>
</html> </html>

View File

@@ -1,75 +1,193 @@
:root { :root {
color-scheme: dark; color-scheme: dark;
--bg: #101417; --bg: #071017;
--surface: #171d21; --sidebar: #0a141c;
--surface-raised: #1e272c; --surface: #0e1a23;
--line: #2c373d; --surface-2: #13232e;
--text: #edf3f2; --surface-3: #192d39;
--muted: #93a2a5; --line: #203540;
--cyan: #55d5c2; --line-soft: #182b35;
--cyan-deep: #183d3b; --text: #edf4f5;
--orange: #ffb454; --muted: #8498a1;
--red: #ff786b; --cyan: #41d6c3;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; --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; } * { 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, input, select { font: inherit; }
button { cursor: pointer; } button { cursor: pointer; }
.app-shell { width: min(1380px, calc(100% - 40px)); margin: 0 auto; padding: 28px 0 40px; } button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; }
.topbar, .panel-heading, .stage-footer { display: flex; align-items: center; justify-content: space-between; gap: 20px; } h1, h2, h3, p { margin-top: 0; }
.topbar { border-bottom: 1px solid var(--line); padding-bottom: 24px; } h1, h2, h3, strong { font-weight: 700; }
.eyebrow { margin: 0 0 7px; color: var(--cyan); font-size: 11px; font-weight: 800; letter-spacing: 0.12em; } code, pre { font-family: "Cascadia Code", Consolas, monospace; }
h1, h2, p { margin-top: 0; }
h1 { margin-bottom: 0; font-size: clamp(24px, 4vw, 38px); letter-spacing: 0; } .platform-shell { display: grid; grid-template-columns: 248px minmax(0, 1fr); min-height: 100vh; }
h2 { margin-bottom: 0; font-size: 17px; } .sidebar { position: sticky; top: 0; z-index: 20; display: flex; height: 100vh; flex-direction: column; border-right: 1px solid var(--line); background: var(--sidebar); }
.status-pill { border: 1px solid var(--line); border-radius: 999px; padding: 8px 12px; color: var(--muted); font-size: 12px; white-space: nowrap; } .brand { display: flex; min-height: 82px; align-items: center; gap: 12px; border-bottom: 1px solid var(--line); padding: 0 22px; }
.status-ready { border-color: #286f67; background: var(--cyan-deep); color: var(--cyan); } .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; }
.status-alert { border-color: #81473f; background: #392321; color: var(--red); } .brand div:last-child { display: grid; gap: 3px; }
.workspace { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 18px; margin-top: 22px; } .brand strong { font-size: 19px; letter-spacing: .12em; }
.stage-panel, .control-panel { border: 1px solid var(--line); background: var(--surface); } .brand span { color: var(--muted); font-size: 11px; }
.stage-panel { min-width: 0; padding: 20px; } .main-nav { display: grid; gap: 4px; padding: 22px 14px; }
.control-panel { padding: 20px; } .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; }
.muted { color: var(--muted); font-size: 13px; } .nav-item:hover { background: #10202a; color: var(--text); }
.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); } .nav-item.is-active { border-left-color: var(--cyan); background: linear-gradient(90deg, #163632, #10212a 74%); color: var(--cyan); }
.media-stage img, .media-stage video { display: block; width: 100%; height: 100%; max-height: min(66vh, 680px); object-fit: contain; } .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; } .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-state { display: grid; justify-items: center; gap: 9px; padding: 30px; 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; } .empty-state strong { color: var(--text); font-size: 14px; }.empty-state span { max-width: 420px; font-size: 11px; line-height: 1.6; }
.loading-state { position: absolute; inset: auto 16px 16px auto; padding: 10px 12px; background: #0e1718e8; border: 1px solid #286f67; color: var(--cyan); font-size: 12px; } .upload-emblem { display: grid; width: 52px; height: 52px; place-items: center; border: 1px dashed #3f635f; color: var(--cyan); font-size: 26px; }
.stage-footer { color: var(--muted); font-size: 12px; } .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; }
.control-stack { display: grid; gap: 10px; margin-top: 22px; } .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); } }
.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; } .video-actions { display: flex; flex-wrap: wrap; gap: 9px; margin-top: 14px; }.file-button input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.button:disabled { cursor: not-allowed; opacity: 0.45; } .inspection-side { display: grid; align-content: start; gap: 16px; }
.button-primary { background: var(--cyan); color: #0b1918; } .micro-copy { color: var(--muted); font-size: 9px; }
.button-secondary { border-color: #3b5658; background: var(--surface-raised); color: var(--text); } .detection-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.button-secondary:hover, .button-quiet:hover { border-color: var(--cyan); color: var(--cyan); } .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); }
.button-quiet { border-color: var(--line); background: transparent; color: var(--muted); } .detection-metrics span { color: var(--muted); font-size: 10px; }.detection-metrics strong { font-size: 20px; }
.upload-control { display: grid; gap: 8px; } .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; }
.upload-control input { position: absolute; width: 1px; height: 1px; opacity: 0; } .alert-status.alert-triggered { border-color: var(--red); background: var(--red-soft); color: var(--red); }
.upload-control small { color: var(--muted); font-size: 11px; } .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); }
.setting-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; color: var(--muted); font-size: 13px; } .empty-copy { margin: 0; padding: 12px 0; color: var(--muted); font-size: 11px; text-align: center; }
.setting-row select { min-height: 38px; border: 1px solid var(--line); border-radius: 6px; padding: 0 10px; background: var(--surface-raised); color: var(--text); } .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; }
.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); } .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; }
.divider { height: 1px; margin: 24px 0; background: var(--line); } .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; }
.compact-heading { margin-bottom: 14px; } .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; }
.metrics-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .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); }
.metric-card { display: grid; gap: 8px; min-height: 78px; padding: 12px; border: 1px solid var(--line); background: var(--surface-raised); } .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); }
.metric-card span { color: var(--muted); font-size: 12px; }
.metric-card strong { font-size: 20px; } .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); }
.detection-list { display: grid; gap: 8px; margin-top: 14px; } .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); }
.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); } .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) { @media (max-width: 860px) {
.app-shell { width: min(100% - 24px, 680px); padding-top: 18px; } .platform-shell { grid-template-columns: 1fr; }
.workspace { grid-template-columns: 1fr; } .sidebar { position: fixed; left: 0; transform: translateX(-101%); width: 248px; transition: transform .2s ease; box-shadow: 18px 0 40px #0008; }
.media-stage { min-height: 48vh; } .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) { @media (max-width: 620px) {
.topbar { align-items: flex-start; flex-direction: column; } .topbar-actions .status-chip { display: none; }
.stage-panel, .control-panel { padding: 14px; } .hero-strip, .page-intro { align-items: flex-start; flex-direction: column; }
.stage-footer { align-items: flex-end; flex-direction: column; } .hero-strip .primary-button, .page-intro .secondary-button { width: 100%; }
.stage-footer .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; }
} }