功能:告警数据看板替换原告警汇总页
- 5 个 KPI 卡片(今日告警/待处理/已处理/平均响应/在线设备) - 告警趋势面积图(7天/30天切换,按类型堆叠) - 告警类型分布环形图(中心显示总数) - 设备告警 Top10 横向条形图 - 告警级别分布柱状图 - 24小时时段分布图(高亮高峰时段) - 最近告警滚动列表 - 60秒自动刷新 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -66,12 +66,38 @@ export namespace AiotAlarmApi {
|
|||||||
export interface AlertStatistics {
|
export interface AlertStatistics {
|
||||||
total?: number;
|
total?: number;
|
||||||
todayCount?: number;
|
todayCount?: number;
|
||||||
|
yesterdayCount?: number;
|
||||||
pendingCount?: number;
|
pendingCount?: number;
|
||||||
handledCount?: number;
|
handledCount?: number;
|
||||||
|
avgResponseMinutes?: number | null;
|
||||||
byType?: Record<string, number>;
|
byType?: Record<string, number>;
|
||||||
byStatus?: Record<string, number>;
|
byStatus?: Record<string, number>;
|
||||||
byLevel?: Record<string, number>;
|
byLevel?: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 告警趋势项 */
|
||||||
|
export interface TrendItem {
|
||||||
|
date: string;
|
||||||
|
total: number;
|
||||||
|
leave_post?: number;
|
||||||
|
intrusion?: number;
|
||||||
|
illegal_parking?: number;
|
||||||
|
vehicle_congestion?: number;
|
||||||
|
[key: string]: number | string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设备告警排行项 */
|
||||||
|
export interface DeviceTopItem {
|
||||||
|
deviceId: string;
|
||||||
|
deviceName: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 时段分布项 */
|
||||||
|
export interface HourDistItem {
|
||||||
|
hour: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 告警管理 API ====================
|
// ==================== 告警管理 API ====================
|
||||||
@@ -115,6 +141,37 @@ export function getAlertStatistics(startTime?: string, endTime?: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取告警趋势 */
|
||||||
|
export function getAlertTrend(days: number = 7) {
|
||||||
|
return requestClient.get<AiotAlarmApi.TrendItem[]>(
|
||||||
|
'/aiot/alarm/alert/trend',
|
||||||
|
{ params: { days } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取设备告警排行 */
|
||||||
|
export function getAlertDeviceTop(limit: number = 10, days: number = 7) {
|
||||||
|
return requestClient.get<AiotAlarmApi.DeviceTopItem[]>(
|
||||||
|
'/aiot/alarm/alert/device-top',
|
||||||
|
{ params: { limit, days } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取24小时告警分布 */
|
||||||
|
export function getAlertHourDistribution(days: number = 7) {
|
||||||
|
return requestClient.get<AiotAlarmApi.HourDistItem[]>(
|
||||||
|
'/aiot/alarm/alert/hour-distribution',
|
||||||
|
{ params: { days } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取最近告警列表 */
|
||||||
|
export function getRecentAlerts(pageSize: number = 10) {
|
||||||
|
return requestClient.get<any>('/aiot/alarm/alert/page', {
|
||||||
|
params: { pageNo: 1, pageSize },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 摄像头告警汇总 API ====================
|
// ==================== 摄像头告警汇总 API ====================
|
||||||
|
|
||||||
/** 以摄像头维度获取告警汇总 */
|
/** 以摄像头维度获取告警汇总 */
|
||||||
|
|||||||
201
apps/web-antd/src/views/aiot/alarm/summary/chart-options.ts
Normal file
201
apps/web-antd/src/views/aiot/alarm/summary/chart-options.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import type { AiotAlarmApi } from '#/api/aiot/alarm';
|
||||||
|
|
||||||
|
const TYPE_NAMES: Record<string, string> = {
|
||||||
|
leave_post: '离岗检测',
|
||||||
|
intrusion: '周界入侵',
|
||||||
|
illegal_parking: '车辆违停',
|
||||||
|
vehicle_congestion: '车辆拥堵',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
|
leave_post: '#1890ff',
|
||||||
|
intrusion: '#f5222d',
|
||||||
|
illegal_parking: '#fa8c16',
|
||||||
|
vehicle_congestion: '#722ed1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEVEL_NAMES: Record<number, string> = {
|
||||||
|
0: '紧急',
|
||||||
|
1: '重要',
|
||||||
|
2: '普通',
|
||||||
|
3: '轻微',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEVEL_COLORS: Record<number, string> = {
|
||||||
|
0: '#f5222d',
|
||||||
|
1: '#fa8c16',
|
||||||
|
2: '#1890ff',
|
||||||
|
3: '#8c8c8c',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 告警趋势面积图 */
|
||||||
|
export function getTrendChartOptions(data: AiotAlarmApi.TrendItem[]): any {
|
||||||
|
const dates = data.map((d) => d.date.slice(5)); // MM-DD
|
||||||
|
const types = Object.keys(TYPE_NAMES);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: { type: 'cross', label: { backgroundColor: '#6a7985' } },
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
data: types.map((t) => TYPE_NAMES[t]),
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
grid: { left: '3%', right: '4%', bottom: '12%', top: '8%', containLabel: true },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
boundaryGap: false,
|
||||||
|
data: dates,
|
||||||
|
},
|
||||||
|
yAxis: { type: 'value', minInterval: 1 },
|
||||||
|
series: types.map((type) => ({
|
||||||
|
name: TYPE_NAMES[type],
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
stack: 'total',
|
||||||
|
areaStyle: { opacity: 0.25 },
|
||||||
|
emphasis: { focus: 'series' },
|
||||||
|
itemStyle: { color: TYPE_COLORS[type] },
|
||||||
|
data: data.map((d) => (d[type] as number) || 0),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 告警类型环形图 */
|
||||||
|
export function getTypePieChartOptions(
|
||||||
|
byType: Record<string, number>,
|
||||||
|
): any {
|
||||||
|
const data = Object.entries(byType)
|
||||||
|
.map(([type, count]) => ({
|
||||||
|
name: TYPE_NAMES[type] || type,
|
||||||
|
value: count,
|
||||||
|
itemStyle: { color: TYPE_COLORS[type] },
|
||||||
|
}))
|
||||||
|
.filter((d) => d.value > 0);
|
||||||
|
const total = data.reduce((s, d) => s + d.value, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||||
|
legend: { bottom: 0, left: 'center' },
|
||||||
|
graphic: {
|
||||||
|
type: 'text',
|
||||||
|
left: 'center',
|
||||||
|
top: '42%',
|
||||||
|
style: {
|
||||||
|
text: `${total}`,
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fill: '#333',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['45%', '70%'],
|
||||||
|
center: ['50%', '48%'],
|
||||||
|
avoidLabelOverlap: false,
|
||||||
|
label: { show: false },
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设备告警 Top10 横向条形图 */
|
||||||
|
export function getDeviceTopChartOptions(
|
||||||
|
data: AiotAlarmApi.DeviceTopItem[],
|
||||||
|
): any {
|
||||||
|
const sorted = [...data].reverse(); // 最多的在上面
|
||||||
|
return {
|
||||||
|
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||||
|
grid: { left: '3%', right: '8%', bottom: '3%', top: '3%', containLabel: true },
|
||||||
|
xAxis: { type: 'value', minInterval: 1 },
|
||||||
|
yAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: sorted.map((d) => {
|
||||||
|
const name = d.deviceName || d.deviceId;
|
||||||
|
return name.length > 12 ? `${name.slice(0, 12)}...` : name;
|
||||||
|
}),
|
||||||
|
axisLabel: { fontSize: 11 },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
data: sorted.map((d) => d.count),
|
||||||
|
itemStyle: {
|
||||||
|
color: {
|
||||||
|
type: 'linear',
|
||||||
|
x: 0, y: 0, x2: 1, y2: 0,
|
||||||
|
colorStops: [
|
||||||
|
{ offset: 0, color: '#1890ff' },
|
||||||
|
{ offset: 1, color: '#36cfc9' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
borderRadius: [0, 4, 4, 0],
|
||||||
|
},
|
||||||
|
barMaxWidth: 20,
|
||||||
|
label: { show: true, position: 'right', fontSize: 11 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 告警级别分布柱状图 */
|
||||||
|
export function getLevelBarChartOptions(
|
||||||
|
byLevel: Record<string, number>,
|
||||||
|
): any {
|
||||||
|
const levels = [0, 1, 2, 3];
|
||||||
|
return {
|
||||||
|
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||||
|
grid: { left: '3%', right: '4%', bottom: '3%', top: '8%', containLabel: true },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: levels.map((l) => LEVEL_NAMES[l]),
|
||||||
|
},
|
||||||
|
yAxis: { type: 'value', minInterval: 1 },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
data: levels.map((l) => ({
|
||||||
|
value: byLevel[l] || 0,
|
||||||
|
itemStyle: { color: LEVEL_COLORS[l], borderRadius: [4, 4, 0, 0] },
|
||||||
|
})),
|
||||||
|
barMaxWidth: 40,
|
||||||
|
label: { show: true, position: 'top', fontSize: 11 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 24小时时段分布柱状图 */
|
||||||
|
export function getHourDistChartOptions(
|
||||||
|
data: AiotAlarmApi.HourDistItem[],
|
||||||
|
): any {
|
||||||
|
const counts = data.map((d) => d.count);
|
||||||
|
const maxCount = Math.max(...counts, 1);
|
||||||
|
return {
|
||||||
|
tooltip: { trigger: 'axis', formatter: '{b}时: {c}次' },
|
||||||
|
grid: { left: '3%', right: '4%', bottom: '3%', top: '8%', containLabel: true },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: data.map((d) => `${d.hour}`),
|
||||||
|
axisLabel: { formatter: '{value}时', fontSize: 10 },
|
||||||
|
},
|
||||||
|
yAxis: { type: 'value', minInterval: 1 },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
data: counts.map((c) => ({
|
||||||
|
value: c,
|
||||||
|
itemStyle: {
|
||||||
|
color: c >= maxCount * 0.8 ? '#f5222d' : c >= maxCount * 0.5 ? '#fa8c16' : '#1890ff',
|
||||||
|
borderRadius: [3, 3, 0, 0],
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
barMaxWidth: 16,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,83 +1,477 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
|
||||||
import type { AiotAlarmApi } from '#/api/aiot/alarm';
|
import type { AiotAlarmApi } from '#/api/aiot/alarm';
|
||||||
|
|
||||||
|
import { nextTick, onMounted, onUnmounted, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { Page } from '@vben/common-ui';
|
import { Page } from '@vben/common-ui';
|
||||||
|
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||||
|
|
||||||
import { Badge, Tag } from 'ant-design-vue';
|
import {
|
||||||
|
Badge,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Empty,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemMeta,
|
||||||
|
Row,
|
||||||
|
Segmented,
|
||||||
|
Spin,
|
||||||
|
Statistic,
|
||||||
|
Tag,
|
||||||
|
} from 'ant-design-vue';
|
||||||
|
|
||||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
import {
|
||||||
import { getCameraAlertSummary } from '#/api/aiot/alarm';
|
getAlertDeviceTop,
|
||||||
|
getAlertHourDistribution,
|
||||||
|
getAlertStatistics,
|
||||||
|
getAlertTrend,
|
||||||
|
getRecentAlerts,
|
||||||
|
} from '#/api/aiot/alarm';
|
||||||
|
|
||||||
import { useCameraSummaryColumns } from '../list/data';
|
import {
|
||||||
|
getDeviceTopChartOptions,
|
||||||
|
getHourDistChartOptions,
|
||||||
|
getLevelBarChartOptions,
|
||||||
|
getTrendChartOptions,
|
||||||
|
getTypePieChartOptions,
|
||||||
|
} from './chart-options';
|
||||||
|
|
||||||
defineOptions({ name: 'AiotAlarmSummary' });
|
defineOptions({ name: 'AiotAlarmDashboard' });
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const loading = ref(true);
|
||||||
|
|
||||||
/** 跳转到该摄像头的告警列表 */
|
// 数据
|
||||||
function handleViewAlerts(row: AiotAlarmApi.CameraAlertSummary) {
|
const stats = ref<AiotAlarmApi.AlertStatistics>({});
|
||||||
router.push({
|
const trendData = ref<AiotAlarmApi.TrendItem[]>([]);
|
||||||
path: '/aiot/alarm/list',
|
const deviceTopData = ref<AiotAlarmApi.DeviceTopItem[]>([]);
|
||||||
query: { cameraId: row.cameraId },
|
const hourDistData = ref<AiotAlarmApi.HourDistItem[]>([]);
|
||||||
});
|
const recentAlerts = ref<AiotAlarmApi.Alert[]>([]);
|
||||||
|
|
||||||
|
// 趋势天数切换
|
||||||
|
const trendDays = ref(7);
|
||||||
|
const trendDaysOptions = [
|
||||||
|
{ label: '近7天', value: 7 },
|
||||||
|
{ label: '近30天', value: 30 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 图表 ref
|
||||||
|
const trendChartRef = ref();
|
||||||
|
const typePieChartRef = ref();
|
||||||
|
const deviceTopChartRef = ref();
|
||||||
|
const levelBarChartRef = ref();
|
||||||
|
const hourDistChartRef = ref();
|
||||||
|
|
||||||
|
const { renderEcharts: renderTrend } = useEcharts(trendChartRef);
|
||||||
|
const { renderEcharts: renderTypePie } = useEcharts(typePieChartRef);
|
||||||
|
const { renderEcharts: renderDeviceTop } = useEcharts(deviceTopChartRef);
|
||||||
|
const { renderEcharts: renderLevelBar } = useEcharts(levelBarChartRef);
|
||||||
|
const { renderEcharts: renderHourDist } = useEcharts(hourDistChartRef);
|
||||||
|
|
||||||
|
// 自动刷新
|
||||||
|
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
// 告警类型映射
|
||||||
|
const TYPE_NAMES: Record<string, string> = {
|
||||||
|
leave_post: '离岗检测',
|
||||||
|
intrusion: '周界入侵',
|
||||||
|
illegal_parking: '车辆违停',
|
||||||
|
vehicle_congestion: '车辆拥堵',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
|
leave_post: 'blue',
|
||||||
|
intrusion: 'red',
|
||||||
|
illegal_parking: 'orange',
|
||||||
|
vehicle_congestion: 'purple',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_NAMES: Record<string, string> = {
|
||||||
|
pending: '待处理',
|
||||||
|
handled: '已处理',
|
||||||
|
ignored: '已忽略',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 加载全部数据 */
|
||||||
|
async function loadAll() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const [statsRes, trendRes, deviceRes, hourRes, alertsRes] =
|
||||||
|
await Promise.all([
|
||||||
|
getAlertStatistics(),
|
||||||
|
getAlertTrend(trendDays.value),
|
||||||
|
getAlertDeviceTop(10, 7),
|
||||||
|
getAlertHourDistribution(7),
|
||||||
|
getRecentAlerts(10),
|
||||||
|
]);
|
||||||
|
stats.value = statsRes;
|
||||||
|
trendData.value = trendRes;
|
||||||
|
deviceTopData.value = deviceRes;
|
||||||
|
hourDistData.value = hourRes;
|
||||||
|
recentAlerts.value = alertsRes?.list || [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
await nextTick();
|
||||||
|
await nextTick();
|
||||||
|
renderCharts();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [Grid] = useVbenVxeGrid({
|
/** 切换趋势天数 */
|
||||||
gridOptions: {
|
async function onTrendDaysChange(val: number) {
|
||||||
columns: useCameraSummaryColumns(),
|
trendDays.value = val;
|
||||||
height: 'auto',
|
try {
|
||||||
keepSource: true,
|
trendData.value = await getAlertTrend(val);
|
||||||
proxyConfig: {
|
await nextTick();
|
||||||
ajax: {
|
renderTrend(getTrendChartOptions(trendData.value));
|
||||||
query: async ({ page }) => {
|
} catch {
|
||||||
return await getCameraAlertSummary({
|
/* ignore */
|
||||||
pageNo: page.currentPage,
|
}
|
||||||
pageSize: page.pageSize,
|
}
|
||||||
|
|
||||||
|
/** 渲染所有图表 */
|
||||||
|
function renderCharts() {
|
||||||
|
if (trendData.value.length > 0) {
|
||||||
|
renderTrend(getTrendChartOptions(trendData.value));
|
||||||
|
}
|
||||||
|
if (stats.value.byType && Object.keys(stats.value.byType).length > 0) {
|
||||||
|
renderTypePie(getTypePieChartOptions(stats.value.byType));
|
||||||
|
}
|
||||||
|
if (deviceTopData.value.length > 0) {
|
||||||
|
renderDeviceTop(getDeviceTopChartOptions(deviceTopData.value));
|
||||||
|
}
|
||||||
|
if (stats.value.byLevel && Object.keys(stats.value.byLevel).length > 0) {
|
||||||
|
renderLevelBar(getLevelBarChartOptions(stats.value.byLevel));
|
||||||
|
}
|
||||||
|
if (hourDistData.value.length > 0) {
|
||||||
|
renderHourDist(getHourDistChartOptions(hourDistData.value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计算同比变化 */
|
||||||
|
function getDayChange(): { value: number; type: 'up' | 'down' | 'flat' } {
|
||||||
|
const today = stats.value.todayCount ?? 0;
|
||||||
|
const yesterday = stats.value.yesterdayCount ?? 0;
|
||||||
|
if (yesterday === 0) return { value: 0, type: 'flat' };
|
||||||
|
const pct = Math.round(((today - yesterday) / yesterday) * 100);
|
||||||
|
return {
|
||||||
|
value: Math.abs(pct),
|
||||||
|
type: pct > 0 ? 'up' : pct < 0 ? 'down' : 'flat',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化时间为简短格式 */
|
||||||
|
function formatTime(time?: string) {
|
||||||
|
if (!time) return '';
|
||||||
|
const d = new Date(time);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 跳转到告警列表 */
|
||||||
|
function goToAlertList() {
|
||||||
|
router.push('/aiot/alarm/list');
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadAll();
|
||||||
|
refreshTimer = setInterval(loadAll, 60_000);
|
||||||
});
|
});
|
||||||
},
|
|
||||||
},
|
onUnmounted(() => {
|
||||||
},
|
if (refreshTimer) clearInterval(refreshTimer);
|
||||||
rowConfig: {
|
|
||||||
keyField: 'cameraId',
|
|
||||||
isHover: true,
|
|
||||||
},
|
|
||||||
toolbarConfig: {
|
|
||||||
refresh: true,
|
|
||||||
},
|
|
||||||
} as VxeTableGridOptions<AiotAlarmApi.CameraAlertSummary>,
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Page auto-content-height>
|
<Page auto-content-height>
|
||||||
<Grid table-title="摄像头告警汇总">
|
<div class="dashboard-container">
|
||||||
<!-- 待处理数量列 -->
|
<!-- KPI 卡片 -->
|
||||||
<template #pendingCount="{ row }">
|
<Row :gutter="[16, 16]">
|
||||||
<Badge
|
<Col :xs="24" :sm="12" :md="8" :lg="5">
|
||||||
v-if="row.pendingCount > 0"
|
<Card size="small" :bordered="false" class="kpi-card">
|
||||||
:count="row.pendingCount"
|
<Statistic
|
||||||
:overflow-count="99"
|
title="今日告警"
|
||||||
:number-style="{ backgroundColor: '#faad14' }"
|
:value="stats.todayCount ?? 0"
|
||||||
|
:value-style="{ color: '#1890ff', fontWeight: 'bold', fontSize: '28px' }"
|
||||||
/>
|
/>
|
||||||
<span v-else class="text-gray-400">0</span>
|
<div class="kpi-footer">
|
||||||
|
<template v-if="getDayChange().type === 'up'">
|
||||||
|
<span class="change-up">▲ {{ getDayChange().value }}%</span>
|
||||||
|
<span class="change-label">较昨日</span>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="getDayChange().type === 'down'">
|
||||||
|
<span class="change-down">▼ {{ getDayChange().value }}%</span>
|
||||||
|
<span class="change-label">较昨日</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="change-flat">与昨日持平</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col :xs="12" :sm="12" :md="8" :lg="5">
|
||||||
|
<Card size="small" :bordered="false" class="kpi-card">
|
||||||
|
<Statistic
|
||||||
|
title="待处理"
|
||||||
|
:value="stats.pendingCount ?? 0"
|
||||||
|
:value-style="{ color: '#faad14', fontWeight: 'bold', fontSize: '28px' }"
|
||||||
|
/>
|
||||||
|
<div class="kpi-footer">
|
||||||
|
<span class="change-label">需及时处理</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col :xs="12" :sm="12" :md="8" :lg="5">
|
||||||
|
<Card size="small" :bordered="false" class="kpi-card">
|
||||||
|
<Statistic
|
||||||
|
title="已处理"
|
||||||
|
:value="stats.handledCount ?? 0"
|
||||||
|
:value-style="{ color: '#52c41a', fontWeight: 'bold', fontSize: '28px' }"
|
||||||
|
/>
|
||||||
|
<div class="kpi-footer">
|
||||||
|
<span class="change-label">
|
||||||
|
总计 {{ stats.total ?? 0 }} 条
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col :xs="12" :sm="12" :md="8" :lg="5">
|
||||||
|
<Card size="small" :bordered="false" class="kpi-card">
|
||||||
|
<Statistic
|
||||||
|
title="平均响应"
|
||||||
|
:value="stats.avgResponseMinutes ?? '-'"
|
||||||
|
suffix="分钟"
|
||||||
|
:value-style="{ color: '#722ed1', fontWeight: 'bold', fontSize: '28px' }"
|
||||||
|
/>
|
||||||
|
<div class="kpi-footer">
|
||||||
|
<span class="change-label">告警到处理</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col :xs="12" :sm="12" :md="8" :lg="4">
|
||||||
|
<Card size="small" :bordered="false" class="kpi-card">
|
||||||
|
<Statistic
|
||||||
|
title="在线设备"
|
||||||
|
:value="deviceTopData.length"
|
||||||
|
:value-style="{ color: '#13c2c2', fontWeight: 'bold', fontSize: '28px' }"
|
||||||
|
/>
|
||||||
|
<div class="kpi-footer">
|
||||||
|
<span class="change-label">活跃摄像头</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
<!-- 操作列 -->
|
<!-- 第一行图表:趋势 + 类型 -->
|
||||||
<template #actions="{ row }">
|
<Row :gutter="[16, 16]" class="mt-4">
|
||||||
<TableAction
|
<Col :xs="24" :lg="14">
|
||||||
:actions="[
|
<Card size="small" :bordered="false">
|
||||||
{
|
<template #title>
|
||||||
label: '查看告警',
|
<div class="chart-title-bar">
|
||||||
type: 'link',
|
<span>告警趋势</span>
|
||||||
icon: ACTION_ICON.VIEW,
|
<Segmented
|
||||||
onClick: handleViewAlerts.bind(null, row),
|
:value="trendDays"
|
||||||
},
|
:options="trendDaysOptions"
|
||||||
]"
|
size="small"
|
||||||
|
@change="onTrendDaysChange"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Grid>
|
<Spin :spinning="loading">
|
||||||
|
<div v-if="trendData.length > 0">
|
||||||
|
<EchartsUI ref="trendChartRef" class="h-[320px] w-full" />
|
||||||
|
</div>
|
||||||
|
<Empty v-else class="h-[320px]" description="暂无数据" />
|
||||||
|
</Spin>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col :xs="24" :lg="10">
|
||||||
|
<Card size="small" :bordered="false" title="告警类型分布">
|
||||||
|
<Spin :spinning="loading">
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
stats.byType && Object.keys(stats.byType).length > 0
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<EchartsUI ref="typePieChartRef" class="h-[320px] w-full" />
|
||||||
|
</div>
|
||||||
|
<Empty v-else class="h-[320px]" description="暂无数据" />
|
||||||
|
</Spin>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<!-- 第二行图表:设备Top + 级别 -->
|
||||||
|
<Row :gutter="[16, 16]" class="mt-4">
|
||||||
|
<Col :xs="24" :lg="14">
|
||||||
|
<Card size="small" :bordered="false" title="设备告警 Top10(近7天)">
|
||||||
|
<Spin :spinning="loading">
|
||||||
|
<div v-if="deviceTopData.length > 0">
|
||||||
|
<EchartsUI
|
||||||
|
ref="deviceTopChartRef"
|
||||||
|
class="h-[320px] w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Empty v-else class="h-[320px]" description="暂无数据" />
|
||||||
|
</Spin>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col :xs="24" :lg="10">
|
||||||
|
<Card size="small" :bordered="false" title="告警级别分布">
|
||||||
|
<Spin :spinning="loading">
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
stats.byLevel && Object.keys(stats.byLevel).length > 0
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<EchartsUI ref="levelBarChartRef" class="h-[320px] w-full" />
|
||||||
|
</div>
|
||||||
|
<Empty v-else class="h-[320px]" description="暂无数据" />
|
||||||
|
</Spin>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<!-- 第三行:时段分布 + 最近告警 -->
|
||||||
|
<Row :gutter="[16, 16]" class="mt-4">
|
||||||
|
<Col :xs="24" :lg="14">
|
||||||
|
<Card size="small" :bordered="false" title="24小时时段分布(近7天)">
|
||||||
|
<Spin :spinning="loading">
|
||||||
|
<div v-if="hourDistData.length > 0">
|
||||||
|
<EchartsUI ref="hourDistChartRef" class="h-[280px] w-full" />
|
||||||
|
</div>
|
||||||
|
<Empty v-else class="h-[280px]" description="暂无数据" />
|
||||||
|
</Spin>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col :xs="24" :lg="10">
|
||||||
|
<Card size="small" :bordered="false">
|
||||||
|
<template #title>
|
||||||
|
<div class="chart-title-bar">
|
||||||
|
<span>最近告警</span>
|
||||||
|
<a class="view-all-link" @click="goToAlertList">查看全部</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="recent-alerts-list">
|
||||||
|
<List
|
||||||
|
:data-source="recentAlerts"
|
||||||
|
:loading="loading"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<template #renderItem="{ item }">
|
||||||
|
<ListItem>
|
||||||
|
<ListItemMeta>
|
||||||
|
<template #title>
|
||||||
|
<div class="alert-item-title">
|
||||||
|
<Tag
|
||||||
|
:color="TYPE_COLORS[item.alertType] || 'default'"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ TYPE_NAMES[item.alertType] || item.alertType }}
|
||||||
|
</Tag>
|
||||||
|
<span class="alert-device-name">
|
||||||
|
{{ item.cameraName || item.cameraId }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #description>
|
||||||
|
<div class="alert-item-desc">
|
||||||
|
<span>{{ formatTime(item.triggerTime) }}</span>
|
||||||
|
<Badge
|
||||||
|
:status="
|
||||||
|
item.status === 'pending'
|
||||||
|
? 'warning'
|
||||||
|
: item.status === 'handled'
|
||||||
|
? 'success'
|
||||||
|
: 'default'
|
||||||
|
"
|
||||||
|
:text="STATUS_NAMES[item.status] || item.status"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ListItemMeta>
|
||||||
|
</ListItem>
|
||||||
|
</template>
|
||||||
|
</List>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
</Page>
|
</Page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-container {
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card {
|
||||||
|
box-shadow: 0 1px 4px rgb(0 0 0 / 8%);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-footer {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-up {
|
||||||
|
color: #f5222d;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-down {
|
||||||
|
color: #52c41a;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-flat {
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-label {
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-all-link {
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-alerts-list {
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-item-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-device-name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-item-desc {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user