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:
lzh
2026-02-26 13:24:51 +08:00
parent cd38d89fe5
commit f1284142ac
6 changed files with 1080 additions and 27 deletions

View File

@@ -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,

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>