feat(web): 新增前端预警列表页面与服务层。实现前端预警列表展示页、WebSocket 订阅服务和全局预警状态管理。
This commit is contained in:
93
apps/web/src/components/AlertNotification.vue
Normal file
93
apps/web/src/components/AlertNotification.vue
Normal 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>
|
||||||
@@ -1,11 +1,26 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import Home from '@/views/Home.vue'
|
import Home from '@/views/Home.vue'
|
||||||
|
import AlertList from '@/views/AlertList.vue'
|
||||||
|
import Layout from '@/layouts/MainLayout.vue'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
|
component: Layout,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
name: 'Home',
|
name: 'Home',
|
||||||
component: Home
|
component: Home,
|
||||||
|
meta: { title: '模型检测', icon: 'VideoCamera', keepAlive: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/alerts',
|
||||||
|
name: 'AlertList',
|
||||||
|
component: AlertList,
|
||||||
|
meta: { title: '预警列表', icon: 'WarningFilled', keepAlive: true }
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
158
apps/web/src/services/mqtt.client.js
Normal file
158
apps/web/src/services/mqtt.client.js
Normal 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)
|
||||||
|
}
|
||||||
132
apps/web/src/stores/alertStore.js
Normal file
132
apps/web/src/stores/alertStore.js
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
546
apps/web/src/views/AlertList.vue
Normal file
546
apps/web/src/views/AlertList.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user