diff --git a/apps/web-antd/src/views/ops/trajectory/index.vue b/apps/web-antd/src/views/ops/trajectory/index.vue index 986677a5e..c32d7ab8f 100644 --- a/apps/web-antd/src/views/ops/trajectory/index.vue +++ b/apps/web-antd/src/views/ops/trajectory/index.vue @@ -29,11 +29,10 @@ import { LEAVE_REASON_TEXT, } from './data'; import AreaStayChart from './modules/area-stay-chart.vue'; +import { ensureAreaTree } from './modules/area-tree-cache'; import BadgeStatusCard from './modules/badge-status-card.vue'; import StatsCards from './modules/stats-cards.vue'; -import TrajectoryGanttArea, { - ensureAreaTree, -} from './modules/trajectory-gantt-area.vue'; +import TrajectoryGanttArea from './modules/trajectory-gantt-area.vue'; import TrendChart from './modules/trend-chart.vue'; defineOptions({ name: 'OpsTrajectory' }); diff --git a/apps/web-antd/src/views/ops/trajectory/modules/area-stay-chart.vue b/apps/web-antd/src/views/ops/trajectory/modules/area-stay-chart.vue index 530a6ad20..416f6a12b 100644 --- a/apps/web-antd/src/views/ops/trajectory/modules/area-stay-chart.vue +++ b/apps/web-antd/src/views/ops/trajectory/modules/area-stay-chart.vue @@ -10,45 +10,23 @@ import { Empty } from 'ant-design-vue'; import { formatDuration } from '../data'; const props = defineProps<{ - records: OpsTrajectoryApi.TrajectoryRecord[]; + data: OpsTrajectoryApi.AreaStayStats[]; }>(); const chartRef = ref(); const { renderEcharts } = useEcharts(chartRef); -/** 按区域聚合停留时长 */ -const areaStayData = computed(() => { - const map = new Map< - string, - { areaName: string; totalStay: number; visitCount: number } - >(); - - for (const record of props.records) { - const existing = map.get(record.areaName); - if (existing) { - existing.totalStay += record.durationSeconds ?? 0; - existing.visitCount += 1; - } else { - map.set(record.areaName, { - areaName: record.areaName, - totalStay: record.durationSeconds ?? 0, - visitCount: 1, - }); - } - } - - return [...map.values()] - .toSorted((a, b) => b.totalStay - a.totalStay) - .slice(0, 10); -}); +const hasData = computed(() => props.data.length > 0); function getChartOptions(): any { - const data = areaStayData.value; + const data = props.data; if (data.length === 0) return {}; - const areaNames = data.map((d) => d.areaName).toReversed(); - const stayMinutes = data - .map((d) => Math.round(d.totalStay / 60)) + // 后端已按 totalStaySeconds 降序排列,取前10 + const top10 = data.slice(0, 10); + const fullNames = top10.map((d) => d.fullAreaName || d.areaName).toReversed(); + const stayMinutes = top10 + .map((d) => Math.round(d.totalStaySeconds / 60)) .toReversed(); return { @@ -57,43 +35,35 @@ function getChartOptions(): any { axisPointer: { type: 'shadow' }, formatter(params: any) { const item = params[0]; - const idx = data.length - 1 - item.dataIndex; - const d = data[idx]; + const idx = top10.length - 1 - item.dataIndex; + const d = top10[idx]; if (!d) return ''; - return `${d.areaName}
停留: ${formatDuration(d.totalStay)}
进出: ${d.visitCount} 次`; + const name = d.fullAreaName || d.areaName; + return `${name}
停留: ${formatDuration(d.totalStaySeconds)}
进出: ${d.visitCount} 次`; }, }, grid: { - left: '3%', - right: '10%', + left: '8px', + right: '8px', bottom: '3%', top: '8%', - containLabel: true, + containLabel: false, }, xAxis: { type: 'value', - name: '分钟', - nameTextStyle: { color: '#8c8c8c', fontSize: 11 }, - axisLabel: { color: '#8c8c8c' }, - splitLine: { lineStyle: { color: '#f0f0f0', type: 'dashed' } }, + show: false, }, yAxis: { type: 'category', - data: areaNames, - axisLabel: { - color: '#595959', - fontSize: 11, - width: 100, - overflow: 'truncate', - }, - axisLine: { show: false }, - axisTick: { show: false }, + data: fullNames, + show: false, }, series: [ { type: 'bar', data: stayMinutes, - barMaxWidth: 20, + barMaxWidth: 24, + barMinWidth: 18, itemStyle: { borderRadius: [0, 4, 4, 0], color: { @@ -110,10 +80,14 @@ function getChartOptions(): any { }, label: { show: true, - position: 'right', - color: '#8c8c8c', + position: 'insideLeft', + color: '#fff', fontSize: 11, - formatter: '{c}m', + textShadowColor: 'rgba(0,0,0,0.3)', + textShadowBlur: 2, + formatter(p: any) { + return `${fullNames[p.dataIndex]} ${p.value}m`; + }, }, }, ], @@ -121,9 +95,9 @@ function getChartOptions(): any { } watch( - () => props.records, + () => props.data, () => { - if (areaStayData.value.length > 0) { + if (hasData.value) { renderEcharts(getChartOptions()); } }, @@ -132,26 +106,18 @@ watch( diff --git a/apps/web-antd/src/views/ops/trajectory/modules/trend-chart.vue b/apps/web-antd/src/views/ops/trajectory/modules/trend-chart.vue index ac1940132..b4e1e4344 100644 --- a/apps/web-antd/src/views/ops/trajectory/modules/trend-chart.vue +++ b/apps/web-antd/src/views/ops/trajectory/modules/trend-chart.vue @@ -8,46 +8,32 @@ import { EchartsUI, useEcharts } from '@vben/plugins/echarts'; import { Empty } from 'ant-design-vue'; const props = defineProps<{ - records: OpsTrajectoryApi.TrajectoryRecord[]; + data: OpsTrajectoryApi.HourlyTrend[]; }>(); const chartRef = ref(); const { renderEcharts } = useEcharts(chartRef); -/** 按小时聚合进出次数 */ -const hourlyData = computed(() => { +const hasData = computed(() => props.data.length > 0); + +function getChartOptions(): any { + // 补全 0-23 小时 const hours: string[] = []; const enterCounts: number[] = []; const leaveCounts: number[] = []; - // 生成 0-23 小时 + const dataMap = new Map(); + for (const item of props.data) { + dataMap.set(item.hour, item); + } + for (let i = 0; i < 24; i++) { hours.push(`${String(i).padStart(2, '0')}:00`); - enterCounts.push(0); - leaveCounts.push(0); + const item = dataMap.get(i); + enterCounts.push(item?.enterCount ?? 0); + leaveCounts.push(item?.leaveCount ?? 0); } - for (const record of props.records) { - // 统计进入 - if (record.enterTime) { - const h = new Date(record.enterTime).getHours(); - enterCounts[h]!++; - } - // 统计离开 - if (record.leaveTime) { - const h = new Date(record.leaveTime).getHours(); - leaveCounts[h]!++; - } - } - - return { hours, enterCounts, leaveCounts }; -}); - -const hasData = computed(() => props.records.length > 0); - -function getChartOptions(): any { - const { hours, enterCounts, leaveCounts } = hourlyData.value; - return { tooltip: { trigger: 'axis' }, legend: { @@ -90,8 +76,8 @@ function getChartOptions(): any { symbol: 'circle', symbolSize: 5, data: enterCounts, - lineStyle: { width: 2.5, color: '#52c41a' }, - itemStyle: { color: '#52c41a' }, + lineStyle: { width: 2.5, color: '#1677ff' }, + itemStyle: { color: '#1677ff' }, areaStyle: { opacity: 0.12, color: { @@ -101,8 +87,8 @@ function getChartOptions(): any { x2: 0, y2: 1, colorStops: [ - { offset: 0, color: 'rgba(82, 196, 26, 0.3)' }, - { offset: 1, color: 'rgba(82, 196, 26, 0.02)' }, + { offset: 0, color: 'rgba(22, 119, 255, 0.3)' }, + { offset: 1, color: 'rgba(22, 119, 255, 0.02)' }, ], }, }, @@ -114,8 +100,8 @@ function getChartOptions(): any { symbol: 'circle', symbolSize: 5, data: leaveCounts, - lineStyle: { width: 2.5, color: '#ff4d4f' }, - itemStyle: { color: '#ff4d4f' }, + lineStyle: { width: 2.5, color: '#fa8c16' }, + itemStyle: { color: '#fa8c16' }, areaStyle: { opacity: 0.12, color: { @@ -125,8 +111,8 @@ function getChartOptions(): any { x2: 0, y2: 1, colorStops: [ - { offset: 0, color: 'rgba(255, 77, 79, 0.3)' }, - { offset: 1, color: 'rgba(255, 77, 79, 0.02)' }, + { offset: 0, color: 'rgba(250, 140, 22, 0.3)' }, + { offset: 1, color: 'rgba(250, 140, 22, 0.02)' }, ], }, }, @@ -136,7 +122,7 @@ function getChartOptions(): any { } watch( - () => props.records, + () => props.data, () => { if (hasData.value) { renderEcharts(getChartOptions()); @@ -147,21 +133,18 @@ watch(