diff --git a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx
index 70e29684..f982fb69 100644
--- a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx
+++ b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx
@@ -135,7 +135,8 @@ test('starts without a selection and supports expand, collapse, reselection, and
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', '');
expect(screen.getByText('车辆总数')).toBeInTheDocument();
expect(screen.getByText('无实时位置')).toBeInTheDocument();
- expect(screen.getByRole('status', { name: '搜索和筛选条件修改后自动生效' })).toHaveTextContent('实时筛选');
+ expect(screen.getByRole('status', { name: '搜索和筛选条件修改后自动生效,已启用 0 个条件' })).toHaveTextContent('自动生效');
+ expect(screen.getByRole('button', { name: /清空/ })).toBeDisabled();
expect(screen.queryByRole('button', { name: '筛选' })).not.toBeInTheDocument();
expect(screen.queryByText('接入车辆')).not.toBeInTheDocument();
@@ -210,21 +211,38 @@ test('restores URL-backed monitor context and carries it into every vehicle work
}
});
+test('shows active filter count and clears all filter fields in one action', () => {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ render();
+
+ expect(screen.getByRole('status', { name: '搜索和筛选条件修改后自动生效,已启用 3 个条件' })).toHaveTextContent('自动生效3');
+ const clear = screen.getByRole('button', { name: /清空/ });
+ expect(clear).toBeEnabled();
+
+ fireEvent.click(clear);
+
+ expect(screen.getByRole('textbox', { name: '搜索车辆' })).toHaveValue('');
+ expect(screen.getByRole('combobox', { name: '协议' })).toHaveTextContent('全部协议');
+ expect(screen.getByRole('combobox', { name: '在线状态' })).toHaveTextContent('全部状态');
+ expect(screen.getByRole('status', { name: '搜索和筛选条件修改后自动生效,已启用 0 个条件' })).toHaveTextContent('自动生效');
+ expect(clear).toBeDisabled();
+});
+
test('switches to a lightweight realtime list and resolves addresses only on demand', async () => {
const row = vehicles[0];
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [row], total: 1, limit: 50, offset: 0 });
const reverseGeocode = vi.spyOn(api, 'reverseGeocode').mockResolvedValue({ provider: 'AMap', longitude: 113.26, latitude: 23.13, formattedAddress: '广东省广州市天河区测试路' });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render();
- const modeGroup = screen.getByRole('group', { name: '监控视图' });
- const mapMode = within(modeGroup).getByRole('button', { name: /地图/ });
- const listMode = within(modeGroup).getByRole('button', { name: /列表/ });
- expect(mapMode).toHaveAttribute('aria-pressed', 'true');
+ const modeGroup = screen.getByRole('tablist', { name: '监控视图' });
+ const mapMode = within(modeGroup).getByRole('tab', { name: /地图/ });
+ const listMode = within(modeGroup).getByRole('tab', { name: /列表/ });
+ expect(mapMode).toHaveAttribute('aria-selected', 'true');
expect(mapMode).toHaveClass('is-active');
- expect(listMode).toHaveAttribute('aria-pressed', 'false');
+ expect(listMode).toHaveAttribute('aria-selected', 'false');
fireEvent.click(listMode);
- expect(mapMode).toHaveAttribute('aria-pressed', 'false');
- expect(listMode).toHaveAttribute('aria-pressed', 'true');
+ expect(mapMode).toHaveAttribute('aria-selected', 'false');
+ expect(listMode).toHaveAttribute('aria-selected', 'true');
expect(listMode).toHaveClass('is-active');
expect(await screen.findByText('车辆实时列表')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '车辆实时列表', level: 5 })).toBeInTheDocument();
@@ -254,7 +272,7 @@ test('switches to a lightweight realtime list and resolves addresses only on dem
expect(screen.queryByText('三路正常')).not.toBeInTheDocument();
expect(screen.queryByTestId('fleet-map')).not.toBeInTheDocument();
- fireEvent.click(screen.getByRole('button', { name: /地图/ }));
+ fireEvent.click(screen.getByRole('tab', { name: /地图/ }));
await waitFor(() => expect(queryClient.getQueryCache().findAll({ queryKey: ['monitor', 'list-address'] })).toHaveLength(0));
view.unmount();
});
@@ -285,7 +303,7 @@ test('keeps authorized vehicles without realtime coordinates in the list without
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render();
- fireEvent.click(screen.getByRole('button', { name: /列表/ }));
+ fireEvent.click(screen.getByRole('tab', { name: /列表/ }));
expect(await screen.findByText('粤A无定位')).toBeInTheDocument();
expect(screen.getByText('暂无实时位置')).toBeInTheDocument();
@@ -301,10 +319,10 @@ test('pauses selected-vehicle polling in list mode and resumes it when returning
fireEvent.click(screen.getByRole('button', { name: /粤A12345 LTEST000000000001/ }));
expect(monitorDataArgsSpy).toHaveBeenLastCalledWith(expect.any(Object), expect.any(Object), 'LTEST000000000001', true, true);
- fireEvent.click(screen.getByRole('button', { name: /列表/ }));
+ fireEvent.click(screen.getByRole('tab', { name: /列表/ }));
expect(monitorDataArgsSpy).toHaveBeenLastCalledWith(expect.any(Object), expect.any(Object), '', false, false);
- fireEvent.click(screen.getByRole('button', { name: /地图/ }));
+ fireEvent.click(screen.getByRole('tab', { name: /地图/ }));
expect(monitorDataArgsSpy).toHaveBeenLastCalledWith(expect.any(Object), expect.any(Object), 'LTEST000000000001', true, true);
});
@@ -371,7 +389,7 @@ test('mounts only the mobile list representation and removes its viewport listen
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render();
- fireEvent.click(screen.getByRole('button', { name: /列表/ }));
+ fireEvent.click(screen.getByRole('tab', { name: /列表/ }));
expect(await screen.findByText('车辆实时列表')).toBeInTheDocument();
await waitFor(() => expect(view.container.querySelectorAll('.v2-monitor-mobile-cards .v2-monitor-mobile-card.semi-card')).toHaveLength(1));
expect(view.container.querySelector('.v2-monitor-table-scroll')).not.toBeInTheDocument();
@@ -391,7 +409,7 @@ test('pastes, deduplicates, and submits multiple plates as one batch search', as
expect(input).toHaveValue('粤A12345,粤B67890');
expect(screen.getAllByText('已找到 2/2')).toHaveLength(3);
- fireEvent.click(screen.getByRole('button', { name: /列表/ }));
+ fireEvent.click(screen.getByRole('tab', { name: /列表/ }));
await waitFor(() => {
const params = vehicleRealtime.mock.calls[vehicleRealtime.mock.calls.length - 1]?.[0];
expect(params?.get('keywords')).toBe('粤A12345,粤B67890');
@@ -412,7 +430,7 @@ test('opens an explicit batch editor and applies copied plate rows', async () =>
expect(screen.queryByRole('dialog', { name: '批量搜索车辆' })).not.toBeInTheDocument();
expect(screen.getByRole('textbox', { name: '搜索车辆' })).toHaveValue('粤A12345,粤B67890');
- fireEvent.click(screen.getByRole('button', { name: /列表/ }));
+ fireEvent.click(screen.getByRole('tab', { name: /列表/ }));
await waitFor(() => {
const params = vehicleRealtime.mock.calls[vehicleRealtime.mock.calls.length - 1]?.[0];
expect(params?.get('keywords')).toBe('粤A12345,粤B67890');
diff --git a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx
index ac6e7508..e96ddbf0 100644
--- a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx
+++ b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx
@@ -2,7 +2,7 @@ import {
IconChevronLeft, IconChevronRight, IconClose, IconFilter, IconList, IconMapPin,
IconQrCode, IconRefresh, IconSearch
} from '@douyinfe/semi-icons';
-import { Button, ButtonGroup, Card, Empty, Input, Modal, Select, Spin, Table, Tag, TextArea } from '@douyinfe/semi-ui';
+import { Button, Card, Empty, Input, Modal, Select, Spin, Table, Tag, TextArea } from '@douyinfe/semi-ui';
import { useQuery } from '@tanstack/react-query';
import { memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
@@ -10,6 +10,7 @@ import { api } from '../../api/client';
import type { Page, VehicleRealtimeRow } from '../../api/types';
import { FleetMap } from '../map/FleetMap';
import { EmptyState, InlineError } from '../shared/AsyncState';
+import { SegmentedTabs } from '../shared/SegmentedTabs';
import { TablePagination } from '../shared/TablePagination';
import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
@@ -23,6 +24,10 @@ const protocols = ['', 'GB32960', 'JT808', 'YUTONG_MQTT'];
const statuses = ['', 'online', 'offline', 'driving', 'idle', 'no_location'];
const EMPTY_VEHICLES: VehicleRealtimeRow[] = [];
const MOBILE_MONITOR_QUERY = '(max-width: 760px)';
+const MONITOR_VIEW_ITEMS = [
+ { key: 'map', label: '地图', icon: },
+ { key: 'list', label: '列表', icon: }
+] as const;
function BatchVehicleSearchDialog({ initialValue, onApply, onClose }: { initialValue: string; onApply: (value: string) => void; onClose: () => void }) {
const [draft, setDraft] = useState(initialValue);
@@ -407,6 +412,17 @@ export default function MonitorPage() {
const selectMapVehicle = useCallback((vehicle: VehicleRealtimeRow) => selectVehicle(vehicle.vin), [selectVehicle]);
const collapseDetail = useCallback(() => setDetailOpen(false), []);
const expandDetail = useCallback(() => setDetailOpen(true), []);
+ const changeMonitorMode = useCallback((nextMode: 'map' | 'list') => {
+ setMode(nextMode);
+ if (nextMode === 'list') setDetailOpen(false);
+ }, []);
+ const clearFilters = useCallback(() => {
+ setKeyword('');
+ setProtocol('');
+ setStatus('');
+ setListOffset(0);
+ }, []);
+ const activeFilterCount = Number(Boolean(keyword.trim())) + Number(Boolean(protocol)) + Number(Boolean(status));
const driving = rows.filter((vehicle) => vehicleStatus(vehicle) === 'driving').length;
const idle = rows.filter((vehicle) => vehicleStatus(vehicle) === 'idle').length;
const offline = rows.filter((vehicle) => vehicleStatus(vehicle) === 'offline').length;
@@ -414,35 +430,52 @@ export default function MonitorPage() {
return (
- 1 ? ' is-batch' : ''}`}>
-
}
- value={keyword}
- onChange={(value) => { setKeyword(value); setListOffset(0); }}
- onPaste={(event) => {
- const pastedTerms = parseMonitorSearchTerms(event.clipboardData.getData('text'));
- if (pastedTerms.length <= 1) return;
- event.preventDefault();
- setKeyword(pastedTerms.join(','));
- setListOffset(0);
- }}
- placeholder="车牌 / VIN;可批量粘贴车牌"
- suffix={
}
- />
- {searchTerms.length > 1 ?
{batchSearchPending ? `已识别 ${searchTerms.length} 辆` : `已找到 ${batchMatch.matched}/${searchTerms.length}`} : null}
+
+
1 ? ' is-batch' : ''}`}>
+ }
+ value={keyword}
+ onChange={(value) => { setKeyword(value); setListOffset(0); }}
+ onPaste={(event) => {
+ const pastedTerms = parseMonitorSearchTerms(event.clipboardData.getData('text'));
+ if (pastedTerms.length <= 1) return;
+ event.preventDefault();
+ setKeyword(pastedTerms.join(','));
+ setListOffset(0);
+ }}
+ placeholder="车牌 / VIN;可批量粘贴车牌"
+ suffix={}
+ />
+ {searchTerms.length > 1 ? {batchSearchPending ? `已识别 ${searchTerms.length} 辆` : `已找到 ${batchMatch.matched}/${searchTerms.length}`} : null}
+
+
协议
+
+
+
+
+ 自动生效
+ {activeFilterCount ? {activeFilterCount} : null}
+
+
+ } aria-label="打开手机端入口" onClick={() => setMobileEntryOpen(true)}>手机端
-
协议
-
{ setProtocol(String(value)); setListOffset(0); }} aria-labelledby="monitor-protocol-filter-label" optionList={protocols.map((item) => ({ value: item, label: item || '全部协议' }))} />
- 在线状态
- { setStatus(String(value)); setListOffset(0); }} aria-labelledby="monitor-status-filter-label" optionList={statuses.map((item) => ({ value: item, label: item === 'no_location' ? '无实时位置' : item ? statusLabel(item as never) : '全部状态' }))} />
- } onClick={() => { setKeyword(''); setProtocol(''); setStatus(''); setListOffset(0); }}>清空
- 实时筛选
-
- } onClick={() => setMode('map')}>地图
- } onClick={() => { setMode('list'); setDetailOpen(false); }}>列表
-
- } onClick={() => setMobileEntryOpen(true)}>手机端
{batchSearchOpen ? setBatchSearchOpen(false)} onApply={(value) => { setKeyword(value); setListOffset(0); setBatchSearchOpen(false); }} /> : null}
diff --git a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts
index f92c6c8d..8f10cc9a 100644
--- a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts
+++ b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts
@@ -347,10 +347,10 @@ describe('V2 production entry', () => {
expect(corePageSources.MonitorPage).toContain('v2-monitor-qr-modal');
expect(corePageSources.MonitorPage).not.toContain('v2-monitor-qr-backdrop');
expect(corePageSources.MonitorPage).toContain('