refine Semi UI monitor workspace
This commit is contained in:
@@ -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.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', '');
|
||||||
expect(screen.getByText('车辆总数')).toBeInTheDocument();
|
expect(screen.getByText('车辆总数')).toBeInTheDocument();
|
||||||
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.queryByRole('button', { name: '筛选' })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('接入车辆')).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(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/monitor?keyword=%E7%B2%A4A12345&protocol=JT808&status=online']}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||||
|
|
||||||
|
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 () => {
|
test('switches to a lightweight realtime list and resolves addresses only on demand', async () => {
|
||||||
const row = vehicles[0];
|
const row = vehicles[0];
|
||||||
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [row], total: 1, limit: 50, offset: 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 reverseGeocode = vi.spyOn(api, 'reverseGeocode').mockResolvedValue({ provider: 'AMap', longitude: 113.26, latitude: 23.13, formattedAddress: '广东省广州市天河区测试路' });
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
const view = render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
const view = render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||||
const modeGroup = screen.getByRole('group', { name: '监控视图' });
|
const modeGroup = screen.getByRole('tablist', { name: '监控视图' });
|
||||||
const mapMode = within(modeGroup).getByRole('button', { name: /地图/ });
|
const mapMode = within(modeGroup).getByRole('tab', { name: /地图/ });
|
||||||
const listMode = within(modeGroup).getByRole('button', { name: /列表/ });
|
const listMode = within(modeGroup).getByRole('tab', { name: /列表/ });
|
||||||
expect(mapMode).toHaveAttribute('aria-pressed', 'true');
|
expect(mapMode).toHaveAttribute('aria-selected', 'true');
|
||||||
expect(mapMode).toHaveClass('is-active');
|
expect(mapMode).toHaveClass('is-active');
|
||||||
expect(listMode).toHaveAttribute('aria-pressed', 'false');
|
expect(listMode).toHaveAttribute('aria-selected', 'false');
|
||||||
fireEvent.click(listMode);
|
fireEvent.click(listMode);
|
||||||
expect(mapMode).toHaveAttribute('aria-pressed', 'false');
|
expect(mapMode).toHaveAttribute('aria-selected', 'false');
|
||||||
expect(listMode).toHaveAttribute('aria-pressed', 'true');
|
expect(listMode).toHaveAttribute('aria-selected', 'true');
|
||||||
expect(listMode).toHaveClass('is-active');
|
expect(listMode).toHaveClass('is-active');
|
||||||
expect(await screen.findByText('车辆实时列表')).toBeInTheDocument();
|
expect(await screen.findByText('车辆实时列表')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('heading', { name: '车辆实时列表', level: 5 })).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.queryByText('三路正常')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByTestId('fleet-map')).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));
|
await waitFor(() => expect(queryClient.getQueryCache().findAll({ queryKey: ['monitor', 'list-address'] })).toHaveLength(0));
|
||||||
view.unmount();
|
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 } } });
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /列表/ }));
|
fireEvent.click(screen.getByRole('tab', { name: /列表/ }));
|
||||||
|
|
||||||
expect(await screen.findByText('粤A无定位')).toBeInTheDocument();
|
expect(await screen.findByText('粤A无定位')).toBeInTheDocument();
|
||||||
expect(screen.getByText('暂无实时位置')).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/ }));
|
fireEvent.click(screen.getByRole('button', { name: /粤A12345 LTEST000000000001/ }));
|
||||||
expect(monitorDataArgsSpy).toHaveBeenLastCalledWith(expect.any(Object), expect.any(Object), 'LTEST000000000001', true, true);
|
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);
|
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);
|
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 queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
const view = render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
const view = render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /列表/ }));
|
fireEvent.click(screen.getByRole('tab', { name: /列表/ }));
|
||||||
expect(await screen.findByText('车辆实时列表')).toBeInTheDocument();
|
expect(await screen.findByText('车辆实时列表')).toBeInTheDocument();
|
||||||
await waitFor(() => expect(view.container.querySelectorAll('.v2-monitor-mobile-cards .v2-monitor-mobile-card.semi-card')).toHaveLength(1));
|
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();
|
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(input).toHaveValue('粤A12345,粤B67890');
|
||||||
expect(screen.getAllByText('已找到 2/2')).toHaveLength(3);
|
expect(screen.getAllByText('已找到 2/2')).toHaveLength(3);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /列表/ }));
|
fireEvent.click(screen.getByRole('tab', { name: /列表/ }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const params = vehicleRealtime.mock.calls[vehicleRealtime.mock.calls.length - 1]?.[0];
|
const params = vehicleRealtime.mock.calls[vehicleRealtime.mock.calls.length - 1]?.[0];
|
||||||
expect(params?.get('keywords')).toBe('粤A12345,粤B67890');
|
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.queryByRole('dialog', { name: '批量搜索车辆' })).not.toBeInTheDocument();
|
||||||
expect(screen.getByRole('textbox', { name: '搜索车辆' })).toHaveValue('粤A12345,粤B67890');
|
expect(screen.getByRole('textbox', { name: '搜索车辆' })).toHaveValue('粤A12345,粤B67890');
|
||||||
fireEvent.click(screen.getByRole('button', { name: /列表/ }));
|
fireEvent.click(screen.getByRole('tab', { name: /列表/ }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const params = vehicleRealtime.mock.calls[vehicleRealtime.mock.calls.length - 1]?.[0];
|
const params = vehicleRealtime.mock.calls[vehicleRealtime.mock.calls.length - 1]?.[0];
|
||||||
expect(params?.get('keywords')).toBe('粤A12345,粤B67890');
|
expect(params?.get('keywords')).toBe('粤A12345,粤B67890');
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {
|
|||||||
IconChevronLeft, IconChevronRight, IconClose, IconFilter, IconList, IconMapPin,
|
IconChevronLeft, IconChevronRight, IconClose, IconFilter, IconList, IconMapPin,
|
||||||
IconQrCode, IconRefresh, IconSearch
|
IconQrCode, IconRefresh, IconSearch
|
||||||
} from '@douyinfe/semi-icons';
|
} 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 { useQuery } from '@tanstack/react-query';
|
||||||
import { memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import { memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Link, useSearchParams } from 'react-router-dom';
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
@@ -10,6 +10,7 @@ import { api } from '../../api/client';
|
|||||||
import type { Page, VehicleRealtimeRow } from '../../api/types';
|
import type { Page, VehicleRealtimeRow } from '../../api/types';
|
||||||
import { FleetMap } from '../map/FleetMap';
|
import { FleetMap } from '../map/FleetMap';
|
||||||
import { EmptyState, InlineError } from '../shared/AsyncState';
|
import { EmptyState, InlineError } from '../shared/AsyncState';
|
||||||
|
import { SegmentedTabs } from '../shared/SegmentedTabs';
|
||||||
import { TablePagination } from '../shared/TablePagination';
|
import { TablePagination } from '../shared/TablePagination';
|
||||||
import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel';
|
import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel';
|
||||||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||||
@@ -23,6 +24,10 @@ const protocols = ['', 'GB32960', 'JT808', 'YUTONG_MQTT'];
|
|||||||
const statuses = ['', 'online', 'offline', 'driving', 'idle', 'no_location'];
|
const statuses = ['', 'online', 'offline', 'driving', 'idle', 'no_location'];
|
||||||
const EMPTY_VEHICLES: VehicleRealtimeRow[] = [];
|
const EMPTY_VEHICLES: VehicleRealtimeRow[] = [];
|
||||||
const MOBILE_MONITOR_QUERY = '(max-width: 760px)';
|
const MOBILE_MONITOR_QUERY = '(max-width: 760px)';
|
||||||
|
const MONITOR_VIEW_ITEMS = [
|
||||||
|
{ key: 'map', label: '地图', icon: <IconMapPin aria-hidden="true" /> },
|
||||||
|
{ key: 'list', label: '列表', icon: <IconList aria-hidden="true" /> }
|
||||||
|
] as const;
|
||||||
|
|
||||||
function BatchVehicleSearchDialog({ initialValue, onApply, onClose }: { initialValue: string; onApply: (value: string) => void; onClose: () => void }) {
|
function BatchVehicleSearchDialog({ initialValue, onApply, onClose }: { initialValue: string; onApply: (value: string) => void; onClose: () => void }) {
|
||||||
const [draft, setDraft] = useState(initialValue);
|
const [draft, setDraft] = useState(initialValue);
|
||||||
@@ -407,6 +412,17 @@ export default function MonitorPage() {
|
|||||||
const selectMapVehicle = useCallback((vehicle: VehicleRealtimeRow) => selectVehicle(vehicle.vin), [selectVehicle]);
|
const selectMapVehicle = useCallback((vehicle: VehicleRealtimeRow) => selectVehicle(vehicle.vin), [selectVehicle]);
|
||||||
const collapseDetail = useCallback(() => setDetailOpen(false), []);
|
const collapseDetail = useCallback(() => setDetailOpen(false), []);
|
||||||
const expandDetail = useCallback(() => setDetailOpen(true), []);
|
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 driving = rows.filter((vehicle) => vehicleStatus(vehicle) === 'driving').length;
|
||||||
const idle = rows.filter((vehicle) => vehicleStatus(vehicle) === 'idle').length;
|
const idle = rows.filter((vehicle) => vehicleStatus(vehicle) === 'idle').length;
|
||||||
const offline = rows.filter((vehicle) => vehicleStatus(vehicle) === 'offline').length;
|
const offline = rows.filter((vehicle) => vehicleStatus(vehicle) === 'offline').length;
|
||||||
@@ -414,35 +430,52 @@ export default function MonitorPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="v2-monitor-page">
|
<div className="v2-monitor-page">
|
||||||
<Card className="v2-filterbar" bodyStyle={{ padding: 0 }} aria-label="车辆筛选">
|
<Card className="v2-filterbar" bodyStyle={{ padding: 0 }} aria-label="车辆筛选">
|
||||||
<div className={`v2-search-field${searchTerms.length > 1 ? ' is-batch' : ''}`}>
|
<div className="v2-monitor-filter-fields" aria-label="筛选条件">
|
||||||
<Input
|
<div className={`v2-search-field${searchTerms.length > 1 ? ' is-batch' : ''}`}>
|
||||||
aria-label="搜索车辆"
|
<Input
|
||||||
prefix={<IconSearch />}
|
aria-label="搜索车辆"
|
||||||
value={keyword}
|
prefix={<IconSearch />}
|
||||||
onChange={(value) => { setKeyword(value); setListOffset(0); }}
|
value={keyword}
|
||||||
onPaste={(event) => {
|
onChange={(value) => { setKeyword(value); setListOffset(0); }}
|
||||||
const pastedTerms = parseMonitorSearchTerms(event.clipboardData.getData('text'));
|
onPaste={(event) => {
|
||||||
if (pastedTerms.length <= 1) return;
|
const pastedTerms = parseMonitorSearchTerms(event.clipboardData.getData('text'));
|
||||||
event.preventDefault();
|
if (pastedTerms.length <= 1) return;
|
||||||
setKeyword(pastedTerms.join(','));
|
event.preventDefault();
|
||||||
setListOffset(0);
|
setKeyword(pastedTerms.join(','));
|
||||||
}}
|
setListOffset(0);
|
||||||
placeholder="车牌 / VIN;可批量粘贴车牌"
|
}}
|
||||||
suffix={<Button className="v2-search-batch-action" size="small" theme="borderless" onClick={() => setBatchSearchOpen(true)}>批量</Button>}
|
placeholder="车牌 / VIN;可批量粘贴车牌"
|
||||||
/>
|
suffix={<Button className="v2-search-batch-action" size="small" theme="borderless" onClick={() => setBatchSearchOpen(true)}>批量</Button>}
|
||||||
{searchTerms.length > 1 ? <span className={`v2-search-batch-count${batchMatch.missing.length && !batchSearchPending ? ' has-missing' : ''}`} aria-live="polite" title={searchTerms.length === MAX_MONITOR_SEARCH_TERMS ? `最多支持 ${MAX_MONITOR_SEARCH_TERMS} 条;${batchStatusTitle}` : batchStatusTitle}>{batchSearchPending ? `已识别 ${searchTerms.length} 辆` : `已找到 ${batchMatch.matched}/${searchTerms.length}`}</span> : null}
|
/>
|
||||||
|
{searchTerms.length > 1 ? <span className={`v2-search-batch-count${batchMatch.missing.length && !batchSearchPending ? ' has-missing' : ''}`} aria-live="polite" title={searchTerms.length === MAX_MONITOR_SEARCH_TERMS ? `最多支持 ${MAX_MONITOR_SEARCH_TERMS} 条;${batchStatusTitle}` : batchStatusTitle}>{batchSearchPending ? `已识别 ${searchTerms.length} 辆` : `已找到 ${batchMatch.matched}/${searchTerms.length}`}</span> : null}
|
||||||
|
</div>
|
||||||
|
<span className="v2-sr-only" id="monitor-protocol-filter-label">协议</span>
|
||||||
|
<Select value={protocol} onChange={(value) => { setProtocol(String(value)); setListOffset(0); }} aria-labelledby="monitor-protocol-filter-label" optionList={protocols.map((item) => ({ value: item, label: item || '全部协议' }))} />
|
||||||
|
<span className="v2-sr-only" id="monitor-status-filter-label">在线状态</span>
|
||||||
|
<Select value={status} onChange={(value) => { 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) : '全部状态' }))} />
|
||||||
|
<Button className="v2-filter-reset" icon={<IconRefresh />} disabled={activeFilterCount === 0} onClick={clearFilters}>清空</Button>
|
||||||
|
</div>
|
||||||
|
<div className="v2-monitor-filter-actions" aria-label="监控操作">
|
||||||
|
<span
|
||||||
|
className={`v2-filter-live-status${activeFilterCount ? ' has-filters' : ''}`}
|
||||||
|
role="status"
|
||||||
|
aria-label={`搜索和筛选条件修改后自动生效,已启用 ${activeFilterCount} 个条件`}
|
||||||
|
title="搜索和筛选条件修改后自动生效"
|
||||||
|
>
|
||||||
|
<IconFilter aria-hidden="true" />
|
||||||
|
<span className="v2-filter-live-status-label">自动生效</span>
|
||||||
|
{activeFilterCount ? <b>{activeFilterCount}</b> : null}
|
||||||
|
</span>
|
||||||
|
<SegmentedTabs
|
||||||
|
ariaLabel="监控视图"
|
||||||
|
className="v2-monitor-mode"
|
||||||
|
value={mode}
|
||||||
|
items={MONITOR_VIEW_ITEMS}
|
||||||
|
onChange={changeMonitorMode}
|
||||||
|
variant="filled"
|
||||||
|
/>
|
||||||
|
<Button className="v2-monitor-mobile-entry" theme="light" icon={<IconQrCode />} aria-label="打开手机端入口" onClick={() => setMobileEntryOpen(true)}><span className="v2-monitor-mobile-entry-label">手机端</span></Button>
|
||||||
</div>
|
</div>
|
||||||
<span className="v2-sr-only" id="monitor-protocol-filter-label">协议</span>
|
|
||||||
<Select value={protocol} onChange={(value) => { setProtocol(String(value)); setListOffset(0); }} aria-labelledby="monitor-protocol-filter-label" optionList={protocols.map((item) => ({ value: item, label: item || '全部协议' }))} />
|
|
||||||
<span className="v2-sr-only" id="monitor-status-filter-label">在线状态</span>
|
|
||||||
<Select value={status} onChange={(value) => { 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) : '全部状态' }))} />
|
|
||||||
<Button className="v2-filter-reset" icon={<IconRefresh />} onClick={() => { setKeyword(''); setProtocol(''); setStatus(''); setListOffset(0); }}>清空</Button>
|
|
||||||
<span className="v2-filter-live-status" role="status" title="搜索和筛选条件修改后自动生效"><IconFilter />实时筛选</span>
|
|
||||||
<ButtonGroup className="v2-monitor-mode" aria-label="监控视图">
|
|
||||||
<Button aria-pressed={mode === 'map'} className={mode === 'map' ? 'is-active' : ''} theme={mode === 'map' ? 'solid' : 'borderless'} type={mode === 'map' ? 'primary' : 'tertiary'} icon={<IconMapPin />} onClick={() => setMode('map')}>地图</Button>
|
|
||||||
<Button aria-pressed={mode === 'list'} className={mode === 'list' ? 'is-active' : ''} theme={mode === 'list' ? 'solid' : 'borderless'} type={mode === 'list' ? 'primary' : 'tertiary'} icon={<IconList />} onClick={() => { setMode('list'); setDetailOpen(false); }}>列表</Button>
|
|
||||||
</ButtonGroup>
|
|
||||||
<Button className="v2-monitor-mobile-entry" theme="light" icon={<IconQrCode />} onClick={() => setMobileEntryOpen(true)}>手机端</Button>
|
|
||||||
</Card>
|
</Card>
|
||||||
{batchSearchOpen ? <BatchVehicleSearchDialog initialValue={searchTerms.join('\n')} onClose={() => setBatchSearchOpen(false)} onApply={(value) => { setKeyword(value); setListOffset(0); setBatchSearchOpen(false); }} /> : null}
|
{batchSearchOpen ? <BatchVehicleSearchDialog initialValue={searchTerms.join('\n')} onClose={() => setBatchSearchOpen(false)} onApply={(value) => { setKeyword(value); setListOffset(0); setBatchSearchOpen(false); }} /> : null}
|
||||||
|
|
||||||
|
|||||||
@@ -347,10 +347,10 @@ describe('V2 production entry', () => {
|
|||||||
expect(corePageSources.MonitorPage).toContain('v2-monitor-qr-modal');
|
expect(corePageSources.MonitorPage).toContain('v2-monitor-qr-modal');
|
||||||
expect(corePageSources.MonitorPage).not.toContain('v2-monitor-qr-backdrop');
|
expect(corePageSources.MonitorPage).not.toContain('v2-monitor-qr-backdrop');
|
||||||
expect(corePageSources.MonitorPage).toContain('<TextArea');
|
expect(corePageSources.MonitorPage).toContain('<TextArea');
|
||||||
expect(corePageSources.MonitorPage).toContain('<ButtonGroup');
|
expect(corePageSources.MonitorPage).toContain('<SegmentedTabs');
|
||||||
expect(corePageSources.MonitorPage).toContain("aria-pressed={mode === 'map'}");
|
expect(corePageSources.MonitorPage).toContain('ariaLabel="监控视图"');
|
||||||
expect(corePageSources.MonitorPage).toContain("aria-pressed={mode === 'list'}");
|
expect(corePageSources.MonitorPage).toContain('variant="filled"');
|
||||||
expect(v2Styles).toContain('.v2-monitor-mode.semi-button-group > .semi-button-group-line { display: none; }');
|
expect(workspaceStyles).toContain('.v2-monitor-mode.v2-segmented-tabs');
|
||||||
expect(corePageSources.MonitorPage).not.toContain('<select');
|
expect(corePageSources.MonitorPage).not.toContain('<select');
|
||||||
expect(corePageSources.MonitorPage).not.toContain('<textarea');
|
expect(corePageSources.MonitorPage).not.toContain('<textarea');
|
||||||
expect(corePageSources.MonitorPage).not.toContain('<button');
|
expect(corePageSources.MonitorPage).not.toContain('<button');
|
||||||
|
|||||||
@@ -8855,3 +8855,340 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Semi UI workspace migration: global monitor command bar and fleet overview. */
|
||||||
|
.v2-filterbar.semi-card {
|
||||||
|
border-color: #dce5ef;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 7px 24px rgba(31, 53, 80, .055);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-filterbar > .semi-card-body {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-monitor-filter-fields {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 680px;
|
||||||
|
grid-template-columns: minmax(260px, 1fr) minmax(122px, 142px) minmax(122px, 142px) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-monitor-filter-fields > .v2-sr-only {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-monitor-filter-fields > .semi-select {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid #d7e0eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-monitor-filter-fields > .semi-select:hover {
|
||||||
|
border-color: #b8c7da;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-monitor-filter-fields > .semi-select-focus {
|
||||||
|
border-color: #8ab4f7;
|
||||||
|
box-shadow: 0 0 0 3px rgba(18, 104, 243, .07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-monitor-filter-fields > .semi-select .semi-select-selection-text {
|
||||||
|
height: 34px;
|
||||||
|
color: #4d5c70;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-monitor-filter-fields > .v2-filter-reset.semi-button {
|
||||||
|
height: 36px;
|
||||||
|
border-color: #d7e0eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #59677c;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-monitor-filter-fields > .v2-filter-reset.semi-button:disabled {
|
||||||
|
border-color: #e7ecf2;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #a7b2c1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-monitor-filter-actions {
|
||||||
|
display: flex;
|
||||||
|
min-width: max-content;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-filter-live-status {
|
||||||
|
position: relative;
|
||||||
|
height: 34px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 17px;
|
||||||
|
background: #f3f6fa;
|
||||||
|
padding: 0 10px;
|
||||||
|
color: #718096;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-filter-live-status.has-filters {
|
||||||
|
background: #edf5ff;
|
||||||
|
color: #2867bb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-filter-live-status b {
|
||||||
|
display: inline-grid;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #1268f3;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-monitor-mode.v2-segmented-tabs {
|
||||||
|
display: grid;
|
||||||
|
width: 142px;
|
||||||
|
height: 36px;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #d7e0eb;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f3f6fa;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-monitor-mode.v2-segmented-tabs > .semi-button {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 30px;
|
||||||
|
justify-content: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 0 8px;
|
||||||
|
color: #65758a;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 650;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-monitor-mode.v2-segmented-tabs > .semi-button.is-active {
|
||||||
|
background: #fff;
|
||||||
|
color: #1268f3;
|
||||||
|
box-shadow: 0 2px 8px rgba(24, 53, 91, .11);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-monitor-mode .v2-segmented-tab-label {
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-monitor-filter-actions > .v2-monitor-mobile-entry.semi-button {
|
||||||
|
height: 36px;
|
||||||
|
border-color: #d7e0eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #52657c;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-kpis.semi-card {
|
||||||
|
border-color: #dce5ef;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 7px 24px rgba(31, 53, 80, .05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-kpi {
|
||||||
|
isolation: isolate;
|
||||||
|
min-height: 74px;
|
||||||
|
padding: 12px 15px;
|
||||||
|
transition: background-color .18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-kpi.is-primary {
|
||||||
|
background: linear-gradient(135deg, #f8fbff 0%, #fff 76%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-kpi.is-online {
|
||||||
|
background: linear-gradient(135deg, #f4fcf8 0%, #fff 78%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-kpi.is-primary::after {
|
||||||
|
position: absolute;
|
||||||
|
z-index: -1;
|
||||||
|
top: 0;
|
||||||
|
right: 14px;
|
||||||
|
left: 14px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 0 0 2px 2px;
|
||||||
|
background: linear-gradient(90deg, #1268f3, #72a8ff);
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-kpi.is-online::after {
|
||||||
|
background: linear-gradient(90deg, #17a673, #75d3aa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-kpi small {
|
||||||
|
color: #708096;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: .015em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-kpi strong {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #23364d;
|
||||||
|
font-weight: 720;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-kpi.is-primary strong {
|
||||||
|
font-weight: 780;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-kpi.is-support {
|
||||||
|
background: #f9fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-kpi.is-support small {
|
||||||
|
color: #8a96a6;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 550;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-kpi.is-support strong {
|
||||||
|
color: #748297;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1320px) {
|
||||||
|
.v2-filterbar > .semi-card-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-monitor-filter-fields {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-filter-live-status-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1080px) {
|
||||||
|
.v2-filterbar > .semi-card-body {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-monitor-filter-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-filter-live-status-label {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.v2-filterbar > .semi-card-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-monitor-filter-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-monitor-filter-fields > .v2-search-field {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-monitor-filter-fields > .semi-select,
|
||||||
|
.v2-monitor-filter-fields > .v2-filter-reset.semi-button {
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-monitor-filter-fields > .semi-select .semi-select-selection-text {
|
||||||
|
height: 40px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-monitor-filter-actions {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-filter-live-status {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-filter-live-status-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-filter-live-status b {
|
||||||
|
position: absolute;
|
||||||
|
top: -3px;
|
||||||
|
right: -3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-monitor-mode.v2-segmented-tabs {
|
||||||
|
width: 100%;
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-monitor-mode.v2-segmented-tabs > .semi-button {
|
||||||
|
height: 36px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-monitor-filter-actions > .v2-monitor-mobile-entry.semi-button {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-monitor-filter-actions > .v2-monitor-mobile-entry .v2-monitor-mobile-entry-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-kpi {
|
||||||
|
min-height: 66px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-kpi.is-primary::after {
|
||||||
|
right: 10px;
|
||||||
|
left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-kpi strong {
|
||||||
|
margin-top: 7px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user