fix(web): harden monitor protocol list
This commit is contained in:
@@ -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 } } });
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user