feat(@vben/web-antd): 重构工牌轨迹页面核心逻辑
- 接口调用:timeline/summary/hourly-trend/area-stay-stats 并行请求 - 轨迹加载:选中设备用 timeline 接口,deviceId 为空时不传参 - 时间规范化:支持毫秒时间戳/数组/字符串三种后端格式 - 轮询策略:今日每5分钟刷新,实时状态每10秒,历史日期不轮询 - badge 搜索加 300ms 防抖 - onMounted 并行加载工牌列表、数据和区域树 - 移除 useTrajectoryColumns 死代码 - 哈希函数改用 Math.trunc + codePointAt,移除非空断言 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,3 @@
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
/** 事件类型:进入/离开 */
|
||||
export type EventType = 'ENTER' | 'LEAVE';
|
||||
|
||||
@@ -92,6 +90,38 @@ export function getBatteryLevel(level: null | number | undefined): {
|
||||
};
|
||||
}
|
||||
|
||||
/** 人员颜色调色板(与区域色系区分) */
|
||||
const PERSON_COLORS = [
|
||||
'#1677FF', // 蓝
|
||||
'#F5222D', // 红
|
||||
'#52C41A', // 绿
|
||||
'#FA8C16', // 橙
|
||||
'#722ED1', // 紫
|
||||
'#13C2C2', // 青
|
||||
'#EB2F96', // 洋红
|
||||
'#A0D911', // 黄绿
|
||||
'#2F54EB', // 靛蓝
|
||||
'#FA541C', // 火红
|
||||
'#1890FF', // 天蓝
|
||||
'#FAAD14', // 金色
|
||||
];
|
||||
|
||||
const personColorCache = new Map<string, string>();
|
||||
|
||||
/** 根据人员名称返回固定颜色 */
|
||||
export function getPersonColor(name: string): string {
|
||||
const cached = personColorCache.get(name);
|
||||
if (cached) return cached;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = Math.trunc(hash * 31 + (name.codePointAt(i) ?? 0));
|
||||
}
|
||||
const idx = Math.abs(hash) % PERSON_COLORS.length;
|
||||
const color = PERSON_COLORS[idx] ?? '#1677FF';
|
||||
personColorCache.set(name, color);
|
||||
return color;
|
||||
}
|
||||
|
||||
/** 格式化停留时长(秒 → 可读文本) */
|
||||
export function formatDuration(seconds: null | number | undefined): string {
|
||||
if (seconds === null || seconds === undefined || seconds <= 0) return '-';
|
||||
@@ -103,63 +133,44 @@ export function formatDuration(seconds: null | number | undefined): string {
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
/** 明细表列定义 */
|
||||
export function useTrajectoryColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{ type: 'seq', width: 60, title: '序号' },
|
||||
{
|
||||
field: 'areaName',
|
||||
title: '区域名称',
|
||||
minWidth: 160,
|
||||
showOverflow: true,
|
||||
},
|
||||
{
|
||||
field: 'buildingName',
|
||||
title: '楼栋',
|
||||
width: 100,
|
||||
showOverflow: true,
|
||||
},
|
||||
{
|
||||
field: 'floorNo',
|
||||
title: '楼层',
|
||||
width: 70,
|
||||
align: 'center',
|
||||
formatter({ cellValue }: { cellValue: null | number }) {
|
||||
return cellValue === null ? '-' : `${cellValue}F`;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'enterTime',
|
||||
title: '进入时间',
|
||||
width: 160,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
field: 'leaveTime',
|
||||
title: '离开时间',
|
||||
width: 160,
|
||||
slots: { default: 'leaveTime' },
|
||||
},
|
||||
{
|
||||
field: 'durationSeconds',
|
||||
title: '停留时长',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
slots: { default: 'duration' },
|
||||
},
|
||||
{
|
||||
field: 'leaveReason',
|
||||
title: '离开原因',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
slots: { default: 'leaveReason' },
|
||||
},
|
||||
{
|
||||
field: 'enterRssi',
|
||||
title: '进入信号',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
slots: { default: 'rssi' },
|
||||
},
|
||||
];
|
||||
/** 区域颜色调色板 */
|
||||
const AREA_COLORS = [
|
||||
'#597EF7', // 蓝紫
|
||||
'#36CFC9', // 青色
|
||||
'#F7629E', // 粉红
|
||||
'#FFA940', // 橙色
|
||||
'#73D13D', // 绿色
|
||||
'#9254DE', // 紫色
|
||||
'#FF7A45', // 橘红
|
||||
'#40A9FF', // 天蓝
|
||||
'#BAE637', // 黄绿
|
||||
'#FF85C0', // 浅粉
|
||||
'#5CDBD3', // 浅青
|
||||
'#FFC53D', // 金色
|
||||
];
|
||||
|
||||
/** 区域名→颜色的缓存 */
|
||||
const areaColorCache = new Map<string, string>();
|
||||
|
||||
/** 根据区域名称返回固定颜色 */
|
||||
export function getAreaColor(areaName: string): string {
|
||||
const cached = areaColorCache.get(areaName);
|
||||
if (cached) return cached;
|
||||
|
||||
// 简单哈希:将字符串映射到调色板索引
|
||||
let hash = 0;
|
||||
for (let i = 0; i < areaName.length; i++) {
|
||||
hash = Math.trunc(hash * 31 + (areaName.codePointAt(i) ?? 0));
|
||||
}
|
||||
const idx = Math.abs(hash) % AREA_COLORS.length;
|
||||
const color = AREA_COLORS[idx] ?? '#597EF7';
|
||||
areaColorCache.set(areaName, color);
|
||||
return color;
|
||||
}
|
||||
|
||||
/** 根据颜色生成浅背景色(用于图例等) */
|
||||
export function getAreaBgColor(areaName: string): string {
|
||||
const color = getAreaColor(areaName);
|
||||
// 转为带透明度的背景色
|
||||
return `${color}20`;
|
||||
}
|
||||
|
||||
@@ -2,22 +2,13 @@
|
||||
import type { OpsCleaningApi } from '#/api/ops/cleaning';
|
||||
import type { OpsTrajectoryApi } from '#/api/ops/trajectory';
|
||||
|
||||
import {
|
||||
computed,
|
||||
onActivated,
|
||||
onDeactivated,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
} from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { formatDateTime } from '@vben/utils';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
@@ -31,15 +22,6 @@ import {
|
||||
} from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { getBadgeRealtimeStatus } from '#/api/ops/cleaning';
|
||||
import {
|
||||
getBadgeList,
|
||||
getCurrentLocation,
|
||||
getTrajectoryPage,
|
||||
getTrajectorySummary,
|
||||
getTrajectoryTimeline,
|
||||
} from '#/api/ops/trajectory';
|
||||
|
||||
import {
|
||||
formatDuration,
|
||||
getRssiLevel,
|
||||
@@ -49,12 +31,15 @@ import {
|
||||
import AreaStayChart from './modules/area-stay-chart.vue';
|
||||
import BadgeStatusCard from './modules/badge-status-card.vue';
|
||||
import StatsCards from './modules/stats-cards.vue';
|
||||
import TrajectoryTimeline from './modules/trajectory-timeline.vue';
|
||||
import TrajectoryGanttArea, {
|
||||
ensureAreaTree,
|
||||
} from './modules/trajectory-gantt-area.vue';
|
||||
import TrendChart from './modules/trend-chart.vue';
|
||||
|
||||
defineOptions({ name: 'OpsTrajectory' });
|
||||
|
||||
const route = useRoute();
|
||||
// ========== Mock 开关 ==========
|
||||
const USE_MOCK = false;
|
||||
|
||||
// ========== 筛选条件 ==========
|
||||
const selectedDeviceId = ref<number | undefined>(undefined);
|
||||
@@ -65,236 +50,456 @@ const badgeSearchLoading = ref(false);
|
||||
// ========== 数据状态 ==========
|
||||
const loading = ref(false);
|
||||
const badgeStatus = ref<null | OpsCleaningApi.BadgeRealtimeStatus>(null);
|
||||
const currentLocation = ref<null | OpsTrajectoryApi.CurrentLocation>(null);
|
||||
const summary = ref<null | OpsTrajectoryApi.TrajectorySummary>(null);
|
||||
const timelineRecords = ref<OpsTrajectoryApi.TrajectoryRecord[]>([]);
|
||||
const allRecords = ref<OpsTrajectoryApi.TrajectoryRecord[]>([]);
|
||||
const trajectorySummary = ref<null | OpsTrajectoryApi.TrajectorySummary>(null);
|
||||
const hourlyTrendData = ref<OpsTrajectoryApi.HourlyTrend[]>([]);
|
||||
const areaStayData = ref<OpsTrajectoryApi.AreaStayStats[]>([]);
|
||||
|
||||
// 明细表分页数据
|
||||
const tableLoading = ref(false);
|
||||
const tableData = ref<OpsTrajectoryApi.TrajectoryRecord[]>([]);
|
||||
const tableTotal = ref(0);
|
||||
// 明细表分页
|
||||
const pageNo = ref(1);
|
||||
const pageSize = ref(20);
|
||||
|
||||
// 轮询定时器
|
||||
const pollTimer = ref<number>();
|
||||
const POLL_INTERVAL = 30_000;
|
||||
// 视图模式: area(按区域甘特图) / person(按人员甘特图) / table(列表)
|
||||
const viewMode = ref<'area' | 'table'>('area');
|
||||
|
||||
// ========== 计算属性 ==========
|
||||
const dateStr = computed(() => selectedDate.value.format('YYYY-MM-DD'));
|
||||
const hasDevice = computed(() => selectedDeviceId.value !== undefined);
|
||||
const isOffline = computed(
|
||||
() =>
|
||||
badgeStatus.value && badgeStatus.value.status?.toUpperCase() === 'OFFLINE',
|
||||
);
|
||||
const isToday = computed(() => selectedDate.value.isSame(dayjs(), 'day'));
|
||||
|
||||
// ========== 工牌下拉搜索 ==========
|
||||
async function handleBadgeSearch(keyword: string) {
|
||||
badgeSearchLoading.value = true;
|
||||
// 分页数据
|
||||
const pagedRecords = computed(() => {
|
||||
const start = (pageNo.value - 1) * pageSize.value;
|
||||
return allRecords.value.slice(start, start + pageSize.value);
|
||||
});
|
||||
const tableTotal = computed(() => allRecords.value.length);
|
||||
|
||||
// ========== 轮询定时器 ==========
|
||||
const REALTIME_INTERVAL = 10 * 1000; // 工牌实时状态:10秒
|
||||
const TRAJECTORY_INTERVAL = 5 * 60 * 1000; // 轨迹数据:5分钟
|
||||
let realtimeTimer: null | ReturnType<typeof setInterval> = null;
|
||||
let trajectoryTimer: null | ReturnType<typeof setInterval> = null;
|
||||
|
||||
function clearRealtimeTimer() {
|
||||
if (realtimeTimer) {
|
||||
clearInterval(realtimeTimer);
|
||||
realtimeTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearTrajectoryTimer() {
|
||||
if (trajectoryTimer) {
|
||||
clearInterval(trajectoryTimer);
|
||||
trajectoryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearAllTimers() {
|
||||
clearRealtimeTimer();
|
||||
clearTrajectoryTimer();
|
||||
}
|
||||
|
||||
// ========== 工牌下拉搜索(防抖 300ms) ==========
|
||||
let _badgeSearchTimer: null | ReturnType<typeof setTimeout> = null;
|
||||
function handleBadgeSearch(keyword: string) {
|
||||
if (USE_MOCK) return;
|
||||
if (_badgeSearchTimer) clearTimeout(_badgeSearchTimer);
|
||||
_badgeSearchTimer = setTimeout(async () => {
|
||||
badgeSearchLoading.value = true;
|
||||
try {
|
||||
const { getBadgeList } = await import('#/api/ops/trajectory');
|
||||
badgeOptions.value = await getBadgeList(keyword || undefined);
|
||||
} catch {
|
||||
badgeOptions.value = [];
|
||||
} finally {
|
||||
badgeSearchLoading.value = false;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// ========== Mock 数据 ==========
|
||||
const mockPeople = [
|
||||
{ deviceId: 31, nickname: '工牌1号', deviceKey: '09207455611' },
|
||||
{ deviceId: 32, nickname: '工牌2号', deviceKey: '09207455612' },
|
||||
{ deviceId: 33, nickname: '工牌3号', deviceKey: '09207455613' },
|
||||
{ deviceId: 34, nickname: '工牌4号', deviceKey: '09207455614' },
|
||||
{ deviceId: 35, nickname: '工牌5号', deviceKey: '09207455615' },
|
||||
{ deviceId: 36, nickname: '工牌6号', deviceKey: '09207455616' },
|
||||
];
|
||||
|
||||
function generateMockRecords(): OpsTrajectoryApi.TrajectoryRecord[] {
|
||||
const today = selectedDate.value.format('YYYY-MM-DD');
|
||||
const areaPool = [
|
||||
{ id: 1301, name: '男卫', building: 'A栋', floor: 1 },
|
||||
{ id: 1302, name: '女卫', building: 'A栋', floor: 1 },
|
||||
{ id: 1309, name: '男卫', building: 'B栋', floor: 2 },
|
||||
{ id: 1310, name: '女卫', building: 'B栋', floor: 2 },
|
||||
{ id: 1320, name: '大堂', building: 'A栋', floor: 1 },
|
||||
{ id: 1321, name: '走廊', building: 'B栋', floor: 1 },
|
||||
];
|
||||
|
||||
const records: OpsTrajectoryApi.TrajectoryRecord[] = [];
|
||||
let idCounter = 173;
|
||||
const now = dayjs();
|
||||
const leaveReasons: OpsTrajectoryApi.LeaveReason[] = [
|
||||
'SIGNAL_LOSS',
|
||||
'AREA_SWITCH',
|
||||
'SIGNAL_LOSS',
|
||||
];
|
||||
|
||||
for (const person of mockPeople) {
|
||||
const assignedAreas = areaPool
|
||||
.toSorted(() => Math.random() - 0.5)
|
||||
.slice(0, 2 + Math.floor(Math.random() * 2));
|
||||
|
||||
let cursor = dayjs(`${today} 07:30:00`).add(
|
||||
Math.floor(Math.random() * 90),
|
||||
'minute',
|
||||
);
|
||||
|
||||
const rounds = 3 + Math.floor(Math.random() * 3);
|
||||
for (let round = 0; round < rounds; round++) {
|
||||
for (const area of assignedAreas) {
|
||||
if (cursor.isAfter(now)) break;
|
||||
const staySec = 30 + Math.floor(Math.random() * 210);
|
||||
const enterTime = cursor;
|
||||
const leaveTime = cursor.add(staySec, 'second');
|
||||
const isLastPossible = round === rounds - 1 && leaveTime.isAfter(now);
|
||||
const isStaying = isLastPossible && Math.random() < 0.3;
|
||||
|
||||
records.push({
|
||||
id: idCounter++,
|
||||
deviceId: person.deviceId,
|
||||
deviceName: person.deviceKey,
|
||||
nickname: person.nickname,
|
||||
areaId: area.id,
|
||||
areaName: area.name,
|
||||
buildingName: area.building,
|
||||
floorNo: area.floor,
|
||||
enterTime: enterTime.format('YYYY-MM-DD HH:mm:ss'),
|
||||
leaveTime: isStaying
|
||||
? undefined
|
||||
: leaveTime.format('YYYY-MM-DD HH:mm:ss'),
|
||||
durationSeconds: isStaying ? undefined : staySec,
|
||||
leaveReason: isStaying
|
||||
? undefined
|
||||
: leaveReasons[Math.floor(Math.random() * leaveReasons.length)],
|
||||
enterRssi: -(48 + Math.floor(Math.random() * 20)),
|
||||
});
|
||||
|
||||
cursor = leaveTime.add(3 + Math.floor(Math.random() * 12), 'minute');
|
||||
if (cursor.isAfter(now)) break;
|
||||
}
|
||||
cursor = cursor.add(10 + Math.floor(Math.random() * 20), 'minute');
|
||||
}
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
function getMockBadgeStatus(
|
||||
deviceId: number,
|
||||
): null | OpsCleaningApi.BadgeRealtimeStatus {
|
||||
const person = mockPeople.find((p) => p.deviceId === deviceId);
|
||||
if (!person) return null;
|
||||
return {
|
||||
deviceId: person.deviceId,
|
||||
deviceKey: person.deviceKey,
|
||||
status: 'BUSY',
|
||||
batteryLevel: 75,
|
||||
onlineTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
|
||||
isInArea: true,
|
||||
areaName: '女卫',
|
||||
};
|
||||
}
|
||||
|
||||
// ========== 工具函数 ==========
|
||||
|
||||
/**
|
||||
* 规范化时间字段:Java LocalDateTime 序列化可能是数组 [2026,4,5,8,30,0]
|
||||
* 统一转为 "YYYY-MM-DD HH:mm:ss" 字符串
|
||||
*/
|
||||
function normalizeDateTime(val: any): string | undefined {
|
||||
if (val === null || val === undefined) return undefined;
|
||||
if (typeof val === 'string') return val;
|
||||
// 毫秒时间戳
|
||||
if (typeof val === 'number') {
|
||||
return dayjs(val).format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
// Java LocalDateTime 数组 [y, M, d, h, m, s]
|
||||
if (Array.isArray(val)) {
|
||||
const [y, M, d, h = 0, m = 0, s = 0] = val;
|
||||
return `${y}-${String(M).padStart(2, '0')}-${String(d).padStart(2, '0')} ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
return String(val);
|
||||
}
|
||||
|
||||
/** 规范化轨迹记录的时间字段 */
|
||||
function normalizeRecords(
|
||||
records: OpsTrajectoryApi.TrajectoryRecord[],
|
||||
): OpsTrajectoryApi.TrajectoryRecord[] {
|
||||
return records.map((r) => ({
|
||||
...r,
|
||||
enterTime: normalizeDateTime(r.enterTime) || r.enterTime,
|
||||
leaveTime: normalizeDateTime(r.leaveTime),
|
||||
}));
|
||||
}
|
||||
|
||||
// ========== 数据加载(按职责拆分) ==========
|
||||
|
||||
/**
|
||||
* 加载工牌下拉列表(页面初始化调用一次)
|
||||
*/
|
||||
async function loadBadgeOptions() {
|
||||
if (USE_MOCK) {
|
||||
badgeOptions.value = mockPeople.map((p) => ({
|
||||
deviceId: p.deviceId,
|
||||
deviceKey: p.deviceKey,
|
||||
nickname: p.nickname,
|
||||
personName: undefined,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
badgeOptions.value = await getBadgeList(keyword || undefined);
|
||||
const { getBadgeList } = await import('#/api/ops/trajectory');
|
||||
badgeOptions.value = await getBadgeList();
|
||||
} catch {
|
||||
badgeOptions.value = [];
|
||||
} finally {
|
||||
badgeSearchLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 数据加载 ==========
|
||||
async function loadAllData() {
|
||||
loading.value = true;
|
||||
/**
|
||||
* 加载轨迹记录(首次 + 今日每5分钟轮询)
|
||||
* - 选中设备时:使用 /timeline 接口一次性获取(无需分页)
|
||||
* - 未选设备时:使用 /page 接口分页获取
|
||||
* @param showLoading 是否显示全局 loading(首次加载时为 true,轮询刷新时为 false)
|
||||
*/
|
||||
async function loadTrajectoryData(showLoading = true) {
|
||||
if (showLoading) loading.value = true;
|
||||
try {
|
||||
const tasks: Promise<void>[] = [loadTableData()];
|
||||
if (hasDevice.value) {
|
||||
tasks.push(
|
||||
loadBadgeStatus(),
|
||||
loadCurrentLocation(),
|
||||
loadSummary(),
|
||||
loadTimeline(),
|
||||
);
|
||||
} else {
|
||||
// 未选设备时清空设备相关数据
|
||||
badgeStatus.value = null;
|
||||
currentLocation.value = null;
|
||||
summary.value = null;
|
||||
timelineRecords.value = [];
|
||||
if (USE_MOCK) {
|
||||
if (showLoading) await new Promise((r) => setTimeout(r, 400));
|
||||
const records = generateMockRecords();
|
||||
allRecords.value = selectedDeviceId.value
|
||||
? records.filter((r) => r.deviceId === selectedDeviceId.value)
|
||||
: records;
|
||||
return;
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
|
||||
// 统一使用 timeline 接口一次性获取当天全部记录(后端有 5000 条安全上限)
|
||||
const { getTrajectoryTimeline } = await import('#/api/ops/trajectory');
|
||||
const params: { date: string; deviceId?: number } = {
|
||||
date: dateStr.value,
|
||||
};
|
||||
if (
|
||||
selectedDeviceId.value !== null &&
|
||||
selectedDeviceId.value !== undefined
|
||||
) {
|
||||
params.deviceId = selectedDeviceId.value;
|
||||
}
|
||||
const raw = await getTrajectoryTimeline(params);
|
||||
allRecords.value = normalizeRecords(raw);
|
||||
} catch {
|
||||
allRecords.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (showLoading) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBadgeStatus() {
|
||||
if (!selectedDeviceId.value) return;
|
||||
/**
|
||||
* 加载工牌实时状态(选中工牌 + 今日时每10秒轮询)
|
||||
*/
|
||||
async function loadRealtimeStatus() {
|
||||
const deviceId = selectedDeviceId.value;
|
||||
if (!deviceId) {
|
||||
badgeStatus.value = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
badgeStatus.value = await getBadgeRealtimeStatus(selectedDeviceId.value);
|
||||
if (USE_MOCK) {
|
||||
badgeStatus.value = getMockBadgeStatus(deviceId);
|
||||
return;
|
||||
}
|
||||
const { getBadgeRealtimeStatus } = await import('#/api/ops/cleaning');
|
||||
badgeStatus.value = await getBadgeRealtimeStatus(deviceId);
|
||||
} catch {
|
||||
badgeStatus.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCurrentLocation() {
|
||||
if (!selectedDeviceId.value) return;
|
||||
/**
|
||||
* 加载统计数据(KPI摘要 + 时段趋势 + 区域停留分布)
|
||||
* 与轨迹记录同步刷新
|
||||
*/
|
||||
async function loadStatsData() {
|
||||
const params = { date: dateStr.value, deviceId: selectedDeviceId.value };
|
||||
|
||||
if (USE_MOCK) {
|
||||
// Mock 数据:从 allRecords 模拟计算
|
||||
const recs = allRecords.value;
|
||||
const totalSeconds = recs.reduce((s, r) => s + (r.durationSeconds || 0), 0);
|
||||
const finishedCount = recs.filter((r) => r.durationSeconds).length;
|
||||
trajectorySummary.value = {
|
||||
workDurationSeconds: totalSeconds,
|
||||
coveredAreaCount: new Set(recs.map((r) => r.areaName)).size,
|
||||
totalEvents: recs.length,
|
||||
avgStaySeconds:
|
||||
finishedCount > 0 ? Math.round(totalSeconds / finishedCount) : 0,
|
||||
};
|
||||
// Mock hourly trend
|
||||
const enterArr = Array.from<number>({ length: 24 }).fill(0);
|
||||
const leaveArr = Array.from<number>({ length: 24 }).fill(0);
|
||||
for (const r of recs) {
|
||||
if (r.enterTime) enterArr[new Date(r.enterTime).getHours()]!++;
|
||||
if (r.leaveTime) leaveArr[new Date(r.leaveTime).getHours()]!++;
|
||||
}
|
||||
hourlyTrendData.value = enterArr.map((_, i) => ({
|
||||
hour: i,
|
||||
enterCount: enterArr[i]!,
|
||||
leaveCount: leaveArr[i]!,
|
||||
}));
|
||||
// Mock area stay
|
||||
const areaMap = new Map<
|
||||
string,
|
||||
{ totalStaySeconds: number; visitCount: number }
|
||||
>();
|
||||
for (const r of recs) {
|
||||
const existing = areaMap.get(r.areaName);
|
||||
if (existing) {
|
||||
existing.totalStaySeconds += r.durationSeconds ?? 0;
|
||||
existing.visitCount += 1;
|
||||
} else {
|
||||
areaMap.set(r.areaName, {
|
||||
totalStaySeconds: r.durationSeconds ?? 0,
|
||||
visitCount: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
areaStayData.value = [...areaMap.entries()]
|
||||
.map(([areaName, v]) => ({ areaName, ...v }))
|
||||
.toSorted((a, b) => b.totalStaySeconds - a.totalStaySeconds);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
currentLocation.value = await getCurrentLocation(selectedDeviceId.value);
|
||||
} catch {
|
||||
currentLocation.value = null;
|
||||
const { getTrajectorySummary, getHourlyTrend, getAreaStayStats } =
|
||||
await import('#/api/ops/trajectory');
|
||||
|
||||
const [summaryRes, trendRes, stayRes] = await Promise.allSettled([
|
||||
getTrajectorySummary(params),
|
||||
getHourlyTrend(params),
|
||||
getAreaStayStats(params),
|
||||
]);
|
||||
|
||||
trajectorySummary.value =
|
||||
summaryRes.status === 'fulfilled' ? summaryRes.value : null;
|
||||
hourlyTrendData.value =
|
||||
trendRes.status === 'fulfilled' ? trendRes.value : [];
|
||||
areaStayData.value = stayRes.status === 'fulfilled' ? stayRes.value : [];
|
||||
} catch (error) {
|
||||
console.error('[loadStatsData] 异常:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
if (!selectedDeviceId.value) return;
|
||||
try {
|
||||
summary.value = await getTrajectorySummary(
|
||||
selectedDeviceId.value,
|
||||
dateStr.value,
|
||||
);
|
||||
} catch {
|
||||
summary.value = null;
|
||||
// ========== 轮询调度 ==========
|
||||
|
||||
/**
|
||||
* 根据当前筛选条件启动/停止轮询
|
||||
* - 实时状态轮询:选中工牌 + 今日 → 每10秒
|
||||
* - 轨迹数据轮询:今日 → 每5分钟
|
||||
* - 历史日期:不轮询
|
||||
*/
|
||||
function setupPolling() {
|
||||
clearAllTimers();
|
||||
|
||||
if (!isToday.value) return; // 历史日期不轮询
|
||||
|
||||
// 今日数据:每5分钟静默刷新(轨迹 + 统计)
|
||||
trajectoryTimer = setInterval(() => {
|
||||
loadTrajectoryData(false);
|
||||
loadStatsData();
|
||||
}, TRAJECTORY_INTERVAL);
|
||||
|
||||
// 选中工牌时:实时状态每10秒轮询
|
||||
if (hasDevice.value) {
|
||||
realtimeTimer = setInterval(() => {
|
||||
loadRealtimeStatus();
|
||||
}, REALTIME_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTimeline() {
|
||||
if (!selectedDeviceId.value) return;
|
||||
try {
|
||||
timelineRecords.value = await getTrajectoryTimeline({
|
||||
deviceId: selectedDeviceId.value,
|
||||
date: dateStr.value,
|
||||
});
|
||||
} catch {
|
||||
timelineRecords.value = [];
|
||||
}
|
||||
}
|
||||
// ========== 操作 ==========
|
||||
|
||||
async function loadTableData() {
|
||||
tableLoading.value = true;
|
||||
try {
|
||||
const startTime = selectedDate.value
|
||||
.startOf('day')
|
||||
.format('YYYY-MM-DD HH:mm:ss');
|
||||
const endTime = selectedDate.value
|
||||
.endOf('day')
|
||||
.format('YYYY-MM-DD HH:mm:ss');
|
||||
const result = await getTrajectoryPage({
|
||||
deviceId: selectedDeviceId.value,
|
||||
enterTime: [startTime, endTime],
|
||||
pageNo: pageNo.value,
|
||||
pageSize: pageSize.value,
|
||||
});
|
||||
tableData.value = result.list;
|
||||
tableTotal.value = result.total;
|
||||
} catch {
|
||||
tableData.value = [];
|
||||
tableTotal.value = 0;
|
||||
} finally {
|
||||
tableLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 查询按钮 ==========
|
||||
function handleQuery() {
|
||||
/** 完整查询(用户主动触发 / 筛选条件变化) */
|
||||
async function handleQuery() {
|
||||
clearAllTimers();
|
||||
pageNo.value = 1;
|
||||
loadAllData();
|
||||
startPolling();
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
if (USE_MOCK) {
|
||||
// Mock 模式:统计依赖 allRecords,需串行
|
||||
await Promise.all([loadTrajectoryData(false), loadRealtimeStatus()]);
|
||||
await loadStatsData();
|
||||
} else {
|
||||
// 真实接口:轨迹、统计、实时状态全部并行,互不影响
|
||||
await Promise.allSettled([
|
||||
loadTrajectoryData(false),
|
||||
loadRealtimeStatus(),
|
||||
loadStatsData(),
|
||||
]);
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
// 根据条件启动轮询
|
||||
setupPolling();
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
selectedDeviceId.value = undefined;
|
||||
selectedDate.value = dayjs();
|
||||
stopPolling();
|
||||
handleQuery();
|
||||
// watch 会触发 handleQuery,无需手动调用
|
||||
}
|
||||
|
||||
// ========== 分页 ==========
|
||||
function handlePageChange(page: number, size: number) {
|
||||
pageNo.value = page;
|
||||
pageSize.value = size;
|
||||
loadTableData();
|
||||
}
|
||||
|
||||
// ========== 时间线点击联动 ==========
|
||||
// 甘特图条形点击 → 高亮
|
||||
const highlightId = ref<number | undefined>();
|
||||
|
||||
function handleTimelineSelect(record: OpsTrajectoryApi.TrajectoryRecord) {
|
||||
function handleGanttSelect(record: OpsTrajectoryApi.TrajectoryRecord) {
|
||||
highlightId.value = record.id;
|
||||
// 3秒后取消高亮
|
||||
setTimeout(() => {
|
||||
highlightId.value = undefined;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// ========== 轮询(仅选中设备时) ==========
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
if (!selectedDeviceId.value) return;
|
||||
pollTimer.value = window.setInterval(() => {
|
||||
loadBadgeStatus();
|
||||
loadCurrentLocation();
|
||||
}, POLL_INTERVAL);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer.value) {
|
||||
clearInterval(pollTimer.value);
|
||||
pollTimer.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 限制日期选择 ==========
|
||||
function disabledDate(current: dayjs.Dayjs) {
|
||||
return current && current > dayjs().endOf('day');
|
||||
}
|
||||
|
||||
// ========== 生命周期 ==========
|
||||
onMounted(async () => {
|
||||
// 加载工牌列表
|
||||
await handleBadgeSearch('');
|
||||
|
||||
// 如果 URL 带参数,自动填充
|
||||
const queryDeviceId = route.query.deviceId;
|
||||
const queryDate = route.query.date;
|
||||
if (queryDeviceId) {
|
||||
selectedDeviceId.value = Number(queryDeviceId);
|
||||
}
|
||||
if (queryDate && typeof queryDate === 'string') {
|
||||
selectedDate.value = dayjs(queryDate);
|
||||
}
|
||||
|
||||
// 始终加载数据(未选设备时展示全量表格)
|
||||
// 筛选条件变化 → 自动查询
|
||||
watch([selectedDeviceId, selectedDate], () => {
|
||||
handleQuery();
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
if (selectedDeviceId.value) {
|
||||
startPolling();
|
||||
}
|
||||
onMounted(() => {
|
||||
// 三者并行:工牌列表、数据查询、区域树预加载
|
||||
Promise.all([loadBadgeOptions(), handleQuery(), ensureAreaTree()]);
|
||||
});
|
||||
|
||||
onDeactivated(() => {
|
||||
stopPolling();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling();
|
||||
onBeforeUnmount(() => {
|
||||
clearAllTimers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<div class="trajectory-page">
|
||||
<!-- 离线警告横幅 -->
|
||||
<Alert
|
||||
v-if="hasDevice && isOffline"
|
||||
banner
|
||||
class="offline-alert"
|
||||
message="该设备当前已离线"
|
||||
:description="`最后心跳时间: ${badgeStatus?.lastHeartbeatTime ? formatDateTime(badgeStatus.lastHeartbeatTime) : '未知'}`"
|
||||
type="warning"
|
||||
show-icon
|
||||
/>
|
||||
|
||||
<!-- 顶部筛选栏 -->
|
||||
<!-- 筛选栏 -->
|
||||
<Card class="filter-card" size="small">
|
||||
<div class="filter-bar">
|
||||
<div class="filter-item">
|
||||
@@ -305,7 +510,7 @@ onUnmounted(() => {
|
||||
:filter-option="false"
|
||||
:loading="badgeSearchLoading"
|
||||
option-filter-prop="label"
|
||||
placeholder="请选择或搜索工牌设备"
|
||||
placeholder="全部设备(可搜索)"
|
||||
show-search
|
||||
style="width: 260px"
|
||||
@search="handleBadgeSearch"
|
||||
@@ -327,7 +532,6 @@ onUnmounted(() => {
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="filter-item">
|
||||
<span class="filter-label">日期</span>
|
||||
<DatePicker
|
||||
@@ -338,7 +542,6 @@ onUnmounted(() => {
|
||||
style="width: 160px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-actions">
|
||||
<Button type="primary" @click="handleQuery">
|
||||
<IconifyIcon icon="solar:magnifer-bold" />
|
||||
@@ -349,71 +552,91 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 数据区域 -->
|
||||
<Spin :spinning="loading">
|
||||
<Row :gutter="16" class="content-area">
|
||||
<!-- 左侧面板(仅选中设备时显示) -->
|
||||
<!-- 左侧面板(仅选中设备时) -->
|
||||
<Col v-if="hasDevice" :span="6" class="left-panel">
|
||||
<BadgeStatusCard :data="badgeStatus" :loading="loading" />
|
||||
<TrajectoryTimeline
|
||||
:current-location="currentLocation"
|
||||
:records="timelineRecords"
|
||||
@select="handleTimelineSelect"
|
||||
<BadgeStatusCard
|
||||
:data="badgeStatus"
|
||||
:loading="loading"
|
||||
:recent-records="allRecords"
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<!-- 右侧内容(未选设备时全宽) -->
|
||||
<!-- 右侧内容 -->
|
||||
<Col :span="hasDevice ? 18 : 24">
|
||||
<!-- KPI 统计卡片(仅选中设备时显示) -->
|
||||
<!-- KPI 统计(仅选中设备时) -->
|
||||
<StatsCards
|
||||
v-if="hasDevice"
|
||||
:current-location="currentLocation"
|
||||
:data="trajectorySummary"
|
||||
:loading="loading"
|
||||
:summary="summary"
|
||||
/>
|
||||
|
||||
<!-- 图表区(仅选中设备时显示) -->
|
||||
<Row v-if="hasDevice" :gutter="16" class="charts-row">
|
||||
<!-- 统计图表 -->
|
||||
<Row :gutter="16" class="chart-area">
|
||||
<Col :span="12">
|
||||
<AreaStayChart :records="timelineRecords" />
|
||||
<TrendChart :data="hourlyTrendData" />
|
||||
</Col>
|
||||
<Col :span="12">
|
||||
<TrendChart :records="timelineRecords" />
|
||||
<AreaStayChart :data="areaStayData" />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 明细表 -->
|
||||
<Card
|
||||
class="table-card"
|
||||
:class="{ 'table-card--no-top': !hasDevice }"
|
||||
size="small"
|
||||
title="出入记录明细"
|
||||
>
|
||||
<template #extra>
|
||||
<span class="table-total">共 {{ tableTotal }} 条</span>
|
||||
</template>
|
||||
<!-- 明细区域:甘特图 / 列表切换 -->
|
||||
<div class="detail-card bg-card">
|
||||
<div class="detail-card-header">
|
||||
<div class="detail-card-title">
|
||||
<span class="detail-card-title-text">出入记录明细</span>
|
||||
<span class="table-total">共 {{ tableTotal }} 条</span>
|
||||
</div>
|
||||
<div class="view-switcher">
|
||||
<button
|
||||
class="view-switcher-btn"
|
||||
:class="{ active: viewMode === 'area' }"
|
||||
@click="viewMode = 'area'"
|
||||
>
|
||||
<IconifyIcon icon="solar:buildings-bold" />
|
||||
<span>甘特图</span>
|
||||
</button>
|
||||
<button
|
||||
class="view-switcher-btn"
|
||||
:class="{ active: viewMode === 'table' }"
|
||||
@click="viewMode = 'table'"
|
||||
>
|
||||
<IconifyIcon icon="solar:list-bold" />
|
||||
<span>列表</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Spin :spinning="tableLoading">
|
||||
<!-- 区域甘特图 -->
|
||||
<TrajectoryGanttArea
|
||||
v-if="viewMode === 'area'"
|
||||
:date="dateStr"
|
||||
:loading="loading"
|
||||
:records="allRecords"
|
||||
@select="handleGanttSelect"
|
||||
/>
|
||||
|
||||
<!-- 列表视图 -->
|
||||
<div v-else>
|
||||
<div class="table-wrapper">
|
||||
<table class="trajectory-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 60px">序号</th>
|
||||
<!-- 未选设备时显示设备列 -->
|
||||
<th v-if="!hasDevice" style="width: 140px">设备名称</th>
|
||||
<th style="min-width: 140px">区域名称</th>
|
||||
<th style="width: 100px">楼栋</th>
|
||||
<th style="width: 70px">楼层</th>
|
||||
<th style="width: 160px">进入时间</th>
|
||||
<th style="width: 160px">离开时间</th>
|
||||
<th style="width: 100px">停留时长</th>
|
||||
<th style="width: 100px">离开原因</th>
|
||||
<th style="width: 90px">进入信号</th>
|
||||
<th style="width: 50px">序号</th>
|
||||
<th v-if="!hasDevice" style="width: 120px">设备名称</th>
|
||||
<th style="min-width: 200px">区域</th>
|
||||
<th style="width: 150px">进入时间</th>
|
||||
<th style="width: 150px">离开时间</th>
|
||||
<th style="width: 90px">停留时长</th>
|
||||
<th style="width: 90px">离开原因</th>
|
||||
<th style="width: 80px">进入信号</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="tableData.length === 0">
|
||||
<td :colspan="hasDevice ? 9 : 10" class="table-empty">
|
||||
<tr v-if="pagedRecords.length === 0">
|
||||
<td :colspan="hasDevice ? 7 : 8" class="table-empty">
|
||||
<Empty
|
||||
description="暂无记录"
|
||||
:image="Empty.PRESENTED_IMAGE_SIMPLE"
|
||||
@@ -421,11 +644,9 @@ onUnmounted(() => {
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="(row, index) in tableData"
|
||||
v-for="(row, index) in pagedRecords"
|
||||
:key="row.id"
|
||||
:class="{
|
||||
'row-highlight': highlightId === row.id,
|
||||
}"
|
||||
:class="{ 'row-highlight': highlightId === row.id }"
|
||||
>
|
||||
<td class="cell-center">
|
||||
{{ (pageNo - 1) * pageSize + index + 1 }}
|
||||
@@ -433,17 +654,13 @@ onUnmounted(() => {
|
||||
<td v-if="!hasDevice">
|
||||
{{ row.nickname || row.deviceName || '-' }}
|
||||
</td>
|
||||
<td>{{ row.areaName }}</td>
|
||||
<td>{{ row.buildingName || '-' }}</td>
|
||||
<td class="cell-center">
|
||||
{{ row.floorNo != null ? `${row.floorNo}F` : '-' }}
|
||||
</td>
|
||||
<td>{{ row.fullAreaName || row.areaName }}</td>
|
||||
<td>{{ formatDateTime(row.enterTime) }}</td>
|
||||
<td>
|
||||
<template v-if="row.leaveTime">
|
||||
{{ formatDateTime(row.leaveTime) }}
|
||||
</template>
|
||||
<Tag v-else color="blue" size="small"> 停留中 </Tag>
|
||||
<Tag v-else color="blue" size="small">停留中</Tag>
|
||||
</td>
|
||||
<td class="cell-center">
|
||||
<span
|
||||
@@ -520,8 +737,8 @@ onUnmounted(() => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Spin>
|
||||
@@ -536,13 +753,9 @@ onUnmounted(() => {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.offline-alert {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* 筛选栏 */
|
||||
.filter-card {
|
||||
border-radius: 8px;
|
||||
/* 样式由全局 .ant-card 规则统一控制 */
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
@@ -570,7 +783,6 @@ onUnmounted(() => {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* 工牌下拉选项 */
|
||||
.badge-option {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -587,7 +799,7 @@ onUnmounted(() => {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
/* 内容区域 */
|
||||
/* 内容区 */
|
||||
.content-area {
|
||||
margin-top: 0;
|
||||
}
|
||||
@@ -598,26 +810,43 @@ onUnmounted(() => {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 图表行 */
|
||||
.charts-row {
|
||||
/* 统计图表区 */
|
||||
.chart-area {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* 明细表 */
|
||||
.table-card {
|
||||
/* 明细卡片 */
|
||||
.detail-card {
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.table-card--no-top {
|
||||
margin-top: 0;
|
||||
.detail-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.detail-card-title {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-card-title-text {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.table-total {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
/* 表格 */
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -694,4 +923,42 @@ onUnmounted(() => {
|
||||
color: #262626;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 视图切换器 */
|
||||
.view-switcher {
|
||||
display: inline-flex;
|
||||
overflow: hidden;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.view-switcher-btn {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
color: #595959;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
background: #fff;
|
||||
border: none;
|
||||
border-right: 1px solid #d9d9d9;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.view-switcher-btn:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.view-switcher-btn:hover {
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.view-switcher-btn.active {
|
||||
color: #fff;
|
||||
background: #1677ff;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user