From c0d504cb7bc1c9c2b6502139aafcd6953ac1bef4 Mon Sep 17 00:00:00 2001 From: kkfluous Date: Fri, 7 Aug 2026 18:39:13 +0800 Subject: [PATCH] feat: persist mileage monitoring context --- docs/bi-refactor-roadmap.md | 1 + src/modules/mileage/MonitoringView.tsx | 135 +++++++++++++---- .../mileage/monitoring-context.test.ts | 100 +++++++++++++ src/modules/mileage/monitoring-context.ts | 136 ++++++++++++++++++ 4 files changed, 348 insertions(+), 24 deletions(-) create mode 100644 src/modules/mileage/monitoring-context.test.ts create mode 100644 src/modules/mileage/monitoring-context.ts diff --git a/docs/bi-refactor-roadmap.md b/docs/bi-refactor-roadmap.md index a1facf7..a00944e 100644 --- a/docs/bi-refactor-roadmap.md +++ b/docs/bi-refactor-roadmap.md @@ -161,6 +161,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0) - 已完成里程考核归因、车辆详情和每日汇报跳转。 - 里程考核年度已纳入 `mileageYear` URL 状态;目标、部门/客户归因和车辆清单下钻继承同一年度,无效年度会按目标真实年度归一。 - 车辆级里程详情继承 `mileageStart`、`mileageEnd` 和数据源优先级;显式下钻日期严格展示所选日,预设区间继续排除尚未完整的当天数据,请求故障显示为不可用并支持重试。 +- 里程实时监控的日期、来源、批次、区域、车牌、归属、高级筛选和排序已纳入独立 URL 状态;分享或刷新链接可恢复完整查询,车辆详情前进后退保留列表上下文且不把下钻车牌误写为筛选车牌。 - 已完成氢能站点、客户下钻代码。 - 已完成氢能每日趋势到加氢订单的 URL 下钻、分页和结构化故障状态;待数据源恢复后验收真实明细。 - 已为电能总览与氢能每日趋势柱图统一 Enter/Space 键盘下钻;氢能有效日期柱提供可读名称、焦点和选中反馈。 diff --git a/src/modules/mileage/MonitoringView.tsx b/src/modules/mileage/MonitoringView.tsx index cc17978..d224490 100644 --- a/src/modules/mileage/MonitoringView.tsx +++ b/src/modules/mileage/MonitoringView.tsx @@ -24,6 +24,11 @@ import { type MileageDrillContext, } from './drill-context'; import { formatMileageSnapshotTime } from './data-freshness'; +import { + buildMileageMonitoringUrl, + parseMileageMonitoringContext, + type MileageMonitoringContext, +} from './monitoring-context'; const HIGH_MILEAGE_ALERT_TARGETS = new Set([ '交投40辆4.5T普货', @@ -454,10 +459,11 @@ export default function MonitoringView() { const [drillContext, setDrillContext] = useState(() => ( parseMileageDrillContext(window.location.search) )); - const [searchTerm, setSearchTerm] = useState(''); - const [filterDept, setFilterDept] = useState('All'); - const [sortBy, setSortBy] = useState<'today' | 'total' | 'statisticTime'>('today'); - const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); + const [initialMonitoringContext] = useState(() => parseMileageMonitoringContext(window.location.search)); + const [searchTerm, setSearchTerm] = useState(initialMonitoringContext.search || ''); + const [filterDept, setFilterDept] = useState(initialMonitoringContext.department || 'All'); + const [sortBy, setSortBy] = useState<'today' | 'total' | 'statisticTime'>(initialMonitoringContext.sortBy); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(initialMonitoringContext.sortOrder); const [isFilterOpen, setIsFilterOpen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [fullscreenVehicles, setFullscreenVehicles] = useState([]); @@ -469,24 +475,31 @@ export default function MonitoringView() { const [fullscreenUpdatedAt, setFullscreenUpdatedAt] = useState(null); // New filters from image - const [filterPlates, setFilterPlates] = useState(() => ( - drillContext.level === 'vehicle' && drillContext.plate ? [drillContext.plate] : [] + const [filterPlates, setFilterPlates] = useState(initialMonitoringContext.plates); + const [drillPlateFallback, setDrillPlateFallback] = useState(() => ( + drillContext.level === 'vehicle' && drillContext.plate ? drillContext.plate : undefined )); - const [filterCustomer, setFilterCustomer] = useState('All'); - const [filterProject, setFilterProject] = useState('All'); - const [filterEntity, setFilterEntity] = useState('All'); - const [filterRentStatus, setFilterRentStatus] = useState('All'); - const [filterPlatePrefix, setFilterPlatePrefix] = useState('All'); - const [filterTargetNames, setFilterTargetNames] = useState([]); - const [filterRegion, setFilterRegion] = useState('All'); - const [filterBrands, setFilterBrands] = useState([]); - const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' }); - const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' }); + const [filterCustomer, setFilterCustomer] = useState(initialMonitoringContext.customer || 'All'); + const [filterProject, setFilterProject] = useState(initialMonitoringContext.project || 'All'); + const [filterEntity, setFilterEntity] = useState(initialMonitoringContext.entity || 'All'); + const [filterRentStatus, setFilterRentStatus] = useState(initialMonitoringContext.rentStatus || 'All'); + const [filterPlatePrefix, setFilterPlatePrefix] = useState(initialMonitoringContext.platePrefix || 'All'); + const [filterTargetNames, setFilterTargetNames] = useState(initialMonitoringContext.targetNames); + const [filterRegion, setFilterRegion] = useState(initialMonitoringContext.region || 'All'); + const [filterBrands, setFilterBrands] = useState(initialMonitoringContext.brands); + const [filterMileageRange, setFilterMileageRange] = useState({ + min: initialMonitoringContext.mileageMin || '', + max: initialMonitoringContext.mileageMax || '', + }); + const [appliedMileageRange, setAppliedMileageRange] = useState({ + min: initialMonitoringContext.mileageMin || '', + max: initialMonitoringContext.mileageMax || '', + }); const [exporting, setExporting] = useState(false); const [exportStatus, setExportStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null); - const [rangeStart, setRangeStart] = useState(() => drillContext.startDate || defaultMileageDate()); - const [rangeEnd, setRangeEnd] = useState(() => drillContext.endDate || defaultMileageDate()); - const [sourcePriority, setSourcePriority] = useState(drillContext.sourcePriority); + const [rangeStart, setRangeStart] = useState(() => initialMonitoringContext.startDate || drillContext.startDate || defaultMileageDate()); + const [rangeEnd, setRangeEnd] = useState(() => initialMonitoringContext.endDate || drillContext.endDate || defaultMileageDate()); + const [sourcePriority, setSourcePriority] = useState(initialMonitoringContext.sourcePriority); const [vehicles, setVehicles] = useState([]); const [stats, setStats] = useState({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 }); @@ -515,6 +528,7 @@ export default function MonitoringView() { }, []); const openVehicleDetail = useCallback((vehicle: MonitoringVehicle) => { + setDrillPlateFallback(undefined); commitDrillContext({ level: 'vehicle', plate: vehicle.plate, @@ -525,6 +539,7 @@ export default function MonitoringView() { }, [commitDrillContext, effectiveRange.end, effectiveRange.start, sourcePriority]); const closeVehicleDetail = useCallback(() => { + setDrillPlateFallback(undefined); commitDrillContext({ level: 'overview', startDate: effectiveRange.start, @@ -541,11 +556,31 @@ export default function MonitoringView() { useEffect(() => { const handlePopState = () => { const next = parseMileageDrillContext(window.location.search); + const nextMonitoring = parseMileageMonitoringContext(window.location.search); setDrillContext(next); - if (next.startDate) setRangeStart(next.startDate); - if (next.endDate) setRangeEnd(next.endDate); - setSourcePriority(next.sourcePriority); - if (next.level === 'vehicle' && next.plate) setFilterPlates([next.plate]); + setRangeStart(nextMonitoring.startDate || defaultMileageDate()); + setRangeEnd(nextMonitoring.endDate || defaultMileageDate()); + setSourcePriority(nextMonitoring.sourcePriority); + setFilterTargetNames(nextMonitoring.targetNames); + setFilterRegion(nextMonitoring.region || 'All'); + setFilterPlates(nextMonitoring.plates); + setDrillPlateFallback(next.level === 'vehicle' && next.plate ? next.plate : undefined); + setFilterDept(nextMonitoring.department || 'All'); + setFilterCustomer(nextMonitoring.customer || 'All'); + setFilterProject(nextMonitoring.project || 'All'); + setFilterEntity(nextMonitoring.entity || 'All'); + setFilterRentStatus(nextMonitoring.rentStatus || 'All'); + setFilterBrands(nextMonitoring.brands); + setFilterPlatePrefix(nextMonitoring.platePrefix || 'All'); + setSearchTerm(nextMonitoring.search || ''); + const nextMileageRange = { + min: nextMonitoring.mileageMin || '', + max: nextMonitoring.mileageMax || '', + }; + setFilterMileageRange(nextMileageRange); + setAppliedMileageRange(nextMileageRange); + setSortBy(nextMonitoring.sortBy); + setSortOrder(nextMonitoring.sortOrder); }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); @@ -565,6 +600,11 @@ export default function MonitoringView() { return inAlertTarget && Math.max(0, v.dailyKm || 0) >= HIGH_MILEAGE_ALERT_KM; }, [filterTargetNames]); + const requestPlates = useMemo(() => Array.from(new Set([ + ...filterPlates, + ...(drillPlateFallback ? [drillPlateFallback] : []), + ])), [drillPlateFallback, filterPlates]); + const monitoringFilters = useMemo(() => ({ search: searchTerm || undefined, dept: filterDept !== 'All' ? filterDept : undefined, @@ -575,13 +615,50 @@ export default function MonitoringView() { platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined, targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined, region: filterRegion !== 'All' ? filterRegion : undefined, - plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined, + plate: requestPlates.length > 0 ? requestPlates.join(',') : undefined, mileageMin: appliedMileageRange.min || undefined, mileageMax: appliedMileageRange.max || undefined, startDate: rangeStart || undefined, endDate: rangeEnd || undefined, brands: filterBrands.length > 0 ? filterBrands : undefined, sourcePriority, + }), [ + appliedMileageRange.max, + appliedMileageRange.min, + filterBrands, + filterCustomer, + filterDept, + filterEntity, + filterPlatePrefix, + filterProject, + filterRegion, + filterRentStatus, + filterTargetNames, + rangeEnd, + rangeStart, + requestPlates, + searchTerm, + sourcePriority, + ]); + const monitoringUrlContext = useMemo(() => ({ + startDate: rangeStart || undefined, + endDate: rangeEnd || undefined, + sourcePriority, + targetNames: filterTargetNames, + region: filterRegion !== 'All' ? filterRegion : undefined, + plates: filterPlates, + department: filterDept !== 'All' ? filterDept : undefined, + customer: filterCustomer !== 'All' ? filterCustomer : undefined, + project: filterProject !== 'All' ? filterProject : undefined, + entity: filterEntity !== 'All' ? filterEntity : undefined, + rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined, + brands: filterBrands, + platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined, + search: searchTerm || undefined, + mileageMin: appliedMileageRange.min || undefined, + mileageMax: appliedMileageRange.max || undefined, + sortBy, + sortOrder, }), [ appliedMileageRange.max, appliedMileageRange.min, @@ -598,8 +675,17 @@ export default function MonitoringView() { rangeEnd, rangeStart, searchTerm, + sortBy, + sortOrder, sourcePriority, ]); + + useEffect(() => { + const nextUrl = buildMileageMonitoringUrl(window.location, monitoringUrlContext); + const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`; + if (nextUrl !== currentUrl) window.history.replaceState(null, '', nextUrl); + }, [monitoringUrlContext]); + const monitoringRequestKey = useMemo( () => JSON.stringify({ ...monitoringFilters, sortBy, sortOrder }), [monitoringFilters, sortBy, sortOrder], @@ -705,6 +791,7 @@ export default function MonitoringView() { // 区域级联:plate 选项收窄后,剔除已选但已不属于该区域的车牌 useEffect(() => { if (filterPlates.length === 0) return; + if (filterOptions.plates.length === 0) return; const valid = new Set(filterOptions.plates); const next = filterPlates.filter(p => valid.has(p)); if (next.length !== filterPlates.length) setFilterPlates(next); diff --git a/src/modules/mileage/monitoring-context.test.ts b/src/modules/mileage/monitoring-context.test.ts new file mode 100644 index 0000000..fb0fd0e --- /dev/null +++ b/src/modules/mileage/monitoring-context.test.ts @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildMileageMonitoringUrl, + parseMileageMonitoringContext, +} from './monitoring-context.js'; + +test('parses a complete mileage monitoring context', () => { + const context = parseMileageMonitoringContext( + '?mileageStart=2026-08-01&mileageEnd=2026-08-07&mileageSource=gps,instrument' + + '&mileageBatch=%E4%BA%A4%E6%8A%95190%E8%BE%86&mileageBatch=%E7%BE%9A%E7%89%9B136%E8%BE%86' + + '&mileageRegion=%E5%B9%BF%E5%B7%9E&mileageFilterPlate=%E7%B2%A4A12345&mileageDepartment=%E4%B8%9A%E5%8A%A1%E4%B8%80%E9%83%A8' + + '&mileageCustomer=%E5%AE%A2%E6%88%B7A&mileageProject=%E9%A1%B9%E7%9B%AEA&mileageEntity=%E4%B8%BB%E4%BD%93A' + + '&mileageRentStatus=%E7%A7%9F%E8%B5%81&mileageBrand=%E7%8E%B0%E4%BB%A3&mileagePlatePrefix=%E7%B2%A4A' + + '&mileageSearch=123&mileageMin=10&mileageMax=500&mileageSort=total&mileageOrder=asc', + ); + + assert.deepEqual(context, { + startDate: '2026-08-01', + endDate: '2026-08-07', + sourcePriority: ['gps', 'instrument'], + targetNames: ['交投190辆', '羚牛136辆'], + region: '广州', + plates: ['粤A12345'], + department: '业务一部', + customer: '客户A', + project: '项目A', + entity: '主体A', + rentStatus: '租赁', + brands: ['现代'], + platePrefix: '粤A', + search: '123', + mileageMin: '10', + mileageMax: '500', + sortBy: 'total', + sortOrder: 'asc', + }); +}); + +test('normalizes invalid monitoring URL values', () => { + const context = parseMileageMonitoringContext( + '?mileageStart=invalid&mileageSource=unknown&mileageBatch=&mileageRegion=All&mileageSort=unknown&mileageOrder=unknown', + ); + + assert.deepEqual(context, { + startDate: undefined, + endDate: undefined, + sourcePriority: ['instrument'], + targetNames: [], + region: undefined, + plates: [], + department: undefined, + customer: undefined, + project: undefined, + entity: undefined, + rentStatus: undefined, + brands: [], + platePrefix: undefined, + search: undefined, + mileageMin: undefined, + mileageMax: undefined, + sortBy: 'today', + sortOrder: 'desc', + }); +}); + +test('builds monitoring state without removing drill or unrelated context', () => { + const url = buildMileageMonitoringUrl( + { + pathname: '/asset', + search: '?company=5&mileageLevel=vehicle&mileagePlate=%E7%B2%A4A08190F&mileageBatch=old', + hash: '#mileage', + }, + { + startDate: '2026-08-01', + endDate: '2026-08-07', + sourcePriority: ['instrument'], + targetNames: ['交投190辆', '羚牛136辆'], + region: '广州', + plates: ['粤A12345'], + department: undefined, + customer: undefined, + project: undefined, + entity: undefined, + rentStatus: undefined, + brands: [], + platePrefix: undefined, + search: undefined, + mileageMin: undefined, + mileageMax: undefined, + sortBy: 'statisticTime', + sortOrder: 'asc', + }, + ); + + assert.equal( + url, + '/asset?company=5&mileageLevel=vehicle&mileagePlate=%E7%B2%A4A08190F&mileageStart=2026-08-01&mileageEnd=2026-08-07&mileageSource=instrument&mileageBatch=%E4%BA%A4%E6%8A%95190%E8%BE%86&mileageBatch=%E7%BE%9A%E7%89%9B136%E8%BE%86&mileageRegion=%E5%B9%BF%E5%B7%9E&mileageFilterPlate=%E7%B2%A4A12345&mileageSort=statisticTime&mileageOrder=asc#mileage', + ); +}); diff --git a/src/modules/mileage/monitoring-context.ts b/src/modules/mileage/monitoring-context.ts new file mode 100644 index 0000000..043b895 --- /dev/null +++ b/src/modules/mileage/monitoring-context.ts @@ -0,0 +1,136 @@ +import type { MileageSourceGroup } from './types'; + +export type MonitoringSortBy = 'today' | 'total' | 'statisticTime'; +export type MonitoringSortOrder = 'asc' | 'desc'; + +export interface MileageMonitoringContext { + startDate?: string; + endDate?: string; + sourcePriority: MileageSourceGroup[]; + targetNames: string[]; + region?: string; + plates: string[]; + department?: string; + customer?: string; + project?: string; + entity?: string; + rentStatus?: string; + brands: string[]; + platePrefix?: string; + search?: string; + mileageMin?: string; + mileageMax?: string; + sortBy: MonitoringSortBy; + sortOrder: MonitoringSortOrder; +} + +interface LocationParts { + pathname: string; + search: string; + hash: string; +} + +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; +const CONTEXT_KEYS = [ + 'mileageStart', + 'mileageEnd', + 'mileageSource', + 'mileageBatch', + 'mileageRegion', + 'mileageFilterPlate', + 'mileageDepartment', + 'mileageCustomer', + 'mileageProject', + 'mileageEntity', + 'mileageRentStatus', + 'mileageBrand', + 'mileagePlatePrefix', + 'mileageSearch', + 'mileageMin', + 'mileageMax', + 'mileageSort', + 'mileageOrder', +] as const; + +function validDate(value: string | null): string | undefined { + if (!value || !DATE_PATTERN.test(value)) return undefined; + const timestamp = Date.parse(`${value}T00:00:00+08:00`); + return Number.isFinite(timestamp) ? value : undefined; +} + +function boundedValue(value: string | null, maxLength = 200): string | undefined { + const normalized = value?.trim(); + if (!normalized || normalized === 'All') return undefined; + return normalized.slice(0, maxLength); +} + +function uniqueValues(values: string[], limit = 200): string[] { + return Array.from(new Set(values.map(value => boundedValue(value)).filter((value): value is string => !!value))).slice(0, limit); +} + +function parseSourcePriority(value: string | null): MileageSourceGroup[] { + const sources = value + ?.split(',') + .filter((source): source is MileageSourceGroup => source === 'instrument' || source === 'gps'); + return sources && sources.length > 0 ? Array.from(new Set(sources)).slice(0, 2) : ['instrument']; +} + +function appendValues(params: URLSearchParams, key: string, values: string[]): void { + uniqueValues(values).forEach(value => params.append(key, value)); +} + +export function parseMileageMonitoringContext(search: string): MileageMonitoringContext { + const params = new URLSearchParams(search); + const rawSortBy = params.get('mileageSort'); + const rawSortOrder = params.get('mileageOrder'); + return { + startDate: validDate(params.get('mileageStart')), + endDate: validDate(params.get('mileageEnd')), + sourcePriority: parseSourcePriority(params.get('mileageSource')), + targetNames: uniqueValues(params.getAll('mileageBatch')), + region: boundedValue(params.get('mileageRegion')), + plates: uniqueValues(params.getAll('mileageFilterPlate')), + department: boundedValue(params.get('mileageDepartment')), + customer: boundedValue(params.get('mileageCustomer')), + project: boundedValue(params.get('mileageProject')), + entity: boundedValue(params.get('mileageEntity')), + rentStatus: boundedValue(params.get('mileageRentStatus')), + brands: uniqueValues(params.getAll('mileageBrand')), + platePrefix: boundedValue(params.get('mileagePlatePrefix')), + search: boundedValue(params.get('mileageSearch')), + mileageMin: boundedValue(params.get('mileageMin'), 32), + mileageMax: boundedValue(params.get('mileageMax'), 32), + sortBy: rawSortBy === 'total' || rawSortBy === 'statisticTime' ? rawSortBy : 'today', + sortOrder: rawSortOrder === 'asc' ? 'asc' : 'desc', + }; +} + +export function buildMileageMonitoringUrl( + location: LocationParts, + context: MileageMonitoringContext, +): string { + const params = new URLSearchParams(location.search); + CONTEXT_KEYS.forEach(key => params.delete(key)); + + if (context.startDate) params.set('mileageStart', context.startDate); + if (context.endDate) params.set('mileageEnd', context.endDate); + if (context.sourcePriority.length > 0) params.set('mileageSource', context.sourcePriority.join(',')); + appendValues(params, 'mileageBatch', context.targetNames); + if (context.region) params.set('mileageRegion', context.region); + appendValues(params, 'mileageFilterPlate', context.plates); + if (context.department) params.set('mileageDepartment', context.department); + if (context.customer) params.set('mileageCustomer', context.customer); + if (context.project) params.set('mileageProject', context.project); + if (context.entity) params.set('mileageEntity', context.entity); + if (context.rentStatus) params.set('mileageRentStatus', context.rentStatus); + appendValues(params, 'mileageBrand', context.brands); + if (context.platePrefix) params.set('mileagePlatePrefix', context.platePrefix); + if (context.search) params.set('mileageSearch', context.search); + if (context.mileageMin) params.set('mileageMin', context.mileageMin); + if (context.mileageMax) params.set('mileageMax', context.mileageMax); + if (context.sortBy !== 'today') params.set('mileageSort', context.sortBy); + if (context.sortOrder !== 'desc') params.set('mileageOrder', context.sortOrder); + + const query = params.toString(); + return `${location.pathname}${query ? `?${query}` : ''}${location.hash}`; +}