468 lines
14 KiB
Vue
468 lines
14 KiB
Vue
<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">▲ {{ getDayChange().value }}%</span>
|
||
<span class="change-label">较昨日</span>
|
||
</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>
|
||
|
||
<!-- 第一行图表:趋势 + 类型 -->
|
||
<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>
|