feat: unify mobile filter sheets
This commit is contained in:
@@ -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>
|
||||
<MobileFilterSheetSection ariaLabel="接入范围" title="接入范围" description="优先定位异常、离线、尚无来源和资料差异车辆">
|
||||
<div>{filterFields}</div>
|
||||
</MobileFilterSheetSection>
|
||||
</form>
|
||||
</SideSheet>
|
||||
</MobileFilterSheet>
|
||||
</div> : <WorkspaceFilterPanel
|
||||
className="v2-access-filter-card-v3"
|
||||
title="接入筛选"
|
||||
|
||||
@@ -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="事件筛选"
|
||||
|
||||
@@ -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="查询范围"
|
||||
|
||||
@@ -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 }}
|
||||
>
|
||||
<MobileFilterSheetSection ariaLabel="里程查询条件" title="查询范围" description="最多 20 台车辆,按自然日比较优先数据源">
|
||||
{filterForm}
|
||||
</MobileFilterSheetSection>
|
||||
{filterValidation}
|
||||
</SideSheet>
|
||||
</MobileFilterSheet>
|
||||
</div> : <WorkspaceFilterPanel
|
||||
className="v2-mileage-query-panel"
|
||||
title="里程范围"
|
||||
|
||||
@@ -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="账号范围"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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="车辆定位"
|
||||
|
||||
@@ -17,6 +17,7 @@ const reconciliationSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/R
|
||||
const profileSyncPanelSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/VehicleProfileSyncPanel.tsx'), 'utf8');
|
||||
const sourceEvidenceSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/VehicleSourceEvidencePanel.tsx'), 'utf8');
|
||||
const mobileFilterToggleSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/MobileFilterToggle.tsx'), 'utf8');
|
||||
const mobileFilterSheetSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/MobileFilterSheet.tsx'), 'utf8');
|
||||
const passwordInputSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/PasswordInput.tsx'), 'utf8');
|
||||
const workspaceFilterPanelSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/WorkspaceFilterPanel.tsx'), 'utf8');
|
||||
const workspaceMetricRailSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/WorkspaceMetricRail.tsx'), 'utf8');
|
||||
@@ -380,7 +381,14 @@ describe('V2 production entry', () => {
|
||||
expect(corePageSources.StatisticsPage).toContain('v2-mileage-source-sidesheet');
|
||||
expect(corePageSources.StatisticsPage).toContain('className="v2-mileage-filter-sidesheet"');
|
||||
expect(corePageSources.StatisticsPage).toContain('<MobileFilterToggle');
|
||||
expect(corePageSources.StatisticsPage).toContain('placement="bottom"');
|
||||
expect(corePageSources.StatisticsPage).toContain('<MobileFilterSheet');
|
||||
expect(mobileFilterSheetSource).toContain('placement="bottom"');
|
||||
for (const name of ['AccessPage', 'AlertsPage', 'HistoryPage', 'StatisticsPage', 'UsersPage']) {
|
||||
expect(corePageSources[name]).toContain("from '../shared/MobileFilterSheet'");
|
||||
expect(corePageSources[name]).toContain('<MobileFilterSheet');
|
||||
}
|
||||
expect(vehicleSearchSource).toContain("from '../shared/MobileFilterSheet'");
|
||||
expect(vehicleSearchSource).toContain('<MobileFilterSheet');
|
||||
expect(corePageSources.StatisticsPage).not.toContain('v2-mileage-source-popover');
|
||||
expect(corePageSources.StatisticsPage).toContain('<Table className="v2-mileage-table"');
|
||||
expect(v2Styles).toContain('.v2-mileage-table.semi-table-wrapper :is(.semi-table-row-head,.semi-table-row-cell).is-plate');
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { MobileFilterSheet, MobileFilterSheetSection } from './MobileFilterSheet';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
test('renders one shared Semi filter sheet with consistent title, section and actions', () => {
|
||||
const onCancel = vi.fn();
|
||||
const onReset = vi.fn();
|
||||
const onApply = vi.fn();
|
||||
render(<MobileFilterSheet
|
||||
className="test-filter-sheet"
|
||||
visible
|
||||
ariaLabel="测试筛选"
|
||||
title="筛选车辆"
|
||||
description="按车辆和状态缩小范围"
|
||||
onCancel={onCancel}
|
||||
secondaryAction={{ label: '重置条件', onClick: onReset }}
|
||||
primaryAction={{ label: '应用并查询', onClick: onApply }}
|
||||
>
|
||||
<MobileFilterSheetSection ariaLabel="车辆条件" title="车辆范围" description="支持车牌或 VIN">
|
||||
<label>关键词<input aria-label="关键词" /></label>
|
||||
</MobileFilterSheetSection>
|
||||
</MobileFilterSheet>);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '测试筛选' });
|
||||
expect(dialog.closest('.semi-sidesheet')).toHaveClass('v2-mobile-filter-sheet', 'test-filter-sheet');
|
||||
expect(screen.getByText('按车辆和状态缩小范围')).toBeInTheDocument();
|
||||
expect(screen.getByRole('region', { name: '车辆条件' })).toHaveTextContent('支持车牌或 VIN');
|
||||
fireEvent.click(screen.getByRole('button', { name: '重置条件' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用并查询' }));
|
||||
expect(onReset).toHaveBeenCalledTimes(1);
|
||||
expect(onApply).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'close' }));
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Button, SideSheet } from '@douyinfe/semi-ui';
|
||||
import { useEffect, useId, type ReactNode } from 'react';
|
||||
|
||||
type MobileFilterSheetAction = {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
type MobileFilterSheetProps = {
|
||||
className?: string;
|
||||
visible: boolean;
|
||||
height?: string | number;
|
||||
ariaLabel: string;
|
||||
title: string;
|
||||
description: string;
|
||||
onCancel: () => void;
|
||||
secondaryAction?: MobileFilterSheetAction;
|
||||
primaryAction: MobileFilterSheetAction;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function MobileFilterSheet({
|
||||
className = '',
|
||||
visible,
|
||||
height = 'min(82dvh, 700px)',
|
||||
ariaLabel,
|
||||
title,
|
||||
description,
|
||||
onCancel,
|
||||
secondaryAction,
|
||||
primaryAction,
|
||||
children
|
||||
}: MobileFilterSheetProps) {
|
||||
const sheetClassName = ['v2-mobile-filter-sheet', className].filter(Boolean).join(' ');
|
||||
const sheetId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const dialog = document.querySelector(`[data-mobile-filter-sheet-id="${sheetId}"] .semi-sidesheet-inner`);
|
||||
dialog?.setAttribute('aria-label', ariaLabel);
|
||||
}, [ariaLabel, sheetId, visible]);
|
||||
|
||||
return <SideSheet
|
||||
className={sheetClassName}
|
||||
data-mobile-filter-sheet-id={sheetId}
|
||||
visible={visible}
|
||||
placement="bottom"
|
||||
height={height}
|
||||
aria-label={ariaLabel}
|
||||
title={<div className="v2-mobile-filter-sheet-title"><strong>{title}</strong><span>{description}</span></div>}
|
||||
onCancel={onCancel}
|
||||
footer={<div className="v2-mobile-filter-sheet-footer">
|
||||
{secondaryAction ? <Button
|
||||
theme="light"
|
||||
disabled={secondaryAction.disabled}
|
||||
loading={secondaryAction.loading}
|
||||
onClick={secondaryAction.onClick}
|
||||
>{secondaryAction.label}</Button> : <span aria-hidden="true" />}
|
||||
<Button
|
||||
theme="solid"
|
||||
disabled={primaryAction.disabled}
|
||||
loading={primaryAction.loading}
|
||||
onClick={primaryAction.onClick}
|
||||
>{primaryAction.label}</Button>
|
||||
</div>}
|
||||
>
|
||||
{children}
|
||||
</SideSheet>;
|
||||
}
|
||||
|
||||
type MobileFilterSheetSectionProps = {
|
||||
className?: string;
|
||||
ariaLabel: string;
|
||||
title: string;
|
||||
description: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function MobileFilterSheetSection({
|
||||
className = '',
|
||||
ariaLabel,
|
||||
title,
|
||||
description,
|
||||
children
|
||||
}: MobileFilterSheetSectionProps) {
|
||||
const sectionClassName = ['v2-mobile-filter-sheet-section', className].filter(Boolean).join(' ');
|
||||
return <section className={sectionClassName} aria-label={ariaLabel}>
|
||||
<header><strong>{title}</strong><span>{description}</span></header>
|
||||
{children}
|
||||
</section>;
|
||||
}
|
||||
@@ -18,6 +18,208 @@
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
/*
|
||||
* Shared Semi UI mobile filter sheet.
|
||||
* Keep the same title hierarchy, scroll surface, condition card and action
|
||||
* boundary across vehicle, mileage, history, alerts, accounts and access.
|
||||
*/
|
||||
@media (max-width: 680px) {
|
||||
.v2-mobile-filter-sheet .semi-sidesheet-inner {
|
||||
width: 100vw !important;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 18px 18px 0 0;
|
||||
background: #f4f7fb;
|
||||
box-shadow: 0 -20px 56px rgba(25, 45, 72, .2);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.v2-mobile-filter-sheet.semi-sidesheet-bottom .semi-sidesheet-header {
|
||||
min-height: 68px;
|
||||
border-bottom: 1px solid #dfe7f0;
|
||||
background: rgba(255, 255, 255, .98);
|
||||
padding: 11px 14px;
|
||||
}
|
||||
|
||||
.v2-mobile-filter-sheet.semi-sidesheet-bottom .semi-sidesheet-body {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
background: #f4f7fb;
|
||||
padding: 12px;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.v2-mobile-filter-sheet.semi-sidesheet-bottom .semi-sidesheet-footer {
|
||||
border-top: 1px solid #dfe7f0;
|
||||
background: rgba(255, 255, 255, .98);
|
||||
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.v2-mobile-filter-sheet-title {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.v2-mobile-filter-sheet-title > strong {
|
||||
color: #253a52;
|
||||
font-size: 15px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.v2-mobile-filter-sheet-title > span {
|
||||
color: #8090a3;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.v2-mobile-filter-sheet-footer {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(0, .72fr) minmax(0, 1.28fr);
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.v2-mobile-filter-sheet-footer > .semi-button {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
justify-content: center;
|
||||
border-radius: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.v2-mobile-filter-sheet-section {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 12px;
|
||||
border: 1px solid #dfe7f0;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
padding: 12px;
|
||||
box-shadow: 0 8px 24px rgba(31, 53, 80, .055);
|
||||
}
|
||||
|
||||
.v2-mobile-filter-sheet-section > header {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.v2-mobile-filter-sheet-section > header > strong {
|
||||
color: #2b4058;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.v2-mobile-filter-sheet-section > header > span {
|
||||
color: #7c8b9e;
|
||||
font-size: 10px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.v2-mobile-filter-sheet-section > .v2-mileage-filter {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.v2-mobile-filter-sheet .v2-access-mobile-filter-form {
|
||||
gap: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.v2-access-mobile-filter-form > .v2-mobile-filter-sheet-section > div {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 11px 9px;
|
||||
}
|
||||
|
||||
.v2-access-mobile-filter-form > .v2-mobile-filter-sheet-section > div > label.is-search,
|
||||
.v2-access-mobile-filter-form > .v2-mobile-filter-sheet-section > div > label:last-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.v2-access-mobile-filter-form > .v2-mobile-filter-sheet-section > div > label {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
color: #5f7188;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.v2-access-mobile-filter-form > .v2-mobile-filter-sheet-section > div > label > .semi-input-wrapper,
|
||||
.v2-access-mobile-filter-form > .v2-mobile-filter-sheet-section > div > label > .semi-select {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) and (max-height: 480px) and (orientation: landscape) {
|
||||
.v2-mobile-filter-sheet.semi-sidesheet-bottom .semi-sidesheet-inner {
|
||||
height: 100dvh !important;
|
||||
max-height: 100dvh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.v2-mobile-filter-sheet.semi-sidesheet-bottom .semi-sidesheet-header {
|
||||
min-height: 54px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
|
||||
.v2-mobile-filter-sheet.semi-sidesheet-bottom .semi-sidesheet-body {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.v2-mobile-filter-sheet.semi-sidesheet-bottom .semi-sidesheet-footer {
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.v2-mobile-filter-sheet-title > span,
|
||||
.v2-mobile-filter-sheet-section > header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.v2-mobile-filter-sheet-section {
|
||||
gap: 8px;
|
||||
padding: 9px;
|
||||
}
|
||||
|
||||
.v2-mobile-filter-sheet-footer > .semi-button {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.v2-access-mobile-filter-form > .v2-mobile-filter-sheet-section > div {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.v2-access-mobile-filter-form > .v2-mobile-filter-sheet-section > div > label.is-search,
|
||||
.v2-access-mobile-filter-form > .v2-mobile-filter-sheet-section > div > label:last-child {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.v2-access-mobile-filter-form > .v2-mobile-filter-sheet-section > div > label {
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.v2-access-mobile-filter-form > .v2-mobile-filter-sheet-section > div > label > .semi-input-wrapper,
|
||||
.v2-access-mobile-filter-form > .v2-mobile-filter-sheet-section > div > label > .semi-select {
|
||||
min-height: 38px;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Global short-landscape entry and navigation.
|
||||
* Keep every authentication action in the first viewport, preserve practical
|
||||
|
||||
Reference in New Issue
Block a user