diff --git a/apps/web-antd/src/views/ops/trajectory/data.ts b/apps/web-antd/src/views/ops/trajectory/data.ts index ef354d734..843120a5b 100644 --- a/apps/web-antd/src/views/ops/trajectory/data.ts +++ b/apps/web-antd/src/views/ops/trajectory/data.ts @@ -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(); + +/** 根据人员名称返回固定颜色 */ +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(); + +/** 根据区域名称返回固定颜色 */ +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`; } diff --git a/apps/web-antd/src/views/ops/trajectory/index.vue b/apps/web-antd/src/views/ops/trajectory/index.vue index 30db5eabe..986677a5e 100644 --- a/apps/web-antd/src/views/ops/trajectory/index.vue +++ b/apps/web-antd/src/views/ops/trajectory/index.vue @@ -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(undefined); @@ -65,236 +50,456 @@ const badgeSearchLoading = ref(false); // ========== 数据状态 ========== const loading = ref(false); const badgeStatus = ref(null); -const currentLocation = ref(null); -const summary = ref(null); -const timelineRecords = ref([]); +const allRecords = ref([]); +const trajectorySummary = ref(null); +const hourlyTrendData = ref([]); +const areaStayData = ref([]); -// 明细表分页数据 -const tableLoading = ref(false); -const tableData = ref([]); -const tableTotal = ref(0); +// 明细表分页 const pageNo = ref(1); const pageSize = ref(20); -// 轮询定时器 -const pollTimer = ref(); -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 = null; +let trajectoryTimer: null | ReturnType = 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 = 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[] = [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({ length: 24 }).fill(0); + const leaveArr = Array.from({ 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(); - -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(); });