feat(platform): persist mileage filters in route
This commit is contained in:
@@ -29,6 +29,9 @@ export default function App() {
|
|||||||
const [historyFilters, setHistoryFilters] = useState<Record<string, string>>(
|
const [historyFilters, setHistoryFilters] = useState<Record<string, string>>(
|
||||||
initialRoute.page === 'history' ? historyFiltersFromRoute(initialRoute) : {}
|
initialRoute.page === 'history' ? historyFiltersFromRoute(initialRoute) : {}
|
||||||
);
|
);
|
||||||
|
const [mileageFilters, setMileageFilters] = useState<Record<string, string>>(
|
||||||
|
initialRoute.page === 'mileage' ? mileageFiltersFromRoute(initialRoute) : {}
|
||||||
|
);
|
||||||
const [qualityFilters, setQualityFilters] = useState<Record<string, string>>(
|
const [qualityFilters, setQualityFilters] = useState<Record<string, string>>(
|
||||||
initialRoute.page === 'quality' ? qualityFiltersFromRoute(initialRoute) : {}
|
initialRoute.page === 'quality' ? qualityFiltersFromRoute(initialRoute) : {}
|
||||||
);
|
);
|
||||||
@@ -110,6 +113,9 @@ export default function App() {
|
|||||||
if (route.page === 'history') {
|
if (route.page === 'history') {
|
||||||
setHistoryFilters(historyFiltersFromRoute(route));
|
setHistoryFilters(historyFiltersFromRoute(route));
|
||||||
}
|
}
|
||||||
|
if (route.page === 'mileage') {
|
||||||
|
setMileageFilters(mileageFiltersFromRoute(route));
|
||||||
|
}
|
||||||
if (route.page === 'quality') {
|
if (route.page === 'quality') {
|
||||||
setQualityFilters(qualityFiltersFromRoute(route));
|
setQualityFilters(qualityFiltersFromRoute(route));
|
||||||
}
|
}
|
||||||
@@ -138,7 +144,7 @@ export default function App() {
|
|||||||
replaceHistoryHash(historyFilters);
|
replaceHistoryHash(historyFilters);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
replaceHash(page, analysisVin, activeProtocol);
|
replaceMileageHash(mileageFilters);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (page === 'quality') {
|
if (page === 'quality') {
|
||||||
@@ -202,6 +208,21 @@ export default function App() {
|
|||||||
replaceHash('history', keyword ?? analysisVin, protocol ?? activeProtocol, routeFilters);
|
replaceHash('history', keyword ?? analysisVin, protocol ?? activeProtocol, routeFilters);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateMileageFilters = (filters: Record<string, unknown> = {}) => {
|
||||||
|
const nextFilters = normalizeMileageFilterValues(filters);
|
||||||
|
setMileageFilters(nextFilters);
|
||||||
|
if (nextFilters.keyword) {
|
||||||
|
setAnalysisVin(nextFilters.keyword);
|
||||||
|
}
|
||||||
|
setActiveProtocol(nextFilters.protocol ?? '');
|
||||||
|
replaceMileageHash(nextFilters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceMileageHash = (filters: Record<string, string> = {}) => {
|
||||||
|
const { keyword, protocol, ...restFilters } = filters;
|
||||||
|
replaceHash('mileage', keyword ?? analysisVin, protocol ?? activeProtocol, restFilters);
|
||||||
|
};
|
||||||
|
|
||||||
const replaceQualityHash = (filters: Record<string, string> = {}) => {
|
const replaceQualityHash = (filters: Record<string, string> = {}) => {
|
||||||
const keyword = filters.keyword;
|
const keyword = filters.keyword;
|
||||||
const protocol = filters.protocol;
|
const protocol = filters.protocol;
|
||||||
@@ -275,6 +296,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
setAnalysisVin(nextVin);
|
setAnalysisVin(nextVin);
|
||||||
setActiveProtocol(nextProtocol);
|
setActiveProtocol(nextProtocol);
|
||||||
|
setMileageFilters({ keyword: nextVin, protocol: nextProtocol });
|
||||||
setActivePage('mileage');
|
setActivePage('mileage');
|
||||||
replaceHash('mileage', nextVin, nextProtocol);
|
replaceHash('mileage', nextVin, nextProtocol);
|
||||||
};
|
};
|
||||||
@@ -296,7 +318,7 @@ export default function App() {
|
|||||||
realtime: <Realtime onOpenVehicle={openVehicle} onFiltersChange={updateRealtimeFilters} initialFilters={realtimeFilters} />,
|
realtime: <Realtime onOpenVehicle={openVehicle} onFiltersChange={updateRealtimeFilters} initialFilters={realtimeFilters} />,
|
||||||
detail: <VehicleDetail vin={activeVin} protocol={activeProtocol} onOpenHistory={openHistoryForVehicle} onOpenRaw={openRawForVehicle} onOpenMileage={openMileageForVehicle} onOpenVehicles={openVehicles} onQueryChange={updateVehicleDetailQuery} />,
|
detail: <VehicleDetail vin={activeVin} protocol={activeProtocol} onOpenHistory={openHistoryForVehicle} onOpenRaw={openRawForVehicle} onOpenMileage={openMileageForVehicle} onOpenVehicles={openVehicles} onQueryChange={updateVehicleDetailQuery} />,
|
||||||
history: <History initialVin={analysisVin} initialProtocol={activeProtocol} initialTab={historyTab} initialFilters={historyFilters} onFiltersChange={updateHistoryFilters} onOpenVehicle={openVehicle} />,
|
history: <History initialVin={analysisVin} initialProtocol={activeProtocol} initialTab={historyTab} initialFilters={historyFilters} onFiltersChange={updateHistoryFilters} onOpenVehicle={openVehicle} />,
|
||||||
mileage: <Mileage initialVin={analysisVin} initialProtocol={activeProtocol} onOpenVehicle={openVehicle} />,
|
mileage: <Mileage initialVin={analysisVin} initialProtocol={activeProtocol} initialFilters={mileageFilters} onFiltersChange={updateMileageFilters} onOpenVehicle={openVehicle} />,
|
||||||
quality: <Quality onOpenVehicle={openVehicle} onHealthLoaded={(health) => setLinkIssueCount(health.linkHealth.filter((item) => item.status !== 'ok').length)} onFiltersChange={updateQualityFilters} initialFilters={qualityFilters} />
|
quality: <Quality onOpenVehicle={openVehicle} onHealthLoaded={(health) => setLinkIssueCount(health.linkHealth.filter((item) => item.status !== 'ok').length)} onFiltersChange={updateQualityFilters} initialFilters={qualityFilters} />
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -343,6 +365,26 @@ function historyFiltersFromRoute(route: ReturnType<typeof parseAppHash>): Record
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mileageFiltersFromRoute(route: ReturnType<typeof parseAppHash>): Record<string, string> {
|
||||||
|
return normalizeMileageFilterValues({
|
||||||
|
...(route.keyword ? { keyword: route.keyword } : {}),
|
||||||
|
...(route.protocol ? { protocol: route.protocol } : {}),
|
||||||
|
...(route.filters?.dateFrom ? { dateFrom: route.filters.dateFrom } : {}),
|
||||||
|
...(route.filters?.dateTo ? { dateTo: route.filters.dateTo } : {})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMileageFilterValues(filters: Record<string, unknown> = {}): Record<string, string> {
|
||||||
|
const normalized: Record<string, string> = {};
|
||||||
|
for (const key of ['keyword', 'protocol', 'dateFrom', 'dateTo'] as const) {
|
||||||
|
const value = String(filters[key] ?? '').trim();
|
||||||
|
if (value) {
|
||||||
|
normalized[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeHistoryFilterValues(filters: Record<string, unknown> = {}): Record<string, string> {
|
function normalizeHistoryFilterValues(filters: Record<string, unknown> = {}): Record<string, string> {
|
||||||
const normalized: Record<string, string> = {};
|
const normalized: Record<string, string> = {};
|
||||||
for (const key of ['keyword', 'protocol', 'dateFrom', 'dateTo', 'fields'] as const) {
|
for (const key of ['keyword', 'protocol', 'dateFrom', 'dateTo', 'fields'] as const) {
|
||||||
|
|||||||
@@ -32,12 +32,32 @@ function canOpenVehicle(vin?: string) {
|
|||||||
return Boolean(value && value !== 'unknown');
|
return Boolean(value && value !== 'unknown');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Mileage({ initialVin, initialProtocol, onOpenVehicle }: { initialVin: string; initialProtocol?: string; onOpenVehicle: (vin: string, protocol?: string) => void }) {
|
function mergeInitialFilters(initialVin: string, initialProtocol?: string, initialFilters: Record<string, string> = {}) {
|
||||||
|
return {
|
||||||
|
keyword: initialVin,
|
||||||
|
protocol: initialProtocol ?? '',
|
||||||
|
...initialFilters
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Mileage({
|
||||||
|
initialVin,
|
||||||
|
initialProtocol,
|
||||||
|
initialFilters = {},
|
||||||
|
onFiltersChange,
|
||||||
|
onOpenVehicle
|
||||||
|
}: {
|
||||||
|
initialVin: string;
|
||||||
|
initialProtocol?: string;
|
||||||
|
initialFilters?: Record<string, string>;
|
||||||
|
onFiltersChange?: (filters: Record<string, string>) => void;
|
||||||
|
onOpenVehicle: (vin: string, protocol?: string) => void;
|
||||||
|
}) {
|
||||||
const [rows, setRows] = useState<DailyMileageRow[]>([]);
|
const [rows, setRows] = useState<DailyMileageRow[]>([]);
|
||||||
const [summary, setSummary] = useState<MileageSummary>(emptySummary);
|
const [summary, setSummary] = useState<MileageSummary>(emptySummary);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [summaryLoading, setSummaryLoading] = useState(true);
|
const [summaryLoading, setSummaryLoading] = useState(true);
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({ keyword: initialVin, protocol: initialProtocol ?? '' });
|
const [filters, setFilters] = useState<Record<string, string>>(mergeInitialFilters(initialVin, initialProtocol, initialFilters));
|
||||||
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 });
|
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 });
|
||||||
const currentVehicleKeyword = filters.keyword?.trim() ?? '';
|
const currentVehicleKeyword = filters.keyword?.trim() ?? '';
|
||||||
const currentProtocol = filters.protocol?.trim() ?? '';
|
const currentProtocol = filters.protocol?.trim() ?? '';
|
||||||
@@ -62,11 +82,11 @@ export function Mileage({ initialVin, initialProtocol, onOpenVehicle }: { initia
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nextFilters = { keyword: initialVin, protocol: initialProtocol ?? '' };
|
const nextFilters = mergeInitialFilters(initialVin, initialProtocol, initialFilters);
|
||||||
setFilters(nextFilters);
|
setFilters(nextFilters);
|
||||||
loadSummary(nextFilters);
|
loadSummary(nextFilters);
|
||||||
load(nextFilters, 1, pagination.pageSize);
|
load(nextFilters, 1, pagination.pageSize);
|
||||||
}, [initialVin, initialProtocol]);
|
}, [initialVin, initialProtocol, JSON.stringify(initialFilters)]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="vp-page">
|
<div className="vp-page">
|
||||||
@@ -85,9 +105,10 @@ export function Mileage({ initialVin, initialProtocol, onOpenVehicle }: { initia
|
|||||||
<Typography.Text type="tertiary">里程统计按当前车辆与来源范围汇总。</Typography.Text>
|
<Typography.Text type="tertiary">里程统计按当前车辆与来源范围汇总。</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
<Card bordered>
|
<Card bordered>
|
||||||
<Form key={filters.keyword ?? ''} initValues={filters} layout="horizontal" onSubmit={(values) => {
|
<Form key={JSON.stringify(filters)} initValues={filters} layout="horizontal" onSubmit={(values) => {
|
||||||
const nextFilters = values as Record<string, string>;
|
const nextFilters = values as Record<string, string>;
|
||||||
setFilters(nextFilters);
|
setFilters(nextFilters);
|
||||||
|
onFiltersChange?.(nextFilters);
|
||||||
loadSummary(nextFilters);
|
loadSummary(nextFilters);
|
||||||
load(nextFilters, 1, pagination.pageSize);
|
load(nextFilters, 1, pagination.pageSize);
|
||||||
}}>
|
}}>
|
||||||
@@ -102,8 +123,9 @@ export function Mileage({ initialVin, initialProtocol, onOpenVehicle }: { initia
|
|||||||
<Space>
|
<Space>
|
||||||
<Button htmlType="submit" theme="solid" type="primary">查询</Button>
|
<Button htmlType="submit" theme="solid" type="primary">查询</Button>
|
||||||
<Button onClick={() => {
|
<Button onClick={() => {
|
||||||
const nextFilters = { keyword: initialVin, protocol: initialProtocol ?? '' };
|
const nextFilters = mergeInitialFilters(initialVin, initialProtocol, {});
|
||||||
setFilters(nextFilters);
|
setFilters(nextFilters);
|
||||||
|
onFiltersChange?.(nextFilters);
|
||||||
loadSummary(nextFilters);
|
loadSummary(nextFilters);
|
||||||
load(nextFilters, 1, pagination.pageSize);
|
load(nextFilters, 1, pagination.pageSize);
|
||||||
}}>重置</Button>
|
}}>重置</Button>
|
||||||
|
|||||||
@@ -1861,6 +1861,103 @@ test('shows vehicle and source scope on mileage hash', async () => {
|
|||||||
expect(screen.getByText('当前来源:GB32960')).toBeInTheDocument();
|
expect(screen.getByText('当前来源:GB32960')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('applies mileage date range from shareable hash to API requests', async () => {
|
||||||
|
window.history.replaceState(null, '', '/#/mileage?keyword=VIN-MILEAGE-002&protocol=JT808&dateFrom=2026-07-01&dateTo=2026-07-03');
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
|
const path = String(input);
|
||||||
|
if (path.includes('/api/ops/health')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: { linkHealth: [], kafkaLag: 0, redisOnlineKeys: 0, tdengineWritable: true, mysqlWritable: true, runtime: { requestTimeoutMs: 5000 } },
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
if (path.includes('/api/mileage/summary')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: { vehicleCount: 1, recordCount: 2, sourceCount: 1, totalMileageKm: 12.3, averageMileagePerVin: 12.3 },
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: { items: [], total: 0, limit: 20, offset: 0 },
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response;
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/mileage/summary?'), undefined);
|
||||||
|
});
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateFrom=2026-07-01'), undefined);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateTo=2026-07-03'), undefined);
|
||||||
|
expect(screen.getByText('当前车辆:VIN-MILEAGE-002')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('当前来源:JT808')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updates mileage hash when mileage filters are submitted', async () => {
|
||||||
|
window.history.replaceState(null, '', '/#/mileage');
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
|
const path = String(input);
|
||||||
|
if (path.includes('/api/ops/health')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: { linkHealth: [], kafkaLag: 0, redisOnlineKeys: 0, tdengineWritable: true, mysqlWritable: true, runtime: { requestTimeoutMs: 5000 } },
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
if (path.includes('/api/mileage/summary')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: { vehicleCount: 1, recordCount: 2, sourceCount: 1, totalMileageKm: 12.3, averageMileagePerVin: 12.3 },
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: { items: [], total: 0, limit: 20, offset: 0 },
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response;
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await screen.findByRole('heading', { name: '里程统计' });
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('VIN / 车牌 / 手机号 / OEM'), { target: { value: '粤AG18312' } });
|
||||||
|
fireEvent.click(screen.getByText('全部来源'));
|
||||||
|
fireEvent.click(await screen.findByText('JT808'));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('2026-07-01'), { target: { value: '2026-07-01' } });
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('2026-07-03'), { target: { value: '2026-07-03' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(window.location.hash).toBe('#/mileage?keyword=%E7%B2%A4AG18312&protocol=JT808&dateFrom=2026-07-01&dateTo=2026-07-03');
|
||||||
|
});
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/mileage/summary?'), undefined);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateFrom=2026-07-01'), undefined);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateTo=2026-07-03'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
test('opens current vehicle service from mileage header with current source evidence', async () => {
|
test('opens current vehicle service from mileage header with current source evidence', async () => {
|
||||||
window.history.replaceState(null, '', '/#/mileage?keyword=VIN-MILEAGE-HEADER&protocol=GB32960');
|
window.history.replaceState(null, '', '/#/mileage?keyword=VIN-MILEAGE-HEADER&protocol=GB32960');
|
||||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user