Files
aiot-platform-ui/apps/web-antd/src/views/ops/traffic/index.vue
lzh f1284142ac fix(ops): 修复 setInterval 在 keepAlive 下未清除导致内存泄漏
页面使用 keepAlive 缓存后 onUnmounted 不触发,setInterval 持续运行,
长时间放置导致 OOM 崩溃。统一使用 onActivated/onDeactivated 管理轮询生命周期。

涉及页面:工单统计栏、工单看板、工单详情、客流统计、工作台、全局布局通知轮询

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 13:24:51 +08:00

974 lines
24 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 { ECOption } from '@vben/plugins/echarts';
import type {
DeviceTrafficRealtimeResp,
TrafficRealtimeResp,
TrafficTrendResp,
} from '#/api/ops/traffic';
import {
nextTick,
onActivated,
onDeactivated,
onMounted,
onUnmounted,
ref,
watch,
} from 'vue';
import { IconifyIcon } from '@vben/icons';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import {
Card,
Col,
Row,
Select,
Spin,
Statistic,
Table,
Tooltip,
} from 'ant-design-vue';
import {
getAreaRealtime,
getAreaTrend,
getDeviceRealtime,
getDeviceTrend,
getTrafficRealtime,
getTrafficTrend,
} from '#/api/ops/traffic';
defineOptions({ name: 'OpsTraffic' });
// ========== 响应式数据 ==========
const loading = ref(true);
const chartLoading = ref(true);
// 维度筛选
const dimension = ref<'area' | 'device' | 'global'>('global');
const selectedDeviceId = ref<number | undefined>();
const selectedAreaId = ref<number | undefined>();
// 全局实时数据
const realtimeData = ref<TrafficRealtimeResp>({
totalIn: 0,
totalOut: 0,
currentOccupancy: 0,
areas: [],
hourlyTrend: { hours: [], inData: [], outData: [] },
});
// 趋势数据
const trendData = ref<TrafficTrendResp>({
dates: [],
inData: [],
outData: [],
netData: [],
totalIn: 0,
totalOut: 0,
});
// 设备/区域实时数据
const deviceRealtimeData = ref<DeviceTrafficRealtimeResp | null>(null);
const areaDeviceList = ref<DeviceTrafficRealtimeResp[]>([]);
// 图表引用
const hourlyChartRef = ref();
const trendChartRef = ref();
const { renderEcharts: renderHourlyChart } = useEcharts(hourlyChartRef);
const { renderEcharts: renderTrendChart } = useEcharts(trendChartRef);
// ========== 区域列表(从实时数据提取) ==========
const areaOptions = ref<Array<{ label: string; value: number }>>([]);
// ========== 区域表格列定义 ==========
const areaColumns = [
{ title: '区域', dataIndex: 'areaName', key: 'areaName' },
{ title: '今日进入', dataIndex: 'todayIn', key: 'todayIn', align: 'right' as const },
{ title: '今日离开', dataIndex: 'todayOut', key: 'todayOut', align: 'right' as const },
{
title: '当前在场',
dataIndex: 'currentOccupancy',
key: 'currentOccupancy',
align: 'right' as const,
},
{ title: '设备数', dataIndex: 'deviceCount', key: 'deviceCount', align: 'right' as const },
];
// 区域明细设备表格列定义
const deviceColumns = [
{ title: '设备', dataIndex: 'deviceName', key: 'deviceName' },
{ title: '今日进入', dataIndex: 'todayIn', key: 'todayIn', align: 'right' as const },
{ title: '今日离开', dataIndex: 'todayOut', key: 'todayOut', align: 'right' as const },
{
title: '当前在场',
dataIndex: 'currentOccupancy',
key: 'currentOccupancy',
align: 'right' as const,
},
];
// ========== 图表配置 ==========
/** 小时客流柱状图 */
function getHourlyChartOptions(): ECOption {
const hourly =
dimension.value === 'global'
? realtimeData.value.hourlyTrend
: dimension.value === 'device'
? deviceRealtimeData.value?.hourlyTrend
: areaDeviceList.value.length > 0
? mergeHourlyTrends(areaDeviceList.value)
: null;
if (!hourly) {
return {};
}
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
},
legend: {
data: ['进入', '离开'],
top: '5%',
textStyle: { color: '#595959' },
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '15%',
containLabel: true,
},
xAxis: {
type: 'category',
data: hourly.hours,
axisLine: { lineStyle: { color: '#d9d9d9' } },
axisLabel: { color: '#8c8c8c', fontSize: 11 },
},
yAxis: {
type: 'value',
name: '人次',
nameTextStyle: { color: '#8c8c8c', fontSize: 12 },
axisLine: { show: false },
axisLabel: { color: '#8c8c8c' },
splitLine: { lineStyle: { color: '#f0f0f0', type: 'dashed' } },
},
series: [
{
name: '进入',
type: 'bar',
barWidth: '35%',
data: hourly.inData,
itemStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: '#52c41a' },
{ offset: 1, color: '#95de64' },
],
},
borderRadius: [4, 4, 0, 0],
},
},
{
name: '离开',
type: 'bar',
barWidth: '35%',
data: hourly.outData,
itemStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: '#ff4d4f' },
{ offset: 1, color: '#ff7875' },
],
},
borderRadius: [4, 4, 0, 0],
},
},
],
};
}
/** 近7天趋势折线图 */
function getTrendChartOptions(): ECOption {
const data = trendData.value;
return {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
label: { backgroundColor: '#6a7985' },
},
},
legend: {
data: ['进入', '离开', '净流量'],
top: '5%',
textStyle: { color: '#595959' },
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '15%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: data.dates,
axisLine: { lineStyle: { color: '#d9d9d9' } },
axisLabel: { color: '#8c8c8c' },
},
yAxis: {
type: 'value',
name: '人次',
nameTextStyle: { color: '#8c8c8c' },
axisLine: { show: false },
axisLabel: { color: '#8c8c8c' },
splitLine: { lineStyle: { color: '#f0f0f0', type: 'dashed' } },
},
series: [
{
name: '进入',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 6,
areaStyle: {
opacity: 0.15,
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(82, 196, 26, 0.4)' },
{ offset: 1, color: 'rgba(82, 196, 26, 0.05)' },
],
},
},
data: data.inData,
itemStyle: { color: '#52c41a' },
},
{
name: '离开',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 6,
areaStyle: {
opacity: 0.15,
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(255, 77, 79, 0.4)' },
{ offset: 1, color: 'rgba(255, 77, 79, 0.05)' },
],
},
},
data: data.outData,
itemStyle: { color: '#ff4d4f' },
},
{
name: '净流量',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 6,
lineStyle: { type: 'dashed' },
data: data.netData,
itemStyle: { color: '#1677ff' },
},
],
};
}
/** 合并区域下多个设备的小时趋势 */
function mergeHourlyTrends(devices: DeviceTrafficRealtimeResp[]) {
if (devices.length === 0) return { hours: [], inData: [], outData: [] };
const hours = devices[0]!.hourlyTrend.hours;
const inData = new Array(hours.length).fill(0);
const outData = new Array(hours.length).fill(0);
for (const device of devices) {
device.hourlyTrend.inData.forEach((v, i) => {
inData[i] += Number(v) || 0;
});
device.hourlyTrend.outData.forEach((v, i) => {
outData[i] += Number(v) || 0;
});
}
return { hours, inData, outData };
}
// ========== 数据加载 ==========
/** 加载全局数据 */
async function loadGlobalData() {
loading.value = true;
try {
const [realtime, trend] = await Promise.all([
getTrafficRealtime(),
getTrafficTrend(),
]);
realtimeData.value = realtime;
trendData.value = trend;
// 更新区域选项
areaOptions.value = (realtime.areas || []).map((a) => ({
label: a.areaName,
value: a.areaId,
}));
chartLoading.value = false;
await nextTick();
renderHourlyChart(getHourlyChartOptions());
renderTrendChart(getTrendChartOptions());
} catch {
// 保持现有数据
} finally {
loading.value = false;
}
}
/** 加载设备维度数据 */
async function loadDeviceData(deviceId: number) {
loading.value = true;
try {
const [realtime, trend] = await Promise.all([
getDeviceRealtime(deviceId),
getDeviceTrend({ deviceId }),
]);
deviceRealtimeData.value = realtime;
trendData.value = trend;
chartLoading.value = false;
await nextTick();
renderHourlyChart(getHourlyChartOptions());
renderTrendChart(getTrendChartOptions());
} catch {
// 保持现有数据
} finally {
loading.value = false;
}
}
/** 加载区域维度数据 */
async function loadAreaData(areaId: number) {
loading.value = true;
try {
const [devices, trend] = await Promise.all([
getAreaRealtime(areaId),
getAreaTrend({ areaId }),
]);
areaDeviceList.value = devices;
trendData.value = trend;
chartLoading.value = false;
await nextTick();
renderHourlyChart(getHourlyChartOptions());
renderTrendChart(getTrendChartOptions());
} catch {
// 保持现有数据
} finally {
loading.value = false;
}
}
/** 根据维度加载数据 */
async function loadData() {
if (dimension.value === 'device' && selectedDeviceId.value) {
await loadDeviceData(selectedDeviceId.value);
} else if (dimension.value === 'area' && selectedAreaId.value) {
await loadAreaData(selectedAreaId.value);
} else {
await loadGlobalData();
}
}
// ========== 统计指标(根据维度计算) ==========
function getStatTotalIn(): number {
if (dimension.value === 'device' && deviceRealtimeData.value) {
return deviceRealtimeData.value.todayIn;
}
if (dimension.value === 'area' && areaDeviceList.value.length > 0) {
return areaDeviceList.value.reduce((sum, d) => sum + d.todayIn, 0);
}
return realtimeData.value.totalIn;
}
function getStatTotalOut(): number {
if (dimension.value === 'device' && deviceRealtimeData.value) {
return deviceRealtimeData.value.todayOut;
}
if (dimension.value === 'area' && areaDeviceList.value.length > 0) {
return areaDeviceList.value.reduce((sum, d) => sum + d.todayOut, 0);
}
return realtimeData.value.totalOut;
}
function getStatOccupancy(): number {
if (dimension.value === 'device' && deviceRealtimeData.value) {
return deviceRealtimeData.value.currentOccupancy;
}
if (dimension.value === 'area' && areaDeviceList.value.length > 0) {
return areaDeviceList.value.reduce(
(sum, d) => sum + d.currentOccupancy,
0,
);
}
return realtimeData.value.currentOccupancy;
}
function getStatAreaCount(): number {
return realtimeData.value.areas?.length || 0;
}
// ========== 维度切换监听 ==========
watch(dimension, () => {
selectedDeviceId.value = undefined;
selectedAreaId.value = undefined;
// 切回全局时自动加载
if (dimension.value === 'global') {
loadData();
}
});
watch(selectedDeviceId, (val) => {
if (val) loadData();
});
watch(selectedAreaId, (val) => {
if (val) loadData();
});
// ========== 生命周期 ==========
let refreshTimer: null | ReturnType<typeof setInterval> = null;
function startPolling() {
stopPolling();
loadData();
refreshTimer = setInterval(loadData, 30_000);
}
function stopPolling() {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
}
onMounted(startPolling);
onActivated(() => {
if (!refreshTimer) {
startPolling();
}
});
onDeactivated(stopPolling);
onUnmounted(stopPolling);
</script>
<template>
<div class="traffic-dashboard">
<Spin :spinning="loading">
<!-- 筛选栏 -->
<Card class="filter-card mb-3">
<div class="filter-bar">
<div class="filter-item">
<span class="filter-label">查询维度</span>
<Select
v-model:value="dimension"
:options="[
{ label: '全局总览', value: 'global' },
{ label: '按设备', value: 'device' },
{ label: '按区域', value: 'area' },
]"
style="width: 140px"
/>
</div>
<div v-if="dimension === 'device'" class="filter-item">
<span class="filter-label">设备ID</span>
<Select
v-model:value="selectedDeviceId"
placeholder="请输入设备ID"
style="width: 200px"
show-search
:filter-option="false"
allow-clear
/>
</div>
<div v-if="dimension === 'area'" class="filter-item">
<span class="filter-label">选择区域</span>
<Select
v-model:value="selectedAreaId"
placeholder="请选择区域"
:options="areaOptions"
style="width: 200px"
show-search
:filter-option="
(input: string, option: any) =>
option.label.toLowerCase().includes(input.toLowerCase())
"
allow-clear
/>
</div>
</div>
</Card>
<!-- 核心指标卡片 -->
<Row :gutter="[12, 12]" class="mb-3">
<Col :xs="24" :sm="12" :lg="6">
<Card class="metric-card metric-card--in">
<div class="metric-content">
<div class="metric-icon">
<IconifyIcon icon="mdi:arrow-right-bold" />
</div>
<div class="metric-info">
<div class="metric-label">今日进入</div>
<Statistic
:value="getStatTotalIn()"
:value-style="{
fontSize: '24px',
fontWeight: 600,
color: '#52c41a',
}"
/>
</div>
</div>
</Card>
</Col>
<Col :xs="24" :sm="12" :lg="6">
<Card class="metric-card metric-card--out">
<div class="metric-content">
<div class="metric-icon">
<IconifyIcon icon="mdi:arrow-left-bold" />
</div>
<div class="metric-info">
<div class="metric-label">今日离开</div>
<Statistic
:value="getStatTotalOut()"
:value-style="{
fontSize: '24px',
fontWeight: 600,
color: '#ff4d4f',
}"
/>
</div>
</div>
</Card>
</Col>
<Col :xs="24" :sm="12" :lg="6">
<Card class="metric-card metric-card--occupancy">
<div class="metric-content">
<div class="metric-icon">
<IconifyIcon icon="mdi:account-group" />
</div>
<div class="metric-info">
<div class="metric-label">当前在场</div>
<Statistic
:value="getStatOccupancy()"
:value-style="{
fontSize: '24px',
fontWeight: 600,
color: '#1677ff',
}"
/>
</div>
</div>
</Card>
</Col>
<Col :xs="24" :sm="12" :lg="6">
<Card class="metric-card metric-card--area">
<div class="metric-content">
<div class="metric-icon">
<IconifyIcon icon="mdi:map-marker-multiple" />
</div>
<div class="metric-info">
<div class="metric-label">监控区域</div>
<Statistic
:value="getStatAreaCount()"
:value-style="{
fontSize: '24px',
fontWeight: 600,
color: '#722ed1',
}"
/>
</div>
</div>
</Card>
</Col>
</Row>
<!-- 第二行:小时趋势 + 区域/设备表格 -->
<Row :gutter="[12, 12]" class="mb-3">
<Col :xs="24" :lg="16">
<Card class="chart-card" title="今日小时客流趋势">
<template #extra>
<Tooltip title="展示今日24小时各时段的进入和离开人次">
<IconifyIcon
icon="solar:info-circle-bold-duotone"
class="info-icon"
/>
</Tooltip>
</template>
<Spin :spinning="chartLoading">
<EchartsUI ref="hourlyChartRef" class="chart-container" />
</Spin>
</Card>
</Col>
<Col :xs="24" :lg="8">
<!-- 全局模式:区域汇总表 -->
<Card
v-if="dimension === 'global'"
class="chart-card"
title="区域实时客流"
>
<Table
:columns="areaColumns"
:data-source="realtimeData.areas"
:pagination="false"
size="small"
row-key="areaId"
:scroll="{ y: 220 }"
/>
</Card>
<!-- 区域模式:设备明细表 -->
<Card
v-else-if="dimension === 'area'"
class="chart-card"
title="区域设备明细"
>
<Table
:columns="deviceColumns"
:data-source="areaDeviceList"
:pagination="false"
size="small"
row-key="deviceId"
:scroll="{ y: 220 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'deviceName'">
{{ record.deviceName || `设备 ${record.deviceId}` }}
</template>
</template>
</Table>
</Card>
<!-- 设备模式:设备信息卡 -->
<Card
v-else
class="chart-card"
title="设备详情"
>
<div v-if="deviceRealtimeData" class="device-info">
<div class="device-info-item">
<span class="device-info-label">设备ID</span>
<span class="device-info-value">{{ deviceRealtimeData.deviceId }}</span>
</div>
<div class="device-info-item">
<span class="device-info-label">设备名称</span>
<span class="device-info-value">{{ deviceRealtimeData.deviceName || '-' }}</span>
</div>
<div class="device-info-item">
<span class="device-info-label">所属区域</span>
<span class="device-info-value">{{ deviceRealtimeData.areaName || '-' }}</span>
</div>
<div class="device-info-item">
<span class="device-info-label">今日进入</span>
<span class="device-info-value" style="color: #52c41a">{{ deviceRealtimeData.todayIn }}</span>
</div>
<div class="device-info-item">
<span class="device-info-label">今日离开</span>
<span class="device-info-value" style="color: #ff4d4f">{{ deviceRealtimeData.todayOut }}</span>
</div>
<div class="device-info-item">
<span class="device-info-label">当前在场</span>
<span class="device-info-value" style="color: #1677ff">{{ deviceRealtimeData.currentOccupancy }}</span>
</div>
</div>
<div v-else class="empty-state">
<IconifyIcon icon="mdi:information-outline" class="text-2xl text-gray-400" />
<p class="mt-2 text-gray-400">请输入设备ID查询</p>
</div>
</Card>
</Col>
</Row>
<!-- 第三行7天趋势 -->
<Row :gutter="[12, 12]">
<Col :span="24">
<Card class="chart-card" title="客流趋势近7天">
<template #extra>
<Tooltip title="展示最近7天的客流进入离开和净流量趋势">
<IconifyIcon
icon="solar:info-circle-bold-duotone"
class="info-icon"
/>
</Tooltip>
</template>
<Spin :spinning="chartLoading">
<EchartsUI ref="trendChartRef" class="chart-container" />
</Spin>
</Card>
</Col>
</Row>
</Spin>
</div>
</template>
<style scoped lang="scss">
@media (max-width: 768px) {
.traffic-dashboard {
padding: 8px;
}
.chart-container {
height: 220px !important;
}
}
.traffic-dashboard {
padding: 16px;
}
// 筛选栏
.filter-card {
:deep(.ant-card-body) {
padding: 12px 16px;
}
}
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
}
.filter-item {
display: flex;
gap: 8px;
align-items: center;
}
.filter-label {
font-size: 13px;
color: #595959;
white-space: nowrap;
}
// 核心指标卡片
.metric-card {
position: relative;
overflow: hidden;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 16px rgb(0 0 0 / 6%);
transform: translateY(-2px);
}
:deep(.ant-card-body) {
padding: 16px;
}
.metric-content {
display: flex;
gap: 12px;
align-items: center;
}
.metric-icon {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
font-size: 22px;
border-radius: 10px;
}
.metric-info {
flex: 1;
}
.metric-label {
margin-bottom: 2px;
font-size: 13px;
color: #262626;
}
&--in .metric-icon {
color: #52c41a;
background: #f6ffed;
}
&--out .metric-icon {
color: #ff4d4f;
background: #fff1f0;
}
&--occupancy .metric-icon {
color: #1677ff;
background: #e6f4ff;
}
&--area .metric-icon {
color: #722ed1;
background: #f9f0ff;
}
}
// 图表卡片
.chart-card {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 2px 12px rgb(0 0 0 / 5%);
}
:deep(.ant-card-head) {
min-height: 44px;
padding: 0 16px;
border-bottom: 1px solid #f0f0f0;
.ant-card-head-title {
padding: 12px 0;
font-size: 14px;
font-weight: 600;
color: #262626;
}
}
:deep(.ant-card-body) {
padding: 12px 16px 16px;
}
.info-icon {
font-size: 14px;
color: #8c8c8c;
cursor: help;
&:hover {
color: #1677ff;
}
}
.chart-container {
height: 280px;
}
}
// 设备详情
.device-info {
display: flex;
flex-direction: column;
gap: 12px;
}
.device-info-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #f5f5f5;
}
.device-info-label {
font-size: 13px;
color: #8c8c8c;
}
.device-info-value {
font-size: 14px;
font-weight: 600;
color: #262626;
}
// 空状态
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 200px;
}
// 暗色模式
html.dark {
.metric-card {
background: #1f1f1f;
border-color: #303030;
.metric-label {
color: rgb(255 255 255 / 65%);
}
}
.chart-card {
background: #1f1f1f;
border-color: #303030;
:deep(.ant-card-head) {
border-bottom-color: #303030;
.ant-card-head-title {
color: rgb(255 255 255 / 85%);
}
}
}
.filter-card {
background: #1f1f1f;
border-color: #303030;
}
.filter-label {
color: rgb(255 255 255 / 65%);
}
.device-info-item {
border-bottom-color: #303030;
}
.device-info-label {
color: rgb(255 255 255 / 45%);
}
.device-info-value {
color: rgb(255 255 255 / 85%);
}
}
</style>