diff --git a/apps/web/src/components/AlertNotification.vue b/apps/web/src/components/AlertNotification.vue new file mode 100644 index 0000000..bbb03d3 --- /dev/null +++ b/apps/web/src/components/AlertNotification.vue @@ -0,0 +1,93 @@ + + + + + \ No newline at end of file diff --git a/apps/web/src/router/index.js b/apps/web/src/router/index.js index d94af19..2f7d5d2 100644 --- a/apps/web/src/router/index.js +++ b/apps/web/src/router/index.js @@ -1,11 +1,26 @@ import { createRouter, createWebHistory } from 'vue-router' import Home from '@/views/Home.vue' +import AlertList from '@/views/AlertList.vue' +import Layout from '@/layouts/MainLayout.vue' const routes = [ { path: '/', - name: 'Home', - component: Home + component: Layout, + children: [ + { + path: '', + name: 'Home', + component: Home, + meta: { title: '模型检测', icon: 'VideoCamera', keepAlive: true } + }, + { + path: '/alerts', + name: 'AlertList', + component: AlertList, + meta: { title: '预警列表', icon: 'WarningFilled', keepAlive: true } + } + ] } ] @@ -14,4 +29,4 @@ const router = createRouter({ routes }) -export default router +export default router \ No newline at end of file diff --git a/apps/web/src/services/mqtt.client.js b/apps/web/src/services/mqtt.client.js new file mode 100644 index 0000000..239c504 --- /dev/null +++ b/apps/web/src/services/mqtt.client.js @@ -0,0 +1,158 @@ +/** + * 预警 WebSocket 客户端 (MVP-2 / D23) + * + * 连接后端 /ws/alerts,接收实时预警事件并推送到 Pinia Store。 + * + * 提供: + * - 自动重连 (指数退避,最多重试 10 次) + * - 心跳 (每 30 秒 ping) + * - 过滤订阅 (按事件类型 / source_id) + * - 连接状态同步到 alertStore + * + * 注意: 当前架构中前端不直连 MQTT Broker,而是通过后端 WebSocket + * 代理接收预警,MQTT 仅用于后端与其他系统(如移动端)的互通。 + */ + +import { useAlertStore } from '@/stores/alertStore' + +// ---- 配置 ---- +const WS_BASE = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/api/ws/alerts` +const PING_INTERVAL = 30000 // 心跳间隔 (ms) +const RECONNECT_BASE = 2000 // 首次重连间隔 (ms) +const RECONNECT_MAX = 30000 // 最大重连间隔 (ms) +const MAX_RECONNECT_RETRIES = 10 // 最大重连次数 + +// ---- 内部状态 ---- +let socket = null +let pingTimer = null +let reconnectTimer = null +let reconnectAttempts = 0 +let filterConfig = null + +/** + * 连接预警 WebSocket + * @param {Object} [filter] - 订阅过滤 { event_types?: string[], source_ids?: string[] } + */ +export function connectAlertSocket(filter) { + const store = useAlertStore() + + // 避免重复连接 + if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) { + if (filter) { + sendSubscribe(filter) + } + return + } + + filterConfig = filter || null + socket = new WebSocket(WS_BASE) + + socket.onopen = () => { + store.setConnectionStatus('connected') + reconnectAttempts = 0 + startPing() + + // 发送订阅 + if (filterConfig) { + sendSubscribe(filterConfig) + } + } + + socket.onmessage = (event) => { + try { + const message = JSON.parse(event.data) + + if (message.type === 'alert') { + store.addAlert(message.data) + } else if (message.type === 'welcome') { + // 连接成功确认, 暂不处理 + } else if (message.type === 'subscribed') { + // 订阅成功确认, 暂不处理 + } else if (message.type === 'pong') { + // 心跳响应, 暂不处理 + } + } catch (e) { + console.warn('[AlertWS] 消息解析失败:', e) + } + } + + socket.onclose = (event) => { + store.setConnectionStatus('disconnected') + stopPing() + scheduleReconnect() + } + + socket.onerror = () => { + // onclose 会自动触发, 不重复处理 + } +} + +/** + * 断开预警 WebSocket + */ +export function disconnectAlertSocket() { + stopPing() + clearTimeout(reconnectTimer) + reconnectAttempts = 0 + + if (socket) { + socket.onclose = null + socket.onerror = null + socket.close() + socket = null + } + + useAlertStore().setConnectionStatus('disconnected') +} + +/** + * 更新订阅过滤 + */ +export function subscribeAlert(filter) { + filterConfig = filter || null + sendSubscribe(filter) +} + +function sendSubscribe(filter) { + if (!socket || socket.readyState !== WebSocket.OPEN) return + socket.send(JSON.stringify({ + action: 'subscribe', + filter: filter || {} + })) +} + +/** + * 心跳: 每 30s 发送 ping + */ +function startPing() { + stopPing() + pingTimer = setInterval(() => { + if (socket && socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ action: 'ping' })) + } + }, PING_INTERVAL) +} + +function stopPing() { + if (pingTimer) { + clearInterval(pingTimer) + pingTimer = null + } +} + +function scheduleReconnect() { + if (reconnectAttempts >= MAX_RECONNECT_RETRIES) return + + const delay = Math.min( + RECONNECT_BASE * Math.pow(2, reconnectAttempts), + RECONNECT_MAX + ) + + useAlertStore().setConnectionStatus('connecting') + reconnectAttempts++ + + clearTimeout(reconnectTimer) + reconnectTimer = setTimeout(() => { + connectAlertSocket(filterConfig) + }, delay) +} \ No newline at end of file diff --git a/apps/web/src/stores/alertStore.js b/apps/web/src/stores/alertStore.js new file mode 100644 index 0000000..804d260 --- /dev/null +++ b/apps/web/src/stores/alertStore.js @@ -0,0 +1,132 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { ElNotification } from 'element-plus' + +/** + * 预警状态管理 Store + * + * 管理实时预警集合、未读计数、连接状态。 + * 预警数据来源: WebSocket (/ws/alerts) + 历史查询 (GET /api/alerts/history) + */ + +export const useAlertStore = defineStore('alerts', () => { + // ---- 状态 ---- + const alerts = ref([]) // 全部已知预警 (最新在前) + const unreadIds = ref(new Set()) // 未读 ID 集合 + const connectionStatus = ref('disconnected') // connected / connecting / disconnected + const loading = ref(false) + const error = ref(null) + + // ---- 计算 ---- + const unreadCount = computed(() => unreadIds.value.size) + + const statusLabel = computed(() => { + const map = { + connected: '已连接', + connecting: '连接中', + disconnected: '未连接' + } + return map[connectionStatus.value] + }) + + const statusClass = computed(() => connectionStatus.value) + + const activeAlerts = computed(() => + alerts.value.filter((a) => a.severity === 'critical' || a.severity === 'high') + ) + + // ---- 行为 ---- + function setConnectionStatus(status) { + connectionStatus.value = status + } + + /** + * 添加实时预警 (来自 WebSocket) + * - 重复的 alert_id 会被忽略 + * - 新预警自动标记为未读 + * - 根据严重级别弹出桌面通知 + */ + function addAlert(alertData) { + if (!alertData || !alertData.alert_id) return + + // 去重 + if (alerts.value.some((a) => a.alert_id === alertData.alert_id)) return + + alerts.value.unshift(alertData) + unreadIds.value.add(alertData.alert_id) + showDesktopNotification(alertData) + } + + /** + * 批量加载历史预警 (来自 REST API) + */ + function setAlerts(list) { + alerts.value = list || [] + // 历史预警不计入未读 + } + + function markAsRead(alertId) { + unreadIds.value.delete(alertId) + } + + function markAllAsRead() { + unreadIds.value.clear() + } + + function clearAlerts() { + alerts.value = [] + unreadIds.value.clear() + } + + function removeAlert(alertId) { + alerts.value = alerts.value.filter((a) => a.alert_id !== alertId) + unreadIds.value.delete(alertId) + } + + // ---- 桌面通知 ---- + function showDesktopNotification(alert) { + const severityLabels = { + critical: '严重', + high: '高危', + medium: '中等', + low: '低危', + info: '信息' + } + const label = severityLabels[alert.severity] || alert.severity + const typeName = alert.event_type || '未知事件' + + ElNotification({ + title: `[${label}] ${typeName}`, + message: alert.source_id + ? `来源: ${alert.source_id} | 置信度: ${(alert.confidence * 100).toFixed(0)}%` + : `置信度: ${(alert.confidence * 100).toFixed(0)}%`, + type: alert.severity === 'critical' || alert.severity === 'high' + ? 'warning' + : 'info', + duration: alert.severity === 'critical' ? 0 : 4500, + dangerouslyUseHTMLString: false, + onClick: () => { + markAsRead(alert.alert_id) + } + }) + } + + return { + alerts, + unreadIds, + connectionStatus, + loading, + error, + unreadCount, + statusLabel, + statusClass, + activeAlerts, + setConnectionStatus, + addAlert, + setAlerts, + markAsRead, + markAllAsRead, + clearAlerts, + removeAlert + } +}) \ No newline at end of file diff --git a/apps/web/src/views/AlertList.vue b/apps/web/src/views/AlertList.vue new file mode 100644 index 0000000..29e13bf --- /dev/null +++ b/apps/web/src/views/AlertList.vue @@ -0,0 +1,546 @@ + + + + + \ No newline at end of file