feat(platform): open quality governance from vehicle detail

This commit is contained in:
lingniu
2026-07-04 08:07:47 +08:00
parent 5fa7858662
commit 7a3f007340
3 changed files with 98 additions and 3 deletions

View File

@@ -238,6 +238,12 @@ export default function App() {
replaceQualityHash(filters); replaceQualityHash(filters);
}; };
const openQuality = (filters: Record<string, string> = {}) => {
setQualityFilters(filters);
setActivePage('quality');
replaceQualityHash(filters);
};
const openVehicle = async (keyword: string, protocol?: string) => { const openVehicle = async (keyword: string, protocol?: string) => {
const lookupKey = keyword.trim(); const lookupKey = keyword.trim();
const nextProtocol = protocol?.trim() ?? ''; const nextProtocol = protocol?.trim() ?? '';
@@ -327,10 +333,10 @@ export default function App() {
}; };
const pages: Record<PageKey, JSX.Element> = { const pages: Record<PageKey, JSX.Element> = {
dashboard: <Dashboard onOpenVehicle={openVehicle} onOpenQuality={() => navigatePage('quality')} onOpenVehicles={openVehicles} />, dashboard: <Dashboard onOpenVehicle={openVehicle} onOpenQuality={() => openQuality(qualityFilters)} onOpenVehicles={openVehicles} />,
vehicles: <Vehicles onOpenVehicle={openVehicle} onFiltersChange={updateVehicleFilters} initialFilters={vehicleFilters} />, vehicles: <Vehicles onOpenVehicle={openVehicle} onFiltersChange={updateVehicleFilters} initialFilters={vehicleFilters} />,
realtime: <Realtime onOpenVehicle={openVehicle} onFiltersChange={updateRealtimeFilters} initialFilters={realtimeFilters} />, realtime: <Realtime onOpenVehicle={openVehicle} onFiltersChange={updateRealtimeFilters} initialFilters={realtimeFilters} />,
detail: <VehicleDetail vin={activeVin} protocol={activeProtocol} onOpenRealtime={openRealtimeForVehicle} onOpenHistory={openHistoryForVehicle} onOpenRaw={openRawForVehicle} onOpenMileage={openMileageForVehicle} onOpenVehicles={openVehicles} onQueryChange={updateVehicleDetailQuery} />, detail: <VehicleDetail vin={activeVin} protocol={activeProtocol} onOpenRealtime={openRealtimeForVehicle} onOpenHistory={openHistoryForVehicle} onOpenRaw={openRawForVehicle} onOpenMileage={openMileageForVehicle} onOpenVehicles={openVehicles} onOpenQuality={openQuality} 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} initialFilters={mileageFilters} onFiltersChange={updateMileageFilters} 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} />

View File

@@ -73,6 +73,7 @@ export function VehicleDetail({
onOpenRaw, onOpenRaw,
onOpenMileage, onOpenMileage,
onOpenVehicles, onOpenVehicles,
onOpenQuality,
onQueryChange onQueryChange
}: { }: {
vin: string; vin: string;
@@ -82,6 +83,7 @@ export function VehicleDetail({
onOpenRaw: (vin: string, protocol?: string) => void; onOpenRaw: (vin: string, protocol?: string) => void;
onOpenMileage: (vin: string, protocol?: string) => void; onOpenMileage: (vin: string, protocol?: string) => void;
onOpenVehicles?: (filters?: Record<string, string>) => void; onOpenVehicles?: (filters?: Record<string, string>) => void;
onOpenQuality?: (filters?: Record<string, string>) => void;
onQueryChange?: (keyword: string, protocol?: string) => void; onQueryChange?: (keyword: string, protocol?: string) => void;
}) { }) {
const [query, setQuery] = useState<VehicleQuery>({ keyword: vin, protocol }); const [query, setQuery] = useState<VehicleQuery>({ keyword: vin, protocol });
@@ -193,6 +195,25 @@ export function VehicleDetail({
onQueryChange?.(nextQuery.keyword, nextQuery.protocol); onQueryChange?.(nextQuery.keyword, nextQuery.protocol);
load(nextQuery); load(nextQuery);
}; };
const qualityFiltersForCurrentVehicle = () => ({
keyword: resolvedVIN,
...(activeProtocol ? { protocol: activeProtocol } : {})
});
const qualityIssueAction = (count: number) => {
if (count <= 0) {
return <Tag color="green"></Tag>;
}
return (
<Button
size="small"
theme="light"
type="warning"
onClick={() => onOpenQuality?.(qualityFiltersForCurrentVehicle())}
>
{count}
</Button>
);
};
const qualityTable = ( const qualityTable = (
<Table <Table
loading={loading} loading={loading}
@@ -267,7 +288,7 @@ export function VehicleDetail({
{ key: '最后上报', value: overview.lastSeen || '-' }, { key: '最后上报', value: overview.lastSeen || '-' },
{ key: '数据规模', value: `历史 ${overview.historyCount} / RAW ${overview.rawCount} / 里程 ${overview.mileageCount}` }, { key: '数据规模', value: `历史 ${overview.historyCount} / RAW ${overview.rawCount} / 里程 ${overview.mileageCount}` },
{ key: '实时来源', value: overview.realtimeCount }, { key: '实时来源', value: overview.realtimeCount },
{ key: '质量问题', value: <Tag color={overview.qualityIssueCount > 0 ? 'orange' : 'green'}>{overview.qualityIssueCount}</Tag> } { key: '质量问题', value: qualityIssueAction(overview.qualityIssueCount) }
]} ]}
/> />
</Card> </Card>

View File

@@ -3537,6 +3537,74 @@ test('shows unified service overview on vehicle detail', async () => {
expect(screen.getByText('历史 12 / RAW 34 / 里程 7')).toBeInTheDocument(); expect(screen.getByText('历史 12 / RAW 34 / 里程 7')).toBeInTheDocument();
}); });
test('opens quality governance from vehicle detail overview', async () => {
window.history.replaceState(null, '', '/#/detail?keyword=VIN001');
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const path = String(input);
if (path.includes('/api/vehicle-service')) {
return {
ok: true,
json: async () => ({
data: {
vin: 'VIN001',
lookupKey: 'VIN001',
lookupResolved: true,
serviceOverview: {
vin: 'VIN001',
plate: '粤AG18312',
phone: '13307795425',
oem: 'G7s',
protocols: ['GB32960', 'JT808'],
primaryProtocol: 'GB32960',
coverageStatus: 'partial',
sourceCount: 2,
onlineSourceCount: 1,
lastSeen: '2026-07-03 20:12:10',
realtimeCount: 2,
historyCount: 12,
rawCount: 34,
mileageCount: 7,
qualityIssueCount: 3
},
sources: ['GB32960', 'JT808'],
sourceStatus: [],
realtime: [],
history: { items: [], total: 12, limit: 20, offset: 0 },
raw: { items: [], total: 34, limit: 10, offset: 0 },
mileage: { items: [], total: 7, limit: 20, offset: 0 },
quality: { items: [], total: 3, limit: 20, offset: 0 }
},
traceId: 'trace-test',
timestamp: 1783094400000
})
} as Response;
}
return {
ok: true,
json: async () => ({
data: path.includes('/api/ops/health')
? { linkHealth: [], kafkaLag: 0, redisOnlineKeys: 0, tdengineWritable: true, mysqlWritable: true, runtime: { requestTimeoutMs: 5000 } }
: path.includes('/api/quality/summary')
? { issueVehicleCount: 1, issueRecordCount: 3, errorCount: 0, warningCount: 3, protocols: [], issueTypes: [] }
: { items: [], total: 3, limit: 20, offset: 0 },
traceId: 'trace-test',
timestamp: 1783094400000
})
} as Response;
});
render(<App />);
expect(await screen.findByText('车辆服务概览')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '查看质量问题 3 项' }));
await waitFor(() => {
expect(window.location.hash).toBe('#/quality?keyword=VIN001');
});
expect(await screen.findByRole('heading', { name: '质量治理' })).toBeInTheDocument();
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/quality/issues?keyword=VIN001&limit=20&offset=0'), undefined);
});
test('shows vehicle service evidence chain before source details', async () => { test('shows vehicle service evidence chain before source details', async () => {
window.history.replaceState(null, '', '/#/detail?keyword=VIN001'); window.history.replaceState(null, '', '/#/detail?keyword=VIN001');
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {