diff --git a/docs/bi-refactor-roadmap.md b/docs/bi-refactor-roadmap.md index de6cf5b..5125962 100644 --- a/docs/bi-refactor-roadmap.md +++ b/docs/bi-refactor-roadmap.md @@ -149,6 +149,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0) - 已完成版本化指标目录和四模块入口。 - 已完成里程、电能、氢能主要 URL 状态。 +- 已完成 ETC 记录/账单视图、日期、搜索和分页 URL 状态,以及超范围页码自动纠正。 - 已完成并发同参 GET 去重。 ### 阶段 B:核心下钻闭环 diff --git a/src/modules/energy/ETCDetails.tsx b/src/modules/energy/ETCDetails.tsx index d3af732..3aaa23d 100644 --- a/src/modules/energy/ETCDetails.tsx +++ b/src/modules/energy/ETCDetails.tsx @@ -3,7 +3,7 @@ import { ChevronLeft, ChevronRight, Loader2, ReceiptText, Route, Search } from ' import Blur from '../../components/Blur'; import { fetchEtcBills, fetchEtcRecords } from './api'; import type { EtcBillResponse, EtcTollRecordResponse } from './types'; -import type { EtcDetailView } from './etc-drill-context'; +import type { EtcDetailView, EtcDrillContext } from './etc-drill-context'; function fmtMoney(value: number): string { return `¥${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; @@ -22,26 +22,27 @@ function paymentLabel(receivable: number, paid: number): string { } export default function ETCDetails({ - view, - onViewChange, + context, + onContextChange, }: { - view: EtcDetailView; - onViewChange: (view: EtcDetailView) => void; + context: EtcDrillContext; + onContextChange: (context: EtcDrillContext, mode: 'push' | 'replace') => void; }) { - const [draftSearch, setDraftSearch] = useState(''); - const [search, setSearch] = useState(''); - const [startDate, setStartDate] = useState(''); - const [endDate, setEndDate] = useState(''); - const [page, setPage] = useState(1); + const { view, startDate, endDate, search, page } = context; + const [draftSearch, setDraftSearch] = useState(search); const [records, setRecords] = useState(null); const [bills, setBills] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + useEffect(() => setDraftSearch(search), [search]); + useEffect(() => { let cancelled = false; setLoading(true); setError(null); + if (view === 'records') setRecords(null); + else setBills(null); const query = { page, limit: 20, @@ -53,6 +54,10 @@ export default function ETCDetails({ request .then(result => { if (cancelled) return; + if (page > result.totalPages) { + onContextChange({ ...context, page: result.totalPages }, 'replace'); + return; + } if (view === 'records') setRecords(result as EtcTollRecordResponse); else setBills(result as EtcBillResponse); }) @@ -61,17 +66,24 @@ export default function ETCDetails({ }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; - }, [endDate, page, search, startDate, view]); + }, [context, endDate, onContextChange, page, search, startDate, view]); const activeData = view === 'records' ? records : bills; const totalPages = activeData?.totalPages || 1; const applySearch = () => { - setPage(1); - setSearch(draftSearch.trim()); + onContextChange({ ...context, search: draftSearch.trim(), page: 1 }, 'replace'); }; const changeView = (next: EtcDetailView) => { - setPage(1); - onViewChange(next); + onContextChange( + { ...context, view: next, page: 1 }, + next === view ? 'replace' : 'push', + ); + }; + const updateDate = (field: 'startDate' | 'endDate', value: string) => { + let nextStart = field === 'startDate' ? value : startDate; + let nextEnd = field === 'endDate' ? value : endDate; + if (nextStart && nextEnd && nextStart > nextEnd) [nextStart, nextEnd] = [nextEnd, nextStart]; + onContextChange({ ...context, startDate: nextStart, endDate: nextEnd, page: 1 }, 'replace'); }; return ( @@ -88,6 +100,7 @@ export default function ETCDetails({ - + + ) : null} diff --git a/src/modules/energy/ETCView.tsx b/src/modules/energy/ETCView.tsx index 13284f0..f0fa971 100644 --- a/src/modules/energy/ETCView.tsx +++ b/src/modules/energy/ETCView.tsx @@ -4,7 +4,12 @@ import { fetchEtcOverview } from './api'; import type { EtcOverviewResponse } from './types'; import { ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface'; import ETCDetails from './ETCDetails'; -import { buildEtcDetailUrl, parseEtcDetailView, type EtcDetailView } from './etc-drill-context'; +import { + buildEtcDrillUrl, + parseEtcDrillContext, + type EtcDetailView, + type EtcDrillContext, +} from './etc-drill-context'; function fmtMoney(value: number): string { return `¥${value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`; @@ -22,15 +27,28 @@ export default function ETCView() { const [data, setData] = useState(null); const [error, setError] = useState(null); const [refreshing, setRefreshing] = useState(false); - const [detailView, setDetailView] = useState(() => parseEtcDetailView(window.location.search)); + const [detailContext, setDetailContext] = useState(() => ( + parseEtcDrillContext(window.location.search) + )); - const selectDetailView = useCallback((next: EtcDetailView) => { - window.history.pushState(null, '', buildEtcDetailUrl(window.location, next)); - setDetailView(next); + const commitDetailContext = useCallback((next: EtcDrillContext, mode: 'push' | 'replace') => { + window.history[mode === 'push' ? 'pushState' : 'replaceState']( + null, + '', + buildEtcDrillUrl(window.location, next), + ); + setDetailContext(next); }, []); + const selectDetailView = useCallback((next: EtcDetailView) => { + commitDetailContext( + { ...detailContext, view: next, page: 1 }, + next === detailContext.view ? 'replace' : 'push', + ); + }, [commitDetailContext, detailContext]); + useEffect(() => { - const handlePopState = () => setDetailView(parseEtcDetailView(window.location.search)); + const handlePopState = () => setDetailContext(parseEtcDrillContext(window.location.search)); window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); }, []); @@ -112,7 +130,7 @@ export default function ETCView() { - + {error && } diff --git a/src/modules/energy/etc-drill-context.test.ts b/src/modules/energy/etc-drill-context.test.ts index 523cf6f..55c3e94 100644 --- a/src/modules/energy/etc-drill-context.test.ts +++ b/src/modules/energy/etc-drill-context.test.ts @@ -1,16 +1,40 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { buildEtcDetailUrl, parseEtcDetailView } from './etc-drill-context.js'; +import { buildEtcDrillUrl, parseEtcDrillContext } from './etc-drill-context.js'; -test('parses only published ETC detail views', () => { - assert.equal(parseEtcDetailView('?etcView=bills'), 'bills'); - assert.equal(parseEtcDetailView('?etcView=unknown'), 'records'); +test('parses and normalizes the ETC analysis context', () => { + assert.deepEqual( + parseEtcDrillContext('?etcView=bills&etcStart=2026-08-07&etcEnd=2026-08-01&etcSearch=%20%E5%AE%A2%E6%88%B7A%20&etcPage=3'), + { + view: 'bills', + startDate: '2026-08-01', + endDate: '2026-08-07', + search: '客户A', + page: 3, + }, + ); }); -test('updates ETC drill state without dropping other URL context', () => { - assert.equal(buildEtcDetailUrl({ - pathname: '/energy', - search: '?electricScope=all&etcView=records', - hash: '#etc', - }, 'bills'), '/energy?electricScope=all&etcView=bills#etc'); +test('rejects invalid ETC URL state', () => { + assert.deepEqual( + parseEtcDrillContext(`?etcView=unknown&etcStart=2026-02-31&etcSearch=${'x'.repeat(129)}&etcPage=-2`), + { view: 'records', startDate: '', endDate: '', search: '', page: 1 }, + ); +}); + +test('builds ETC state without dropping unrelated URL context', () => { + assert.equal( + buildEtcDrillUrl({ + pathname: '/energy', + search: '?electricScope=all&etcView=records', + hash: '#etc', + }, { + view: 'bills', + startDate: '2026-08-01', + endDate: '2026-08-07', + search: '客户A', + page: 2, + }), + '/energy?electricScope=all&etcView=bills&etcStart=2026-08-01&etcEnd=2026-08-07&etcSearch=%E5%AE%A2%E6%88%B7A&etcPage=2#etc', + ); }); diff --git a/src/modules/energy/etc-drill-context.ts b/src/modules/energy/etc-drill-context.ts index 70f2b50..3e9b0da 100644 --- a/src/modules/energy/etc-drill-context.ts +++ b/src/modules/energy/etc-drill-context.ts @@ -1,18 +1,63 @@ export type EtcDetailView = 'records' | 'bills'; +export interface EtcDrillContext { + view: EtcDetailView; + startDate: string; + endDate: string; + search: string; + page: number; +} + interface LocationParts { pathname: string; search: string; hash: string; } -export function parseEtcDetailView(search: string): EtcDetailView { - return new URLSearchParams(search).get('etcView') === 'bills' ? 'bills' : 'records'; +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +function validDate(value: string | null): string { + if (!value || !DATE_PATTERN.test(value)) return ''; + const [year, month, day] = value.split('-').map(Number); + const date = new Date(Date.UTC(year, month - 1, day)); + return date.getUTCFullYear() === year + && date.getUTCMonth() === month - 1 + && date.getUTCDate() === day + ? value + : ''; } -export function buildEtcDetailUrl(location: LocationParts, view: EtcDetailView): string { +function validPage(value: string | null): number { + if (!value || !/^\d+$/.test(value)) return 1; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 1; +} + +export function parseEtcDrillContext(search: string): EtcDrillContext { + const params = new URLSearchParams(search); + const rawSearch = params.get('etcSearch')?.trim() ?? ''; + const firstDate = validDate(params.get('etcStart')); + const secondDate = validDate(params.get('etcEnd')); + const startDate = firstDate && secondDate && firstDate > secondDate ? secondDate : firstDate; + const endDate = firstDate && secondDate && firstDate > secondDate ? firstDate : secondDate; + return { + view: params.get('etcView') === 'bills' ? 'bills' : 'records', + startDate, + endDate, + search: rawSearch.length <= 128 ? rawSearch : '', + page: validPage(params.get('etcPage')), + }; +} + +export function buildEtcDrillUrl(location: LocationParts, context: EtcDrillContext): string { const params = new URLSearchParams(location.search); - params.set('etcView', view); + for (const key of ['etcView', 'etcStart', 'etcEnd', 'etcSearch', 'etcPage']) params.delete(key); + params.set('etcView', context.view); + if (context.startDate) params.set('etcStart', context.startDate); + if (context.endDate) params.set('etcEnd', context.endDate); + const search = context.search.trim(); + if (search && search.length <= 128) params.set('etcSearch', search); + if (context.page > 1) params.set('etcPage', String(context.page)); const query = params.toString(); return `${location.pathname}${query ? `?${query}` : ''}${location.hash}`; }