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