diff --git a/vehicle-data-platform/apps/web/src/App.tsx b/vehicle-data-platform/apps/web/src/App.tsx index 3a1fbfa2..2859ac83 100644 --- a/vehicle-data-platform/apps/web/src/App.tsx +++ b/vehicle-data-platform/apps/web/src/App.tsx @@ -20,6 +20,9 @@ export default function App() { const [analysisVin, setAnalysisVin] = useState(initialVehicleKey); const [activeProtocol, setActiveProtocol] = useState(initialRoute.protocol ?? ''); const [vehicleFilters, setVehicleFilters] = useState>(initialRoute.filters ?? {}); + const [qualityFilters, setQualityFilters] = useState>( + initialRoute.page === 'quality' ? qualityFiltersFromRoute(initialRoute) : {} + ); const [linkIssueCount, setLinkIssueCount] = useState(null); const [currentVehicleStatus, setCurrentVehicleStatus] = useState(); 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 = {}) => { + const keyword = filters.keyword; + const protocol = filters.protocol; + const issueFilters: Record = {}; + if (filters.issueType) { + issueFilters.issueType = filters.issueType; + } + replaceHash('quality', keyword, protocol, issueFilters); + }; + + const updateQualityFilters = (filters: Record = {}) => { + 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: , history: , mileage: , - quality: setLinkIssueCount(health.linkHealth.filter((item) => item.status !== 'ok').length)} /> + quality: 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): Record { + 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; diff --git a/vehicle-data-platform/apps/web/src/domain/appRoute.test.ts b/vehicle-data-platform/apps/web/src/domain/appRoute.test.ts index 114a6dde..5e4c3af7 100644 --- a/vehicle-data-platform/apps/web/src/domain/appRoute.test.ts +++ b/vehicle-data-platform/apps/web/src/domain/appRoute.test.ts @@ -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'); }); diff --git a/vehicle-data-platform/apps/web/src/domain/appRoute.ts b/vehicle-data-platform/apps/web/src/domain/appRoute.ts index 56716c4b..9b7d6a65 100644 --- a/vehicle-data-platform/apps/web/src/domain/appRoute.ts +++ b/vehicle-data-platform/apps/web/src/domain/appRoute.ts @@ -9,7 +9,7 @@ export type AppRoute = { filters?: Record; }; -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(/^#\/?/, ''); diff --git a/vehicle-data-platform/apps/web/src/pages/Quality.tsx b/vehicle-data-platform/apps/web/src/pages/Quality.tsx index 19aafee8..719e83c0 100644 --- a/vehicle-data-platform/apps/web/src/pages/Quality.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Quality.tsx @@ -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) => void; + initialFilters?: Record; }) { const [issues, setIssues] = useState([]); const [summary, setSummary] = useState(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>({}); + const [filters, setFilters] = useState>(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) => { setFilters(nextFilters); + onFiltersChange?.(nextFilters); loadSummary(nextFilters); loadIssues(nextFilters, 1, pagination.pageSize); }; diff --git a/vehicle-data-platform/apps/web/src/test/App.test.tsx b/vehicle-data-platform/apps/web/src/test/App.test.tsx index 2b555a15..679172f1 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -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(); + + 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({