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:
lzh
2026-04-05 15:30:37 +08:00
parent 1468a4062b
commit 3a14de4d1c
2 changed files with 588 additions and 310 deletions

View File

@@ -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`;
}

View File

@@ -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>