feat(platform): persist history filters in route
This commit is contained in:
@@ -26,6 +26,9 @@ export default function App() {
|
||||
const [realtimeFilters, setRealtimeFilters] = useState<Record<string, string>>(
|
||||
initialRoute.page === 'realtime' ? realtimeFiltersFromRoute(initialRoute) : {}
|
||||
);
|
||||
const [historyFilters, setHistoryFilters] = useState<Record<string, string>>(
|
||||
initialRoute.page === 'history' ? historyFiltersFromRoute(initialRoute) : {}
|
||||
);
|
||||
const [qualityFilters, setQualityFilters] = useState<Record<string, string>>(
|
||||
initialRoute.page === 'quality' ? qualityFiltersFromRoute(initialRoute) : {}
|
||||
);
|
||||
@@ -104,6 +107,9 @@ export default function App() {
|
||||
if (route.page === 'realtime') {
|
||||
setRealtimeFilters(realtimeFiltersFromRoute(route));
|
||||
}
|
||||
if (route.page === 'history') {
|
||||
setHistoryFilters(historyFiltersFromRoute(route));
|
||||
}
|
||||
if (route.page === 'quality') {
|
||||
setQualityFilters(qualityFiltersFromRoute(route));
|
||||
}
|
||||
@@ -128,6 +134,10 @@ export default function App() {
|
||||
return;
|
||||
}
|
||||
if (page === 'history' || page === 'mileage') {
|
||||
if (page === 'history') {
|
||||
replaceHistoryHash(historyFilters);
|
||||
return;
|
||||
}
|
||||
replaceHash(page, analysisVin, activeProtocol);
|
||||
return;
|
||||
}
|
||||
@@ -172,6 +182,26 @@ export default function App() {
|
||||
replaceHash('realtime', keyword, protocol, restFilters);
|
||||
};
|
||||
|
||||
const updateHistoryFilters = (filters: Record<string, unknown> = {}, tab = historyTab) => {
|
||||
const nextFilters = normalizeHistoryFilterValues(filters);
|
||||
setHistoryFilters(nextFilters);
|
||||
if (nextFilters.keyword) {
|
||||
setAnalysisVin(nextFilters.keyword);
|
||||
}
|
||||
setActiveProtocol(nextFilters.protocol ?? '');
|
||||
setHistoryTab(tab);
|
||||
replaceHistoryHash(nextFilters, tab);
|
||||
};
|
||||
|
||||
const replaceHistoryHash = (filters: Record<string, string> = {}, tab = historyTab) => {
|
||||
const { keyword, protocol, ...restFilters } = filters;
|
||||
const routeFilters = { ...restFilters };
|
||||
if (tab && tab !== 'location') {
|
||||
routeFilters.tab = tab;
|
||||
}
|
||||
replaceHash('history', keyword ?? analysisVin, protocol ?? activeProtocol, routeFilters);
|
||||
};
|
||||
|
||||
const replaceQualityHash = (filters: Record<string, string> = {}) => {
|
||||
const keyword = filters.keyword;
|
||||
const protocol = filters.protocol;
|
||||
@@ -218,6 +248,7 @@ export default function App() {
|
||||
setAnalysisVin(nextVin);
|
||||
setActiveProtocol(nextProtocol);
|
||||
setHistoryTab('location');
|
||||
setHistoryFilters({ keyword: nextVin, protocol: nextProtocol });
|
||||
setActivePage('history');
|
||||
replaceHash('history', nextVin, nextProtocol);
|
||||
};
|
||||
@@ -231,6 +262,7 @@ export default function App() {
|
||||
setAnalysisVin(nextVin);
|
||||
setActiveProtocol(nextProtocol);
|
||||
setHistoryTab('raw');
|
||||
setHistoryFilters({ keyword: nextVin, protocol: nextProtocol });
|
||||
setActivePage('history');
|
||||
replaceHash('history', nextVin, nextProtocol, { tab: 'raw' });
|
||||
};
|
||||
@@ -263,7 +295,7 @@ export default function App() {
|
||||
vehicles: <Vehicles onOpenVehicle={openVehicle} onFiltersChange={updateVehicleFilters} initialFilters={vehicleFilters} />,
|
||||
realtime: <Realtime onOpenVehicle={openVehicle} onFiltersChange={updateRealtimeFilters} initialFilters={realtimeFilters} />,
|
||||
detail: <VehicleDetail vin={activeVin} protocol={activeProtocol} onOpenHistory={openHistoryForVehicle} onOpenRaw={openRawForVehicle} onOpenMileage={openMileageForVehicle} onOpenVehicles={openVehicles} onQueryChange={updateVehicleDetailQuery} />,
|
||||
history: <History initialVin={analysisVin} initialProtocol={activeProtocol} initialTab={historyTab} onOpenVehicle={openVehicle} />,
|
||||
history: <History initialVin={analysisVin} initialProtocol={activeProtocol} initialTab={historyTab} initialFilters={historyFilters} onFiltersChange={updateHistoryFilters} onOpenVehicle={openVehicle} />,
|
||||
mileage: <Mileage initialVin={analysisVin} initialProtocol={activeProtocol} onOpenVehicle={openVehicle} />,
|
||||
quality: <Quality onOpenVehicle={openVehicle} onHealthLoaded={(health) => setLinkIssueCount(health.linkHealth.filter((item) => item.status !== 'ok').length)} onFiltersChange={updateQualityFilters} initialFilters={qualityFilters} />
|
||||
};
|
||||
@@ -300,6 +332,32 @@ function realtimeFiltersFromRoute(route: ReturnType<typeof parseAppHash>): Recor
|
||||
};
|
||||
}
|
||||
|
||||
function historyFiltersFromRoute(route: ReturnType<typeof parseAppHash>): Record<string, string> {
|
||||
return normalizeHistoryFilterValues({
|
||||
...(route.keyword ? { keyword: route.keyword } : {}),
|
||||
...(route.protocol ? { protocol: route.protocol } : {}),
|
||||
...(route.filters?.dateFrom ? { dateFrom: route.filters.dateFrom } : {}),
|
||||
...(route.filters?.dateTo ? { dateTo: route.filters.dateTo } : {}),
|
||||
...(route.filters?.includeFields ? { includeFields: route.filters.includeFields } : {}),
|
||||
...(route.filters?.fields ? { fields: route.filters.fields } : {})
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeHistoryFilterValues(filters: Record<string, unknown> = {}): Record<string, string> {
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const key of ['keyword', 'protocol', 'dateFrom', 'dateTo', 'fields'] as const) {
|
||||
const value = String(filters[key] ?? '').trim();
|
||||
if (value) {
|
||||
normalized[key] = value;
|
||||
}
|
||||
}
|
||||
const includeFields = filters.includeFields;
|
||||
if (includeFields === true || includeFields === 'true') {
|
||||
normalized.includeFields = 'true';
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function serviceStatusFromOverview(overview: VehicleServiceOverview): VehicleServiceStatus {
|
||||
const sourceCount = overview.sourceCount;
|
||||
const onlineSourceCount = overview.onlineSourceCount;
|
||||
|
||||
@@ -9,7 +9,19 @@ export type AppRoute = {
|
||||
filters?: Record<string, string>;
|
||||
};
|
||||
|
||||
const filterKeys = ['coverage', 'serviceStatus', 'online', 'bindingStatus', 'missingProtocol', 'issueType', 'tab'] as const;
|
||||
const filterKeys = [
|
||||
'coverage',
|
||||
'serviceStatus',
|
||||
'online',
|
||||
'bindingStatus',
|
||||
'missingProtocol',
|
||||
'issueType',
|
||||
'tab',
|
||||
'dateFrom',
|
||||
'dateTo',
|
||||
'includeFields',
|
||||
'fields'
|
||||
] as const;
|
||||
|
||||
export function parseAppHash(hash: string): AppRoute {
|
||||
const normalized = hash.trim().replace(/^#\/?/, '');
|
||||
|
||||
@@ -11,7 +11,7 @@ type HistoryFilters = {
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
fields?: string;
|
||||
includeFields?: boolean;
|
||||
includeFields?: boolean | string;
|
||||
};
|
||||
|
||||
const defaultFilters: HistoryFilters = {
|
||||
@@ -39,19 +39,37 @@ function normalizeHistoryTab(value?: string): HistoryTabKey {
|
||||
return value === 'raw' ? 'raw' : 'location';
|
||||
}
|
||||
|
||||
function isIncludeFieldsEnabled(value?: boolean | string) {
|
||||
return value === true || value === 'true';
|
||||
}
|
||||
|
||||
function mergeInitialFilters(initialVin: string, initialProtocol?: string, initialFilters: Record<string, string> = {}): HistoryFilters {
|
||||
return {
|
||||
...defaultFilters,
|
||||
keyword: initialVin || defaultFilters.keyword,
|
||||
protocol: initialProtocol,
|
||||
...initialFilters,
|
||||
includeFields: isIncludeFieldsEnabled(initialFilters.includeFields)
|
||||
};
|
||||
}
|
||||
|
||||
export function History({
|
||||
initialVin,
|
||||
initialProtocol,
|
||||
initialTab,
|
||||
initialFilters = {},
|
||||
onFiltersChange,
|
||||
onOpenVehicle
|
||||
}: {
|
||||
initialVin: string;
|
||||
initialProtocol?: string;
|
||||
initialTab?: string;
|
||||
initialFilters?: Record<string, string>;
|
||||
onFiltersChange?: (filters: HistoryFilters, tab?: HistoryTabKey) => void;
|
||||
onOpenVehicle: (vin: string, protocol?: string) => void;
|
||||
}) {
|
||||
const initialFilters = { ...defaultFilters, keyword: initialVin || defaultFilters.keyword, protocol: initialProtocol };
|
||||
const [filters, setFilters] = useState<HistoryFilters>(initialFilters);
|
||||
const initialHistoryFilters = mergeInitialFilters(initialVin, initialProtocol, initialFilters);
|
||||
const [filters, setFilters] = useState<HistoryFilters>(initialHistoryFilters);
|
||||
const [activeTab, setActiveTab] = useState<HistoryTabKey>(normalizeHistoryTab(initialTab));
|
||||
const [locations, setLocations] = useState<Page<HistoryLocationRow>>(defaultPage);
|
||||
const [rawFrames, setRawFrames] = useState<Page<RawFrameRow>>(defaultPage);
|
||||
@@ -67,7 +85,7 @@ export function History({
|
||||
if (nextFilters.protocol?.trim()) params.set('protocol', nextFilters.protocol.trim());
|
||||
if (nextFilters.dateFrom?.trim()) params.set('dateFrom', nextFilters.dateFrom.trim());
|
||||
if (nextFilters.dateTo?.trim()) params.set('dateTo', nextFilters.dateTo.trim());
|
||||
if (raw && nextFilters.includeFields) params.set('includeFields', 'true');
|
||||
if (raw && isIncludeFieldsEnabled(nextFilters.includeFields)) params.set('includeFields', 'true');
|
||||
if (raw && nextFilters.fields?.trim()) params.set('fields', nextFilters.fields.trim());
|
||||
return params;
|
||||
};
|
||||
@@ -78,7 +96,7 @@ export function History({
|
||||
if (nextFilters.protocol?.trim()) query.protocol = nextFilters.protocol.trim();
|
||||
if (nextFilters.dateFrom?.trim()) query.dateFrom = nextFilters.dateFrom.trim();
|
||||
if (nextFilters.dateTo?.trim()) query.dateTo = nextFilters.dateTo.trim();
|
||||
if (nextFilters.includeFields) query.includeFields = true;
|
||||
if (isIncludeFieldsEnabled(nextFilters.includeFields)) query.includeFields = true;
|
||||
const fields = splitFields(nextFilters.fields);
|
||||
if (fields.length > 0) query.fields = fields;
|
||||
return query;
|
||||
@@ -116,24 +134,32 @@ export function History({
|
||||
includeFields: Boolean(values.includeFields)
|
||||
};
|
||||
setFilters(nextFilters);
|
||||
onFiltersChange?.(nextFilters, activeTab);
|
||||
loadLocations(nextFilters, 1, locationPagination.pageSize);
|
||||
loadRawFrames(nextFilters, 1, rawPagination.pageSize);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
const nextFilters = { ...defaultFilters, keyword: initialVin || defaultFilters.keyword, protocol: initialProtocol };
|
||||
const nextFilters = mergeInitialFilters(initialVin, initialProtocol, {});
|
||||
setFilters(nextFilters);
|
||||
onFiltersChange?.(nextFilters, activeTab);
|
||||
loadLocations(nextFilters, 1, locationPagination.pageSize);
|
||||
loadRawFrames(nextFilters, 1, rawPagination.pageSize);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const nextFilters = { ...defaultFilters, keyword: initialVin || defaultFilters.keyword, protocol: initialProtocol };
|
||||
const nextFilters = mergeInitialFilters(initialVin, initialProtocol, initialFilters);
|
||||
setFilters(nextFilters);
|
||||
setActiveTab(normalizeHistoryTab(initialTab));
|
||||
loadLocations(nextFilters, 1, locationPagination.pageSize);
|
||||
loadRawFrames(nextFilters, 1, rawPagination.pageSize);
|
||||
}, [initialVin, initialProtocol, initialTab]);
|
||||
}, [initialVin, initialProtocol, initialTab, JSON.stringify(initialFilters)]);
|
||||
|
||||
const changeTab = (key: string) => {
|
||||
const nextTab = normalizeHistoryTab(String(key));
|
||||
setActiveTab(nextTab);
|
||||
onFiltersChange?.(filters, nextTab);
|
||||
};
|
||||
|
||||
const rawFieldCount = useMemo(() => Object.keys(selectedRaw?.parsedFields ?? {}).length, [selectedRaw]);
|
||||
const currentVehicleKeyword = filters.keyword?.trim() ?? '';
|
||||
@@ -156,7 +182,7 @@ export function History({
|
||||
<Typography.Text type="tertiary">历史位置和 RAW 帧按当前车辆与来源范围查询。</Typography.Text>
|
||||
</div>
|
||||
<Card bordered>
|
||||
<Form key={`${filters.keyword ?? ''}-${filters.protocol ?? ''}-${filters.includeFields ? 'fields' : 'light'}`} initValues={filters} layout="horizontal" onSubmit={(values) => submit(values)}>
|
||||
<Form key={JSON.stringify(filters)} initValues={filters} layout="horizontal" onSubmit={(values) => submit(values)}>
|
||||
<Form.Input field="keyword" label="车辆关键词" placeholder="VIN / 车牌 / 手机号" style={{ width: 260 }} />
|
||||
<Form.Select field="protocol" label="数据来源" placeholder="全部来源" style={{ width: 190 }}>
|
||||
<Select.Option value="GB32960">GB32960</Select.Option>
|
||||
@@ -182,7 +208,7 @@ export function History({
|
||||
</Form>
|
||||
</Card>
|
||||
<Card bordered style={{ marginTop: 16 }}>
|
||||
<Tabs activeKey={activeTab} onChange={(key) => setActiveTab(normalizeHistoryTab(String(key)))}>
|
||||
<Tabs activeKey={activeTab} onChange={(key) => changeTab(String(key))}>
|
||||
<Tabs.TabPane tab="位置历史" itemKey="location">
|
||||
<Table
|
||||
rowKey="deviceTime"
|
||||
|
||||
@@ -1776,6 +1776,51 @@ test('applies protocol from shareable history hash to API requests', async () =>
|
||||
expect(screen.getByText('当前来源:JT808')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('updates history hash when vehicle history filters are submitted', async () => {
|
||||
window.history.replaceState(null, '', '/#/history');
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
||||
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;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: { items: [], total: 0, limit: 10, offset: 0 },
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await screen.findByRole('heading', { name: '历史数据' });
|
||||
fireEvent.change(screen.getByPlaceholderText('VIN / 车牌 / 手机号'), { target: { value: '粤AG18312' } });
|
||||
fireEvent.click(screen.getByText('全部来源'));
|
||||
fireEvent.click(await screen.findByText('JT808'));
|
||||
fireEvent.change(screen.getByPlaceholderText('2026-07-03 00:00:00'), { target: { value: '2026-07-01 00:00:00' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('2026-07-03 23:59:59'), { target: { value: '2026-07-01 23:59:59' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'search 查询' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.hash).toBe('#/history?keyword=%E7%B2%A4AG18312&protocol=JT808&dateFrom=2026-07-01+00%3A00%3A00&dateTo=2026-07-01+23%3A59%3A59');
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/history/locations?'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateFrom=2026-07-01+00%3A00%3A00'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/history/raw-frames/query', expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.stringContaining('"dateFrom":"2026-07-01 00:00:00"')
|
||||
}));
|
||||
});
|
||||
|
||||
test('shows vehicle and source scope on mileage hash', async () => {
|
||||
window.history.replaceState(null, '', '/#/mileage?keyword=VIN-MILEAGE-001&protocol=GB32960');
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||
|
||||
Reference in New Issue
Block a user