refactor: persist ETC analysis state
This commit is contained in:
@@ -149,6 +149,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
|
|||||||
|
|
||||||
- 已完成版本化指标目录和四模块入口。
|
- 已完成版本化指标目录和四模块入口。
|
||||||
- 已完成里程、电能、氢能主要 URL 状态。
|
- 已完成里程、电能、氢能主要 URL 状态。
|
||||||
|
- 已完成 ETC 记录/账单视图、日期、搜索和分页 URL 状态,以及超范围页码自动纠正。
|
||||||
- 已完成并发同参 GET 去重。
|
- 已完成并发同参 GET 去重。
|
||||||
|
|
||||||
### 阶段 B:核心下钻闭环
|
### 阶段 B:核心下钻闭环
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ChevronLeft, ChevronRight, Loader2, ReceiptText, Route, Search } from '
|
|||||||
import Blur from '../../components/Blur';
|
import Blur from '../../components/Blur';
|
||||||
import { fetchEtcBills, fetchEtcRecords } from './api';
|
import { fetchEtcBills, fetchEtcRecords } from './api';
|
||||||
import type { EtcBillResponse, EtcTollRecordResponse } from './types';
|
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 {
|
function fmtMoney(value: number): string {
|
||||||
return `¥${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
return `¥${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||||
@@ -22,26 +22,27 @@ function paymentLabel(receivable: number, paid: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ETCDetails({
|
export default function ETCDetails({
|
||||||
view,
|
context,
|
||||||
onViewChange,
|
onContextChange,
|
||||||
}: {
|
}: {
|
||||||
view: EtcDetailView;
|
context: EtcDrillContext;
|
||||||
onViewChange: (view: EtcDetailView) => void;
|
onContextChange: (context: EtcDrillContext, mode: 'push' | 'replace') => void;
|
||||||
}) {
|
}) {
|
||||||
const [draftSearch, setDraftSearch] = useState('');
|
const { view, startDate, endDate, search, page } = context;
|
||||||
const [search, setSearch] = useState('');
|
const [draftSearch, setDraftSearch] = useState(search);
|
||||||
const [startDate, setStartDate] = useState('');
|
|
||||||
const [endDate, setEndDate] = useState('');
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [records, setRecords] = useState<EtcTollRecordResponse | null>(null);
|
const [records, setRecords] = useState<EtcTollRecordResponse | null>(null);
|
||||||
const [bills, setBills] = useState<EtcBillResponse | null>(null);
|
const [bills, setBills] = useState<EtcBillResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => setDraftSearch(search), [search]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
if (view === 'records') setRecords(null);
|
||||||
|
else setBills(null);
|
||||||
const query = {
|
const query = {
|
||||||
page,
|
page,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
@@ -53,6 +54,10 @@ export default function ETCDetails({
|
|||||||
request
|
request
|
||||||
.then(result => {
|
.then(result => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
if (page > result.totalPages) {
|
||||||
|
onContextChange({ ...context, page: result.totalPages }, 'replace');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (view === 'records') setRecords(result as EtcTollRecordResponse);
|
if (view === 'records') setRecords(result as EtcTollRecordResponse);
|
||||||
else setBills(result as EtcBillResponse);
|
else setBills(result as EtcBillResponse);
|
||||||
})
|
})
|
||||||
@@ -61,17 +66,24 @@ export default function ETCDetails({
|
|||||||
})
|
})
|
||||||
.finally(() => { if (!cancelled) setLoading(false); });
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [endDate, page, search, startDate, view]);
|
}, [context, endDate, onContextChange, page, search, startDate, view]);
|
||||||
|
|
||||||
const activeData = view === 'records' ? records : bills;
|
const activeData = view === 'records' ? records : bills;
|
||||||
const totalPages = activeData?.totalPages || 1;
|
const totalPages = activeData?.totalPages || 1;
|
||||||
const applySearch = () => {
|
const applySearch = () => {
|
||||||
setPage(1);
|
onContextChange({ ...context, search: draftSearch.trim(), page: 1 }, 'replace');
|
||||||
setSearch(draftSearch.trim());
|
|
||||||
};
|
};
|
||||||
const changeView = (next: EtcDetailView) => {
|
const changeView = (next: EtcDetailView) => {
|
||||||
setPage(1);
|
onContextChange(
|
||||||
onViewChange(next);
|
{ ...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 (
|
return (
|
||||||
@@ -88,6 +100,7 @@ export default function ETCDetails({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => changeView('records')}
|
onClick={() => changeView('records')}
|
||||||
|
aria-pressed={view === 'records'}
|
||||||
className={`inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-[10px] font-black ${view === 'records' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500'}`}
|
className={`inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-[10px] font-black ${view === 'records' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500'}`}
|
||||||
>
|
>
|
||||||
<Route size={13} />通行记录
|
<Route size={13} />通行记录
|
||||||
@@ -95,6 +108,7 @@ export default function ETCDetails({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => changeView('bills')}
|
onClick={() => changeView('bills')}
|
||||||
|
aria-pressed={view === 'bills'}
|
||||||
className={`inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-[10px] font-black ${view === 'bills' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500'}`}
|
className={`inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-[10px] font-black ${view === 'bills' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500'}`}
|
||||||
>
|
>
|
||||||
<ReceiptText size={13} />结算账单
|
<ReceiptText size={13} />结算账单
|
||||||
@@ -107,14 +121,14 @@ export default function ETCDetails({
|
|||||||
type="date"
|
type="date"
|
||||||
aria-label="ETC 开始日期"
|
aria-label="ETC 开始日期"
|
||||||
value={startDate}
|
value={startDate}
|
||||||
onChange={event => { setPage(1); setStartDate(event.target.value); }}
|
onChange={event => updateDate('startDate', event.target.value)}
|
||||||
className="h-9 min-w-0 rounded-lg border border-slate-200 bg-white px-2 text-[11px] font-bold text-slate-700 outline-none focus:border-blue-300"
|
className="h-9 min-w-0 rounded-lg border border-slate-200 bg-white px-2 text-[11px] font-bold text-slate-700 outline-none focus:border-blue-300"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
aria-label="ETC 结束日期"
|
aria-label="ETC 结束日期"
|
||||||
value={endDate}
|
value={endDate}
|
||||||
onChange={event => { setPage(1); setEndDate(event.target.value); }}
|
onChange={event => updateDate('endDate', event.target.value)}
|
||||||
className="h-9 min-w-0 rounded-lg border border-slate-200 bg-white px-2 text-[11px] font-bold text-slate-700 outline-none focus:border-blue-300"
|
className="h-9 min-w-0 rounded-lg border border-slate-200 bg-white px-2 text-[11px] font-bold text-slate-700 outline-none focus:border-blue-300"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
@@ -122,6 +136,7 @@ export default function ETCDetails({
|
|||||||
aria-label={view === 'records' ? '搜索车牌、客户或流水号' : '搜索客户或账单号'}
|
aria-label={view === 'records' ? '搜索车牌、客户或流水号' : '搜索客户或账单号'}
|
||||||
placeholder={view === 'records' ? '车牌 / 客户 / 流水号' : '客户 / 账单号'}
|
placeholder={view === 'records' ? '车牌 / 客户 / 流水号' : '客户 / 账单号'}
|
||||||
value={draftSearch}
|
value={draftSearch}
|
||||||
|
maxLength={128}
|
||||||
onChange={event => setDraftSearch(event.target.value)}
|
onChange={event => setDraftSearch(event.target.value)}
|
||||||
onKeyDown={event => { if (event.key === 'Enter') applySearch(); }}
|
onKeyDown={event => { if (event.key === 'Enter') applySearch(); }}
|
||||||
className="h-9 min-w-0 rounded-lg border border-slate-200 bg-white px-3 text-[11px] font-bold text-slate-700 outline-none placeholder:text-slate-300 focus:border-blue-300"
|
className="h-9 min-w-0 rounded-lg border border-slate-200 bg-white px-3 text-[11px] font-bold text-slate-700 outline-none placeholder:text-slate-300 focus:border-blue-300"
|
||||||
@@ -235,8 +250,8 @@ export default function ETCDetails({
|
|||||||
<footer className="flex items-center justify-between border-t border-slate-100 px-3 py-3 md:px-4">
|
<footer className="flex items-center justify-between border-t border-slate-100 px-3 py-3 md:px-4">
|
||||||
<span className="text-[10px] font-bold text-slate-400">第 {page} / {totalPages} 页</span>
|
<span className="text-[10px] font-bold text-slate-400">第 {page} / {totalPages} 页</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button type="button" onClick={() => setPage(current => Math.max(1, current - 1))} disabled={page <= 1 || loading} className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-200 text-slate-500 disabled:opacity-30" aria-label="上一页" title="上一页"><ChevronLeft size={14} /></button>
|
<button type="button" onClick={() => onContextChange({ ...context, page: Math.max(1, page - 1) }, 'push')} disabled={page <= 1 || loading} className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-200 text-slate-500 disabled:opacity-30" aria-label="上一页" title="上一页"><ChevronLeft size={14} /></button>
|
||||||
<button type="button" onClick={() => setPage(current => Math.min(totalPages, current + 1))} disabled={page >= totalPages || loading} className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-200 text-slate-500 disabled:opacity-30" aria-label="下一页" title="下一页"><ChevronRight size={14} /></button>
|
<button type="button" onClick={() => onContextChange({ ...context, page: Math.min(totalPages, page + 1) }, 'push')} disabled={page >= totalPages || loading} className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-200 text-slate-500 disabled:opacity-30" aria-label="下一页" title="下一页"><ChevronRight size={14} /></button>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import { fetchEtcOverview } from './api';
|
|||||||
import type { EtcOverviewResponse } from './types';
|
import type { EtcOverviewResponse } from './types';
|
||||||
import { ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
import { ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||||
import ETCDetails from './ETCDetails';
|
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 {
|
function fmtMoney(value: number): string {
|
||||||
return `¥${value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
|
return `¥${value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
|
||||||
@@ -22,15 +27,28 @@ export default function ETCView() {
|
|||||||
const [data, setData] = useState<EtcOverviewResponse | null>(null);
|
const [data, setData] = useState<EtcOverviewResponse | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [detailView, setDetailView] = useState<EtcDetailView>(() => parseEtcDetailView(window.location.search));
|
const [detailContext, setDetailContext] = useState<EtcDrillContext>(() => (
|
||||||
|
parseEtcDrillContext(window.location.search)
|
||||||
|
));
|
||||||
|
|
||||||
const selectDetailView = useCallback((next: EtcDetailView) => {
|
const commitDetailContext = useCallback((next: EtcDrillContext, mode: 'push' | 'replace') => {
|
||||||
window.history.pushState(null, '', buildEtcDetailUrl(window.location, next));
|
window.history[mode === 'push' ? 'pushState' : 'replaceState'](
|
||||||
setDetailView(next);
|
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(() => {
|
useEffect(() => {
|
||||||
const handlePopState = () => setDetailView(parseEtcDetailView(window.location.search));
|
const handlePopState = () => setDetailContext(parseEtcDrillContext(window.location.search));
|
||||||
window.addEventListener('popstate', handlePopState);
|
window.addEventListener('popstate', handlePopState);
|
||||||
return () => window.removeEventListener('popstate', handlePopState);
|
return () => window.removeEventListener('popstate', handlePopState);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -112,7 +130,7 @@ export default function ETCView() {
|
|||||||
</SurfaceCard>
|
</SurfaceCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ETCDetails view={detailView} onViewChange={selectDetailView} />
|
<ETCDetails context={detailContext} onContextChange={commitDetailContext} />
|
||||||
|
|
||||||
{error && <ErrorState message={error} />}
|
{error && <ErrorState message={error} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,40 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
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', () => {
|
test('parses and normalizes the ETC analysis context', () => {
|
||||||
assert.equal(parseEtcDetailView('?etcView=bills'), 'bills');
|
assert.deepEqual(
|
||||||
assert.equal(parseEtcDetailView('?etcView=unknown'), 'records');
|
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', () => {
|
test('rejects invalid ETC URL state', () => {
|
||||||
assert.equal(buildEtcDetailUrl({
|
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',
|
pathname: '/energy',
|
||||||
search: '?electricScope=all&etcView=records',
|
search: '?electricScope=all&etcView=records',
|
||||||
hash: '#etc',
|
hash: '#etc',
|
||||||
}, 'bills'), '/energy?electricScope=all&etcView=bills#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',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,18 +1,63 @@
|
|||||||
export type EtcDetailView = 'records' | 'bills';
|
export type EtcDetailView = 'records' | 'bills';
|
||||||
|
|
||||||
|
export interface EtcDrillContext {
|
||||||
|
view: EtcDetailView;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
search: string;
|
||||||
|
page: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface LocationParts {
|
interface LocationParts {
|
||||||
pathname: string;
|
pathname: string;
|
||||||
search: string;
|
search: string;
|
||||||
hash: string;
|
hash: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseEtcDetailView(search: string): EtcDetailView {
|
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
return new URLSearchParams(search).get('etcView') === 'bills' ? 'bills' : 'records';
|
|
||||||
|
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);
|
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();
|
const query = params.toString();
|
||||||
return `${location.pathname}${query ? `?${query}` : ''}${location.hash}`;
|
return `${location.pathname}${query ? `?${query}` : ''}${location.hash}`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user