fix(ops): 修复 setInterval 在 keepAlive 下未清除导致内存泄漏
页面使用 keepAlive 缓存后 onUnmounted 不触发,setInterval 持续运行, 长时间放置导致 OOM 崩溃。统一使用 onActivated/onDeactivated 管理轮询生命周期。 涉及页面:工单统计栏、工单看板、工单详情、客流统计、工作台、全局布局通知轮询 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@ import type { NotificationItem } from '@vben/layouts';
|
||||
|
||||
import type { SystemTenantApi } from '#/api/system/tenant';
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { AuthenticationLoginExpiredModal, useVbenModal } from '@vben/common-ui';
|
||||
@@ -185,13 +185,15 @@ async function handleTenantChange(tenant: SystemTenantApi.Tenant) {
|
||||
}
|
||||
|
||||
// ========== 初始化 ==========
|
||||
let notifyTimer: null | ReturnType<typeof setInterval> = null;
|
||||
|
||||
onMounted(() => {
|
||||
// 首次加载未读数量
|
||||
handleNotificationGetUnreadCount();
|
||||
// 获取租户列表
|
||||
handleGetTenantList();
|
||||
// 轮询刷新未读数量
|
||||
setInterval(
|
||||
notifyTimer = setInterval(
|
||||
() => {
|
||||
if (userStore.userInfo) {
|
||||
handleNotificationGetUnreadCount();
|
||||
@@ -201,6 +203,13 @@ onMounted(() => {
|
||||
);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (notifyTimer) {
|
||||
clearInterval(notifyTimer);
|
||||
notifyTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => ({
|
||||
enable: preferences.app.watermark,
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import {
|
||||
computed,
|
||||
onActivated,
|
||||
onDeactivated,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
} from 'vue';
|
||||
|
||||
import { GlassCard } from '@vben/common-ui';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
@@ -285,16 +292,27 @@ function getColorClass(color: string, type: 'bg' | 'text') {
|
||||
// --- 生命周期 ---
|
||||
let refreshTimer: null | ReturnType<typeof setInterval> = null;
|
||||
|
||||
onMounted(() => {
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
loadData();
|
||||
refreshTimer = setInterval(loadData, 30_000);
|
||||
});
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
function stopPolling() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(startPolling);
|
||||
onActivated(() => {
|
||||
if (!refreshTimer) {
|
||||
startPolling();
|
||||
}
|
||||
});
|
||||
onDeactivated(stopPolling);
|
||||
onUnmounted(stopPolling);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { ECOption } from '@vben/plugins/echarts';
|
||||
|
||||
import { nextTick, onMounted, onUnmounted, ref } from 'vue';
|
||||
import {
|
||||
nextTick,
|
||||
onActivated,
|
||||
onDeactivated,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
} from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
@@ -772,17 +779,27 @@ async function loadStats() {
|
||||
// ========== 生命周期 ==========
|
||||
let refreshTimer: null | ReturnType<typeof setInterval> = null;
|
||||
|
||||
onMounted(() => {
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
loadStats();
|
||||
// 每30秒刷新一次数据
|
||||
refreshTimer = setInterval(loadStats, 30_000);
|
||||
});
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
function stopPolling() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(startPolling);
|
||||
onActivated(() => {
|
||||
if (!refreshTimer) {
|
||||
startPolling();
|
||||
}
|
||||
});
|
||||
onDeactivated(stopPolling);
|
||||
onUnmounted(stopPolling);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
import type { OpsCleaningApi } from '#/api/ops/cleaning';
|
||||
import type { OpsOrderCenterApi } from '#/api/ops/order-center';
|
||||
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import {
|
||||
computed,
|
||||
onActivated,
|
||||
onDeactivated,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
} from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
@@ -562,6 +569,21 @@ function isBusyStatus(status: string) {
|
||||
return status?.toUpperCase() === 'BUSY';
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
refreshTimer.value = window.setInterval(() => {
|
||||
if (order.value.status === 'ARRIVED' && order.value.assigneeId)
|
||||
loadBadgeStatus();
|
||||
}, 30_000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (refreshTimer.value) {
|
||||
clearInterval(refreshTimer.value);
|
||||
refreshTimer.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!id && !USE_MOCK_DATA) {
|
||||
message.warning('参数错误');
|
||||
@@ -569,15 +591,17 @@ onMounted(async () => {
|
||||
return;
|
||||
}
|
||||
await loadOrderDetail();
|
||||
refreshTimer.value = window.setInterval(() => {
|
||||
if (order.value.status === 'ARRIVED' && order.value.assigneeId)
|
||||
loadBadgeStatus();
|
||||
}, 30_000);
|
||||
startPolling();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer.value) clearInterval(refreshTimer.value);
|
||||
onActivated(async () => {
|
||||
if (!refreshTimer.value) {
|
||||
await loadOrderDetail();
|
||||
startPolling();
|
||||
}
|
||||
});
|
||||
onDeactivated(stopPolling);
|
||||
onUnmounted(stopPolling);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { OpsOrderCenterApi } from '#/api/ops/order-center';
|
||||
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
@@ -67,6 +67,19 @@ const MOCK_STATS: DashboardStats = {
|
||||
|
||||
let refreshTimer: null | ReturnType<typeof setInterval> = null;
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
loadStats();
|
||||
refreshTimer = setInterval(loadStats, 30_000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载统计数据 */
|
||||
async function loadStats() {
|
||||
loading.value = true;
|
||||
@@ -112,16 +125,15 @@ function handleStatClick(statKey: string) {
|
||||
emit('statClick', statKey);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStats();
|
||||
refreshTimer = setInterval(loadStats, 30_000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
onMounted(startPolling);
|
||||
onActivated(() => {
|
||||
// 首次 mount 时 onActivated 也会触发,此时 refreshTimer 已存在,跳过
|
||||
if (!refreshTimer) {
|
||||
startPolling();
|
||||
}
|
||||
});
|
||||
onDeactivated(stopPolling);
|
||||
onUnmounted(stopPolling);
|
||||
|
||||
defineExpose({ refresh: loadStats });
|
||||
</script>
|
||||
|
||||
973
apps/web-antd/src/views/ops/traffic/index.vue
Normal file
973
apps/web-antd/src/views/ops/traffic/index.vue
Normal file
@@ -0,0 +1,973 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user