页面使用 keepAlive 缓存后 onUnmounted 不触发,setInterval 持续运行, 长时间放置导致 OOM 崩溃。统一使用 onActivated/onDeactivated 管理轮询生命周期。 涉及页面:工单统计栏、工单看板、工单详情、客流统计、工作台、全局布局通知轮询 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
974 lines
24 KiB
Vue
974 lines
24 KiB
Vue
<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>
|