fix(web): harden monitor protocol list

This commit is contained in:
lingniu
2026-07-20 10:54:10 +08:00
parent ddbba18431
commit 9d9185790f
3 changed files with 103 additions and 33 deletions

View File

@@ -37,6 +37,22 @@ const monitorSummaryFixture = vi.hoisted(() => ({
const vehicleCardFixture = vi.hoisted(() => ({ detail: undefined as unknown }));
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
function mockMobileViewport() {
const addEventListener = vi.fn();
const removeEventListener = vi.fn();
vi.spyOn(window, 'matchMedia').mockReturnValue({
matches: true,
media: '(max-width: 760px)',
onchange: null,
addEventListener,
removeEventListener,
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(() => false)
} as unknown as MediaQueryList);
return { addEventListener, removeEventListener };
}
vi.mock('../map/FleetMap', () => ({
FleetMap: ({ selectedVin, onSelectVin, initialViewport }: { selectedVin?: string; onSelectVin?: (vin: string) => void; initialViewport?: { zoom: number; bounds: string } }) => {
fleetMapRenderSpy(selectedVin, initialViewport);
@@ -384,6 +400,21 @@ test('keeps authorized vehicles without realtime coordinates in the list without
expect(reverseGeocode).not.toHaveBeenCalled();
});
test('keeps the desktop realtime list available when a runtime response omits protocols', async () => {
const runtimeRow = {
...vehicles[0],
protocols: null,
primaryProtocol: ''
} as unknown as VehicleRealtimeRow;
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [runtimeRow], total: 1, limit: 50, offset: 0 });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/monitor?mode=list']}><MonitorPage /></MemoryRouter></QueryClientProvider>);
expect(await screen.findByText('粤A12345')).toBeInTheDocument();
expect(screen.getByText('待识别')).toBeInTheDocument();
expect(screen.queryByText('当前模块暂时无法显示')).not.toBeInTheDocument();
});
test('pauses selected-vehicle polling in list mode and resumes it when returning to the map', () => {
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: vehicles, total: 2, limit: 50, offset: 0 });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -446,18 +477,7 @@ test('shows a retry action when on-demand QR generation fails', async () => {
});
test('mounts only the mobile list representation and removes its viewport listener', async () => {
const addEventListener = vi.fn();
const removeEventListener = vi.fn();
vi.spyOn(window, 'matchMedia').mockReturnValue({
matches: true,
media: '(max-width: 760px)',
onchange: null,
addEventListener,
removeEventListener,
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(() => false)
} as unknown as MediaQueryList);
const { addEventListener, removeEventListener } = mockMobileViewport();
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [vehicles[0]], total: 1, limit: 50, offset: 0 });
monitorSummaryFixture.frameToday = 2_861_323;
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -479,11 +499,11 @@ test('mounts only the mobile list representation and removes its viewport listen
await waitFor(() => expect(view.container.querySelectorAll('.v2-monitor-mobile-cards .v2-monitor-mobile-card.semi-card')).toHaveLength(1));
const mobileCard = view.container.querySelector('.v2-monitor-mobile-card');
expect(mobileCard).toHaveAttribute('aria-label', '粤A12345 实时数据');
expect(mobileCard?.querySelector('.v2-monitor-mobile-primary')).toHaveTextContent('当日里程18.6 km总里程1,234 km协议来源JT/T 808');
expect(mobileCard?.querySelectorAll('.v2-monitor-mobile-primary > div')).toHaveLength(3);
expect(mobileCard?.querySelector('.v2-monitor-mobile-primary')).toHaveTextContent('速度42 km/h当日里程18.6 km总里程1,234 km协议来源JT/T 808');
expect(mobileCard?.querySelectorAll('.v2-monitor-mobile-primary > div')).toHaveLength(4);
expect(mobileCard?.querySelector('.v2-monitor-mobile-location')).toHaveTextContent('113.260000, 23.130000解析位置');
expect(view.container.querySelector('.v2-monitor-table-scroll')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '在地图中定位粤A12345' })).toHaveTextContent('地图');
expect(screen.getByRole('button', { name: '在地图中定位粤A12345' })).toHaveTextContent('定位');
expect(screen.getByTitle('今日上报 2,861,323 条')).toHaveTextContent('今日上报286.1万条');
expect(view.container.querySelector('.v2-monitor-mobile-card footer')).not.toBeInTheDocument();
expect(addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
@@ -492,6 +512,27 @@ test('mounts only the mobile list representation and removes its viewport listen
expect(removeEventListener).toHaveBeenCalledWith('change', expect.any(Function));
});
test('renders missing and multiple runtime protocol sources on mobile without crashing', async () => {
mockMobileViewport();
const runtimeRows = [{
...vehicles[0],
protocols: null,
primaryProtocol: ''
}, {
...vehicles[1],
protocols: ['GB32960', 'JT808'],
primaryProtocol: 'GB32960'
}] as unknown as VehicleRealtimeRow[];
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: runtimeRows, total: 2, limit: 50, offset: 0 });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/monitor?mode=list']}><MonitorPage /></MemoryRouter></QueryClientProvider>);
await waitFor(() => expect(view.container.querySelectorAll('.v2-monitor-mobile-card')).toHaveLength(2));
expect(screen.getByText('待识别')).toBeInTheDocument();
expect(screen.getByText('2 路')).toBeInTheDocument();
expect(screen.queryByText('当前模块暂时无法显示')).not.toBeInTheDocument();
});
test('pastes, deduplicates, and submits multiple plates as one batch search', async () => {
const vehicleRealtime = vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: vehicles, total: 2, limit: 50, offset: 0 });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });

View File

@@ -89,6 +89,33 @@ function hasTodayMileage(vehicle: VehicleRealtimeRow): vehicle is VehicleRealtim
return vehicle.todayMileageAvailable === true && vehicle.todayMileageKm != null;
}
function vehicleProtocols(vehicle: VehicleRealtimeRow): string[] {
const runtimeValue = (vehicle as VehicleRealtimeRow & { protocols?: unknown }).protocols;
const normalized: string[] = [];
const seen = new Set<string>();
if (Array.isArray(runtimeValue)) {
for (const value of runtimeValue) {
if (typeof value !== 'string') continue;
const protocol = value.trim();
if (!protocol || seen.has(protocol)) continue;
seen.add(protocol);
normalized.push(protocol);
}
}
const primaryProtocol = typeof vehicle.primaryProtocol === 'string' ? vehicle.primaryProtocol.trim() : '';
if (!normalized.length && primaryProtocol) normalized.push(primaryProtocol);
return normalized;
}
function vehiclePrimaryProtocol(vehicle: VehicleRealtimeRow, sourceProtocols = vehicleProtocols(vehicle)) {
const primaryProtocol = typeof vehicle.primaryProtocol === 'string' ? vehicle.primaryProtocol.trim() : '';
return primaryProtocol || sourceProtocols[0] || '';
}
function vehicleProtocolSignature(vehicle: VehicleRealtimeRow) {
return vehicleProtocols(vehicle).join('|');
}
function formatSupportCount(value: number, compact: boolean) {
if (!compact) return formatNumber(value);
const absolute = Math.abs(value);
@@ -142,18 +169,20 @@ type MonitorMobileVehicleCardProps = {
const MonitorMobileVehicleCard = memo(function MonitorMobileVehicleCard({ row, onSelect }: MonitorMobileVehicleCardProps) {
const identity = row.plate || '未绑定车牌';
const location = hasRealtimeLocation(row);
const sourceProtocols = vehicleProtocols(row);
const primaryProtocol = vehiclePrimaryProtocol(row, sourceProtocols);
return <Card className="v2-monitor-mobile-card" bodyStyle={{ padding: 0 }} aria-label={`${identity} 实时数据`}>
<header className="v2-monitor-mobile-card-header">
<div className="v2-monitor-mobile-identity"><strong>{identity}</strong><span>{row.vin}</span></div>
<div className="v2-monitor-card-heading-actions">
<b>{hasRealtimeSpeed(row) ? formatNumber(row.speedKmh, 1) : '—'}{hasRealtimeSpeed(row) ? <small>km/h</small> : null}</b>
<Button className="v2-monitor-card-locate" size="small" theme="light" type="primary" icon={<IconMapPin />} aria-label={`在地图中定位${identity}`} onClick={() => onSelect(row.vin)}></Button>
<Button className="v2-monitor-card-locate" size="small" theme="light" type="primary" icon={<IconMapPin />} aria-label={`在地图中定位${identity}`} onClick={() => onSelect(row.vin)}></Button>
</div>
</header>
<dl className="v2-monitor-mobile-primary">
<div><dt></dt><dd className="is-today">{hasTodayMileage(row) ? `${formatNumber(row.todayMileageKm, 1)} km` : '—'}</dd></div>
<div><dt></dt><dd>{hasRealtimeMileage(row) ? `${formatNumber(row.totalMileageKm, 1)} km` : '—'}</dd></div>
<div><dt></dt><dd><span className="v2-monitor-mobile-protocol"><ProtocolTag protocol={row.primaryProtocol} compact unknownLabel="未知协议" />{row.protocols.length > 1 ? <small>{row.protocols.length} </small> : null}</span></dd></div>
<div><dt></dt><dd>{hasRealtimeSpeed(row) ? <>{formatNumber(row.speedKmh, 1)}<small> km/h</small></> : '—'}</dd></div>
<div><dt></dt><dd className="is-today">{hasTodayMileage(row) ? <>{formatNumber(row.todayMileageKm, 1)}<small> km</small></> : '—'}</dd></div>
<div><dt></dt><dd>{hasRealtimeMileage(row) ? <>{formatNumber(row.totalMileageKm, 1)}<small> km</small></> : '—'}</dd></div>
<div><dt></dt><dd><span className="v2-monitor-mobile-protocol"><ProtocolTag protocol={primaryProtocol} compact unknownLabel="待识别" />{sourceProtocols.length > 1 ? <small>{sourceProtocols.length} </small> : null}</span></dd></div>
</dl>
<div className={`v2-monitor-mobile-location${location ? '' : ' is-unavailable'}`}>
<span className="v2-monitor-mobile-coordinate"><IconMapPin aria-hidden="true" />{location ? <code>{row.longitude.toFixed(6)}, {row.latitude.toFixed(6)}</code> : <small></small>}</span>
@@ -173,7 +202,7 @@ const MonitorMobileVehicleCard = memo(function MonitorMobileVehicleCard({ row, o
&& before.mileageAvailable === after.mileageAvailable
&& before.totalMileageKm === after.totalMileageKm
&& before.primaryProtocol === after.primaryProtocol
&& before.protocols.join('|') === after.protocols.join('|')
&& vehicleProtocolSignature(before) === vehicleProtocolSignature(after)
&& before.locationAvailable === after.locationAvailable
&& before.longitude === after.longitude
&& before.latitude === after.latitude;
@@ -185,7 +214,10 @@ function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, er
{ title: '速度', dataIndex: 'speedKmh', width: 120, render: (_value: number, row: VehicleRealtimeRow) => <><strong className="v2-monitor-live-value">{hasRealtimeSpeed(row) ? formatNumber(row.speedKmh, 1) : '—'}</strong>{hasRealtimeSpeed(row) ? <small className="v2-monitor-live-unit">km/h</small> : null}</> },
{ title: '当日里程', dataIndex: 'todayMileageKm', width: 140, render: (_value: number, row: VehicleRealtimeRow) => <><strong className="v2-monitor-live-value is-today">{hasTodayMileage(row) ? formatNumber(row.todayMileageKm, 1) : '—'}</strong>{hasTodayMileage(row) ? <small className="v2-monitor-live-unit">km</small> : null}</> },
{ title: '总里程', dataIndex: 'totalMileageKm', width: 170, render: (_value: number, row: VehicleRealtimeRow) => <><strong className="v2-monitor-live-value">{hasRealtimeMileage(row) ? formatNumber(row.totalMileageKm, 1) : '—'}</strong>{hasRealtimeMileage(row) ? <small className="v2-monitor-live-unit">km</small> : null}</> },
{ title: '协议来源', dataIndex: 'primaryProtocol', width: 150, render: (_value: string, row: VehicleRealtimeRow) => <div className="v2-monitor-protocol"><ProtocolTag protocol={row.primaryProtocol} compact unknownLabel="未知协议" />{row.protocols.length > 1 ? <small>+{row.protocols.length - 1} </small> : null}</div> },
{ title: '协议来源', dataIndex: 'primaryProtocol', width: 150, render: (_value: string, row: VehicleRealtimeRow) => {
const sourceProtocols = vehicleProtocols(row);
return <div className="v2-monitor-protocol"><ProtocolTag protocol={vehiclePrimaryProtocol(row, sourceProtocols)} compact unknownLabel="待识别" />{sourceProtocols.length > 1 ? <small>+{sourceProtocols.length - 1} </small> : null}</div>;
} },
{ title: '经纬度', dataIndex: 'longitude', width: 190, render: (_value: number, row: VehicleRealtimeRow) => hasRealtimeLocation(row) ? <code className="v2-monitor-coordinate">{row.longitude.toFixed(6)}<br />{row.latitude.toFixed(6)}</code> : <span className="v2-monitor-unavailable"></span> },
{ title: '地理位置', dataIndex: 'vin', render: (_value: string, row: VehicleRealtimeRow) => <MonitorAddressCell vehicle={row} /> }
], [onSelect]);

View File

@@ -26869,17 +26869,8 @@
gap: 6px !important;
}
.v2-monitor-card-heading-actions > b {
font-size: 15px;
font-weight: 740;
}
.v2-monitor-card-heading-actions > b small {
font-size: 9px;
}
.v2-monitor-card-heading-actions > .v2-monitor-card-locate.semi-button {
width: 46px;
width: 52px;
height: 28px;
min-height: 28px;
border-radius: 7px;
@@ -26889,7 +26880,7 @@
.v2-monitor-mobile-cards .v2-monitor-mobile-primary {
display: grid;
grid-template-columns: .9fr 1.08fr 1.12fr;
grid-template-columns: .72fr 1fr 1.08fr 1.2fr;
gap: 0;
margin: 0;
border-top: 1px solid #edf1f5;
@@ -26935,6 +26926,12 @@
white-space: nowrap;
}
.v2-monitor-mobile-cards .v2-monitor-mobile-primary dd > small {
color: #8b99aa;
font-size: 8px;
font-weight: 600;
}
.v2-monitor-mobile-cards .v2-monitor-mobile-primary dd.is-today {
color: #1268df;
}