feat(platform): open raw evidence from vehicle detail

This commit is contained in:
lingniu
2026-07-04 06:17:27 +08:00
parent 8cd90f629b
commit b9a173f8d2
6 changed files with 141 additions and 6 deletions

View File

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

View File

@@ -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');
});

View File

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

View File

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

View File

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

View File

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