feat: SQLite persistence, non-blocking inference, modular frontend
Backend:
- Persist alert events and robot delivery counters in SQLite (data/app.db,
YOLO_DB_PATH override); EventStore keeps its interface, DeliveryStatsStore
uses read/write-through UPSERT; in-memory fallback remains for standalone
AlertManager use
- Serve /api/detect from a sync endpoint and serialize YOLO inference with a
lock, so concurrent requests no longer block the event loop
- Unify single-image and session detection: requests without session_id
share the fixed single-image session and run full frame confirmation
- Store UTC Z-suffixed timestamps for sortable string comparison; close
executor and database on shutdown via FastAPI lifespan
Frontend:
- Split app.js into ES modules (main.js + modules/{dom,api,ui,charts,store}
+ views/{dashboard,inspection,events,robots,robotCards}), no build step
- Escape all server data interpolated into innerHTML; guard missing
event.classes; replace lazy element ID list with memoized qs()
- Remove hardcoded fake stats (trend badge, device donut segment)
Docs:
- Rewrite README: accurate weight policy (only trained best.pt committed,
no Git LFS), SQLite persistence, single-worker note, data asset inventory
- Align models/pretrained/README.md with actual files; add YOLO_DB_PATH to
.env.example; add persistence unit tests; ruff clean
This commit is contained in:
104
frontend/views/dashboard.js
Normal file
104
frontend/views/dashboard.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import { qs, escapeHtml, formatDate, STATUS_LABELS, classLabel, enabledChannelNames } from "../modules/dom.js";
|
||||
import { apiFetch } from "../modules/api.js";
|
||||
import { showToast, setServiceStatus, setChannelReady } from "../modules/ui.js";
|
||||
import { renderTrend } from "../modules/charts.js";
|
||||
import { renderRobots } from "./robotCards.js";
|
||||
import { store } from "../modules/store.js";
|
||||
|
||||
export async function loadDashboard() {
|
||||
try {
|
||||
const data = await apiFetch("/api/dashboard");
|
||||
setServiceStatus(true);
|
||||
updateDashboard(data);
|
||||
} catch (error) {
|
||||
setServiceStatus(false);
|
||||
showToast(`无法读取管理数据:${error.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function updateDashboard(data) {
|
||||
store.dashboardData = data;
|
||||
const { summary, system, detection } = data;
|
||||
const channels = system.alert_channels || {};
|
||||
const channelNames = enabledChannelNames(channels);
|
||||
qs("#todayEventCount").textContent = String(summary.today);
|
||||
qs("#pendingEventCount").textContent = String(summary.pending);
|
||||
qs("#pendingNavCount").textContent = String(summary.pending);
|
||||
qs("#notificationDot").hidden = summary.pending === 0;
|
||||
qs("#modelStatusValue").textContent = system.weights_available ? "运行正常" : "权重缺失";
|
||||
qs("#modelStatusDetail").textContent = system.weights_available ? "模型文件已就绪" : "请检查 YOLO_WEIGHTS";
|
||||
qs("#modelStatusValue").className = "text-value";
|
||||
const robotPayload = data.robots || { robots: [], summary: {} };
|
||||
const robotSummary = robotPayload.summary || {};
|
||||
qs("#onlineRobotCount").textContent = String(robotSummary.configured || 0);
|
||||
qs("#robotSummary").textContent = `${robotSummary.configured || 0} / ${robotSummary.total || 2} 已配置 · 成功投递 ${robotSummary.delivered || 0} 次`;
|
||||
qs("#overviewMessage").textContent = summary.pending
|
||||
? `当前有 ${summary.pending} 条告警事件等待处置,请尽快进入告警中心确认。`
|
||||
: "当前无待处置事件,模型与视频巡检服务保持监测状态。";
|
||||
qs("#readyWeights").textContent = system.weights_available ? "已就绪" : "缺失";
|
||||
qs("#readyWeights").className = system.weights_available ? "ready" : "not-ready";
|
||||
setChannelReady(qs("#readyWechat"), channels.wechat);
|
||||
setChannelReady(qs("#readyFeishu"), channels.feishu);
|
||||
qs("#riskPendingValue").textContent = String(summary.pending);
|
||||
qs("#riskChannelValue").textContent = `${channelNames.length} / 2`;
|
||||
qs("#riskConfidenceValue").textContent = `${Math.round(detection.confidence * 100)}%`;
|
||||
qs("#confirmFramesValue").textContent = `${detection.confirm_frames} 帧`;
|
||||
qs("#cooldownValue").textContent = `${detection.cooldown_seconds} 秒`;
|
||||
qs("#settingWeights").textContent = system.weights;
|
||||
qs("#settingImageSize").textContent = `${detection.image_size} px`;
|
||||
qs("#settingConfidence").textContent = detection.confidence.toFixed(2);
|
||||
qs("#settingIou").textContent = detection.iou.toFixed(2);
|
||||
qs("#settingConfirmFrames").textContent = `${detection.confirm_frames} 帧`;
|
||||
qs("#settingCooldown").textContent = `${detection.cooldown_seconds} 秒`;
|
||||
renderRecentEvents(data.recent_events || []);
|
||||
const visualEvents = store.eventData.length ? store.eventData : data.recent_events || [];
|
||||
renderTrend(visualEvents);
|
||||
updateCommandVisuals(visualEvents, summary, channels, robotSummary);
|
||||
renderRobots(robotPayload, detection);
|
||||
}
|
||||
|
||||
function updateCommandVisuals(events, summary, channels, robotSummary = {}) {
|
||||
const totalRobots = robotSummary.total || 2;
|
||||
const configuredRobots = robotSummary.configured || 0;
|
||||
const total = summary.total || 0;
|
||||
const closureRate = total ? Math.round(((summary.resolved || 0) / total) * 100) : 100;
|
||||
const notificationRate = Math.round((configuredRobots / totalRobots) * 100);
|
||||
const safetyScore = Math.max(42, Math.min(98, Math.round(notificationRate * 0.35 + closureRate * 0.35 + Math.max(0, 100 - (summary.pending || 0) * 8) * 0.3)));
|
||||
qs("#deviceOnlineRate").textContent = `${notificationRate}%`;
|
||||
qs("#eventClosureRate").textContent = `${closureRate}%`;
|
||||
qs("#notificationCoverage").textContent = `${notificationRate}%`;
|
||||
qs("#safetyScore").textContent = String(safetyScore);
|
||||
qs("#safetyGauge").style.setProperty("--score", safetyScore);
|
||||
qs("#overviewWechatState").textContent = channels.wechat ? "已配置" : "未配置";
|
||||
qs("#overviewFeishuState").textContent = channels.feishu ? "已配置" : "未配置";
|
||||
qs("#overviewPolicyText").textContent = `${store.dashboardData?.detection?.confirm_frames || 3} 帧确认`;
|
||||
qs("#overviewDeliveryCount").textContent = `累计成功 ${robotSummary.delivered || 0} 次`;
|
||||
|
||||
let fireEvents = 0;
|
||||
let smokeEvents = 0;
|
||||
events.forEach((event) => {
|
||||
const classes = event.classes || [];
|
||||
if (classes.includes("fire")) fireEvents += 1;
|
||||
if (classes.includes("smoke")) smokeEvents += 1;
|
||||
});
|
||||
const detectedTotal = fireEvents + smokeEvents;
|
||||
const fireArc = detectedTotal ? Math.round((fireEvents / detectedTotal) * 100) : 0;
|
||||
const smokeArc = detectedTotal ? 100 - fireArc : 0;
|
||||
qs("#riskTotal").textContent = String(detectedTotal);
|
||||
qs("#fireRiskRatio").textContent = `${detectedTotal ? Math.round((fireEvents / detectedTotal) * 100) : 0}%`;
|
||||
qs("#smokeRiskRatio").textContent = `${detectedTotal ? Math.round((smokeEvents / detectedTotal) * 100) : 0}%`;
|
||||
qs("#riskDonut").style.background = `conic-gradient(var(--red) 0 ${fireArc}%, var(--cyan) ${fireArc}% ${fireArc + smokeArc}%, var(--line) ${fireArc + smokeArc}% 100%)`;
|
||||
}
|
||||
|
||||
function renderRecentEvents(events) {
|
||||
if (!events.length) {
|
||||
qs("#recentEventList").innerHTML = "<p>暂无告警事件。开始视频巡检后,满足连续帧条件的告警会显示在这里。</p>";
|
||||
return;
|
||||
}
|
||||
qs("#recentEventList").innerHTML = events.map((event) => {
|
||||
const classes = event.classes || [];
|
||||
const primary = classes.includes("fire") ? "fire" : "smoke";
|
||||
const label = 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)} · ${escapeHtml(event.id)}</span></div><strong class="event-confidence">${Math.round(event.max_confidence * 100)}%</strong><span class="event-status status-${escapeHtml(event.status)}">${STATUS_LABELS[event.status] || escapeHtml(event.status)}</span></div>`;
|
||||
}).join("");
|
||||
}
|
||||
77
frontend/views/events.js
Normal file
77
frontend/views/events.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import { qs, escapeHtml, STATUS_LABELS, classLabel, formatDate, enabledChannelNames } from "../modules/dom.js";
|
||||
import { apiFetch } from "../modules/api.js";
|
||||
import { showToast, setServiceStatus } from "../modules/ui.js";
|
||||
import { renderTrend } from "../modules/charts.js";
|
||||
import { store } from "../modules/store.js";
|
||||
import { loadDashboard } from "./dashboard.js";
|
||||
|
||||
let eventFilter = "";
|
||||
|
||||
export async function loadEvents() {
|
||||
try {
|
||||
const query = eventFilter ? `?status=${eventFilter}` : "";
|
||||
const result = await apiFetch(`/api/events${query}`);
|
||||
store.eventData = result.events || [];
|
||||
renderEventTable(store.eventData);
|
||||
updateEventSummary(result.summary);
|
||||
renderTrend(store.eventData);
|
||||
setServiceStatus(true);
|
||||
} catch (error) {
|
||||
setServiceStatus(false);
|
||||
showToast(`无法读取告警事件:${error.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function updateEventSummary(summary) {
|
||||
qs("#allEventsCount").textContent = String(summary.total);
|
||||
qs("#pendingEventsCount").textContent = String(summary.pending);
|
||||
qs("#ackEventsCount").textContent = String(summary.acknowledged);
|
||||
qs("#resolvedEventsCount").textContent = String(summary.resolved);
|
||||
qs("#pendingNavCount").textContent = String(summary.pending);
|
||||
qs("#notificationDot").hidden = summary.pending === 0;
|
||||
}
|
||||
|
||||
function renderEventTable(events) {
|
||||
qs("#eventTableMeta").textContent = `${events.length} 条记录`;
|
||||
if (!events.length) {
|
||||
qs("#eventTableBody").innerHTML = '<tr><td colspan="7" class="empty-cell">当前筛选条件下暂无告警事件</td></tr>';
|
||||
return;
|
||||
}
|
||||
qs("#eventTableBody").innerHTML = events.map((event) => {
|
||||
const classes = (event.classes || [])
|
||||
.map((name) => `<span class="type-tag type-${escapeHtml(name)}">${classLabel(name)}</span>`)
|
||||
.join(" ");
|
||||
const channels = enabledChannelNames(event.notification_channels).join(" + ") || "未发送";
|
||||
const statusLabel = STATUS_LABELS[event.status] || escapeHtml(event.status);
|
||||
return `<tr><td>${escapeHtml(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-${escapeHtml(event.status)}">${statusLabel}</span></td><td><div class="table-actions">${event.status === "pending" ? `<button class="table-action" data-event-id="${escapeHtml(event.id)}" data-event-status="acknowledged">确认</button>` : ""}${event.status !== "resolved" ? `<button class="table-action" data-event-id="${escapeHtml(event.id)}" data-event-status="resolved">解决</button>` : ""}</div></td></tr>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
export async function updateEventStatus(eventId, status) {
|
||||
try {
|
||||
await apiFetch(`/api/events/${encodeURIComponent(eventId)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
showToast(`事件 ${eventId} 已更新为${STATUS_LABELS[status]}`, "success");
|
||||
await Promise.all([loadEvents(), loadDashboard()]);
|
||||
} catch (error) {
|
||||
showToast(`事件更新失败:${error.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
export function initEvents() {
|
||||
qs("#refreshEventsButton").addEventListener("click", loadEvents);
|
||||
qs("#eventTableBody").addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-event-id]");
|
||||
if (button) updateEventStatus(button.dataset.eventId, button.dataset.eventStatus);
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
196
frontend/views/inspection.js
Normal file
196
frontend/views/inspection.js
Normal file
@@ -0,0 +1,196 @@
|
||||
import { qs, classLabel, formatTime, enabledChannelNames } from "../modules/dom.js";
|
||||
import { apiFetch } from "../modules/api.js";
|
||||
import { showToast, setServiceStatus } from "../modules/ui.js";
|
||||
import { loadDashboard } from "./dashboard.js";
|
||||
|
||||
const API_ENDPOINT = "/api/detect";
|
||||
const frameCanvas = document.createElement("canvas");
|
||||
let videoUrl = null;
|
||||
let sessionId = null;
|
||||
let detectionActive = false;
|
||||
let requestInFlight = false;
|
||||
let lastDetectionAt = 0;
|
||||
|
||||
function setInspectionStatus(text, className) {
|
||||
const status = qs("#inspectionStatus");
|
||||
status.textContent = text;
|
||||
status.className = className;
|
||||
}
|
||||
|
||||
function clearDetectionResults() {
|
||||
qs("#smokeCount").textContent = "0";
|
||||
qs("#fireCount").textContent = "0";
|
||||
qs("#maxConfidence").textContent = "--";
|
||||
qs("#riskStatus").textContent = "待机";
|
||||
qs("#detectionList").innerHTML = '<p class="empty-copy">暂无检测结果</p>';
|
||||
const context = qs("#overlayCanvas").getContext("2d");
|
||||
context.clearRect(0, 0, qs("#overlayCanvas").width, qs("#overlayCanvas").height);
|
||||
}
|
||||
|
||||
function drawDetections(detections = []) {
|
||||
const video = qs("#videoPreview");
|
||||
if (!video.videoWidth || !video.videoHeight) return;
|
||||
const canvas = qs("#overlayCanvas");
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
const context = canvas.getContext("2d");
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
detections.forEach((detection) => {
|
||||
const [x1, y1, x2, y2] = detection.box || [];
|
||||
const color = detection.class === "fire" ? "#ff665e" : "#41d6c3";
|
||||
context.strokeStyle = color;
|
||||
context.lineWidth = Math.max(2, canvas.width / 320);
|
||||
context.strokeRect(x1, y1, x2 - x1, y2 - y1);
|
||||
context.fillStyle = color;
|
||||
context.font = `${Math.max(13, canvas.width / 60)}px sans-serif`;
|
||||
context.fillText(`${detection.class} ${Math.round(detection.confidence * 100)}%`, x1 + 4, Math.max(18, y1 - 6));
|
||||
});
|
||||
}
|
||||
|
||||
function updateDetectionResults(result) {
|
||||
const detections = result.detections || [];
|
||||
const smoke = detections.filter((item) => item.class === "smoke").length;
|
||||
const fire = detections.filter((item) => item.class === "fire").length;
|
||||
const max = detections.reduce((highest, item) => Math.max(highest, item.confidence || 0), 0);
|
||||
qs("#smokeCount").textContent = String(smoke);
|
||||
qs("#fireCount").textContent = String(fire);
|
||||
qs("#maxConfidence").textContent = max ? `${Math.round(max * 100)}%` : "--";
|
||||
qs("#riskStatus").textContent = fire ? "高风险" : smoke ? "需关注" : "正常";
|
||||
qs("#detectionList").innerHTML = detections.length
|
||||
? detections.map((item) => `<div class="detection-row"><span>${classLabel(item.class)}</span><strong>${Math.round(item.confidence * 100)}%</strong></div>`).join("")
|
||||
: '<p class="empty-copy">未发现烟雾或火焰目标</p>';
|
||||
const alert = result.alert || {};
|
||||
const channelNames = enabledChannelNames(alert.notification_channels);
|
||||
if (alert.triggered) {
|
||||
const labels = alert.classes.map(classLabel).join("、");
|
||||
const alertStatus = qs("#alertStatus");
|
||||
alertStatus.textContent = channelNames.length ? `已触发 ${channelNames.join(" + ")} 告警:${labels}` : `已生成告警事件:${labels}(机器人未配置)`;
|
||||
alertStatus.className = "alert-status alert-triggered";
|
||||
showToast(`检测到${labels},已生成告警事件`, "error");
|
||||
loadDashboard();
|
||||
} else {
|
||||
const fireFrames = alert.consecutive?.fire || 0;
|
||||
const smokeFrames = alert.consecutive?.smoke || 0;
|
||||
const alertStatus = qs("#alertStatus");
|
||||
alertStatus.textContent = channelNames.length ? `${channelNames.join(" + ")}已启用 · 连续帧 火焰 ${fireFrames} / 烟雾 ${smokeFrames}` : `机器人未配置 · 连续帧 火焰 ${fireFrames} / 烟雾 ${smokeFrames}`;
|
||||
alertStatus.className = "alert-status";
|
||||
}
|
||||
drawDetections(detections);
|
||||
qs("#lastUpdated").textContent = `推理 ${result.inference_ms ?? "--"} ms`;
|
||||
}
|
||||
|
||||
function captureFrame() {
|
||||
const video = qs("#videoPreview");
|
||||
frameCanvas.width = video.videoWidth;
|
||||
frameCanvas.height = video.videoHeight;
|
||||
frameCanvas.getContext("2d").drawImage(video, 0, 0);
|
||||
return new Promise((resolve) => frameCanvas.toBlob(resolve, "image/jpeg", 0.88));
|
||||
}
|
||||
|
||||
async function detectCurrentFrame(timestamp) {
|
||||
const video = qs("#videoPreview");
|
||||
if (!detectionActive || requestInFlight || !sessionId || video.paused || video.ended) return;
|
||||
const interval = Number(qs("#intervalSelect").value);
|
||||
if (timestamp - lastDetectionAt < interval) return;
|
||||
lastDetectionAt = timestamp;
|
||||
requestInFlight = true;
|
||||
qs("#loadingState").hidden = false;
|
||||
try {
|
||||
const frame = await captureFrame();
|
||||
if (!frame) throw new Error("无法截取视频帧");
|
||||
const form = new FormData();
|
||||
form.append("file", frame, "video-frame.jpg");
|
||||
const result = await apiFetch(`${API_ENDPOINT}?session_id=${encodeURIComponent(sessionId)}`, { method: "POST", body: form });
|
||||
updateDetectionResults(result);
|
||||
setInspectionStatus("检测运行中", "status-chip status-online");
|
||||
setServiceStatus(true);
|
||||
} catch (error) {
|
||||
setInspectionStatus("检测异常", "status-chip status-offline");
|
||||
showToast(`视频检测失败:${error.message}`, "error");
|
||||
} finally {
|
||||
requestInFlight = false;
|
||||
qs("#loadingState").hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleDetection(timestamp) {
|
||||
detectCurrentFrame(timestamp);
|
||||
if (detectionActive) requestAnimationFrame(scheduleDetection);
|
||||
}
|
||||
|
||||
async function startDetection() {
|
||||
const video = qs("#videoPreview");
|
||||
if (!video.src) return;
|
||||
detectionActive = true;
|
||||
lastDetectionAt = -Infinity;
|
||||
qs("#runDetectionButton").textContent = "停止检测";
|
||||
qs("#intervalSelect").disabled = true;
|
||||
try {
|
||||
await video.play();
|
||||
requestAnimationFrame(scheduleDetection);
|
||||
} catch {
|
||||
stopDetection();
|
||||
showToast("浏览器未允许视频播放,请手动点击播放后重试。", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function stopDetection() {
|
||||
detectionActive = false;
|
||||
qs("#runDetectionButton").textContent = "开始连续检测";
|
||||
qs("#intervalSelect").disabled = false;
|
||||
const video = qs("#videoPreview");
|
||||
setInspectionStatus(video.src ? "检测已暂停" : "等待视频", "status-chip status-idle");
|
||||
}
|
||||
|
||||
export async function resetSession() {
|
||||
const previous = sessionId;
|
||||
sessionId = crypto.randomUUID();
|
||||
if (previous) fetch(`/api/sessions/${encodeURIComponent(previous)}`, { method: "DELETE" }).catch(() => {});
|
||||
}
|
||||
|
||||
export function initInspection() {
|
||||
const video = qs("#videoPreview");
|
||||
qs("#runDetectionButton").addEventListener("click", () => detectionActive ? stopDetection() : startDetection());
|
||||
video.addEventListener("ended", stopDetection);
|
||||
video.addEventListener("timeupdate", () => {
|
||||
qs("#videoClock").textContent = `${formatTime(video.currentTime)} / ${formatTime(video.duration)}`;
|
||||
});
|
||||
video.addEventListener("seeked", async () => {
|
||||
drawDetections([]);
|
||||
await resetSession();
|
||||
});
|
||||
video.addEventListener("resize", () => drawDetections([]));
|
||||
qs("#videoInput").addEventListener("change", async () => {
|
||||
const [file] = qs("#videoInput").files;
|
||||
if (!file) return;
|
||||
stopDetection();
|
||||
video.pause();
|
||||
if (videoUrl) URL.revokeObjectURL(videoUrl);
|
||||
videoUrl = URL.createObjectURL(file);
|
||||
video.src = videoUrl;
|
||||
video.hidden = false;
|
||||
qs("#emptyState").hidden = true;
|
||||
qs("#sourceLabel").textContent = file.name;
|
||||
qs("#videoMeta").textContent = `${(file.size / 1024 / 1024).toFixed(1)} MB · 本地视频`;
|
||||
qs("#runDetectionButton").disabled = false;
|
||||
setInspectionStatus("视频已就绪", "status-chip status-online");
|
||||
await resetSession();
|
||||
clearDetectionResults();
|
||||
});
|
||||
qs("#clearButton").addEventListener("click", async () => {
|
||||
stopDetection();
|
||||
video.pause();
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
video.hidden = true;
|
||||
if (videoUrl) URL.revokeObjectURL(videoUrl);
|
||||
videoUrl = null;
|
||||
qs("#videoInput").value = "";
|
||||
qs("#sourceLabel").textContent = "未选择视频";
|
||||
qs("#videoMeta").textContent = "支持 MP4、WebM 等浏览器可播放格式";
|
||||
qs("#emptyState").hidden = false;
|
||||
qs("#runDetectionButton").disabled = true;
|
||||
await resetSession();
|
||||
clearDetectionResults();
|
||||
});
|
||||
}
|
||||
39
frontend/views/robotCards.js
Normal file
39
frontend/views/robotCards.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { qs, escapeHtml, formatDate } from "../modules/dom.js";
|
||||
|
||||
export function robotCardTemplate(robot) {
|
||||
const id = escapeHtml(robot.id);
|
||||
const platformClass = robot.id === "wechat" ? "wechat" : "feishu";
|
||||
const platformMark = robot.id === "wechat" ? "微" : "飞";
|
||||
const statusLabel = robot.configured ? "已配置" : "未配置";
|
||||
const lastActivity = robot.last_test_at || robot.last_delivery_at;
|
||||
const activityText = lastActivity ? formatDate(lastActivity) : "尚无发送记录";
|
||||
const statusText = robot.last_status === "success" ? "最近发送成功" : robot.last_status === "failed" ? "最近发送失败" : "等待首次发送";
|
||||
const credential = robot.id === "wechat" ? "WECHAT_WEBHOOK_URL" : "FEISHU_WEBHOOK_URL";
|
||||
const capabilities = (robot.capabilities || [])
|
||||
.map((capability) => `<span>${escapeHtml(capability)}</span>`)
|
||||
.join("");
|
||||
return `<article class="panel message-robot-card ${robot.configured ? "is-configured" : ""}" data-robot-id="${id}">
|
||||
<div class="message-robot-head"><div class="platform-logo ${platformClass}">${platformMark}</div><div><span class="robot-id">${escapeHtml(robot.id.toUpperCase())} ROBOT</span><h3>${escapeHtml(robot.name)}群机器人</h3><p>${robot.id === "wechat" ? "群聊 Markdown 与告警截图" : "群聊交互卡片与签名校验"}</p></div><span class="robot-status ${robot.configured ? "active" : "offline"}"><i></i>${statusLabel}</span></div>
|
||||
<div class="capability-list">${capabilities}</div>
|
||||
<div class="delivery-metrics"><div><span>成功投递</span><strong>${robot.delivered}</strong></div><div><span>失败</span><strong class="${robot.failed ? "danger" : ""}">${robot.failed}</strong></div><div><span>连接测试</span><strong>${robot.tests}</strong></div></div>
|
||||
<div class="robot-activity"><span>${statusText}</span><strong>${activityText}</strong></div>
|
||||
<div class="credential-key"><span>服务端配置</span><code>${credential}${robot.id === "feishu" ? " / FEISHU_SECRET" : ""}</code></div>
|
||||
${robot.last_error ? `<p class="delivery-error">${escapeHtml(robot.last_error)}</p>` : ""}
|
||||
<button class="primary-button test-robot-button" type="button" data-test-robot="${id}" ${robot.configured ? "" : "disabled"}>${robot.configured ? "发送测试消息" : "配置后可测试"}</button>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
export function renderRobots(payload = { robots: [], summary: {} }, policy = {}) {
|
||||
const robots = payload.robots || [];
|
||||
const summary = payload.summary || {};
|
||||
qs("#robotTotalCount").textContent = String(summary.total || robots.length);
|
||||
qs("#robotConfiguredCount").textContent = String(summary.configured || 0);
|
||||
qs("#robotDeliveredCount").textContent = String(summary.delivered || 0);
|
||||
qs("#robotFailedCount").textContent = String(summary.failed || 0);
|
||||
qs("#robotNavCount").textContent = String(summary.configured || 0);
|
||||
qs("#robotPolicyConfirm").textContent = `${policy.confirm_frames || 3} 帧连续命中后发送`;
|
||||
qs("#robotPolicyCooldown").textContent = `${policy.cooldown_seconds || 60} 秒内同类不重复发送`;
|
||||
qs("#robotCardGrid").innerHTML = robots.length
|
||||
? robots.map(robotCardTemplate).join("")
|
||||
: '<article class="panel robot-empty">暂无机器人通道数据</article>';
|
||||
}
|
||||
41
frontend/views/robots.js
Normal file
41
frontend/views/robots.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { qs } from "../modules/dom.js";
|
||||
import { apiFetch } from "../modules/api.js";
|
||||
import { showToast, setServiceStatus } from "../modules/ui.js";
|
||||
import { renderRobots } from "./robotCards.js";
|
||||
import { store } from "../modules/store.js";
|
||||
import { loadDashboard } from "./dashboard.js";
|
||||
|
||||
export async function loadRobots() {
|
||||
try {
|
||||
const result = await apiFetch("/api/robots");
|
||||
renderRobots(result, store.dashboardData?.detection);
|
||||
setServiceStatus(true);
|
||||
} catch (error) {
|
||||
setServiceStatus(false);
|
||||
showToast(`无法读取机器人状态:${error.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
export async function testRobot(channel, button) {
|
||||
const originalText = button.textContent;
|
||||
button.disabled = true;
|
||||
button.textContent = "正在发送";
|
||||
try {
|
||||
const result = await apiFetch(`/api/robots/${encodeURIComponent(channel)}/test`, { method: "POST" });
|
||||
showToast(`${result.robot.name}测试消息发送成功`, "success");
|
||||
await Promise.all([loadRobots(), loadDashboard()]);
|
||||
} catch (error) {
|
||||
showToast(`测试消息发送失败:${error.message}`, "error");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
export function initRobots() {
|
||||
qs("#refreshRobotsButton").addEventListener("click", loadRobots);
|
||||
qs("#robotCardGrid").addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-test-robot]");
|
||||
if (button) testRobot(button.dataset.testRobot, button);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user