refine Semi UI mobile monitor

This commit is contained in:
lingniu
2026-07-18 14:59:55 +08:00
parent fe056019eb
commit 50b5107bc2
3 changed files with 129 additions and 32 deletions

View File

@@ -396,10 +396,23 @@ test('mounts only the mobile list representation and removes its viewport listen
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
const filterFields = view.container.querySelector('#monitor-filter-fields');
expect(filterFields).toHaveClass('is-mobile-collapsed');
const expandFilters = screen.getByRole('button', { name: '展开车辆筛选,当前 0 个条件' });
expect(expandFilters).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(expandFilters);
expect(filterFields).not.toHaveClass('is-mobile-collapsed');
const collapseFilters = screen.getByRole('button', { name: '收起车辆筛选,当前 0 个条件' });
expect(collapseFilters).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(collapseFilters);
expect(filterFields).toHaveClass('is-mobile-collapsed');
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();
expect(screen.getByRole('button', { name: '在地图中定位粤A12345' })).toHaveTextContent('地图');
expect(view.container.querySelector('.v2-monitor-mobile-card footer')).not.toBeInTheDocument();
expect(addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
view.unmount();

View File

@@ -16,6 +16,7 @@ import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { formatNumber, relativeFreshness, statusLabel, vehicleStatus } from '../domain/monitor';
import { formatTelemetryValue } from '../domain/telemetry';
import { useMobileLayout } from '../hooks/useMobileLayout';
import { MAX_MONITOR_SEARCH_TERMS, MONITOR_REFRESH, monitorFilterScope, monitorQueryParams, parseMonitorSearchTerms, useMonitorData, useMonitorVehicleCard, type MonitorViewport } from '../hooks/useMonitorData';
import { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
import { buildMonitorPath, parseMonitorRouteContext, withMonitorReturn } from '../routing/monitorContext';
@@ -23,7 +24,6 @@ import { buildMonitorPath, parseMonitorRouteContext, withMonitorReturn } from '.
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: <IconMapPin aria-hidden="true" /> },
{ key: 'list', label: '列表', icon: <IconList aria-hidden="true" /> }
@@ -52,18 +52,6 @@ function BatchVehicleSearchDialog({ initialValue, onApply, onClose }: { initialV
</Modal>;
}
function useMobileMonitorLayout() {
const [mobile, setMobile] = useState(() => window.matchMedia(MOBILE_MONITOR_QUERY).matches);
useEffect(() => {
const media = window.matchMedia(MOBILE_MONITOR_QUERY);
const update = () => setMobile(media.matches);
media.addEventListener('change', update);
update();
return () => media.removeEventListener('change', update);
}, []);
return mobile;
}
type AddressCoordinate = { longitude: number; latitude: number; key: string };
function hasRealtimeLocation(vehicle: VehicleRealtimeRow) {
@@ -122,8 +110,7 @@ const MonitorAddressCell = memo(function MonitorAddressCell({ vehicle }: { vehic
return <div className="v2-monitor-address-result"><span title={addressQuery.data?.formattedAddress}>{addressQuery.data?.formattedAddress || '暂无地址'}</span><small>{addressQuery.dataUpdatedAt ? `解析于 ${new Date(addressQuery.dataUpdatedAt).toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' })}` : ''}</small>{moved ? <Button className="v2-monitor-address-update" size="small" theme="borderless" type="warning" title="车辆已移动,按需更新地址" onClick={() => setRequested(current)}> · </Button> : null}</div>;
});
function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, onSelect, onPage, onLimit }: { rows: VehicleRealtimeRow[]; total: number; page: number; totalPages: number; limit: number; loading: boolean; onSelect: (vin: string) => void; onPage: (page: number) => void; onLimit: (limit: number) => void }) {
const mobile = useMobileMonitorLayout();
function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, mobile, onSelect, onPage, onLimit }: { rows: VehicleRealtimeRow[]; total: number; page: number; totalPages: number; limit: number; loading: boolean; mobile: boolean; onSelect: (vin: string) => void; onPage: (page: number) => void; onLimit: (limit: number) => void }) {
const columns = useMemo(() => [
{ title: '车辆', dataIndex: 'plate', width: 210, className: 'v2-monitor-table-vehicle', render: (_value: string, row: VehicleRealtimeRow) => <Button className="v2-monitor-vehicle-action" theme="borderless" type="tertiary" title="在地图中定位" onClick={() => onSelect(row.vin)}><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></Button> },
{ 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}</> },
@@ -141,9 +128,8 @@ function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, on
/>
{!mobile ? <div className="v2-monitor-table-scroll"><Table className="v2-monitor-table" columns={columns} dataSource={rows} rowKey="vin" pagination={false} empty={null} />{loading ? <div className="v2-monitor-table-loading" role="status"><Spin size="small" tip="正在更新车辆实时数据…" /></div> : null}{!loading && !rows.length ? <Empty className="v2-monitor-table-empty" title="暂无符合条件的车辆" description="调整搜索、协议或在线状态筛选后重试。" /> : null}</div> : null}
{mobile ? <div className="v2-monitor-mobile-cards">{rows.map((row) => <Card className="v2-monitor-mobile-card" key={row.vin}>
<header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div><b>{hasRealtimeSpeed(row) ? formatNumber(row.speedKmh, 1) : '—'}{hasRealtimeSpeed(row) ? <small>km/h</small> : null}</b></header>
<header><div><strong>{row.plate || '未绑定车牌'}</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={`在地图中定位${row.plate || row.vin}`} onClick={() => onSelect(row.vin)}></Button></div></header>
<dl><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">{row.primaryProtocol || '未知'}{row.protocols.length > 1 ? ` · ${row.protocols.length}` : ''}</span></dd></div><div><dt></dt><dd>{hasRealtimeLocation(row) ? <code>{row.longitude.toFixed(6)}, {row.latitude.toFixed(6)}</code> : '—'}</dd></div><div className="is-address"><dt></dt><dd><MonitorAddressCell vehicle={row} /></dd></div></dl>
<footer><Button className="v2-monitor-card-locate" theme="borderless" type="primary" icon={<IconMapPin />} onClick={() => onSelect(row.vin)}>{hasRealtimeLocation(row) ? '地图定位' : '查看车辆'}</Button></footer>
</Card>)}{loading ? <div className="v2-monitor-table-loading" role="status"><Spin size="small" tip="正在更新车辆实时数据…" /></div> : null}{!loading && !rows.length ? <Empty className="v2-monitor-table-empty" title="暂无符合条件的车辆" description="调整搜索、协议或在线状态筛选后重试。" /> : null}</div> : null}
<footer><TablePagination page={page} totalPages={totalPages} info={`${total.toLocaleString('zh-CN')} 辆车辆`} onPageChange={onPage} pageSize={limit} pageSizeLabel="每页车辆数" onPageSizeChange={onLimit} pageSizeOptions={[{ value: 20, label: '20 辆/页' }, { value: 50, label: '50 辆/页' }, { value: 100, label: '100 辆/页' }]} /></footer>
</Card>;
@@ -329,12 +315,14 @@ export default function MonitorPage() {
const [routeParams, setRouteParams] = useSearchParams();
const initialContextRef = useRef(parseMonitorRouteContext(routeParams));
const initialContext = initialContextRef.current;
const mobileLayout = useMobileLayout(760);
const [mode, setMode] = useState<'map' | 'list'>(initialContext.mode);
const [mobileEntryOpen, setMobileEntryOpen] = useState(false);
const [listOffset, setListOffset] = useState(initialContext.listOffset);
const [listLimit, setListLimit] = useState(initialContext.listLimit);
const [keyword, setKeyword] = useState(initialContext.keyword);
const [batchSearchOpen, setBatchSearchOpen] = useState(false);
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
const deferredKeyword = useDeferredValue(keyword);
const searchTerms = useMemo(() => parseMonitorSearchTerms(keyword), [keyword]);
const [protocol, setProtocol] = useState(initialContext.protocol);
@@ -437,7 +425,7 @@ export default function MonitorPage() {
return (
<div className="v2-monitor-page">
<Card className="v2-filterbar" bodyStyle={{ padding: 0 }} aria-label="车辆筛选">
<div className="v2-monitor-filter-fields" aria-label="筛选条件">
<div id="monitor-filter-fields" className={`v2-monitor-filter-fields${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} aria-label="筛选条件">
<div className={`v2-search-field${searchTerms.length > 1 ? ' is-batch' : ''}`}>
<Input
aria-label="搜索车辆"
@@ -463,7 +451,17 @@ export default function MonitorPage() {
<Button className="v2-filter-reset" icon={<IconRefresh />} disabled={activeFilterCount === 0} onClick={clearFilters}></Button>
</div>
<div className="v2-monitor-filter-actions" aria-label="监控操作">
<span
{mobileLayout ? <Button
className={`v2-monitor-filter-toggle${activeFilterCount ? ' has-filters' : ''}`}
theme="light"
icon={<IconFilter />}
aria-controls="monitor-filter-fields"
aria-expanded={!filtersCollapsed}
aria-label={`${filtersCollapsed ? '展开' : '收起'}车辆筛选,当前 ${activeFilterCount} 个条件`}
onClick={() => setFiltersCollapsed((value) => !value)}
>
{filtersCollapsed ? '筛选' : '收起'}{activeFilterCount ? <b>{activeFilterCount}</b> : null}
</Button> : <span
className={`v2-filter-live-status${activeFilterCount ? ' has-filters' : ''}`}
role="status"
aria-label={`搜索和筛选条件修改后自动生效,已启用 ${activeFilterCount} 个条件`}
@@ -472,7 +470,7 @@ export default function MonitorPage() {
<IconFilter aria-hidden="true" />
<span className="v2-filter-live-status-label"></span>
{activeFilterCount ? <b>{activeFilterCount}</b> : null}
</span>
</span>}
<SegmentedTabs
ariaLabel="监控视图"
className="v2-monitor-mode"
@@ -538,7 +536,7 @@ export default function MonitorPage() {
</Button>
</Card>
) : null}
</section> : <MonitorVehicleTable rows={visibleListRows} total={visibleListTotal} page={Math.floor(listOffset / listLimit) + 1} totalPages={Math.max(1, Math.ceil(visibleListTotal / listLimit))} limit={listLimit} loading={filterTransitionPending || realtimeListQuery.isFetching} onSelect={selectVehicle} onPage={(page) => setListOffset((page - 1) * listLimit)} onLimit={(next) => { setListLimit(next); setListOffset(0); }} />}
</section> : <MonitorVehicleTable rows={visibleListRows} total={visibleListTotal} page={Math.floor(listOffset / listLimit) + 1} totalPages={Math.max(1, Math.ceil(visibleListTotal / listLimit))} limit={listLimit} loading={filterTransitionPending || realtimeListQuery.isFetching} mobile={mobileLayout} onSelect={selectVehicle} onPage={(page) => setListOffset((page - 1) * listLimit)} onLimit={(next) => { setListLimit(next); setListOffset(0); }} />}
<Card className="v2-event-strip" bodyStyle={{ padding: 0 }} aria-label="实时数据状态">
<div className="v2-event-sync">

View File

@@ -11409,6 +11409,10 @@
}
@media (max-width: 760px) {
.v2-monitor-filter-fields.is-mobile-collapsed {
display: none;
}
.v2-filterbar > .semi-card-body {
display: grid;
gap: 8px;
@@ -11439,24 +11443,106 @@
display: grid;
width: 100%;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-columns: 70px minmax(0, 1fr) 42px;
gap: 7px;
}
.v2-filter-live-status {
width: 40px;
height: 40px;
padding: 0;
.v2-monitor-filter-toggle.semi-button {
width: 70px;
height: 42px;
justify-content: center;
gap: 5px;
border-color: #d7e2ef;
border-radius: 9px;
background: #f6f9fd;
color: #52677f;
padding: 0 8px;
font-size: 11px;
font-weight: 700;
}
.v2-filter-live-status-label {
display: none;
.v2-monitor-filter-toggle.semi-button[aria-expanded="true"],
.v2-monitor-filter-toggle.semi-button.has-filters {
border-color: #b7cff0;
background: #edf5ff;
color: #2465b7;
}
.v2-filter-live-status b {
position: absolute;
top: -3px;
right: -3px;
.v2-monitor-filter-toggle.semi-button 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-filterbar > .semi-card-body:has(.v2-monitor-filter-fields.is-mobile-collapsed) {
gap: 0;
padding: 6px;
}
.v2-monitor-mobile-card .semi-card-body {
padding: 8px 9px;
}
.v2-monitor-card-heading-actions {
display: flex !important;
flex: 0 0 auto;
align-items: center;
flex-direction: row !important;
gap: 7px !important;
}
.v2-monitor-card-heading-actions > b {
color: #1f4f8d;
font-size: 16px;
font-variant-numeric: tabular-nums;
}
.v2-monitor-card-heading-actions > b small {
margin-left: 3px;
color: #8996a8;
font-size: 8px;
font-weight: 500;
}
.v2-monitor-card-heading-actions > .v2-monitor-card-locate.semi-button {
width: 52px;
height: 30px;
min-height: 30px;
border-radius: 7px;
padding: 0 7px;
font-size: 10px;
}
.v2-monitor-mobile-cards dl {
gap: 4px 9px;
margin-top: 6px;
padding-top: 6px;
}
.v2-monitor-mobile-cards dl > div {
min-height: 26px;
}
.v2-monitor-mobile-cards dl > div.is-address {
min-height: 32px;
}
.v2-monitor-mobile-cards dt {
font-size: 8px;
}
.v2-monitor-mobile-cards dd {
font-size: 10px;
}
.v2-monitor-mobile-cards > .v2-monitor-mobile-card.semi-card {
contain-intrinsic-size: auto 154px;
}
.v2-monitor-mode.v2-segmented-tabs {