feat(@vben/web-antd): 重构轨迹页面子组件

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) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-04-05 15:37:59 +08:00
parent 3a14de4d1c
commit cc98ba4d0b
7 changed files with 1122 additions and 182 deletions

View File

@@ -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' });

View File

@@ -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}<br/>停留: ${formatDuration(d.totalStay)}<br/>进出: ${d.visitCount}`;
const name = d.fullAreaName || d.areaName;
return `${name}<br/>停留: ${formatDuration(d.totalStaySeconds)}<br/>进出: ${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(
</script>
<template>
<div class="area-stay-chart">
<div class="area-stay-chart bg-card">
<div class="chart-title">区域停留分布</div>
<div v-if="areaStayData.length === 0" class="chart-empty">
<div v-if="!hasData" class="chart-empty">
<Empty description="暂无数据" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
</div>
<EchartsUI
v-show="areaStayData.length > 0"
ref="chartRef"
:height="`${Math.max(200, areaStayData.length * 36 + 40)}`"
width="100%"
/>
<EchartsUI v-show="hasData" ref="chartRef" height="260px" width="100%" />
</div>
</template>
<style scoped>
.area-stay-chart {
padding: 16px;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
}
.chart-title {

View File

@@ -0,0 +1,34 @@
import type { OpsAreaApi } from '#/api/ops/area';
import { ref } from 'vue';
import { handleTree } from '@vben/utils';
import { getAreaTree } from '#/api/ops/area';
// 模块级缓存:所有组件实例共享,只请求一次
let _treePromise: null | Promise<void> = null;
export const cachedAreaTree = ref<OpsAreaApi.BusArea[]>([]);
export const areaTreeLoaded = ref(false);
/** 确保区域树已加载(并发安全:同一个 Promise 不会重复请求) */
export function ensureAreaTree(): Promise<void> {
if (_treePromise) return _treePromise;
_treePromise = getAreaTree({ isActive: true })
.then((data) => {
const list = Array.isArray(data) ? data : ((data as any)?.list ?? []);
cachedAreaTree.value = handleTree(
list,
'id',
'parentId',
) as OpsAreaApi.BusArea[];
})
.catch(() => {
cachedAreaTree.value = [];
_treePromise = null; // 失败时重置,下次可重试
})
.finally(() => {
areaTreeLoaded.value = true;
});
return _treePromise;
}

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { OpsCleaningApi } from '#/api/ops/cleaning';
import type { OpsTrajectoryApi } from '#/api/ops/trajectory';
import { computed } from 'vue';
@@ -9,13 +10,14 @@ import { Progress, Tag, Tooltip } from 'ant-design-vue';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { BADGE_STATUS_MAP, getBatteryLevel, getRssiLevel } from '../data';
import { BADGE_STATUS_MAP, formatDuration, getBatteryLevel } from '../data';
import 'dayjs/locale/zh-cn';
const props = defineProps<{
data: null | OpsCleaningApi.BadgeRealtimeStatus;
loading?: boolean;
recentRecords?: OpsTrajectoryApi.TrajectoryRecord[];
}>();
dayjs.extend(relativeTime);
dayjs.locale('zh-cn');
@@ -26,22 +28,31 @@ const statusInfo = computed(() => {
});
const batteryInfo = computed(() => getBatteryLevel(props.data?.batteryLevel));
const rssiInfo = computed(() => getRssiLevel(props.data?.rssi));
const relativeHeartbeat = computed(() => {
if (!props.data?.lastHeartbeatTime) return '未知';
return dayjs(props.data.lastHeartbeatTime).fromNow();
const relativeOnlineTime = computed(() => {
if (!props.data?.onlineTime) return '未知';
return dayjs(props.data.onlineTime).fromNow();
});
// 近期轨迹按时间倒序最近15条
const recentTimeline = computed(() => {
if (!props.recentRecords || props.recentRecords.length === 0) return [];
return [...props.recentRecords]
.toSorted(
(a, b) => dayjs(b.enterTime).valueOf() - dayjs(a.enterTime).valueOf(),
)
.slice(0, 15);
});
</script>
<template>
<div class="badge-status-card">
<div class="badge-status-card bg-card">
<div class="card-header">
<IconifyIcon
icon="solar:user-id-bold-duotone"
:style="{ color: '#fa8c16', fontSize: '20px' }"
/>
<span class="card-title">工牌状态</span>
<span class="card-title">工牌状态实时</span>
</div>
<!-- 空状态 -->
@@ -88,22 +99,6 @@ const relativeHeartbeat = computed(() => {
</div>
</div>
<!-- 信号强度 -->
<div class="status-row">
<span class="label">
<IconifyIcon
:icon="rssiInfo.icon"
:style="{ color: rssiInfo.color }"
/>
信号
</span>
<Tooltip :title="data.rssi != null ? `${data.rssi} dBm` : '无信号'">
<span :style="{ color: rssiInfo.color, fontWeight: 500 }">
{{ rssiInfo.label }}
</span>
</Tooltip>
</div>
<!-- 当前区域 -->
<div class="status-row">
<span class="label">当前区域</span>
@@ -112,23 +107,68 @@ const relativeHeartbeat = computed(() => {
</span>
</div>
<!-- 最后心跳 -->
<!-- 上线时间 -->
<div class="status-row">
<span class="label">最后心跳</span>
<Tooltip :title="data.lastHeartbeatTime">
<span class="value muted">{{ relativeHeartbeat }}</span>
<span class="label">上线时间</span>
<Tooltip :title="data.onlineTime">
<span class="value muted">{{ relativeOnlineTime }}</span>
</Tooltip>
</div>
<!-- 近期轨迹 -->
<div class="recent-timeline">
<div class="timeline-header">
<span class="timeline-title">实时轨迹</span>
<span class="timeline-count">{{ recentTimeline.length }} </span>
</div>
<div v-if="recentTimeline.length > 0" class="timeline-list">
<div
v-for="item in recentTimeline"
:key="item.id"
class="timeline-item"
>
<div class="timeline-dot-wrap">
<span
class="timeline-dot"
:class="{ 'timeline-dot--active': !item.leaveTime }"
></span>
<span class="timeline-line"></span>
</div>
<div class="timeline-content">
<div class="timeline-area">
{{ item.fullAreaName || item.areaName }}
</div>
<div class="timeline-meta">
<span>{{ dayjs(item.enterTime).format('HH:mm') }}</span>
<template v-if="item.leaveTime"><span> {{ dayjs(item.leaveTime).format('HH:mm') }}</span></template>
<span v-else class="staying-tag">停留中</span>
<span v-if="item.durationSeconds" class="timeline-duration">
{{ formatDuration(item.durationSeconds) }}
</span>
</div>
</div>
</div>
</div>
<div v-else class="timeline-empty">暂无实时轨迹</div>
</div>
</template>
</div>
</template>
<style scoped>
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
.badge-status-card {
padding: 16px;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
}
.card-header {
@@ -193,15 +233,112 @@ const relativeHeartbeat = computed(() => {
animation: pulse 2s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
/* 近期轨迹 */
.recent-timeline {
padding-top: 12px;
margin-top: 12px;
border-top: 1px solid #f5f5f5;
}
50% {
opacity: 0.4;
}
.timeline-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.timeline-title {
font-size: 13px;
font-weight: 500;
color: #262626;
}
.timeline-count {
font-size: 11px;
color: #bfbfbf;
}
.timeline-list {
max-height: 280px;
overflow-y: auto;
}
.timeline-item {
display: flex;
gap: 8px;
min-height: 48px;
}
.timeline-dot-wrap {
display: flex;
flex-shrink: 0;
flex-direction: column;
align-items: center;
width: 12px;
padding-top: 5px;
}
.timeline-dot {
flex-shrink: 0;
width: 8px;
height: 8px;
background: #d9d9d9;
border-radius: 50%;
}
.timeline-dot--active {
background: #52c41a;
box-shadow: 0 0 0 3px rgb(82 196 26 / 15%);
}
.timeline-line {
flex: 1;
width: 1px;
min-height: 20px;
background: #f0f0f0;
}
.timeline-item:last-child .timeline-line {
display: none;
}
.timeline-content {
flex: 1;
min-width: 0;
padding-bottom: 10px;
}
.timeline-area {
font-size: 12px;
line-height: 1.4;
color: #262626;
word-break: break-all;
}
.timeline-meta {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
margin-top: 2px;
font-size: 11px;
color: #8c8c8c;
}
.staying-tag {
font-weight: 500;
color: #52c41a;
}
.timeline-duration {
color: #1677ff;
}
.timeline-empty {
padding: 16px 0;
font-size: 12px;
color: #bfbfbf;
text-align: center;
}
.empty-state {

View File

@@ -10,43 +10,40 @@ import { Skeleton } from 'ant-design-vue';
import { formatDuration } from '../data';
const props = defineProps<{
currentLocation?: null | OpsTrajectoryApi.CurrentLocation;
data: null | OpsTrajectoryApi.TrajectorySummary;
loading?: boolean;
summary: null | OpsTrajectoryApi.TrajectorySummary;
}>();
const cards = computed(() => [
{
title: '今日出入次数',
value: props.summary?.totalEvents ?? 0,
suffix: '次',
icon: 'solar:transfer-horizontal-bold-duotone',
color: '#1677ff',
bgColor: '#e6f4ff',
},
{
title: '到访区域数',
value: props.summary?.uniqueAreaCount ?? 0,
suffix: '个',
icon: 'solar:map-point-bold-duotone',
color: '#52c41a',
bgColor: '#f6ffed',
},
{
title: '在线时长',
value: formatDuration(props.summary?.onlineDurationSeconds),
title: '作业时长',
value: formatDuration(props.data?.workDurationSeconds),
suffix: '',
icon: 'solar:clock-circle-bold-duotone',
color: '#722ed1',
bgColor: '#f9f0ff',
},
{
title: '当前所在区域',
value: props.currentLocation?.inArea
? props.currentLocation.areaName || '未知区域'
: '已离开',
title: '覆盖区域',
value: props.data?.coveredAreaCount ?? 0,
suffix: '个',
icon: 'solar:map-point-bold-duotone',
color: '#52c41a',
bgColor: '#f6ffed',
},
{
title: '出入次数',
value: props.data?.totalEvents ?? 0,
suffix: '次',
icon: 'solar:transfer-horizontal-bold-duotone',
color: '#1677ff',
bgColor: '#e6f4ff',
},
{
title: '平均停留时长',
value: formatDuration(props.data?.avgStaySeconds),
suffix: '',
icon: 'solar:buildings-bold-duotone',
icon: 'solar:stopwatch-bold-duotone',
color: '#fa8c16',
bgColor: '#fff7e6',
},
@@ -55,7 +52,7 @@ const cards = computed(() => [
<template>
<div class="stats-cards">
<div v-for="(card, index) in cards" :key="index" class="stat-card">
<div v-for="(card, index) in cards" :key="index" class="stat-card bg-card">
<Skeleton :loading="loading" active :paragraph="{ rows: 1 }">
<div class="stat-card-inner">
<div class="stat-icon" :style="{ backgroundColor: card.bgColor }">
@@ -88,14 +85,6 @@ const cards = computed(() => [
.stat-card {
padding: 16px;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
transition: box-shadow 0.2s;
}
.stat-card:hover {
box-shadow: 0 2px 8px rgb(0 0 0 / 6%);
}
.stat-card-inner {

View File

@@ -0,0 +1,832 @@
<script setup lang="ts">
import type { OpsTrajectoryApi } from '#/api/ops/trajectory';
import { computed, onMounted, ref } from 'vue';
import dayjs from 'dayjs';
import { formatDuration, getPersonColor } from '../data';
import {
areaTreeLoaded,
cachedAreaTree,
ensureAreaTree,
} from './area-tree-cache';
// 使用模块级缓存的区域树
const areaTree = cachedAreaTree;
const treeLoaded = areaTreeLoaded;
onMounted(() => {
ensureAreaTree();
});
// ========== 时间轴范围(可调整) ==========
const timeRangeMode = ref<'auto' | 'custom' | 'full'>('auto');
const customStart = ref(6);
const customEnd = ref(24);
/** 根据轨迹数据自动计算时间范围(向下/上取整到小时) */
const autoRange = computed<[number, number]>(() => {
if (props.records.length === 0) return [6, 24];
let minH = 24;
let maxH = 0;
for (const r of props.records) {
const enter = dayjs(r.enterTime);
const leave = r.leaveTime ? dayjs(r.leaveTime) : dayjs();
minH = Math.min(minH, enter.hour());
const leaveH = leave.minute() > 0 ? leave.hour() + 1 : leave.hour();
maxH = Math.max(maxH, leaveH);
}
// 前后各留1小时余量至少跨4小时
const start = Math.max(0, minH - 1);
const end = Math.min(24, Math.max(maxH + 1, start + 4));
return [start, end];
});
const hourStart = computed(() => {
if (timeRangeMode.value === 'full') return 0;
if (timeRangeMode.value === 'custom') return customStart.value;
return autoRange.value[0];
});
const hourEnd = computed(() => {
if (timeRangeMode.value === 'full') return 24;
if (timeRangeMode.value === 'custom') return customEnd.value;
return autoRange.value[1];
});
const totalHours = computed(() => hourEnd.value - hourStart.value);
const hours = computed(() =>
Array.from({ length: totalHours.value }, (_, i) => hourStart.value + i),
);
// 预设时间跨度选项
const timePresets = [
{ label: '自适应', value: 'auto' as const },
{ label: '全天', value: 'full' as const },
{ label: '上午', start: 6, end: 12 },
{ label: '下午', start: 12, end: 18 },
{ label: '工作时段', start: 8, end: 18 },
] as const;
function setTimeRange(preset: (typeof timePresets)[number]) {
if ('value' in preset) {
timeRangeMode.value = preset.value;
} else {
timeRangeMode.value = 'custom';
customStart.value = preset.start;
customEnd.value = preset.end;
}
}
/** 获取当前激活的预设标识 */
function isPresetActive(preset: (typeof timePresets)[number]): boolean {
if ('value' in preset) return timeRangeMode.value === preset.value;
return (
timeRangeMode.value === 'custom' &&
customStart.value === preset.start &&
customEnd.value === preset.end
);
}
// ========== 横向缩放 ==========
const ZOOM_LEVELS = [1, 2, 4, 8, 16];
const zoomIndex = ref(0);
const zoom = computed(() => ZOOM_LEVELS[zoomIndex.value]!);
function zoomIn() {
if (zoomIndex.value < ZOOM_LEVELS.length - 1) zoomIndex.value++;
}
function zoomOut() {
if (zoomIndex.value > 0) zoomIndex.value--;
}
/** 时间轴内容宽度百分比zoom=1 时为 100% */
const timelineWidthPct = computed(() => `${zoom.value * 100}%`);
// ========== 按 areaId 索引轨迹记录 ==========
const recordsByAreaId = computed(() => {
const map = new Map<number, OpsTrajectoryApi.TrajectoryRecord[]>();
for (const r of props.records) {
if (!map.has(r.areaId)) map.set(r.areaId, []);
map.get(r.areaId)!.push(r);
}
// 每个区域内按进入时间排序
for (const recs of map.values()) {
recs.sort((a, b) => a.enterTime.localeCompare(b.enterTime));
}
return map;
});
// 有轨迹数据的 areaId 集合(含祖先节点)
const activeAreaIds = computed(() => {
const ids = new Set<number>();
// 收集所有有记录的 areaId
for (const areaId of recordsByAreaId.value.keys()) {
ids.add(areaId);
}
return ids;
});
// ========== 展开/折叠 ==========
const collapsedNodes = ref(new Set<number>());
function toggleNode(id: number) {
const s = collapsedNodes.value;
if (s.has(id)) s.delete(id);
else s.add(id);
}
// ========== 扁平化树为可见行 ==========
type RowType = 'BUILDING' | 'FLOOR' | 'FUNCTION' | 'PARK';
interface FlatRow {
areaId: number;
depth: number;
hasChildren: boolean;
hasData: boolean;
key: string;
label: string;
records: OpsTrajectoryApi.TrajectoryRecord[];
type: RowType;
}
/** 检测某个节点(或其子孙)是否包含轨迹数据 */
function hasDataInSubtree(node: OpsAreaApi.BusArea): boolean {
if (
node.id !== null &&
node.id !== undefined &&
activeAreaIds.value.has(node.id)
)
return true;
if (node.children) {
return node.children.some((c) => hasDataInSubtree(c));
}
return false;
}
const flatRows = computed<FlatRow[]>(() => {
const rows: FlatRow[] = [];
function walk(nodes: OpsAreaApi.BusArea[], depth: number) {
for (const node of nodes) {
const id = node.id!;
const hasData = hasDataInSubtree(node);
// 跳过无数据的节点
if (!hasData) continue;
const type = (node.areaType || 'FUNCTION') as RowType;
const hasChildren = !!(node.children && node.children.length > 0);
const records =
type === 'FUNCTION' ? recordsByAreaId.value.get(id) || [] : [];
const label = node.areaName;
rows.push({
key: `node_${id}`,
areaId: id,
type,
label,
depth,
hasChildren,
hasData,
records,
});
if (hasChildren && !collapsedNodes.value.has(id)) {
walk(node.children!, depth + 1);
}
}
}
walk(areaTree.value, 0);
return rows;
});
// ========== 时间→位置百分比 ==========
function timeToPercent(timeStr: string): number {
const d = dayjs(timeStr);
const minutesSinceStart = d.hour() * 60 + d.minute() - hourStart.value * 60;
return Math.max(
0,
Math.min(100, (minutesSinceStart / (totalHours.value * 60)) * 100),
);
}
function barStyle(record: OpsTrajectoryApi.TrajectoryRecord) {
const left = timeToPercent(record.enterTime);
const endTime = record.leaveTime || dayjs().format('YYYY-MM-DD HH:mm:ss');
const right = timeToPercent(endTime);
const naturalWidth = right - left;
// 最小宽度:缩放后保证至少 6px 可见(换算为百分比)
const minWidthPct = 0.8 / zoom.value;
const width = Math.max(naturalWidth, minWidthPct);
const personName = record.nickname || record.deviceName;
return {
left: `${left}%`,
width: `${width}%`,
backgroundColor: getPersonColor(personName),
};
}
// ========== 当前时间线 ==========
const nowPercent = computed(() => {
const now = dayjs();
if (now.format('YYYY-MM-DD') !== props.date) return -1;
const mins = now.hour() * 60 + now.minute() - hourStart.value * 60;
const pct = (mins / (totalHours.value * 60)) * 100;
return pct >= 0 && pct <= 100 ? pct : -1;
});
// ========== Tooltip ==========
const tooltip = ref<{
record: null | OpsTrajectoryApi.TrajectoryRecord;
visible: boolean;
x: number;
y: number;
}>({ visible: false, x: 0, y: 0, record: null });
function showTooltip(e: MouseEvent, record: OpsTrajectoryApi.TrajectoryRecord) {
tooltip.value = { visible: true, x: e.clientX, y: e.clientY, record };
}
function hideTooltip() {
tooltip.value.visible = false;
}
function handleBarClick(record: OpsTrajectoryApi.TrajectoryRecord) {
emit('select', record);
}
// ========== 左右纵向滚动同步 ==========
function handleScroll(e: Event) {
const rightEl = e.target as HTMLElement;
const leftBody = rightEl
.closest('.gantt-main')
?.querySelector('.gantt-left-body') as HTMLElement | null;
if (leftBody) {
leftBody.scrollTop = rightEl.scrollTop;
}
}
// ========== 人员图例 ==========
const personLegend = computed(() => {
const nameSet = new Set<string>();
for (const r of props.records) {
nameSet.add(r.nickname || r.deviceName);
}
return [...nameSet].map((name) => ({ name, color: getPersonColor(name) }));
});
</script>
<template>
<div class="gantt-area-wrapper">
<div v-if="!treeLoaded" class="gantt-empty">
<span style="color: #8c8c8c">加载区域结构...</span>
</div>
<div v-else-if="flatRows.length === 0" class="gantt-empty">
<span style="color: #8c8c8c">暂无区域数据</span>
</div>
<div v-else class="gantt-container">
<!-- 工具栏 -->
<div class="gantt-toolbar">
<span class="toolbar-label">时段</span>
<div class="time-presets">
<button
v-for="preset in timePresets"
:key="preset.label"
class="preset-btn"
:class="{ active: isPresetActive(preset) }"
@click="setTimeRange(preset)"
>
{{ preset.label }}
</button>
</div>
<div class="toolbar-zoom">
<button
class="zoom-btn"
:disabled="zoomIndex <= 0"
title="缩小"
@click="zoomOut"
>
</button>
<span class="zoom-label">{{ zoom }}x</span>
<button
class="zoom-btn"
:disabled="zoomIndex >= ZOOM_LEVELS.length - 1"
title="放大"
@click="zoomIn"
>
+
</button>
</div>
</div>
<!-- 头部 + 数据行左右两栏布局 -->
<div class="gantt-main">
<!-- 固定左侧标签列 -->
<div class="gantt-left">
<div class="gantt-label-col gantt-header-label">区域</div>
<div class="gantt-left-body">
<div
v-for="row in flatRows"
:key="row.key"
class="gantt-row-label"
:class="[
`gantt-row--${row.type.toLowerCase()}`,
{ 'gantt-row--inactive': !row.hasData },
]"
>
<div
class="gantt-label-inner"
:style="{ paddingLeft: `${row.depth * 16 + 8}px` }"
>
<span
v-if="row.hasChildren"
class="gantt-toggle"
@click="toggleNode(row.areaId)"
>
{{ collapsedNodes.has(row.areaId) ? '▶' : '▼' }}
</span>
<span v-else class="gantt-toggle-placeholder"></span>
<span class="gantt-label-text">{{ row.label }}</span>
</div>
</div>
</div>
</div>
<!-- 可滚动右侧时间线 -->
<div class="gantt-right" @scroll="handleScroll">
<!-- 时间刻度头 -->
<div
class="gantt-timeline-header"
:style="{ width: timelineWidthPct }"
>
<div
v-for="h in hours"
:key="h"
class="gantt-hour-cell"
:style="{ width: `${100 / totalHours}%` }"
>
{{ String(h).padStart(2, '0') }}:00
</div>
</div>
<!-- 时间线数据行 -->
<div class="gantt-right-body">
<div
v-for="row in flatRows"
:key="row.key"
class="gantt-row-timeline"
:style="{ width: timelineWidthPct }"
:class="[
`gantt-row--${row.type.toLowerCase()}`,
{ 'gantt-row--inactive': !row.hasData },
]"
>
<!-- 小时网格线 -->
<div
v-for="h in hours"
:key="h"
class="gantt-grid-line"
:style="{ left: `${((h - hourStart) / totalHours) * 100}%` }"
></div>
<!-- 当前时间线 -->
<div
v-if="nowPercent >= 0"
class="gantt-now-line"
:style="{ left: `${nowPercent}%` }"
></div>
<!-- 轨迹条形 -->
<template
v-if="row.type === 'FUNCTION' && row.records.length > 0"
>
<div
v-for="rec in row.records"
:key="rec.id"
class="gantt-bar"
:style="barStyle(rec)"
@click="handleBarClick(rec)"
@mouseenter="showTooltip($event, rec)"
@mouseleave="hideTooltip"
></div>
</template>
<!-- 无数据虚线 -->
<div
v-else-if="row.type === 'FUNCTION' && row.records.length === 0"
class="gantt-no-data-line"
></div>
</div>
</div>
</div>
</div>
</div>
<!-- 人员图例 -->
<div v-if="personLegend.length > 0" class="gantt-legend">
<span class="legend-title">人员:</span>
<span v-for="item in personLegend" :key="item.name" class="legend-item">
<span
class="legend-dot"
:style="{ backgroundColor: item.color }"
></span>
{{ item.name }}
</span>
</div>
<!-- Tooltip -->
<Teleport to="body">
<div
v-if="tooltip.visible && tooltip.record"
class="gantt-tooltip"
:style="{ left: `${tooltip.x + 12}px`, top: `${tooltip.y - 10}px` }"
>
<div class="gantt-tooltip-title">
{{ tooltip.record.nickname || tooltip.record.deviceName }}
</div>
<div class="gantt-tooltip-sub">{{ tooltip.record.areaName }}</div>
<div class="gantt-tooltip-sub">
{{ dayjs(tooltip.record.enterTime).format('HH:mm:ss') }}
{{
tooltip.record.leaveTime
? dayjs(tooltip.record.leaveTime).format('HH:mm:ss')
: '至今'
}}
</div>
<div class="gantt-tooltip-sub">
停留
{{
tooltip.record.durationSeconds
? formatDuration(tooltip.record.durationSeconds)
: '进行中'
}}
</div>
</div>
</Teleport>
</div>
</template>
<style scoped>
.gantt-area-wrapper {
display: flex;
flex-direction: column;
}
.gantt-empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 160px;
}
.gantt-container {
overflow: hidden;
border: 1px solid #f0f0f0;
border-radius: 6px;
}
/* ===== 工具栏 ===== */
.gantt-toolbar {
display: flex;
gap: 12px;
align-items: center;
padding: 8px 12px;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
}
.toolbar-label {
font-size: 12px;
color: #8c8c8c;
white-space: nowrap;
}
.time-presets {
display: flex;
gap: 4px;
}
.preset-btn {
padding: 2px 10px;
font-size: 12px;
color: #595959;
white-space: nowrap;
cursor: pointer;
outline: none;
background: #fff;
border: 1px solid #d9d9d9;
border-radius: 4px;
transition: all 0.2s;
}
.preset-btn:hover {
color: #1677ff;
border-color: #1677ff;
}
.preset-btn.active {
color: #fff;
background: #1677ff;
border-color: #1677ff;
}
.toolbar-zoom {
display: flex;
gap: 4px;
align-items: center;
margin-left: auto;
}
.zoom-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
font-size: 16px;
font-weight: 600;
color: #595959;
cursor: pointer;
outline: none;
background: #fff;
border: 1px solid #d9d9d9;
border-radius: 4px;
transition: all 0.2s;
}
.zoom-btn:hover:not(:disabled) {
color: #1677ff;
border-color: #1677ff;
}
.zoom-btn:disabled {
color: #d9d9d9;
cursor: not-allowed;
}
.zoom-label {
min-width: 28px;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 12px;
color: #8c8c8c;
text-align: center;
}
/* ===== 主体:左固定 + 右可滚动 ===== */
.gantt-main {
display: flex;
max-height: 520px;
}
/* 左侧标签列(固定) */
.gantt-left {
flex-shrink: 0;
width: 200px;
border-right: 1px solid #f0f0f0;
}
.gantt-header-label {
padding: 0 8px;
font-size: 12px;
font-weight: 500;
line-height: 36px;
color: #595959;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
}
.gantt-left-body {
max-height: 484px;
overflow-y: auto;
/* 隐藏左侧滚动条,由右侧驱动 */
scrollbar-width: none;
}
.gantt-left-body::-webkit-scrollbar {
display: none;
}
.gantt-row-label {
display: flex;
align-items: center;
height: 36px;
border-bottom: 1px solid #f5f5f5;
}
.gantt-label-inner {
display: flex;
gap: 4px;
align-items: center;
overflow: hidden;
white-space: nowrap;
}
/* 右侧时间线(横向 + 纵向可滚动) */
.gantt-right {
flex: 1;
max-height: 520px;
overflow: auto;
}
.gantt-timeline-header {
position: sticky;
top: 0;
z-index: 3;
display: flex;
min-width: 100%;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
}
.gantt-hour-cell {
flex-shrink: 0;
font-size: 11px;
line-height: 36px;
color: #8c8c8c;
text-align: center;
border-left: 1px solid #f0f0f0;
}
.gantt-row-timeline {
position: relative;
min-width: 100%;
height: 36px;
border-bottom: 1px solid #f5f5f5;
}
/* ===== 行类型样式 ===== */
.gantt-row--park {
font-size: 13px;
font-weight: 600;
color: #262626;
background: #f5f5f5;
}
.gantt-row--building {
font-size: 13px;
font-weight: 500;
color: #262626;
background: #fafafa;
}
.gantt-row--floor {
font-size: 12px;
color: #595959;
background: #fdfdfd;
}
.gantt-row--function {
font-size: 12px;
color: #262626;
}
.gantt-row--inactive {
opacity: 0.45;
}
.gantt-row--inactive .gantt-label-text {
color: #bfbfbf;
}
/* ===== 交互元素 ===== */
.gantt-toggle {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 16px;
font-size: 10px;
color: #8c8c8c;
cursor: pointer;
user-select: none;
}
.gantt-toggle-placeholder {
display: inline-block;
flex-shrink: 0;
width: 16px;
}
.gantt-label-text {
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
}
/* ===== 时间线元素 ===== */
.gantt-grid-line {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
pointer-events: none;
background: #f5f5f5;
}
.gantt-now-line {
position: absolute;
top: 0;
bottom: 0;
z-index: 2;
width: 2px;
pointer-events: none;
background: #ff4d4f;
}
.gantt-now-line::before {
position: absolute;
top: -3px;
left: -3px;
width: 8px;
height: 8px;
content: '';
background: #ff4d4f;
border-radius: 50%;
}
.gantt-no-data-line {
position: absolute;
top: 50%;
right: 8px;
left: 8px;
height: 1px;
pointer-events: none;
border-top: 1px dashed #e8e8e8;
}
/* ===== 条形 ===== */
.gantt-bar {
position: absolute;
top: 4px;
z-index: 1;
display: flex;
align-items: center;
min-width: 6px;
height: 28px;
overflow: hidden;
cursor: pointer;
border-radius: 4px;
transition: opacity 0.15s;
}
.gantt-bar:hover {
box-shadow: 0 1px 4px rgb(0 0 0 / 20%);
opacity: 0.85;
}
/* ===== Tooltip ===== */
.gantt-tooltip {
position: fixed;
z-index: 9999;
padding: 8px 12px;
font-size: 12px;
line-height: 1.6;
color: #fff;
white-space: nowrap;
pointer-events: none;
background: rgb(0 0 0 / 80%);
border-radius: 6px;
}
.gantt-tooltip-title {
font-weight: 500;
}
.gantt-tooltip-sub {
color: rgb(255 255 255 / 80%);
}
/* 图例 */
.gantt-legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
padding: 10px 0 0;
}
.legend-title {
font-size: 12px;
color: #8c8c8c;
}
.legend-item {
display: flex;
gap: 4px;
align-items: center;
font-size: 12px;
color: #595959;
}
.legend-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
}
</style>

View File

@@ -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<number, OpsTrajectoryApi.HourlyTrend>();
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(
</script>
<template>
<div class="trend-chart">
<div class="trend-chart bg-card">
<div class="chart-title">时段出入趋势</div>
<div v-if="!hasData" class="chart-empty">
<Empty description="暂无数据" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
</div>
<EchartsUI v-show="hasData" ref="chartRef" height="260" width="100%" />
<EchartsUI v-show="hasData" ref="chartRef" height="260px" width="100%" />
</div>
</template>
<style scoped>
.trend-chart {
padding: 16px;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
}
.chart-title {