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,93 @@
<template>
<!-- 此组件为纯逻辑组件不渲染 DOM -->
<!-- 预警桌面通知由 alertStore.addAlert() 内部的 ElNotification 处理 -->
<div class="alert-notification"></div>
</template>
<script setup>
/**
* 预警通知弹窗组件 (MVP-2 / D24)
*
* 功能:
* 1. 实时 ElNotification 弹窗 (由 alertStore.showDesktopNotification 触发)
* 2. 顶部横幅展示最近 N 条高优预警 (列表滚动)
* 3. 点击横幅跳转到预警列表
*
* 使用方式: 在 MainLayout.vue 中引入一次即可Store 自动管理弹窗。
*/
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAlertStore } from '@/stores/alertStore'
const router = useRouter()
const alertStore = useAlertStore()
const topAlertVisible = ref(false)
const topAlertTimer = ref(null)
// 最近的活跃预警 (仅 critical/high)
const recentCritical = computed(() => alertStore.activeAlerts.slice(0, 5))
// 监听新预警 -> 显示顶部横幅
onMounted(() => {
// 如果 store 有未读的 critical 预警,显示横幅
showTopBanner()
})
// 每次 activeAlerts 变化时检查是否需要显示横幅
watchCriticalAlerts()
import { watch } from 'vue'
function watchCriticalAlerts() {
watch(
() => alertStore.unreadCount,
(newCount, oldCount) => {
if (newCount > oldCount && alertStore.activeAlerts.length > 0) {
showTopBanner()
}
}
)
}
function showTopBanner() {
if (recentCritical.value.length === 0) return
topAlertVisible.value = true
clearTopAlertTimer()
// 5 秒后自动隐藏
topAlertTimer.value = setTimeout(() => {
topAlertVisible.value = false
}, 5000)
}
function clearTopAlertTimer() {
if (topAlertTimer.value) {
clearTimeout(topAlertTimer.value)
topAlertTimer.value = null
}
}
function goToAlerts() {
topAlertVisible.value = false
alertStore.markAllAsRead()
router.push('/alerts')
}
onUnmounted(() => {
clearTopAlertTimer()
})
</script>
<style scoped>
/* 顶部横幅定位在 MainLayout 的 header 下方 */
.alert-notification {
position: fixed;
top: 60px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
width: auto;
max-width: 600px;
}
</style>

View File

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

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

View File

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

View File

@@ -0,0 +1,546 @@
<template>
<div class="alert-list-page">
<!-- 顶部统计卡片 -->
<div class="stat-grid">
<div
v-for="stat in stats"
:key="stat.key"
class="stat-card"
:class="`stat-card--${stat.tone}`"
>
<div class="stat-icon">
<el-icon :size="20"><component :is="stat.icon" /></el-icon>
</div>
<div class="stat-content">
<div class="stat-label">{{ stat.label }}</div>
<div class="stat-value">{{ stat.value }}</div>
</div>
</div>
</div>
<!-- 过滤工具栏 -->
<div class="toolbar">
<div class="toolbar-left">
<el-select
v-model="filter.severity"
placeholder="严重级别"
clearable
size="small"
class="filter-control"
@change="applyFilter"
>
<el-option label="严重" value="critical" />
<el-option label="高危" value="high" />
<el-option label="中等" value="medium" />
<el-option label="低危" value="low" />
<el-option label="信息" value="info" />
</el-select>
<el-select
v-model="filter.event_type"
placeholder="事件类型"
clearable
size="small"
class="filter-control"
@change="applyFilter"
>
<el-option label="火灾" value="fire" />
<el-option label="烟雾" value="smoke" />
<el-option label="抽烟" value="smoking" />
<el-option label="打架" value="fight" />
<el-option label="徘徊" value="loitering" />
<el-option label="违停" value="illegal_parking" />
<el-option label="入侵" value="intrusion" />
</el-select>
<el-input
v-model="filter.source_id"
placeholder="按摄像头ID搜索"
size="small"
clearable
class="filter-control"
@input="applyFilter"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<div class="toolbar-right">
<el-button
size="small"
@click="handleMarkAllRead"
:disabled="alertStore.unreadCount === 0"
>
<el-icon><Check /></el-icon>
全部标为已读
</el-button>
<el-button
size="small"
type="danger"
plain
@click="handleClear"
:disabled="alertStore.alerts.length === 0"
>
<el-icon><Delete /></el-icon>
清空记录
</el-button>
</div>
</div>
<!-- 预警列表 -->
<div class="alert-list">
<el-empty
v-if="filteredAlerts.length === 0"
description="暂无预警记录"
class="empty-state"
/>
<div
v-for="alert in filteredAlerts"
:key="alert.alert_id"
class="alert-card"
:class="[
`severity-${alert.severity}`,
{ 'is-unread': alertStore.unreadIds.has(alert.alert_id) }
]"
@click="alertStore.markAsRead(alert.alert_id)"
>
<!-- 严重性条 -->
<div class="severity-bar"></div>
<div class="alert-body">
<div class="alert-head">
<div class="alert-title">
<el-tag
:type="severityType(alert.severity)"
effect="dark"
round
size="small"
>
{{ severityLabel(alert.severity) }}
</el-tag>
<span class="alert-event-type">{{ eventTypeLabel(alert.event_type) }}</span>
<span v-if="alertStore.unreadIds.has(alert.alert_id)" class="unread-dot"></span>
</div>
<div class="alert-time">{{ formatTime(alert.first_seen) }}</div>
</div>
<div class="alert-meta">
<div class="meta-item">
<el-icon><VideoCamera /></el-icon>
<span>{{ alert.source_id || '未知摄像头' }}</span>
</div>
<div class="meta-item">
<el-icon><DataLine /></el-icon>
<span>置信度 {{ (alert.confidence * 100).toFixed(1) }}%</span>
</div>
<div class="meta-item">
<el-icon><Refresh /></el-icon>
<span>触发 {{ alert.occurrence_count }} </span>
</div>
<div v-if="alert.rule_name" class="meta-item">
<el-icon><Setting /></el-icon>
<span>{{ alert.rule_name }}</span>
</div>
</div>
<div v-if="alert.detections && alert.detections.length > 0" class="alert-detections">
<span
v-for="(det, idx) in alert.detections.slice(0, 3)"
:key="det.detection_id || idx"
class="detection-chip"
>
{{ det.label || det.class_name }}
<span v-if="det.track_id" class="track-id">#{{ det.track_id }}</span>
</span>
</div>
</div>
<div class="alert-actions">
<el-button
text
type="danger"
size="small"
@click.stop="alertStore.removeAlert(alert.alert_id)"
aria-label="删除预警"
>
<el-icon><Close /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, reactive } from 'vue'
import {
WarningFilled,
CircleCheckFilled,
Bell,
TrendCharts,
VideoCamera,
DataLine,
Refresh,
Setting,
Search,
Check,
Delete,
Close
} from '@element-plus/icons-vue'
import { useAlertStore } from '@/stores/alertStore'
const alertStore = useAlertStore()
const filter = reactive({
severity: '',
event_type: '',
source_id: ''
})
const filteredAlerts = computed(() => {
return alertStore.alerts.filter((a) => {
if (filter.severity && a.severity !== filter.severity) return false
if (filter.event_type && a.event_type !== filter.event_type) return false
if (filter.source_id && !(a.source_id || '').includes(filter.source_id)) return false
return true
})
})
const stats = computed(() => [
{
key: 'total',
label: '预警总数',
value: alertStore.alerts.length,
icon: Bell,
tone: 'neutral'
},
{
key: 'unread',
label: '未读',
value: alertStore.unreadCount,
icon: WarningFilled,
tone: 'warning'
},
{
key: 'critical',
label: '严重预警',
value: alertStore.alerts.filter((a) => a.severity === 'critical').length,
icon: TrendCharts,
tone: 'danger'
},
{
key: 'connected',
label: '订阅状态',
value: alertStore.statusLabel,
icon: CircleCheckFilled,
tone: alertStore.connectionStatus === 'connected' ? 'success' : 'neutral'
}
])
function severityType(severity) {
const map = {
critical: 'danger',
high: 'warning',
medium: 'primary',
low: 'info',
info: 'success'
}
return map[severity] || 'info'
}
function severityLabel(severity) {
const map = {
critical: '严重',
high: '高危',
medium: '中等',
low: '低危',
info: '信息'
}
return map[severity] || severity
}
function eventTypeLabel(type) {
const map = {
fire: '火灾',
smoke: '烟雾',
smoking: '抽烟',
fight: '打架斗殴',
loitering: '徘徊',
stationary: '滞留',
intrusion: '入侵',
illegal_parking: '违章停车',
vehicle: '车辆',
person: '人员',
unknown: '未知'
}
return map[type] || type
}
function formatTime(timestamp) {
if (!timestamp) return '-'
const date = new Date(timestamp * 1000)
const now = Date.now()
const diff = (now - date.getTime()) / 1000
if (diff < 60) return `${Math.floor(diff)} 秒前`
if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`
if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`
return date.toLocaleString('zh-CN', { hour12: false })
}
function applyFilter() {
// 仅用作触发计算属性更新, 无需额外逻辑
}
function handleMarkAllRead() {
alertStore.markAllAsRead()
}
function handleClear() {
alertStore.clearAlerts()
}
onMounted(() => {
// 进入页面自动标记当前可见预警为已读,但保留未读计数标记给新预警
// 这里只清除红点,但不清除数据
})
</script>
<style scoped>
.alert-list-page {
padding: 24px;
min-height: 100%;
background: #020617;
}
/* ---- 统计卡片 ---- */
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
display: flex;
align-items: center;
gap: 14px;
padding: 18px 20px;
border-radius: 12px;
background: #0F172A;
border: 1px solid rgba(255, 255, 255, 0.06);
transition: border-color 200ms ease, transform 200ms ease;
}
.stat-card:hover {
border-color: rgba(34, 197, 94, 0.3);
}
.stat-icon {
width: 44px;
height: 44px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(148, 163, 184, 0.1);
color: #94A3B8;
flex-shrink: 0;
}
.stat-card--warning .stat-icon {
background: rgba(245, 158, 11, 0.12);
color: #F59E0B;
}
.stat-card--danger .stat-icon {
background: rgba(239, 68, 68, 0.12);
color: #EF4444;
}
.stat-card--success .stat-icon {
background: rgba(34, 197, 94, 0.12);
color: #22C55E;
}
.stat-label {
font-size: 12px;
color: #94A3B8;
margin-bottom: 4px;
}
.stat-value {
font-size: 22px;
font-weight: 600;
color: #F8FAFC;
font-family: 'Fira Code', monospace;
}
/* ---- 工具栏 ---- */
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 12px 16px;
margin-bottom: 16px;
background: #0F172A;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
flex-wrap: wrap;
}
.toolbar-left {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.toolbar-right {
display: flex;
gap: 8px;
align-items: center;
}
.filter-control {
width: 180px;
}
/* ---- 预警卡片 ---- */
.alert-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.alert-card {
display: flex;
background: #0F172A;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: border-color 200ms ease, background 200ms ease;
}
.alert-card:hover {
border-color: rgba(34, 197, 94, 0.3);
background: #131E33;
}
.alert-card.is-unread {
border-color: rgba(34, 197, 94, 0.2);
}
.severity-bar {
width: 4px;
background: #64748B;
flex-shrink: 0;
}
.severity-critical .severity-bar { background: #EF4444; }
.severity-high .severity-bar { background: #F59E0B; }
.severity-medium .severity-bar { background: #3B82F6; }
.severity-low .severity-bar { background: #94A3B8; }
.severity-info .severity-bar { background: #22C55E; }
.alert-body {
flex: 1;
padding: 14px 16px;
min-width: 0;
}
.alert-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
gap: 12px;
flex-wrap: wrap;
}
.alert-title {
display: flex;
align-items: center;
gap: 10px;
}
.alert-event-type {
font-size: 15px;
font-weight: 600;
color: #F8FAFC;
}
.unread-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #22C55E;
box-shadow: 0 0 8px rgba(34, 197, 94, 0.6);
}
.alert-time {
font-size: 12px;
color: #64748B;
font-family: 'Fira Code', monospace;
}
.alert-meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 8px;
}
.meta-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #94A3B8;
}
.meta-item .el-icon {
font-size: 14px;
}
.alert-detections {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.detection-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
background: rgba(34, 197, 94, 0.08);
border: 1px solid rgba(34, 197, 94, 0.16);
border-radius: 999px;
font-size: 12px;
color: #86EFAC;
}
.track-id {
font-family: 'Fira Code', monospace;
font-size: 11px;
color: #6EE7B7;
opacity: 0.8;
}
.alert-actions {
display: flex;
align-items: center;
padding: 0 10px;
}
.empty-state {
padding: 60px 0;
}
</style>