feat(platform): persist history filters in route

This commit is contained in:
lingniu
2026-07-04 06:54:29 +08:00
parent b9534af233
commit 266b20b97a
4 changed files with 153 additions and 12 deletions

View File

@@ -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;

View File

@@ -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(/^#\/?/, '');

View File

@@ -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"

View File

@@ -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) => {