From cc98ba4d0bbe28e7aae055b5c09bf2ccdf0f7c1d Mon Sep 17 00:00:00 2001 From: lzh Date: Sun, 5 Apr 2026 15:37:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(@vben/web-antd):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E8=BD=A8=E8=BF=B9=E9=A1=B5=E9=9D=A2=E5=AD=90=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trajectory-gantt-area: - 数字孪生甘特图,纵轴加载完整区域树,仅显示有数据区域 - 区域树缓存提取到 area-tree-cache.ts,单例 Promise 防并发重复请求 - 支持时段预设(自适应/全天/上午/下午/工作时段)和 1x-16x 缩放 - tooltip 类型安全:record 改为 null | TrajectoryRecord badge-status-card: - 新增实时轨迹时间线(最近15条,倒序展示) - 排序改用 toSorted + dayjs valueOf area-stay-chart: - 显示 fullAreaName 全路径,标签内置于条形,统一高度 260px trend-chart: - 进入改蓝色,离开改橙色 - 修复 height 缺少 px 单位导致图表不渲染 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/views/ops/trajectory/index.vue | 5 +- .../trajectory/modules/area-stay-chart.vue | 98 +-- .../ops/trajectory/modules/area-tree-cache.ts | 34 + .../trajectory/modules/badge-status-card.vue | 213 ++++- .../ops/trajectory/modules/stats-cards.vue | 57 +- .../modules/trajectory-gantt-area.vue | 832 ++++++++++++++++++ .../ops/trajectory/modules/trend-chart.vue | 65 +- 7 files changed, 1122 insertions(+), 182 deletions(-) create mode 100644 apps/web-antd/src/views/ops/trajectory/modules/area-tree-cache.ts create mode 100644 apps/web-antd/src/views/ops/trajectory/modules/trajectory-gantt-area.vue 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(