feat(platform): persist quality filters in route

This commit is contained in:
lingniu
2026-07-04 04:45:52 +08:00
parent b53cfbabfe
commit 753faca269
5 changed files with 120 additions and 7 deletions

View File

@@ -20,6 +20,9 @@ export default function App() {
const [analysisVin, setAnalysisVin] = useState(initialVehicleKey);
const [activeProtocol, setActiveProtocol] = useState(initialRoute.protocol ?? '');
const [vehicleFilters, setVehicleFilters] = useState<Record<string, string>>(initialRoute.filters ?? {});
const [qualityFilters, setQualityFilters] = useState<Record<string, string>>(
initialRoute.page === 'quality' ? qualityFiltersFromRoute(initialRoute) : {}
);
const [linkIssueCount, setLinkIssueCount] = useState<number | null>(null);
const [currentVehicleStatus, setCurrentVehicleStatus] = useState<VehicleServiceStatus | undefined>();
const [currentVehicleLabel, setCurrentVehicleLabel] = useState('');
@@ -87,6 +90,9 @@ export default function App() {
if (route.page === 'vehicles') {
setVehicleFilters(route.filters ?? {});
}
if (route.page === 'quality') {
setQualityFilters(qualityFiltersFromRoute(route));
}
setActiveProtocol(route.protocol ?? '');
};
window.addEventListener('hashchange', applyHashRoute);
@@ -110,6 +116,10 @@ export default function App() {
replaceHash(page, analysisVin, activeProtocol);
return;
}
if (page === 'quality') {
replaceQualityHash(qualityFilters);
return;
}
replaceHash(page, undefined, undefined, page === 'vehicles' ? vehicleFilters : undefined);
};
@@ -124,6 +134,21 @@ export default function App() {
replaceHash('vehicles', undefined, undefined, filters);
};
const replaceQualityHash = (filters: Record<string, string> = {}) => {
const keyword = filters.keyword;
const protocol = filters.protocol;
const issueFilters: Record<string, string> = {};
if (filters.issueType) {
issueFilters.issueType = filters.issueType;
}
replaceHash('quality', keyword, protocol, issueFilters);
};
const updateQualityFilters = (filters: Record<string, string> = {}) => {
setQualityFilters(filters);
replaceQualityHash(filters);
};
const openVehicle = async (keyword: string, protocol?: string) => {
const lookupKey = keyword.trim();
const nextProtocol = protocol?.trim() ?? '';
@@ -188,7 +213,7 @@ export default function App() {
detail: <VehicleDetail vin={activeVin} protocol={activeProtocol} onOpenHistory={openHistoryForVehicle} onOpenMileage={openMileageForVehicle} onQueryChange={updateVehicleDetailQuery} />,
history: <History initialVin={analysisVin} initialProtocol={activeProtocol} 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)} />
quality: <Quality onOpenVehicle={openVehicle} onHealthLoaded={(health) => setLinkIssueCount(health.linkHealth.filter((item) => item.status !== 'ok').length)} onFiltersChange={updateQualityFilters} initialFilters={qualityFilters} />
};
return (
@@ -198,6 +223,14 @@ export default function App() {
);
}
function qualityFiltersFromRoute(route: ReturnType<typeof parseAppHash>): Record<string, string> {
return {
...(route.keyword ? { keyword: route.keyword } : {}),
...(route.protocol ? { protocol: route.protocol } : {}),
...(route.filters?.issueType ? { issueType: route.filters.issueType } : {})
};
}
function serviceStatusFromOverview(overview: VehicleServiceOverview): VehicleServiceStatus {
const sourceCount = overview.sourceCount;
const onlineSourceCount = overview.onlineSourceCount;

View File

@@ -23,6 +23,17 @@ describe('parseAppHash', () => {
});
});
test('parses quality governance filters from hash query', () => {
expect(parseAppHash('#/quality?keyword=%E7%B2%A4A&protocol=VEHICLE_SERVICE&issueType=NO_SOURCE')).toEqual({
page: 'quality',
keyword: '粤A',
protocol: 'VEHICLE_SERVICE',
filters: {
issueType: 'NO_SOURCE'
}
});
});
test('ignores unknown pages', () => {
expect(parseAppHash('#/unknown?keyword=VIN001')).toEqual({});
});
@@ -37,6 +48,10 @@ describe('buildAppHash', () => {
expect(buildAppHash({ page: 'vehicles', filters: { coverage: 'multi', serviceStatus: 'degraded' } })).toBe('#/vehicles?coverage=multi&serviceStatus=degraded');
});
test('builds shareable quality hash with filters', () => {
expect(buildAppHash({ page: 'quality', keyword: '粤A', protocol: 'VEHICLE_SERVICE', filters: { issueType: 'NO_SOURCE' } })).toBe('#/quality?keyword=%E7%B2%A4A&protocol=VEHICLE_SERVICE&issueType=NO_SOURCE');
});
test('builds page-only hash when keyword is empty', () => {
expect(buildAppHash({ page: 'quality', keyword: '' })).toBe('#/quality');
});

View File

@@ -9,7 +9,7 @@ export type AppRoute = {
filters?: Record<string, string>;
};
const filterKeys = ['coverage', 'serviceStatus', 'online', 'bindingStatus'] as const;
const filterKeys = ['coverage', 'serviceStatus', 'online', 'bindingStatus', 'issueType'] as const;
export function parseAppHash(hash: string): AppRoute {
const normalized = hash.trim().replace(/^#\/?/, '');

View File

@@ -59,10 +59,14 @@ async function copyText(value: string, label: string) {
export function Quality({
onOpenVehicle,
onHealthLoaded
onHealthLoaded,
onFiltersChange,
initialFilters = {}
}: {
onOpenVehicle: (vin: string) => void;
onHealthLoaded?: (health: OpsHealth) => void;
onFiltersChange?: (filters: Record<string, string>) => void;
initialFilters?: Record<string, string>;
}) {
const [issues, setIssues] = useState<QualityIssueRow[]>([]);
const [summary, setSummary] = useState<QualitySummary>(emptySummary);
@@ -70,7 +74,7 @@ export function Quality({
const [loadingIssues, setLoadingIssues] = useState(true);
const [loadingSummary, setLoadingSummary] = useState(true);
const [loadingHealth, setLoadingHealth] = useState(true);
const [filters, setFilters] = useState<Record<string, string>>({});
const [filters, setFilters] = useState<Record<string, string>>(initialFilters);
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 });
const primaryIssueType = summary.issueTypes[0]?.name;
@@ -108,13 +112,15 @@ export function Quality({
};
useEffect(() => {
loadSummary({});
loadIssues({}, 1, pagination.pageSize);
setFilters(initialFilters);
loadSummary(initialFilters);
loadIssues(initialFilters, 1, pagination.pageSize);
loadHealth();
}, []);
}, [JSON.stringify(initialFilters)]);
const applyFilters = (nextFilters: Record<string, string>) => {
setFilters(nextFilters);
onFiltersChange?.(nextFilters);
loadSummary(nextFilters);
loadIssues(nextFilters, 1, pagination.pageSize);
};

View File

@@ -795,6 +795,65 @@ test('drills into quality issues by issue type', async () => {
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/quality/summary?issueType=NO_SOURCE'), undefined);
});
test('applies shareable quality filters from hash', async () => {
window.history.replaceState(null, '', '/#/quality?keyword=%E7%B2%A4A&protocol=VEHICLE_SERVICE&issueType=NO_SOURCE');
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/quality/summary')) {
return {
ok: true,
json: async () => ({
data: {
issueVehicleCount: 1,
issueRecordCount: 1,
errorCount: 0,
warningCount: 1,
protocols: [{ name: 'VEHICLE_SERVICE', count: 1 }],
issueTypes: [{ name: 'NO_SOURCE', count: 1 }]
},
traceId: 'trace-test',
timestamp: 1783094400000
})
} as Response;
}
if (path.includes('/api/quality/issues')) {
return {
ok: true,
json: async () => ({
data: { items: [], total: 1, limit: 20, offset: 0 },
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/quality/issues?keyword=%E7%B2%A4A&protocol=VEHICLE_SERVICE&issueType=NO_SOURCE&limit=20&offset=0'), undefined);
});
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/quality/summary?keyword=%E7%B2%A4A&protocol=VEHICLE_SERVICE&issueType=NO_SOURCE'), undefined);
});
test('opens vehicle detail from shareable hash', async () => {
window.history.replaceState(null, '', '/#/detail?keyword=%E7%B2%A4AG18312');
vi.spyOn(globalThis, 'fetch').mockResolvedValue({