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 [activeVin, setActiveVin] = useState(initialVehicleKey);
|
||||||
const [analysisVin, setAnalysisVin] = useState(initialVehicleKey);
|
const [analysisVin, setAnalysisVin] = useState(initialVehicleKey);
|
||||||
const [activeProtocol, setActiveProtocol] = useState(initialRoute.protocol ?? '');
|
const [activeProtocol, setActiveProtocol] = useState(initialRoute.protocol ?? '');
|
||||||
|
const [historyTab, setHistoryTab] = useState(initialRoute.filters?.tab ?? 'location');
|
||||||
const [vehicleFilters, setVehicleFilters] = useState<Record<string, string>>(initialRoute.filters ?? {});
|
const [vehicleFilters, setVehicleFilters] = useState<Record<string, string>>(initialRoute.filters ?? {});
|
||||||
const [qualityFilters, setQualityFilters] = useState<Record<string, string>>(
|
const [qualityFilters, setQualityFilters] = useState<Record<string, string>>(
|
||||||
initialRoute.page === 'quality' ? qualityFiltersFromRoute(initialRoute) : {}
|
initialRoute.page === 'quality' ? qualityFiltersFromRoute(initialRoute) : {}
|
||||||
@@ -94,6 +95,7 @@ export default function App() {
|
|||||||
setQualityFilters(qualityFiltersFromRoute(route));
|
setQualityFilters(qualityFiltersFromRoute(route));
|
||||||
}
|
}
|
||||||
setActiveProtocol(route.protocol ?? '');
|
setActiveProtocol(route.protocol ?? '');
|
||||||
|
setHistoryTab(route.filters?.tab ?? 'location');
|
||||||
};
|
};
|
||||||
window.addEventListener('hashchange', applyHashRoute);
|
window.addEventListener('hashchange', applyHashRoute);
|
||||||
return () => window.removeEventListener('hashchange', applyHashRoute);
|
return () => window.removeEventListener('hashchange', applyHashRoute);
|
||||||
@@ -179,10 +181,24 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
setAnalysisVin(nextVin);
|
setAnalysisVin(nextVin);
|
||||||
setActiveProtocol(nextProtocol);
|
setActiveProtocol(nextProtocol);
|
||||||
|
setHistoryTab('location');
|
||||||
setActivePage('history');
|
setActivePage('history');
|
||||||
replaceHash('history', nextVin, nextProtocol);
|
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 openMileageForVehicle = (vin: string, protocol?: string) => {
|
||||||
const nextVin = vin.trim();
|
const nextVin = vin.trim();
|
||||||
const nextProtocol = protocol?.trim() ?? activeProtocol;
|
const nextProtocol = protocol?.trim() ?? activeProtocol;
|
||||||
@@ -210,8 +226,8 @@ export default function App() {
|
|||||||
dashboard: <Dashboard onOpenVehicle={openVehicle} onOpenQuality={() => navigatePage('quality')} onOpenVehicles={openVehicles} />,
|
dashboard: <Dashboard onOpenVehicle={openVehicle} onOpenQuality={() => navigatePage('quality')} onOpenVehicles={openVehicles} />,
|
||||||
vehicles: <Vehicles onOpenVehicle={openVehicle} onFiltersChange={updateVehicleFilters} initialFilters={vehicleFilters} />,
|
vehicles: <Vehicles onOpenVehicle={openVehicle} onFiltersChange={updateVehicleFilters} initialFilters={vehicleFilters} />,
|
||||||
realtime: <Realtime onOpenVehicle={openVehicle} />,
|
realtime: <Realtime onOpenVehicle={openVehicle} />,
|
||||||
detail: <VehicleDetail vin={activeVin} protocol={activeProtocol} onOpenHistory={openHistoryForVehicle} onOpenMileage={openMileageForVehicle} onOpenVehicles={openVehicles} onQueryChange={updateVehicleDetailQuery} />,
|
detail: <VehicleDetail vin={activeVin} protocol={activeProtocol} onOpenHistory={openHistoryForVehicle} onOpenRaw={openRawForVehicle} onOpenMileage={openMileageForVehicle} onOpenVehicles={openVehicles} onQueryChange={updateVehicleDetailQuery} />,
|
||||||
history: <History initialVin={analysisVin} initialProtocol={activeProtocol} onOpenVehicle={openVehicle} />,
|
history: <History initialVin={analysisVin} initialProtocol={activeProtocol} initialTab={historyTab} onOpenVehicle={openVehicle} />,
|
||||||
mileage: <Mileage 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)} onFiltersChange={updateQualityFilters} initialFilters={qualityFilters} />
|
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', () => {
|
test('parses vehicle list filters from hash query', () => {
|
||||||
expect(parseAppHash('#/vehicles?coverage=multi&serviceStatus=degraded&online=online&bindingStatus=bound&missingProtocol=YUTONG_MQTT')).toEqual({
|
expect(parseAppHash('#/vehicles?coverage=multi&serviceStatus=degraded&online=online&bindingStatus=bound&missingProtocol=YUTONG_MQTT')).toEqual({
|
||||||
page: 'vehicles',
|
page: 'vehicles',
|
||||||
@@ -45,6 +56,10 @@ describe('buildAppHash', () => {
|
|||||||
expect(buildAppHash({ page: 'history', keyword: '粤AG18312', protocol: 'GB32960' })).toBe('#/history?keyword=%E7%B2%A4AG18312&protocol=GB32960');
|
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', () => {
|
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');
|
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>;
|
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 {
|
export function parseAppHash(hash: string): AppRoute {
|
||||||
const normalized = hash.trim().replace(/^#\/?/, '');
|
const normalized = hash.trim().replace(/^#\/?/, '');
|
||||||
|
|||||||
@@ -33,9 +33,26 @@ function splitFields(value?: string) {
|
|||||||
.filter(Boolean);
|
.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 initialFilters = { ...defaultFilters, keyword: initialVin || defaultFilters.keyword, protocol: initialProtocol };
|
||||||
const [filters, setFilters] = useState<HistoryFilters>(initialFilters);
|
const [filters, setFilters] = useState<HistoryFilters>(initialFilters);
|
||||||
|
const [activeTab, setActiveTab] = useState<HistoryTabKey>(normalizeHistoryTab(initialTab));
|
||||||
const [locations, setLocations] = useState<Page<HistoryLocationRow>>(defaultPage);
|
const [locations, setLocations] = useState<Page<HistoryLocationRow>>(defaultPage);
|
||||||
const [rawFrames, setRawFrames] = useState<Page<RawFrameRow>>(defaultPage);
|
const [rawFrames, setRawFrames] = useState<Page<RawFrameRow>>(defaultPage);
|
||||||
const [selectedRaw, setSelectedRaw] = useState<RawFrameRow | null>(null);
|
const [selectedRaw, setSelectedRaw] = useState<RawFrameRow | null>(null);
|
||||||
@@ -113,9 +130,10 @@ export function History({ initialVin, initialProtocol, onOpenVehicle }: { initia
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nextFilters = { ...defaultFilters, keyword: initialVin || defaultFilters.keyword, protocol: initialProtocol };
|
const nextFilters = { ...defaultFilters, keyword: initialVin || defaultFilters.keyword, protocol: initialProtocol };
|
||||||
setFilters(nextFilters);
|
setFilters(nextFilters);
|
||||||
|
setActiveTab(normalizeHistoryTab(initialTab));
|
||||||
loadLocations(nextFilters, 1, locationPagination.pageSize);
|
loadLocations(nextFilters, 1, locationPagination.pageSize);
|
||||||
loadRawFrames(nextFilters, 1, rawPagination.pageSize);
|
loadRawFrames(nextFilters, 1, rawPagination.pageSize);
|
||||||
}, [initialVin, initialProtocol]);
|
}, [initialVin, initialProtocol, initialTab]);
|
||||||
|
|
||||||
const rawFieldCount = useMemo(() => Object.keys(selectedRaw?.parsedFields ?? {}).length, [selectedRaw]);
|
const rawFieldCount = useMemo(() => Object.keys(selectedRaw?.parsedFields ?? {}).length, [selectedRaw]);
|
||||||
const currentVehicleKeyword = filters.keyword?.trim() ?? '';
|
const currentVehicleKeyword = filters.keyword?.trim() ?? '';
|
||||||
@@ -164,7 +182,7 @@ export function History({ initialVin, initialProtocol, onOpenVehicle }: { initia
|
|||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
<Card bordered style={{ marginTop: 16 }}>
|
<Card bordered style={{ marginTop: 16 }}>
|
||||||
<Tabs>
|
<Tabs activeKey={activeTab} onChange={(key) => setActiveTab(normalizeHistoryTab(String(key)))}>
|
||||||
<Tabs.TabPane tab="位置历史" itemKey="location">
|
<Tabs.TabPane tab="位置历史" itemKey="location">
|
||||||
<Table
|
<Table
|
||||||
rowKey="deviceTime"
|
rowKey="deviceTime"
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export function VehicleDetail({
|
|||||||
vin,
|
vin,
|
||||||
protocol,
|
protocol,
|
||||||
onOpenHistory,
|
onOpenHistory,
|
||||||
|
onOpenRaw,
|
||||||
onOpenMileage,
|
onOpenMileage,
|
||||||
onOpenVehicles,
|
onOpenVehicles,
|
||||||
onQueryChange
|
onQueryChange
|
||||||
@@ -75,6 +76,7 @@ export function VehicleDetail({
|
|||||||
vin: string;
|
vin: string;
|
||||||
protocol?: string;
|
protocol?: string;
|
||||||
onOpenHistory: (vin: string, protocol?: string) => void;
|
onOpenHistory: (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;
|
||||||
onQueryChange?: (keyword: string, protocol?: string) => void;
|
onQueryChange?: (keyword: string, protocol?: string) => void;
|
||||||
@@ -384,6 +386,15 @@ export function VehicleDetail({
|
|||||||
>
|
>
|
||||||
查看 {row.protocol} 历史
|
查看 {row.protocol} 历史
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
theme="light"
|
||||||
|
type="tertiary"
|
||||||
|
disabled={!row.hasRaw}
|
||||||
|
onClick={() => onOpenRaw(resolvedVIN, row.protocol)}
|
||||||
|
>
|
||||||
|
查看 {row.protocol} RAW
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
theme="light"
|
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 () => {
|
test('opens unified vehicle service from an empty history query', async () => {
|
||||||
window.history.replaceState(null, '', '/#/history?keyword=VIN404&protocol=JT808');
|
window.history.replaceState(null, '', '/#/history?keyword=VIN404&protocol=JT808');
|
||||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user