Files
iot-device-management-frontend/apps/web-antd/src/views/aiot/alarm/summary/index.vue
2026-03-18 17:31:21 +08:00

468 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import type { AiotAlarmApi } from '#/api/aiot/alarm';
import { nextTick, onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import {
Badge,
Card,
Col,
Empty,
List,
ListItem,
ListItemMeta,
Row,
Segmented,
Spin,
Statistic,
Tag,
} from 'ant-design-vue';
import {
getAlertDashboard,
getAlertTrend,
} from '#/api/aiot/alarm';
import {
getDeviceTopChartOptions,
getHourDistChartOptions,
getLevelBarChartOptions,
getTrendChartOptions,
getTypePieChartOptions,
} from './chart-options';
defineOptions({ name: 'AiotAlarmDashboard' });
const router = useRouter();
const loading = ref(true);
// 数据
const stats = ref<AiotAlarmApi.AlertStatistics>({});
const trendData = ref<AiotAlarmApi.TrendItem[]>([]);
const deviceTopData = ref<AiotAlarmApi.DeviceTopItem[]>([]);
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 data = await getAlertDashboard(trendDays.value);
stats.value = data.statistics;
trendData.value = data.trend;
deviceTopData.value = data.deviceTop;
hourDistData.value = data.hourDistribution;
recentAlerts.value = data.recentAlerts || [];
} finally {
loading.value = false;
await nextTick();
await nextTick();
renderCharts();
}
}
/** 切换趋势天数 */
async function onTrendDaysChange(val: number) {
trendDays.value = val;
try {
trendData.value = await getAlertTrend(val);
await nextTick();
renderTrend(getTrendChartOptions(trendData.value));
} catch {
/* ignore */
}
}
/** 渲染所有图表 */
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);
});
</script>
<template>
<Page auto-content-height>
<div class="dashboard-container">
<!-- KPI 卡片 -->
<Row :gutter="[16, 16]">
<Col :xs="24" :sm="12" :md="8" :lg="5">
<Card size="small" :bordered="false" class="kpi-card">
<Statistic
title="今日告警"
:value="stats.todayCount ?? 0"
:value-style="{ color: '#1890ff', fontWeight: 'bold', fontSize: '28px' }"
/>
<div class="kpi-footer">
<template v-if="getDayChange().type === 'up'">
<span class="change-up">&#9650; {{ getDayChange().value }}%</span>
<span class="change-label">较昨日</span>
</template>
<template v-else-if="getDayChange().type === 'down'">
<span class="change-down">&#9660; {{ 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>
<!-- 第一行图表趋势 + 类型 -->
<Row :gutter="[16, 16]" class="mt-4">
<Col :xs="24" :lg="14">
<Card size="small" :bordered="false">
<template #title>
<div class="chart-title-bar">
<span>告警趋势</span>
<Segmented
:value="trendDays"
:options="trendDaysOptions"
size="small"
@change="onTrendDaysChange"
/>
</div>
</template>
<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>
</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>