feat(web): 新增前端预警列表页面与服务层。实现前端预警列表展示页、WebSocket 订阅服务和全局预警状态管理。

This commit is contained in:
2026-06-12 14:09:24 +08:00
parent 535fa89e64
commit 8c2ea57119
5 changed files with 947 additions and 3 deletions

View File

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