feat: unify mobile filter sheets

This commit is contained in:
lingniu
2026-07-19 04:44:21 +08:00
parent d3eb77e2c7
commit 9a87d14d6d
11 changed files with 417 additions and 64 deletions

View File

@@ -9,6 +9,7 @@ import { accessIssueSummary, accessRowsToCSV, formatAccessTime, formatSeconds, u
import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
import { MetricActionButton } from '../shared/MetricActionButton';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet';
import { TablePagination } from '../shared/TablePagination';
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
@@ -415,21 +416,23 @@ export default function AccessPage() {
collapsedLabel="修改"
onToggle={() => setFiltersCollapsed((value) => !value)}
/>
<SideSheet
<MobileFilterSheet
className="v2-access-mobile-filter-sidesheet"
visible={mobileFiltersOpen}
placement="bottom"
height="min(76dvh, 640px)"
aria-label="接入车辆筛选"
title={<div className="v2-access-sheet-title"><strong></strong><span></span></div>}
ariaLabel="接入车辆筛选"
title="筛选接入车辆"
description="车辆、接入状态、真实协议与品牌"
onCancel={() => setFiltersCollapsed(true)}
footer={<div className="v2-access-mobile-filter-footer"><Button theme="light" onClick={resetFilters}></Button><Button theme="solid" onClick={applyDraft}></Button></div>}
secondaryAction={{ label: '重置条件', onClick: resetFilters }}
primaryAction={{ label: '应用并查询', onClick: applyDraft }}
>
<form className="v2-access-mobile-filter-form" onSubmit={submit}>
<header><strong></strong><span>线</span></header>
<div>{filterFields}</div>
<MobileFilterSheetSection ariaLabel="接入范围" title="接入范围" description="优先定位异常、离线、尚无来源和资料差异车辆">
<div>{filterFields}</div>
</MobileFilterSheetSection>
</form>
</SideSheet>
</MobileFilterSheet>
</div> : <WorkspaceFilterPanel
className="v2-access-filter-card-v3"
title="接入筛选"

View File

@@ -9,6 +9,7 @@ import { actionLabels, alertDeltaText, alertValue, canAct, formatAlertTime, oper
import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
import { MetricActionButton } from '../shared/MetricActionButton';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet';
import { ProtocolTag } from '../shared/ProtocolTag';
import { SegmentedTabs } from '../shared/SegmentedTabs';
import { TablePagination } from '../shared/TablePagination';
@@ -247,27 +248,26 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e
collapsedLabel="修改"
onToggle={() => setFiltersCollapsed((value) => !value)}
/>
<SideSheet
<MobileFilterSheet
className="v2-alert-filter-sidesheet v2-alert-mobile-filter-sidesheet"
visible={mobileFiltersOpen}
placement="bottom"
height="min(82dvh, 690px)"
aria-label="告警事件筛选"
title={<div className="v2-alert-sheet-title"><strong></strong><span></span></div>}
ariaLabel="告警事件筛选"
title="筛选告警事件"
description="车辆、状态、规则与时间范围"
onCancel={() => setFiltersCollapsed(true)}
footer={<div className="v2-alert-mobile-filter-footer"><Button theme="light" onClick={resetFilters}></Button><Button theme="solid" onClick={applyDraft}></Button></div>}
secondaryAction={{ label: '重置条件', onClick: resetFilters }}
primaryAction={{ label: '应用并查询', onClick: applyDraft }}
>
<form className="v2-alert-mobile-filter-form" onSubmit={submit}>
<section className="v2-alert-mobile-filter-section" aria-label="常用筛选">
<header><strong></strong><span></span></header>
<MobileFilterSheetSection className="v2-alert-mobile-filter-section" ariaLabel="常用筛选" title="常用条件" description="快速定位车辆与待处置事件">
<div className="v2-alert-mobile-filter-grid is-primary">{coreFilterFields}</div>
</section>
<section className="v2-alert-mobile-filter-section" aria-label="精确筛选">
<header><strong></strong><span></span></header>
</MobileFilterSheetSection>
<MobileFilterSheetSection className="v2-alert-mobile-filter-section" ariaLabel="精确筛选" title="精确范围" description="按规则、协议和触发时间进一步收窄">
<div className="v2-alert-mobile-filter-grid">{advancedFilterFields}</div>
</section>
</MobileFilterSheetSection>
</form>
</SideSheet>
</MobileFilterSheet>
</div> : <WorkspaceFilterPanel
className="v2-alert-filter-card"
title="事件筛选"

View File

@@ -11,6 +11,7 @@ import { formatShanghaiDateTime } from '../domain/formatters';
import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet';
import { PlatformTime } from '../shared/PlatformTime';
import { ProtocolTag } from '../shared/ProtocolTag';
import { TablePagination } from '../shared/TablePagination';
@@ -430,24 +431,24 @@ export default function HistoryPage() {
collapsedLabel="修改"
onToggle={() => setFiltersCollapsed((value) => !value)}
/>
<SideSheet
<MobileFilterSheet
className="v2-history-filter-sidesheet"
visible={mobileFiltersOpen}
placement="bottom"
height="min(82dvh, 700px)"
aria-label="历史查询范围"
title={<div className="v2-history-mobile-sheet-title"><strong></strong><span></span></div>}
ariaLabel="历史查询范围"
title="筛选历史数据"
description="车辆、时间、数据类型与协议来源"
onCancel={() => setFiltersCollapsed(true)}
footer={<div className="v2-history-mobile-filter-footer"><Button theme="light" onClick={reset}></Button><Button theme="solid" disabled={!parseHistoryKeywords(draft.keywords).length} onClick={applyDraft}></Button></div>}
secondaryAction={{ label: '重置条件', onClick: reset }}
primaryAction={{ label: '应用并查询', disabled: !parseHistoryKeywords(draft.keywords).length, onClick: applyDraft }}
>
<form className="v2-history-mobile-filter-form" onSubmit={submit}>
<section className="v2-history-mobile-filter-section" aria-label="历史查询条件">
<header><strong></strong><span> 5 </span></header>
<MobileFilterSheetSection className="v2-history-mobile-filter-section" ariaLabel="历史查询条件" title="查询范围" description="最多 5 台车辆,按上海时区检索可追溯数据">
<div className="v2-history-mobile-filter-grid">{historyFilterFields}</div>
</section>
</MobileFilterSheetSection>
{exportAllowed ? <section className="v2-history-mobile-export-action" aria-label="导出当前历史查询"><span><strong></strong><small> CSV</small></span><CreateExportButton disabled={!resultRows.length} request={exportRequest} /></section> : <Tag className="v2-history-permission-tag" color="grey" type="light" size="large"></Tag>}
</form>
</SideSheet>
</MobileFilterSheet>
</div> : <WorkspaceFilterPanel
className="v2-history-filter-card"
title="查询范围"

View File

@@ -10,6 +10,7 @@ import { formatZhNumber } from '../domain/formatters';
import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet';
import { TablePagination } from '../shared/TablePagination';
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
@@ -451,6 +452,10 @@ export default function StatisticsPage() {
if (error) return;
setPage(1); setExportFeedback(''); setCriteria(draft); setSearchParams(preserveMonitorReturn(mileageParams(draft, -1), monitorReturn), { replace: true }); setFiltersCollapsed(true);
};
const resetDraft = () => {
setDraft(initialCriteria(new URLSearchParams()));
setValidationError('');
};
const submit = (event: FormEvent) => { event.preventDefault(); applyDraft(); };
const setRange = (range: { dateFrom: string; dateTo: string }) => { const next = { ...draft, ...range }; setPage(1); setExportFeedback(''); setValidationError(''); setDraft(next); setCriteria(next); setSearchParams(preserveMonitorReturn(mileageParams(next, -1), monitorReturn), { replace: true }); setFiltersCollapsed(true); };
const closeMobileFilters = () => {
@@ -594,19 +599,22 @@ export default function StatisticsPage() {
onToggle={toggleFilters}
/>
<SummaryRail data={statistics.data} criteria={criteria} fleetTotal={fleetVehicles.data?.total} loading={statistics.isLoading} />
<SideSheet
<MobileFilterSheet
className="v2-mileage-filter-sidesheet"
visible={mobileFiltersOpen}
placement="bottom"
height="min(82dvh, 690px)"
aria-label="里程范围筛选"
title={<div className="v2-mileage-filter-title"><strong></strong><span></span></div>}
ariaLabel="里程范围筛选"
title="里程范围"
description="选择车辆、自然日与数据源"
onCancel={closeMobileFilters}
footer={<div className="v2-mileage-filter-footer"><span></span><Button theme="solid" onClick={applyDraft}></Button></div>}
secondaryAction={{ label: '重置条件', onClick: resetDraft }}
primaryAction={{ label: '应用并查询', onClick: applyDraft }}
>
{filterForm}
<MobileFilterSheetSection ariaLabel="里程查询条件" title="查询范围" description="最多 20 台车辆,按自然日比较优先数据源">
{filterForm}
</MobileFilterSheetSection>
{filterValidation}
</SideSheet>
</MobileFilterSheet>
</div> : <WorkspaceFilterPanel
className="v2-mileage-query-panel"
title="里程范围"

View File

@@ -7,6 +7,7 @@ import type { AdminUser, CustomerUserInput, CustomerVehicleGrantInput } from '..
import { useMobileLayout } from '../hooks/useMobileLayout';
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet';
import { TablePagination } from '../shared/TablePagination';
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
@@ -389,19 +390,19 @@ export default function UsersPage() {
collapsedLabel="修改"
onToggle={mobileFiltersOpen ? closeMobileFilters : openMobileFilters}
/>
<SideSheet
<MobileFilterSheet
className="v2-user-mobile-filter-sidesheet"
visible={mobileFiltersOpen}
placement="bottom"
height="min(62dvh, 480px)"
aria-label="客户账号筛选"
title={<div className="v2-user-mobile-filter-title"><strong></strong><span></span></div>}
ariaLabel="客户账号筛选"
title="筛选客户账号"
description="按账号信息与权限状态缩小客户范围"
onCancel={closeMobileFilters}
footer={<div className="v2-user-mobile-filter-footer"><Button theme="light" onClick={resetMobileFilters}></Button><Button theme="solid" onClick={applyMobileFilters}></Button></div>}
secondaryAction={{ label: '重置条件', onClick: resetMobileFilters }}
primaryAction={{ label: '应用筛选', onClick: applyMobileFilters }}
>
<form className="v2-user-mobile-filter-form" onSubmit={submitMobileFilters}>
<section aria-label="客户账号筛选条件">
<header><strong></strong><span></span></header>
<MobileFilterSheetSection ariaLabel="客户账号筛选条件" title="账号范围" description="筛选仅在应用后更新客户目录,关闭不会丢失当前结果。">
<div>
<label>
<span></span>
@@ -417,9 +418,9 @@ export default function UsersPage() {
/>
</label>
</div>
</section>
</MobileFilterSheetSection>
</form>
</SideSheet>
</MobileFilterSheet>
</div> : <WorkspaceFilterPanel
className="v2-user-filter-panel"
title="账号范围"

View File

@@ -29,7 +29,7 @@ import { useMobileLayout } from '../hooks/useMobileLayout';
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
function fmt(value?: string) { return value?.trim() || '—'; }
function metric(value: number | undefined, fallback = '—') { return typeof value === 'number' && Number.isFinite(value) ? formatZhNumber(value, 1) : fallback; }
function metric(value: number | undefined) { return Number.isFinite(value as number) ? formatZhNumber(value!, 1) : '—'; }
function availableMetric(value: number | undefined, available?: boolean) { return available === false ? '—' : metric(value); }
function issueTone(issue: QualityIssueRow) { return issue.severity === 'error' ? 'error' : 'warning'; }
function durationHours(seconds?: number | null) { return seconds == null ? '—' : `${formatZhNumber(seconds / 3600, 1)} 小时`; }
@@ -246,7 +246,7 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon
const [copiedValue, setCopiedValue] = useState(false);
const inspectedPresentation = useMemo(() => inspectedValue ? telemetryValuePresentation(inspectedValue) : undefined, [inspectedValue]);
const inspectedDetail = useMemo(() => inspectedValue ? telemetryValueDetail(inspectedValue) : '', [inspectedValue]);
useSideSheetA11y(Boolean(inspectedValue), '.v2-telemetry-value-sidesheet', 'v2-telemetry-value-detail', '遥测字段详情', '关闭遥测字段详情');
useSideSheetA11y(!!inspectedValue, '.v2-telemetry-value-sidesheet', 'v2-telemetry-value-detail', '遥测字段详情', '关闭遥测字段详情');
const indexed = useMemo(() => {
const valuesByProtocolCategory = new Map<string, LatestTelemetryResponse['values']>();
const sources = new Map<string, { protocol: string; endpoint?: string }>();
@@ -314,7 +314,7 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon
<footer><b></b>{indexed.sources.map((source) => <span key={`${source.protocol}-${source.endpoint ?? ''}`} title={source.endpoint}>{protocolDisplayLabel(source.protocol)}</span>)}<small> {data?.scannedFrames ?? 0} · {formatTelemetryTime(data?.asOf)}</small></footer>
<SideSheet
className="v2-telemetry-value-sidesheet"
visible={Boolean(inspectedValue)}
visible={!!inspectedValue}
placement={mobileLayout ? 'bottom' : 'right'}
width={mobileLayout ? undefined : 520}
height={mobileLayout ? 'min(88dvh, 780px)' : undefined}
@@ -373,7 +373,7 @@ function CurrentVehicleAddress({ vehicle, fallback }: { vehicle?: VehicleRealtim
refetchOnWindowFocus: false,
retry: 1
});
const moved = Boolean(coordinateKey && requestedKey && coordinateKey !== requestedKey);
const moved = !!(coordinateKey && requestedKey && coordinateKey !== requestedKey);
return <div className="v2-current-location">
<span><IconMapPin /><small></small><strong>{coordinate ? `${vehicle!.longitude.toFixed(6)}, ${vehicle!.latitude.toFixed(6)}` : '—'}</strong></span>
@@ -433,7 +433,7 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele
const [sourceEvidenceOpen, setSourceEvidenceOpen] = useState(false);
const realtime = liveRealtime ?? detail.realtimeSummary;
const identity = detail.identity;
const hasLocation = Boolean(realtime && realtime.locationAvailable !== false && isValidAMapCoordinate(realtime.longitude, realtime.latitude));
const hasLocation = !!(realtime && realtime.locationAvailable !== false && isValidAMapCoordinate(realtime.longitude, realtime.latitude));
const mapVehicles = hasLocation && realtime ? [realtime] : [];
const actions = [
{ key: 'switch', label: '切换车辆', icon: <IconSearch />, to: '/vehicles', type: 'primary' as const },
@@ -543,18 +543,18 @@ export default function VehiclePage() {
const { vin } = useParams();
const [searchParams] = useSearchParams();
const monitorReturn = monitorReturnFromParams(searchParams);
const query = useQuery({ queryKey: ['vehicle-detail', vin], enabled: Boolean(vin), queryFn: ({ signal }) => api.vehicleDetail(new URLSearchParams({ keyword: vin!, limit: '20' }), signal), gcTime: QUERY_MEMORY.summaryGcTime });
const query = useQuery({ queryKey: ['vehicle-detail', vin], enabled: !!vin, queryFn: ({ signal }) => api.vehicleDetail(new URLSearchParams({ keyword: vin!, limit: '20' }), signal), gcTime: QUERY_MEMORY.summaryGcTime });
const resolvedVin = query.data?.lookupResolved ? query.data.vin : '';
const realtime = useQuery({
queryKey: ['vehicle-detail-realtime', resolvedVin],
queryFn: ({ signal }) => api.vehicleRealtime(new URLSearchParams({ keywords: resolvedVin, limit: '1', offset: '0' }), signal),
enabled: Boolean(resolvedVin),
enabled: !!resolvedVin,
staleTime: 5_000,
gcTime: QUERY_MEMORY.highVolumeGcTime,
refetchInterval: resolvedVin ? SINGLE_VEHICLE_REFRESH_MS : false,
...LIVE_QUERY_POLICY
});
const telemetry = useQuery({ queryKey: ['vehicle-latest-telemetry', vin], enabled: Boolean(vin), queryFn: ({ signal }) => api.latestTelemetry(vin!, signal), staleTime: 10_000, gcTime: QUERY_MEMORY.summaryGcTime, refetchInterval: 20_000, ...LIVE_QUERY_POLICY });
const telemetry = useQuery({ queryKey: ['vehicle-latest-telemetry', vin], enabled: !!vin, queryFn: ({ signal }) => api.latestTelemetry(vin!, signal), staleTime: 10_000, gcTime: QUERY_MEMORY.summaryGcTime, refetchInterval: 20_000, ...LIVE_QUERY_POLICY });
useEffect(() => {
resetWorkspaceScroll();
let trailingFrame = 0;

View File

@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { IconArrowRight, IconBox, IconChevronRight, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { Button, Card, Empty, Input, SideSheet, Table, Tag } from '@douyinfe/semi-ui';
import { Button, Card, Empty, Input, Table, Tag } from '@douyinfe/semi-ui';
import { type FormEvent, lazy, Suspense, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../../api/client';
@@ -12,6 +12,7 @@ import { useMobileLayout } from '../hooks/useMobileLayout';
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from '../queryPolicy';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet';
import { ProtocolTag } from '../shared/ProtocolTag';
import { TablePagination } from '../shared/TablePagination';
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
@@ -174,19 +175,19 @@ export default function VehicleSearchWorkspace() {
collapsedLabel="修改"
onToggle={mobileFiltersOpen ? closeMobileFilters : openMobileFilters}
/>
<SideSheet
<MobileFilterSheet
className="v2-vehicle-mobile-filter-sidesheet"
visible={mobileFiltersOpen}
placement="bottom"
height="min(68dvh, 560px)"
aria-label="车辆搜索"
title={<div className="v2-vehicle-mobile-filter-title"><strong></strong><span> VIN </span></div>}
ariaLabel="车辆搜索"
title="查找车辆"
description="车牌优先,支持 VIN 与终端手机号"
onCancel={closeMobileFilters}
footer={<div className="v2-vehicle-mobile-filter-footer"><Button theme="light" onClick={resetMobileFilters}></Button><Button theme="solid" onClick={applyMobileFilters}></Button></div>}
secondaryAction={{ label: '重置范围', onClick: resetMobileFilters }}
primaryAction={{ label: '应用搜索', onClick: applyMobileFilters }}
>
<form className="v2-vehicle-mobile-filter-form" onSubmit={submitMobileFilters}>
<section aria-label="车辆搜索条件">
<header><strong></strong><span></span></header>
<MobileFilterSheetSection ariaLabel="车辆搜索条件" title="车辆身份" description="输入关键词可直接打开候选车辆,或应用到下方目录。">
<label>
<span> / VIN / </span>
<Input
@@ -217,9 +218,9 @@ export default function VehicleSearchWorkspace() {
actionLabel="打开"
onSelect={(vehicle) => openVehicle(vehicle.vin)}
/> : <div className="v2-vehicle-mobile-filter-hint"><IconSearch /><span><strong></strong><small></small></span></div>}
</section>
</MobileFilterSheetSection>
</form>
</SideSheet>
</MobileFilterSheet>
</div> : <WorkspaceFilterPanel
className="v2-vehicle-query-panel"
title="车辆定位"