feat: add fire prevention management platform
This commit is contained in:
399
frontend/app.js
399
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 = "<p>暂无告警事件。开始视频巡检后,满足连续帧条件的告警会显示在这里。</p>";
|
||||
return;
|
||||
}
|
||||
elements.recentEventList.innerHTML = events.map((event) => {
|
||||
const primary = event.classes.includes("fire") ? "fire" : "smoke";
|
||||
const label = event.classes.map(classLabel).join("、");
|
||||
return `<div class="event-list-item"><span class="event-type-icon ${primary}">${primary === "fire" ? "火" : "烟"}</span><div class="event-description"><strong>${label}检测告警</strong><span>${formatDate(event.created_at)} · ${event.id}</span></div><strong class="event-confidence">${Math.round(event.max_confidence * 100)}%</strong><span class="event-status status-${event.status}">${STATUS_LABELS[event.status]}</span></div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function renderEventTable(events) {
|
||||
elements.eventTableMeta.textContent = `${events.length} 条记录`;
|
||||
if (!events.length) {
|
||||
elements.eventTableBody.innerHTML = '<tr><td colspan="7" class="empty-cell">当前筛选条件下暂无告警事件</td></tr>';
|
||||
return;
|
||||
}
|
||||
elements.eventTableBody.innerHTML = events.map((event) => {
|
||||
const classes = event.classes.map((name) => `<span class="type-tag type-${name}">${classLabel(name)}</span>`).join(" ");
|
||||
const channels = enabledChannelNames(event.notification_channels).join(" + ") || "未发送";
|
||||
return `<tr><td>${event.id}</td><td>${formatDate(event.created_at)}</td><td>${classes}</td><td>${Math.round(event.max_confidence * 100)}%</td><td>${channels}</td><td><span class="event-status status-${event.status}">${STATUS_LABELS[event.status]}</span></td><td><div class="table-actions">${event.status === "pending" ? `<button class="table-action" data-event-id="${event.id}" data-event-status="acknowledged">确认</button>` : ""}${event.status !== "resolved" ? `<button class="table-action" data-event-id="${event.id}" data-event-status="resolved">解决</button>` : ""}</div></td></tr>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function classLabel(name) {
|
||||
return name === "fire" ? "火焰" : "烟雾";
|
||||
}
|
||||
|
||||
async function updateEventStatus(eventId, status) {
|
||||
try {
|
||||
const response = await fetch(`/api/events/${eventId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`API ${response.status}`);
|
||||
showToast(`事件 ${eventId} 已更新为${STATUS_LABELS[status]}`, "success");
|
||||
await Promise.all([loadEvents(), loadDashboard()]);
|
||||
} catch (error) {
|
||||
showToast(`事件更新失败:${error.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function renderTrend(events) {
|
||||
const days = Array.from({ length: 7 }, (_, index) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - (6 - index));
|
||||
return { key: date.toISOString().slice(0, 10), label: `${date.getMonth() + 1}/${date.getDate()}`, fire: 0, smoke: 0 };
|
||||
});
|
||||
events.forEach((event) => {
|
||||
const day = days.find((item) => item.key === String(event.created_at).slice(0, 10));
|
||||
if (!day) return;
|
||||
if (event.classes.includes("fire")) day.fire += 1;
|
||||
if (event.classes.includes("smoke")) day.smoke += 1;
|
||||
});
|
||||
const width = 720;
|
||||
const height = 225;
|
||||
const padding = { left: 34, right: 12, top: 14, bottom: 28 };
|
||||
const maxValue = Math.max(3, ...days.flatMap((day) => [day.fire, day.smoke]));
|
||||
const x = (index) => padding.left + index * ((width - padding.left - padding.right) / 6);
|
||||
const y = (value) => height - padding.bottom - value * ((height - padding.top - padding.bottom) / maxValue);
|
||||
const path = (key) => days.map((day, index) => `${index ? "L" : "M"}${x(index)},${y(day[key])}`).join(" ");
|
||||
const gridValues = [0, Math.ceil(maxValue / 2), maxValue];
|
||||
elements.trendChart.innerHTML = `<svg viewBox="0 0 ${width} ${height}" preserveAspectRatio="none"><title>最近七天火焰与烟雾告警趋势</title>${gridValues.map((value) => `<line class="chart-grid" x1="${padding.left}" x2="${width - padding.right}" y1="${y(value)}" y2="${y(value)}"></line><text class="chart-axis-label" x="4" y="${y(value) + 3}">${value}</text>`).join("")}<path class="chart-fire" d="${path("fire")}"></path><path class="chart-smoke" d="${path("smoke")}"></path>${days.map((day, index) => `<circle class="chart-point-fire" cx="${x(index)}" cy="${y(day.fire)}" r="3"></circle><circle class="chart-point-smoke" cx="${x(index)}" cy="${y(day.smoke)}" r="3"></circle><text class="chart-axis-label" text-anchor="middle" x="${x(index)}" y="${height - 8}">${day.label}</text>`).join("")}</svg>`;
|
||||
}
|
||||
|
||||
function clearDetectionResults() {
|
||||
elements.smokeCount.textContent = "0";
|
||||
elements.fireCount.textContent = "0";
|
||||
elements.maxConfidence.textContent = "--";
|
||||
elements.riskStatus.textContent = "待机";
|
||||
elements.detectionList.innerHTML = '<p class="muted">暂无检测结果</p>';
|
||||
elements.detectionList.innerHTML = '<p class="empty-copy">暂无检测结果</p>';
|
||||
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) => `<div class="detection-row"><span>${item.class === "fire" ? "火焰" : "烟雾"}</span><strong>${Math.round(item.confidence * 100)}%</strong></div>`).join("")
|
||||
: '<p class="muted">未发现目标</p>';
|
||||
? detections.map((item) => `<div class="detection-row"><span>${classLabel(item.class)}</span><strong>${Math.round(item.confidence * 100)}%</strong></div>`).join("")
|
||||
: '<p class="empty-copy">未发现烟雾或火焰目标</p>';
|
||||
const alert = result.alert || {};
|
||||
const channelNames = enabledChannelNames(alert.notification_channels);
|
||||
if (alert.triggered) {
|
||||
const labels = alert.classes.map((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 = `<p class="muted">${error.message}</p>`;
|
||||
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()]);
|
||||
|
||||
@@ -3,88 +3,177 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<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" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="app-shell">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">YOLO11S / VIDEO DETECTION</p>
|
||||
<h1>烟火视频检测</h1>
|
||||
</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 class="platform-shell">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand-mark" aria-hidden="true">燧</div>
|
||||
<div>
|
||||
<strong>燧安</strong>
|
||||
<span>火灾预防管理平台</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="main-nav" aria-label="主导航">
|
||||
<button class="nav-item is-active" type="button" data-view="overview"><span class="nav-icon">▦</span><span>系统总览</span></button>
|
||||
<button class="nav-item" type="button" data-view="inspection"><span class="nav-icon">▶</span><span>视频巡检</span></button>
|
||||
<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>
|
||||
<button class="nav-item" type="button" data-view="risks"><span class="nav-icon">◇</span><span>风险台账</span></button>
|
||||
<button class="nav-item" type="button" data-view="channels"><span class="nav-icon">⌁</span><span>通知通道</span></button>
|
||||
<button class="nav-item" type="button" data-view="settings"><span class="nav-icon">⚙</span><span>系统设置</span></button>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="system-indicator"><span id="sidebarStatusDot" class="status-dot"></span><div><strong id="sidebarStatus">连接中</strong><span>智能视觉预警服务</span></div></div>
|
||||
<span class="version">YOLO11S · v0.1.0</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside class="control-panel">
|
||||
<div class="panel-heading">
|
||||
<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="eyebrow">INPUT</p>
|
||||
<h2>视频源</h2>
|
||||
<p class="breadcrumb">消防安全 / <span id="viewBreadcrumb">系统总览</span></p>
|
||||
<h1 id="viewTitle">系统总览</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-stack">
|
||||
<label class="upload-control">
|
||||
<span class="button button-secondary">选择视频</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 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="divider"></div>
|
||||
<div class="panel-heading compact-heading">
|
||||
<div>
|
||||
<p class="eyebrow">RESULTS</p>
|
||||
<h2>检测摘要</h2>
|
||||
<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>
|
||||
<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>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<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 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>
|
||||
</div>
|
||||
<div id="toastRegion" class="toast-region" aria-live="polite"></div>
|
||||
<script src="./app.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user