diff --git a/docs/bi-refactor-roadmap.md b/docs/bi-refactor-roadmap.md index c6db161..2fdad00 100644 --- a/docs/bi-refactor-roadmap.md +++ b/docs/bi-refactor-roadmap.md @@ -162,6 +162,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0) - 已完成电能数据截至时间和实际趋势月展示。 - 已完成氢能结构化故障状态与重试体验。 +- 已完成里程今日仪表快照复用:普通查询命中分钟级快照,手动刷新生成一致的分页快照。 - 待恢复氢能数据库后进行总览、站点、客户三层对账。 - 待 ETC 同步后进行通行记录、账单和收款三层对账。 diff --git a/src/modules/mileage/MonitoringView.tsx b/src/modules/mileage/MonitoringView.tsx index 8890c2f..adae5bc 100644 --- a/src/modules/mileage/MonitoringView.tsx +++ b/src/modules/mileage/MonitoringView.tsx @@ -565,7 +565,7 @@ export default function MonitoringView() { }, [filterTargetNames]); // 加载首页数据 - const loadFirstPage = useCallback((showPageLoading = true) => { + const loadFirstPage = useCallback((showPageLoading = true, force = false) => { if (showPageLoading) setPageLoading(true); setPageError(null); return fetchMonitoring({ @@ -589,6 +589,7 @@ export default function MonitoringView() { endDate: rangeEnd || undefined, brands: filterBrands.length > 0 ? filterBrands : undefined, sourcePriority, + force, }).then(d => { setVehicles(d.vehicles); setStats(d.stats); @@ -613,7 +614,7 @@ export default function MonitoringView() { if (manualRefreshing) return; setManualRefreshing(true); try { - await loadFirstPage(false); + await loadFirstPage(false, true); } finally { setManualRefreshing(false); } diff --git a/src/modules/mileage/api.ts b/src/modules/mileage/api.ts index f557f94..ec486b0 100644 --- a/src/modules/mileage/api.ts +++ b/src/modules/mileage/api.ts @@ -44,6 +44,7 @@ export async function fetchMonitoring(params?: { endDate?: string; brands?: string[]; sourcePriority?: MileageSourceGroup[]; + force?: boolean; }): Promise { const query = new URLSearchParams(); if (params?.sortBy) query.set('sortBy', params.sortBy); @@ -76,6 +77,7 @@ export async function fetchMonitoring(params?: { if (params?.startDate) query.set('startDate', params.startDate); if (params?.endDate) query.set('endDate', params.endDate); if (params?.sourcePriority?.length) query.set('sourcePriority', params.sourcePriority.join(',')); + if (params?.force) query.set('force', '1'); const qs = query.toString(); return fetchJson(`${BASE}/monitoring${qs ? `?${qs}` : ''}`); } diff --git a/src/server/routes/mileage/manual-snapshot.test.ts b/src/server/routes/mileage/manual-snapshot.test.ts new file mode 100644 index 0000000..37fb7f7 --- /dev/null +++ b/src/server/routes/mileage/manual-snapshot.test.ts @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + clearManualMonitoringSnapshot, + getManualMonitoringSnapshot, + storeManualMonitoringSnapshot, +} from './manual-snapshot.js'; + +const result = { + vehicles: [], + dailyTotals: [{ date: '2026-08-07', totalKm: 12 }], + start: '2026-08-07', + end: '2026-08-07', +}; + +test('reuses a manual snapshot only for its date range and TTL', () => { + clearManualMonitoringSnapshot(); + storeManualMonitoringSnapshot(result, 1_000); + assert.ok(getManualMonitoringSnapshot('2026-08-07', '2026-08-07', 60_999)); + assert.equal(getManualMonitoringSnapshot('2026-08-06', '2026-08-06', 60_999), null); + assert.equal(getManualMonitoringSnapshot('2026-08-07', '2026-08-07', 61_000), null); +}); diff --git a/src/server/routes/mileage/manual-snapshot.ts b/src/server/routes/mileage/manual-snapshot.ts new file mode 100644 index 0000000..f04d2f5 --- /dev/null +++ b/src/server/routes/mileage/manual-snapshot.ts @@ -0,0 +1,40 @@ +import type { RangeMileageResult } from './cache.js'; + +const MANUAL_SNAPSHOT_TTL_MS = 60 * 1000; + +interface ManualSnapshot { + result: RangeMileageResult; + updatedAt: string; + expiresAt: number; +} + +let snapshot: ManualSnapshot | null = null; + +export function storeManualMonitoringSnapshot( + result: RangeMileageResult, + now = Date.now(), +): ManualSnapshot { + snapshot = { + result, + updatedAt: new Date(now).toISOString(), + expiresAt: now + MANUAL_SNAPSHOT_TTL_MS, + }; + return snapshot; +} + +export function getManualMonitoringSnapshot( + start: string, + end: string, + now = Date.now(), +): ManualSnapshot | null { + if (!snapshot || snapshot.expiresAt <= now) { + snapshot = null; + return null; + } + if (snapshot.result.start !== start || snapshot.result.end !== end) return null; + return snapshot; +} + +export function clearManualMonitoringSnapshot(): void { + snapshot = null; +} diff --git a/src/server/routes/mileage/monitoring-cache-policy.test.ts b/src/server/routes/mileage/monitoring-cache-policy.test.ts new file mode 100644 index 0000000..629ee9f --- /dev/null +++ b/src/server/routes/mileage/monitoring-cache-policy.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + isMonitoringCacheEligible, + isMonitoringSnapshotScope, + shanghaiDate, +} from './monitoring-cache-policy.js'; + +test('uses the monitoring snapshot only for the current instrument day', () => { + const base = { + startDate: '2026-08-07', + endDate: '2026-08-07', + today: '2026-08-07', + sourcePriority: ['instrument'] as const, + force: false, + }; + assert.equal(isMonitoringCacheEligible({ ...base, sourcePriority: [...base.sourcePriority] }), true); + assert.equal(isMonitoringCacheEligible({ ...base, startDate: '2026-08-06', sourcePriority: ['instrument'] }), false); + assert.equal(isMonitoringCacheEligible({ ...base, sourcePriority: ['gps'] }), false); + assert.equal(isMonitoringCacheEligible({ ...base, sourcePriority: ['instrument'], force: true }), false); + assert.equal(isMonitoringSnapshotScope({ ...base, sourcePriority: ['instrument'] }), true); +}); + +test('formats the current date in Asia/Shanghai', () => { + assert.equal(shanghaiDate(new Date('2026-08-06T16:30:00.000Z')), '2026-08-07'); +}); diff --git a/src/server/routes/mileage/monitoring-cache-policy.ts b/src/server/routes/mileage/monitoring-cache-policy.ts new file mode 100644 index 0000000..6329fc9 --- /dev/null +++ b/src/server/routes/mileage/monitoring-cache-policy.ts @@ -0,0 +1,32 @@ +import type { MileageSourceGroup } from './source-policy.js'; + +export function isMonitoringCacheEligible(input: { + startDate: string; + endDate: string; + today: string; + sourcePriority: MileageSourceGroup[]; + force: boolean; +}): boolean { + return !input.force && isMonitoringSnapshotScope(input); +} + +export function isMonitoringSnapshotScope(input: { + startDate: string; + endDate: string; + today: string; + sourcePriority: MileageSourceGroup[]; +}): boolean { + return input.startDate === input.today + && input.endDate === input.today + && input.sourcePriority.length === 1 + && input.sourcePriority[0] === 'instrument'; +} + +export function shanghaiDate(now = new Date()): string { + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(now); +} diff --git a/src/server/routes/mileage/monitoring.ts b/src/server/routes/mileage/monitoring.ts index 7165e7c..f01ddbe 100644 --- a/src/server/routes/mileage/monitoring.ts +++ b/src/server/routes/mileage/monitoring.ts @@ -8,6 +8,15 @@ import { parseMileageSourcePriority, protocolsForSourcePriority, } from './source-policy.js'; +import { + isMonitoringCacheEligible, + isMonitoringSnapshotScope, + shanghaiDate, +} from './monitoring-cache-policy.js'; +import { + getManualMonitoringSnapshot, + storeManualMonitoringSnapshot, +} from './manual-snapshot.js'; const app = new Hono(); @@ -115,6 +124,7 @@ app.get('/', async (c) => { const range = normalizeRange(c.req.query('startDate') || '', c.req.query('endDate') || ''); const sourcePriority = parseMileageSourcePriority(c.req.query('sourcePriority')); const protocolPriority = protocolsForSourcePriority(sourcePriority); + const force = c.req.query('force') === '1'; const filterParams = { search: c.req.query('search') || '', @@ -136,17 +146,66 @@ app.get('/', async (c) => { let filters: MonitoringFilters; let rangeDailyTotals: { date: string; totalKm: number }[] | undefined; let dateRange: { start: string; end: string } | undefined; + let dataUpdatedAt: string | undefined; + let cacheStatus: 'hit' | 'manual-hit' | 'refresh' | 'miss' | 'bypass' = 'bypass'; if (range) { - try { - const result = await queryRangeMileage(range.start, range.end, protocolPriority); - allVehicles = result.vehicles; - rangeDailyTotals = result.dailyTotals; - dateRange = { start: result.start, end: result.end }; + const cache = getCache(); + const today = shanghaiDate(); + const snapshotScope = isMonitoringSnapshotScope({ + startDate: range.start, + endDate: range.end, + today, + sourcePriority, + }); + const cacheEligible = isMonitoringCacheEligible({ + startDate: range.start, + endDate: range.end, + today, + sourcePriority, + force, + }); + const manualSnapshot = cacheEligible + ? getManualMonitoringSnapshot(range.start, range.end) + : null; + if (manualSnapshot) { + cacheStatus = 'manual-hit'; + allVehicles = manualSnapshot.result.vehicles; + rangeDailyTotals = manualSnapshot.result.dailyTotals; + dateRange = { start: manualSnapshot.result.start, end: manualSnapshot.result.end }; + dataUpdatedAt = manualSnapshot.updatedAt; filters = buildDateFilters(allVehicles); - } catch (e: unknown) { - console.error('monitoring range query error:', e); - return c.json(EMPTY_RESPONSE, 500); + } else if (cacheEligible && cache) { + cacheStatus = 'hit'; + allVehicles = cache.vehicles.map(vehicle => ({ + ...vehicle, + dailyMileage: { [range.start]: vehicle.dailyKm }, + dailySourceProtocols: { [range.start]: vehicle.sourceProtocol }, + })); + rangeDailyTotals = [{ + date: range.start, + totalKm: Math.round(allVehicles.reduce((sum, vehicle) => sum + vehicle.dailyKm, 0)), + }]; + dateRange = { ...range }; + dataUpdatedAt = cache.updatedAt; + filters = cache.filters; + } else { + cacheStatus = cacheEligible ? 'miss' : 'bypass'; + try { + const result = await queryRangeMileage(range.start, range.end, protocolPriority); + allVehicles = result.vehicles; + rangeDailyTotals = result.dailyTotals; + dateRange = { start: result.start, end: result.end }; + filters = buildDateFilters(allVehicles); + if (force && snapshotScope) { + const stored = storeManualMonitoringSnapshot(result); + cacheStatus = 'refresh'; + dataUpdatedAt = stored.updatedAt; + } + } catch (e: unknown) { + console.error('monitoring range query error:', e); + return c.json(EMPTY_RESPONSE, 500); + } } } else if (date) { try { @@ -218,6 +277,7 @@ app.get('/', async (c) => { const paged = sorted.slice(offset, offset + limit); const total = filtered.length; + c.header('X-Mileage-Cache', cacheStatus); return c.json({ vehicles: maskCustomerNames(paged), stats, @@ -227,7 +287,7 @@ app.get('/', async (c) => { total, page, totalPages: Math.ceil(total / limit), - updatedAt: dateRange?.end || date || getCache()?.updatedAt || new Date().toISOString(), + updatedAt: dataUpdatedAt || dateRange?.end || date || getCache()?.updatedAt || new Date().toISOString(), sourcePriority, }); });