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:
@@ -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' });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user