feat(platform): open raw evidence from vehicle detail
This commit is contained in:
@@ -19,6 +19,7 @@ export default function App() {
|
||||
const [activeVin, setActiveVin] = useState(initialVehicleKey);
|
||||
const [analysisVin, setAnalysisVin] = useState(initialVehicleKey);
|
||||
const [activeProtocol, setActiveProtocol] = useState(initialRoute.protocol ?? '');
|
||||
const [historyTab, setHistoryTab] = useState(initialRoute.filters?.tab ?? 'location');
|
||||
const [vehicleFilters, setVehicleFilters] = useState<Record<string, string>>(initialRoute.filters ?? {});
|
||||
const [qualityFilters, setQualityFilters] = useState<Record<string, string>>(
|
||||
initialRoute.page === 'quality' ? qualityFiltersFromRoute(initialRoute) : {}
|
||||
@@ -94,6 +95,7 @@ export default function App() {
|
||||
setQualityFilters(qualityFiltersFromRoute(route));
|
||||
}
|
||||
setActiveProtocol(route.protocol ?? '');
|
||||
setHistoryTab(route.filters?.tab ?? 'location');
|
||||
};
|
||||
window.addEventListener('hashchange', applyHashRoute);
|
||||
return () => window.removeEventListener('hashchange', applyHashRoute);
|
||||
@@ -179,10 +181,24 @@ export default function App() {
|
||||
}
|
||||
setAnalysisVin(nextVin);
|
||||
setActiveProtocol(nextProtocol);
|
||||
setHistoryTab('location');
|
||||
setActivePage('history');
|
||||
replaceHash('history', nextVin, nextProtocol);
|
||||
};
|
||||
|
||||
const openRawForVehicle = (vin: string, protocol?: string) => {
|
||||
const nextVin = vin.trim();
|
||||
const nextProtocol = protocol?.trim() ?? activeProtocol;
|
||||
if (!nextVin) {
|
||||
return;
|
||||
}
|
||||
setAnalysisVin(nextVin);
|
||||
setActiveProtocol(nextProtocol);
|
||||
setHistoryTab('raw');
|
||||
setActivePage('history');
|
||||
replaceHash('history', nextVin, nextProtocol, { tab: 'raw' });
|
||||
};
|
||||
|
||||
const openMileageForVehicle = (vin: string, protocol?: string) => {
|
||||
const nextVin = vin.trim();
|
||||
const nextProtocol = protocol?.trim() ?? activeProtocol;
|
||||
@@ -210,8 +226,8 @@ export default function App() {
|
||||
dashboard: <Dashboard onOpenVehicle={openVehicle} onOpenQuality={() => navigatePage('quality')} onOpenVehicles={openVehicles} />,
|
||||
vehicles: <Vehicles onOpenVehicle={openVehicle} onFiltersChange={updateVehicleFilters} initialFilters={vehicleFilters} />,
|
||||
realtime: <Realtime onOpenVehicle={openVehicle} />,
|
||||
detail: <VehicleDetail vin={activeVin} protocol={activeProtocol} onOpenHistory={openHistoryForVehicle} onOpenMileage={openMileageForVehicle} onOpenVehicles={openVehicles} onQueryChange={updateVehicleDetailQuery} />,
|
||||
history: <History initialVin={analysisVin} initialProtocol={activeProtocol} onOpenVehicle={openVehicle} />,
|
||||
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} />,
|
||||
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} />
|
||||
};
|
||||
|
||||
@@ -11,6 +11,17 @@ describe('parseAppHash', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('parses history tab from hash query', () => {
|
||||
expect(parseAppHash('#/history?keyword=VIN001&protocol=JT808&tab=raw')).toEqual({
|
||||
page: 'history',
|
||||
keyword: 'VIN001',
|
||||
protocol: 'JT808',
|
||||
filters: {
|
||||
tab: 'raw'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('parses vehicle list filters from hash query', () => {
|
||||
expect(parseAppHash('#/vehicles?coverage=multi&serviceStatus=degraded&online=online&bindingStatus=bound&missingProtocol=YUTONG_MQTT')).toEqual({
|
||||
page: 'vehicles',
|
||||
@@ -45,6 +56,10 @@ describe('buildAppHash', () => {
|
||||
expect(buildAppHash({ page: 'history', keyword: '粤AG18312', protocol: 'GB32960' })).toBe('#/history?keyword=%E7%B2%A4AG18312&protocol=GB32960');
|
||||
});
|
||||
|
||||
test('builds shareable raw history hash', () => {
|
||||
expect(buildAppHash({ page: 'history', keyword: 'VIN001', protocol: 'JT808', filters: { tab: 'raw' } })).toBe('#/history?keyword=VIN001&protocol=JT808&tab=raw');
|
||||
});
|
||||
|
||||
test('builds shareable vehicle list hash with filters', () => {
|
||||
expect(buildAppHash({ page: 'vehicles', filters: { coverage: 'multi', serviceStatus: 'degraded', missingProtocol: 'YUTONG_MQTT' } })).toBe('#/vehicles?coverage=multi&serviceStatus=degraded&missingProtocol=YUTONG_MQTT');
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ export type AppRoute = {
|
||||
filters?: Record<string, string>;
|
||||
};
|
||||
|
||||
const filterKeys = ['coverage', 'serviceStatus', 'online', 'bindingStatus', 'missingProtocol', 'issueType'] as const;
|
||||
const filterKeys = ['coverage', 'serviceStatus', 'online', 'bindingStatus', 'missingProtocol', 'issueType', 'tab'] as const;
|
||||
|
||||
export function parseAppHash(hash: string): AppRoute {
|
||||
const normalized = hash.trim().replace(/^#\/?/, '');
|
||||
|
||||
@@ -33,9 +33,26 @@ function splitFields(value?: string) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function History({ initialVin, initialProtocol, onOpenVehicle }: { initialVin: string; initialProtocol?: string; onOpenVehicle: (vin: string, protocol?: string) => void }) {
|
||||
type HistoryTabKey = 'location' | 'raw';
|
||||
|
||||
function normalizeHistoryTab(value?: string): HistoryTabKey {
|
||||
return value === 'raw' ? 'raw' : 'location';
|
||||
}
|
||||
|
||||
export function History({
|
||||
initialVin,
|
||||
initialProtocol,
|
||||
initialTab,
|
||||
onOpenVehicle
|
||||
}: {
|
||||
initialVin: string;
|
||||
initialProtocol?: string;
|
||||
initialTab?: string;
|
||||
onOpenVehicle: (vin: string, protocol?: string) => void;
|
||||
}) {
|
||||
const initialFilters = { ...defaultFilters, keyword: initialVin || defaultFilters.keyword, protocol: initialProtocol };
|
||||
const [filters, setFilters] = useState<HistoryFilters>(initialFilters);
|
||||
const [activeTab, setActiveTab] = useState<HistoryTabKey>(normalizeHistoryTab(initialTab));
|
||||
const [locations, setLocations] = useState<Page<HistoryLocationRow>>(defaultPage);
|
||||
const [rawFrames, setRawFrames] = useState<Page<RawFrameRow>>(defaultPage);
|
||||
const [selectedRaw, setSelectedRaw] = useState<RawFrameRow | null>(null);
|
||||
@@ -113,9 +130,10 @@ export function History({ initialVin, initialProtocol, onOpenVehicle }: { initia
|
||||
useEffect(() => {
|
||||
const nextFilters = { ...defaultFilters, keyword: initialVin || defaultFilters.keyword, protocol: initialProtocol };
|
||||
setFilters(nextFilters);
|
||||
setActiveTab(normalizeHistoryTab(initialTab));
|
||||
loadLocations(nextFilters, 1, locationPagination.pageSize);
|
||||
loadRawFrames(nextFilters, 1, rawPagination.pageSize);
|
||||
}, [initialVin, initialProtocol]);
|
||||
}, [initialVin, initialProtocol, initialTab]);
|
||||
|
||||
const rawFieldCount = useMemo(() => Object.keys(selectedRaw?.parsedFields ?? {}).length, [selectedRaw]);
|
||||
const currentVehicleKeyword = filters.keyword?.trim() ?? '';
|
||||
@@ -164,7 +182,7 @@ export function History({ initialVin, initialProtocol, onOpenVehicle }: { initia
|
||||
</Form>
|
||||
</Card>
|
||||
<Card bordered style={{ marginTop: 16 }}>
|
||||
<Tabs>
|
||||
<Tabs activeKey={activeTab} onChange={(key) => setActiveTab(normalizeHistoryTab(String(key)))}>
|
||||
<Tabs.TabPane tab="位置历史" itemKey="location">
|
||||
<Table
|
||||
rowKey="deviceTime"
|
||||
|
||||
@@ -68,6 +68,7 @@ export function VehicleDetail({
|
||||
vin,
|
||||
protocol,
|
||||
onOpenHistory,
|
||||
onOpenRaw,
|
||||
onOpenMileage,
|
||||
onOpenVehicles,
|
||||
onQueryChange
|
||||
@@ -75,6 +76,7 @@ export function VehicleDetail({
|
||||
vin: string;
|
||||
protocol?: string;
|
||||
onOpenHistory: (vin: string, protocol?: string) => void;
|
||||
onOpenRaw: (vin: string, protocol?: string) => void;
|
||||
onOpenMileage: (vin: string, protocol?: string) => void;
|
||||
onOpenVehicles?: (filters?: Record<string, string>) => void;
|
||||
onQueryChange?: (keyword: string, protocol?: string) => void;
|
||||
@@ -384,6 +386,15 @@ export function VehicleDetail({
|
||||
>
|
||||
查看 {row.protocol} 历史
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
theme="light"
|
||||
type="tertiary"
|
||||
disabled={!row.hasRaw}
|
||||
onClick={() => onOpenRaw(resolvedVIN, row.protocol)}
|
||||
>
|
||||
查看 {row.protocol} RAW
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
theme="light"
|
||||
|
||||
@@ -2440,6 +2440,81 @@ test('opens source-specific history directly from vehicle detail source card', a
|
||||
});
|
||||
});
|
||||
|
||||
test('opens source-specific raw history directly from vehicle detail source evidence', async () => {
|
||||
window.history.replaceState(null, '', '/#/detail?keyword=VIN001');
|
||||
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,
|
||||
resolution: {
|
||||
lookupKey: 'VIN001',
|
||||
resolved: true,
|
||||
vin: 'VIN001',
|
||||
plate: '粤AG18312',
|
||||
phone: '13307795425',
|
||||
oem: 'G7s',
|
||||
protocols: ['GB32960', 'JT808'],
|
||||
online: true,
|
||||
lastSeen: '2026-07-03 20:12:10'
|
||||
},
|
||||
realtimeSummary: {
|
||||
vin: 'VIN001',
|
||||
plate: '粤AG18312',
|
||||
phone: '13307795425',
|
||||
oem: 'G7s',
|
||||
protocols: ['GB32960', 'JT808'],
|
||||
sourceCount: 2,
|
||||
onlineSourceCount: 2,
|
||||
online: true,
|
||||
primaryProtocol: 'GB32960',
|
||||
longitude: 113.2,
|
||||
latitude: 23.1,
|
||||
speedKmh: 30,
|
||||
socPercent: 78,
|
||||
totalMileageKm: 100,
|
||||
lastSeen: '2026-07-03 20:12:10'
|
||||
},
|
||||
sources: ['GB32960', 'JT808'],
|
||||
sourceStatus: [
|
||||
{ protocol: 'GB32960', online: true, lastSeen: '2026-07-03 20:12:10', hasRealtime: true, hasHistory: true, hasRaw: true, hasMileage: true },
|
||||
{ protocol: 'JT808', online: true, lastSeen: '2026-07-03 20:12:08', hasRealtime: true, hasHistory: true, hasRaw: true, hasMileage: true }
|
||||
],
|
||||
realtime: [],
|
||||
history: { items: [], total: 0, limit: 20, offset: 0 },
|
||||
raw: { items: [], total: 0, limit: 10, offset: 0 },
|
||||
mileage: { items: [], total: 0, limit: 20, offset: 0 },
|
||||
quality: { items: [], total: 0, limit: 20, offset: 0 }
|
||||
},
|
||||
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 />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '查看 JT808 RAW' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.hash).toBe('#/history?keyword=VIN001&protocol=JT808&tab=raw');
|
||||
});
|
||||
});
|
||||
|
||||
test('opens unified vehicle service from an empty history query', async () => {
|
||||
window.history.replaceState(null, '', '/#/history?keyword=VIN404&protocol=JT808');
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||
|
||||
Reference in New Issue
Block a user