feat: unify filter workbenches

This commit is contained in:
lingniu
2026-07-19 07:36:08 +08:00
parent af608b35e0
commit 3e80da1b73
16 changed files with 281 additions and 78 deletions

View File

@@ -22,7 +22,6 @@ import { canAdminister } from '../auth/session';
import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from '../queryPolicy';
import { downloadBlob } from '../domain/download';
import { useMobileLayout } from '../hooks/useMobileLayout';
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'] as const;
const protocolThresholdMeta: Record<(typeof PROTOCOLS)[number], { label: string; description: string }> = {
@@ -357,7 +356,6 @@ export default function AccessPage() {
}, [mobileLayout]);
const rows = vehiclesQuery.data?.items ?? []; const selected = rows.find((row) => row.vin === selectedVIN);
const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
useSideSheetA11y(mobileFiltersOpen, '.v2-access-mobile-filter-sidesheet', 'v2-access-mobile-filters', '接入车辆筛选', '关闭接入车辆筛选');
const syncURL = (filters: Filters) => { const next = new URLSearchParams(); Object.entries(filters).forEach(([key, value]) => { if (value) next.set(key, value); }); setSearchParams(next, { replace: true }); };
const apply = (next: Filters) => { setDraft(next); setCriteria(next); setOffset(0); setSelectedVIN(''); syncURL(next); };
const applyDraft = () => { apply(draft); setFiltersCollapsed(true); };
@@ -416,6 +414,7 @@ export default function AccessPage() {
title="接入筛选"
summary={activeFilterCount ? `已启用 ${activeFilterCount}` : '全部主车辆'}
expanded={mobileFiltersOpen}
controls="v2-access-mobile-filters"
expandedLabel="关闭"
collapsedLabel="修改"
onToggle={() => setFiltersCollapsed((value) => !value)}
@@ -425,6 +424,8 @@ export default function AccessPage() {
visible={mobileFiltersOpen}
height="min(76dvh, 640px)"
ariaLabel="接入车辆筛选"
dialogId="v2-access-mobile-filters"
closeLabel="关闭接入车辆筛选"
title="筛选接入车辆"
description="车辆、接入状态、真实协议与品牌"
onCancel={() => setFiltersCollapsed(true)}

View File

@@ -1,5 +1,5 @@
import { IconAlarm, IconBell, IconChevronRight, IconClose, IconFilter, IconPlus, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
import { Button, Card, CardGroup, Collapse, Descriptions, Empty, Input, Radio, Select, SideSheet, Spin, Table, Tag, TextArea, Timeline, Typography } from '@douyinfe/semi-ui';
import { Button, Card, CardGroup, Collapse, Descriptions, Empty, Input, Radio, Select, Spin, Table, Tag, TextArea, Timeline, Typography } from '@douyinfe/semi-ui';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FormEvent, memo, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
@@ -23,7 +23,6 @@ import { usePlatformSession } from '../auth/AuthGate';
import { canAdminister, canOperate } from '../auth/session';
import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from '../queryPolicy';
import { useMobileLayout } from '../hooks/useMobileLayout';
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
type Tab = 'events' | 'rules' | 'notifications';
type Filters = { keyword: string; severity: string; status: string; ruleId: string; protocol: string; dateFrom: string; dateTo: string };
@@ -196,8 +195,6 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e
const selectEvent = useCallback((id: string) => setSelection({ scope: eventScope, id }), [eventScope]);
const selectedEvent = detail.data ?? rows.find((row) => row.id === selectedID);
const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
useSideSheetA11y(!mobileLayout && advancedOpen, '.v2-alert-filter-sidesheet', 'v2-alert-advanced-filters', '告警高级筛选', '关闭告警高级筛选');
useSideSheetA11y(mobileFiltersOpen, '.v2-alert-mobile-filter-sidesheet', 'v2-alert-mobile-filters', '告警事件筛选', '关闭告警事件筛选');
const applyDraft = () => {
setFilters(draft);
setOffset(0);
@@ -218,6 +215,7 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e
const sums = summary.data;
const activeFilterCount = Object.values(filters).filter(Boolean).length;
const advancedFilterCount = [filters.ruleId, filters.protocol, filters.dateFrom, filters.dateTo].filter(Boolean).length;
const draftAdvancedFilterCount = [draft.ruleId, draft.protocol, draft.dateFrom, draft.dateTo].filter(Boolean).length;
const allEventCount = Number(sums?.active ?? 0) + Number(sums?.recovered ?? 0) + Number(sums?.closed ?? 0) + Number(sums?.ignored ?? 0);
const inspector = <EventInspector event={selectedEvent} note={note} acting={action.isPending} actionError={action.error instanceof Error ? action.error.message : undefined} editable={editable} onNote={setNote} onAction={(name) => { const event = detail.data; if (event) action.mutate({ name, event }); }} onClose={closeDetail} />;
const coreFilterFields = <>
@@ -249,6 +247,7 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e
title="事件筛选"
summary={activeFilterCount ? `已启用 ${activeFilterCount}` : '全部告警'}
expanded={mobileFiltersOpen}
controls="v2-alert-mobile-filters"
expandedLabel="关闭"
collapsedLabel="修改"
onToggle={() => setFiltersCollapsed((value) => !value)}
@@ -258,6 +257,8 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e
visible={mobileFiltersOpen}
height="min(82dvh, 690px)"
ariaLabel="告警事件筛选"
dialogId="v2-alert-mobile-filters"
closeLabel="关闭告警事件筛选"
title="筛选告警事件"
description="车辆、状态、规则与时间范围"
onCancel={() => setFiltersCollapsed(true)}
@@ -328,18 +329,31 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e
</Card>
{!mobileLayout && selectedID ? inspector : null}
</div>
{!mobileLayout ? <SideSheet
{!mobileLayout ? <WorkspaceSideSheet
className="v2-alert-filter-sidesheet"
variant="filter"
visible={advancedOpen}
aria-label="告警高级筛选"
placement="right"
ariaLabel="告警高级筛选"
dialogId="v2-alert-advanced-filters"
closeLabel="关闭告警高级筛选"
width={430}
title={<div className="v2-alert-sheet-title"><strong></strong><span></span></div>}
title="更多筛选"
description="按规则、协议和触发时间收窄告警范围"
icon={<IconFilter />}
badge={draftAdvancedFilterCount ? `${draftAdvancedFilterCount} 项待应用` : '全部范围'}
badgeColor={draftAdvancedFilterCount ? 'blue' : 'grey'}
summaryItems={[
{ label: '规则', value: rules.find((rule) => rule.id === draft.ruleId)?.name || '全部规则', tone: draft.ruleId ? 'primary' : 'neutral' },
{ label: '协议', value: draft.protocol || '全部协议', tone: draft.protocol ? 'primary' : 'neutral' },
{ label: '触发时间', value: draft.dateFrom || draft.dateTo ? '已限定' : '不限时间', detail: draft.dateFrom || draft.dateTo ? '按本地时间查询' : undefined, tone: draft.dateFrom || draft.dateTo ? 'primary' : 'neutral' }
]}
footerNote="高级条件仅在应用后更新事件列表"
secondaryActions={[{ label: '清空高级条件', onClick: () => setDraft({ ...draft, ruleId: '', protocol: '', dateFrom: '', dateTo: '' }) }]}
primaryAction={{ label: '应用筛选', onClick: () => { applyDraft(); setAdvancedOpen(false); } }}
onCancel={() => setAdvancedOpen(false)}
footer={<div className="v2-alert-sheet-footer"><Button theme="light" onClick={() => setDraft({ ...draft, ruleId: '', protocol: '', dateFrom: '', dateTo: '' })}></Button><Button theme="solid" onClick={() => { applyDraft(); setAdvancedOpen(false); }}></Button></div>}
>
<div className="v2-alert-advanced-filter">{advancedFilterFields}</div>
</SideSheet>
</WorkspaceSideSheet>
: null}
<WorkspaceDetailSideSheet
className="v2-alert-detail-sidesheet"

View File

@@ -369,7 +369,6 @@ export default function HistoryPage() {
}, [dataScope]);
const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
useSideSheetA11y(exportAllowed && exportJobsOpen, '.v2-history-export-sidesheet', 'v2-history-export-jobs', '历史数据导出任务', '关闭历史数据导出任务');
useSideSheetA11y(mobileFiltersOpen, '.v2-history-filter-sidesheet', 'v2-history-filter-sheet', '历史查询范围', '关闭历史查询范围');
const applyDraft = () => {
const parsed = parseHistoryKeywords(draft.keywords);
@@ -453,6 +452,7 @@ export default function HistoryPage() {
title="查询范围"
summary={keywords.length ? `${keywords.length} 辆 · ${criteria.category === 'location' ? '位置数据' : criteria.category === 'raw' ? '原始报文' : '日里程'}` : '请选择车辆'}
expanded={mobileFiltersOpen}
controls="v2-history-filter-sheet"
expandedLabel="关闭"
collapsedLabel="修改"
onToggle={() => setFiltersCollapsed((value) => !value)}
@@ -462,6 +462,8 @@ export default function HistoryPage() {
visible={mobileFiltersOpen}
height="min(82dvh, 700px)"
ariaLabel="历史查询范围"
dialogId="v2-history-filter-sheet"
closeLabel="关闭历史查询范围"
title="筛选历史数据"
description="车辆、时间、数据类型与协议来源"
onCancel={() => setFiltersCollapsed(true)}

View File

@@ -423,6 +423,7 @@ test('allows provider maintenance but keeps canonical source policy read only',
const filterDialog = await screen.findByRole('dialog', { name: '诊断车辆筛选' });
expect(document.querySelector('.v2-source-mobile-filter-sidesheet')).toHaveClass('semi-sidesheet-bottom');
expect(document.querySelector('.v2-source-mobile-filter-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(78dvh, 660px)' });
expect(within(filterDialog).getByText('输入车牌或 VIN 开始查找')).toBeInTheDocument();
fireEvent.change(within(filterDialog).getByLabelText('按车牌或 VIN 搜索诊断车辆'), { target: { value: '沪A' } });
const candidateButton = await waitFor(() => {
const button = filterDialog.querySelector<HTMLButtonElement>('.v2-source-candidates button');

View File

@@ -1,5 +1,5 @@
import { IconRefresh, IconSearch, IconTickCircle } from '@douyinfe/semi-icons';
import { Button, Card, CardGroup, Checkbox, Descriptions, Empty, Input, Progress, SideSheet, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui';
import { Button, Card, CardGroup, Checkbox, Descriptions, Empty, Input, Progress, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FormEvent, useDeferredValue, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
@@ -7,6 +7,7 @@ import { api } from '../../api/client';
import type { VehicleCoverageRow, VehicleLocationSourceEvidence, VehicleSourceDiagnostic } from '../../api/types';
import { InlineError } from '../shared/AsyncState';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet';
import { PlatformTime } from '../shared/PlatformTime';
import { SegmentedTabs } from '../shared/SegmentedTabs';
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
@@ -16,7 +17,6 @@ import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
import { LIVE_QUERY_POLICY, QUERY_MEMORY } from '../queryPolicy';
import { useMobileLayout } from '../hooks/useMobileLayout';
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
import ReconciliationCenter from './ReconciliationCenter';
type OperationsWorkspace = 'reconciliation' | 'diagnostic' | 'health';
@@ -296,7 +296,6 @@ function SourceDiagnosticWorkspace() {
if (selectedLabel) setKeyword(selectedLabel);
setFiltersCollapsed(true);
};
useSideSheetA11y(mobileFiltersOpen, '.v2-source-mobile-filter-sidesheet', 'v2-source-mobile-filter-sheet', '诊断车辆筛选', '关闭诊断车辆筛选');
const filterStatus = selectedLabel
? `已选 ${selectedLabel}`
: deferredKeyword
@@ -334,28 +333,35 @@ function SourceDiagnosticWorkspace() {
title="诊断车辆"
summary={selectedLabel ? `已选 ${selectedLabel}` : '尚未选择车辆'}
expanded={mobileFiltersOpen}
controls="v2-source-mobile-filter-sheet"
expandedLabel="关闭"
collapsedLabel="修改"
onToggle={() => mobileFiltersOpen ? closeMobileFilters() : setFiltersCollapsed(false)}
/>
<SideSheet
<MobileFilterSheet
className="v2-source-mobile-filter-sidesheet"
visible={mobileFiltersOpen}
placement="bottom"
height="min(78dvh, 660px)"
aria-label="诊断车辆筛选"
title={<div className="v2-source-mobile-filter-title"><strong></strong><span> VIN </span></div>}
ariaLabel="诊断车辆筛选"
dialogId="v2-source-mobile-filter-sheet"
closeLabel="关闭诊断车辆筛选"
title="选择诊断车辆"
description="按车牌或 VIN 搜索已绑定车辆"
onCancel={closeMobileFilters}
footer={<div className="v2-source-mobile-filter-footer"><Button theme="light" disabled={!selected && !keyword} onClick={clearSelection}></Button><Button theme="solid" disabled={!browsingCandidates || !candidates.data?.items.length} onClick={chooseFirst}></Button></div>}
secondaryAction={{ label: '清除选择', disabled: !selected && !keyword, onClick: clearSelection }}
primaryAction={{ label: '诊断首个结果', disabled: !browsingCandidates || !candidates.data?.items.length, onClick: chooseFirst }}
>
<form className="v2-source-mobile-filter-form" onSubmit={submit}>
<section className="v2-source-mobile-filter-section" aria-label="诊断车辆搜索">
<header><strong></strong><span>{selectedLabel ? `当前诊断 ${selectedLabel},选择其他车辆后再切换` : '每次只读取一辆车的来源证据,不扫描整车队'}</span></header>
<MobileFilterSheetSection className="v2-source-mobile-filter-section" ariaLabel="诊断车辆搜索" title="车辆范围" description={selectedLabel ? `当前诊断 ${selectedLabel},选择其他车辆后再切换` : '每次只读取一辆车的来源证据,不扫描整车队'}>
{sourceSearchInput}
</section>
{sourceCandidateList}
</MobileFilterSheetSection>
{sourceCandidateList ?? <div className="v2-source-mobile-filter-empty" role="status">
<i aria-hidden="true"><IconSearch /></i>
<strong>{selectedLabel ? '输入新关键词切换车辆' : '输入车牌或 VIN 开始查找'}</strong>
<small>{selectedLabel ? `当前保留 ${selectedLabel},关闭筛选不会改变诊断结果` : '候选结果会同时展示车牌、VIN 与已接协议'}</small>
</div>}
</form>
</SideSheet>
</MobileFilterSheet>
</div> : <WorkspaceFilterPanel
className="v2-source-filter-panel"
title="诊断车辆"

View File

@@ -22,7 +22,6 @@ import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
import { QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
import { monitorReturnFromParams, preserveMonitorReturn } from '../routing/monitorContext';
import { useMobileLayout } from '../hooks/useMobileLayout';
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
const DAY = 86_400_000;
const DETAIL_LIMIT = 10_000;
@@ -386,7 +385,6 @@ export default function StatisticsPage() {
const pageRef = useRef<HTMLDivElement>(null);
const tableScrollRef = useRef<HTMLDivElement>(null);
const mountedRef = useRef(true);
useSideSheetA11y(mobileFiltersOpen, '.v2-mileage-filter-sidesheet', 'v2-mileage-filter-panel', '里程范围筛选', '关闭里程范围筛选');
useEffect(() => {
mountedRef.current = true;
return () => {
@@ -604,6 +602,7 @@ export default function StatisticsPage() {
title="里程范围"
summary={rangeSummary}
expanded={mobileFiltersOpen}
controls="v2-mileage-filter-panel"
expandedLabel="关闭"
collapsedLabel="修改"
onToggle={toggleFilters}
@@ -614,6 +613,8 @@ export default function StatisticsPage() {
visible={mobileFiltersOpen}
height="min(82dvh, 690px)"
ariaLabel="里程范围筛选"
dialogId="v2-mileage-filter-panel"
closeLabel="关闭里程范围筛选"
title="里程范围"
description="选择车辆、自然日与数据源"
onCancel={closeMobileFilters}

View File

@@ -5,7 +5,6 @@ import { FormEvent, useDeferredValue, useEffect, useMemo, useState } from 'react
import { api } from '../../api/client';
import type { AdminUser, CustomerUserInput, CustomerVehicleGrantInput } from '../../api/types';
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';
@@ -160,7 +159,6 @@ export default function UsersPage() {
const vehicleComposerVisible = !mobileLayout || creating || draft.vehicleGrants.length === 0 || vehicleComposerOpen;
const hasUnsavedChanges = useMemo(() => draftSignature(draft) !== draftSignature(baselineDraft), [baselineDraft, draft]);
const draftAccessState = customerAccessState(draft.status, draft.menuKeys.length, draft.vehicleGrants.length);
useSideSheetA11y(mobileFiltersOpen, '.v2-user-mobile-filter-sidesheet', 'v2-user-mobile-filters', '客户账号筛选', '关闭客户账号筛选');
useEffect(() => {
if (!creating && selected) {
@@ -387,6 +385,7 @@ export default function UsersPage() {
title="账号范围"
summary={`${customerScopeLabel(customerStatus)}${customerKeyword.trim() ? ` · ${customerKeyword.trim()}` : ''}`}
expanded={mobileFiltersOpen}
controls="v2-user-mobile-filters"
expandedLabel="关闭"
collapsedLabel="修改"
onToggle={mobileFiltersOpen ? closeMobileFilters : openMobileFilters}
@@ -396,6 +395,8 @@ export default function UsersPage() {
visible={mobileFiltersOpen}
height="min(62dvh, 480px)"
ariaLabel="客户账号筛选"
dialogId="v2-user-mobile-filters"
closeLabel="关闭客户账号筛选"
title="筛选客户账号"
description="按账号信息与权限状态缩小客户范围"
onCancel={closeMobileFilters}

View File

@@ -9,7 +9,6 @@ import { usePlatformSession } from '../auth/AuthGate';
import { canAdminister } from '../auth/session';
import { formatZhNumber } from '../domain/formatters';
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';
@@ -99,7 +98,6 @@ export default function VehicleSearchWorkspace() {
setFiltersCollapsed(mobileLayout);
setCandidatesOpen(false);
}, [mobileLayout]);
useSideSheetA11y(mobileFiltersOpen, '.v2-vehicle-mobile-filter-sidesheet', 'v2-vehicle-mobile-filter-dialog', '车辆搜索', '关闭车辆搜索');
const openCandidates = () => {
window.clearTimeout(closeTimerRef.current);
if (!mobileLayout) setCandidatesOpen(Boolean(keyword.trim()));
@@ -168,6 +166,7 @@ export default function VehicleSearchWorkspace() {
title="车辆范围"
summary={mobileFilterSummary}
expanded={mobileFiltersOpen}
controls="v2-vehicle-mobile-filter-dialog"
expandedLabel="关闭"
collapsedLabel="修改"
onToggle={mobileFiltersOpen ? closeMobileFilters : openMobileFilters}
@@ -177,6 +176,8 @@ export default function VehicleSearchWorkspace() {
visible={mobileFiltersOpen}
height="min(68dvh, 560px)"
ariaLabel="车辆搜索"
dialogId="v2-vehicle-mobile-filter-dialog"
closeLabel="关闭车辆搜索"
title="查找车辆"
description="车牌优先,支持 VIN 与终端手机号"
onCancel={closeMobileFilters}

View File

@@ -189,7 +189,8 @@ describe('V2 production entry', () => {
expect(corePageSources.AlertsPage).toContain('<Card className={`v2-alert-inspector');
expect(corePageSources.AlertsPage).toContain('<WorkspaceCommandBar');
expect(corePageSources.AlertsPage).toContain('className="v2-alert-navigation v2-alert-command-bar"');
expect(corePageSources.AlertsPage).toContain('<SideSheet\n className="v2-alert-filter-sidesheet"');
expect(corePageSources.AlertsPage).toContain('<WorkspaceSideSheet\n className="v2-alert-filter-sidesheet"');
expect(corePageSources.AlertsPage).toContain('variant="filter"');
expect(corePageSources.AlertsPage).toContain('<WorkspaceDetailSideSheet\n className="v2-alert-detail-sidesheet"');
expect(corePageSources.AlertsPage).toContain('<WorkspaceSideSheet\n className="v2-alert-rule-editor-sidesheet"');
expect(corePageSources.AlertsPage).toContain('variant="editor"');
@@ -339,7 +340,9 @@ describe('V2 production entry', () => {
expect(corePageSources.OperationsPage).toContain('<Card className="v2-source-audit"');
expect(corePageSources.OperationsPage).toContain('className="v2-source-table"');
expect(corePageSources.OperationsPage).toContain('<Table');
expect(corePageSources.OperationsPage).toContain('<SideSheet');
expect(corePageSources.OperationsPage).toContain('<MobileFilterSheet');
expect(corePageSources.OperationsPage).not.toContain('<SideSheet');
expect(corePageSources.OperationsPage).toContain('className="v2-source-mobile-filter-empty"');
expect(corePageSources.OperationsPage).toContain('<WorkspaceSideSheet');
expect(corePageSources.OperationsPage).toContain('v2-source-policy-sidesheet');
expect(corePageSources.OperationsPage).toContain('className={`v2-source-mobile-card');
@@ -412,7 +415,10 @@ describe('V2 production entry', () => {
expect(corePageSources.StatisticsPage).toContain('<MobileFilterToggle');
expect(corePageSources.StatisticsPage).toContain('<MobileFilterSheet');
expect(mobileFilterSheetSource).toContain('placement="bottom"');
for (const name of ['AccessPage', 'AlertsPage', 'HistoryPage', 'StatisticsPage', 'UsersPage']) {
expect(mobileFilterSheetSource).toContain('<WorkspaceSideSheet');
expect(mobileFilterSheetSource).toContain('variant="filter"');
expect(mobileFilterSheetSource).not.toContain('<SideSheet');
for (const name of ['AccessPage', 'AlertsPage', 'HistoryPage', 'OperationsPage', 'StatisticsPage', 'UsersPage']) {
expect(corePageSources[name]).toContain("from '../shared/MobileFilterSheet'");
expect(corePageSources[name]).toContain('<MobileFilterSheet');
}
@@ -424,7 +430,8 @@ describe('V2 production entry', () => {
expect(workspaceSideSheetSource).toContain("from '@douyinfe/semi-ui'");
expect(workspaceSideSheetSource).toContain('<SideSheet');
expect(workspaceSideSheetSource).toContain('className={sheetClassName}');
expect(workspaceStyles).toContain('.v2-workspace-config-sidesheet .semi-sidesheet-inner {');
expect(workspaceStyles).toContain(':is(.v2-workspace-config-sidesheet, .v2-workspace-filter-sidesheet) .semi-sidesheet-inner {');
expect(workspaceStyles).toContain('.v2-workspace-filter-sidesheet .v2-workspace-config-title-icon {');
expect(workspaceStyles).toContain('.v2-workspace-config-summary {');
expect(workspaceStyles).toContain('.v2-workspace-config-footer {');
expect(vehicleSearchSource).toContain('<MobileFilterSheet');

View File

@@ -12,6 +12,8 @@ test('renders one shared Semi filter sheet with consistent title, section and ac
className="test-filter-sheet"
visible
ariaLabel="测试筛选"
dialogId="test-filter-dialog"
closeLabel="关闭测试筛选"
title="筛选车辆"
description="按车辆和状态缩小范围"
onCancel={onCancel}
@@ -24,14 +26,17 @@ test('renders one shared Semi filter sheet with consistent title, section and ac
</MobileFilterSheet>);
const dialog = screen.getByRole('dialog', { name: '测试筛选' });
expect(dialog.closest('.semi-sidesheet')).toHaveClass('v2-mobile-filter-sheet', 'test-filter-sheet');
expect(dialog).toHaveAttribute('id', 'test-filter-dialog');
expect(dialog.closest('.semi-sidesheet')).toHaveClass('v2-workspace-filter-sidesheet', 'v2-mobile-filter-sheet', 'test-filter-sheet');
expect(screen.getByText('按车辆和状态缩小范围')).toBeInTheDocument();
expect(screen.getByText('筛选范围')).toBeInTheDocument();
expect(screen.getByText('筛选条件仅在应用后更新当前结果')).toBeInTheDocument();
expect(screen.getByRole('region', { name: '车辆条件' })).toHaveTextContent('支持车牌或 VIN');
fireEvent.click(screen.getByRole('button', { name: '重置条件' }));
expect(screen.getByRole('dialog', { name: '测试筛选' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '应用并查询' }));
expect(onReset).toHaveBeenCalledTimes(1);
expect(onApply).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: 'close' }));
fireEvent.click(screen.getByRole('button', { name: '关闭测试筛选' }));
expect(onCancel).toHaveBeenCalledTimes(1);
});

View File

@@ -1,5 +1,6 @@
import { Button, SideSheet } from '@douyinfe/semi-ui';
import { useEffect, useId, type ReactNode } from 'react';
import { IconFilter } from '@douyinfe/semi-icons';
import { type ReactNode } from 'react';
import { WorkspaceSideSheet } from './WorkspaceSideSheet';
type MobileFilterSheetAction = {
label: string;
@@ -13,6 +14,8 @@ type MobileFilterSheetProps = {
visible: boolean;
height?: string | number;
ariaLabel: string;
dialogId?: string;
closeLabel?: string;
title: string;
description: string;
onCancel: () => void;
@@ -26,6 +29,8 @@ export function MobileFilterSheet({
visible,
height = 'min(82dvh, 700px)',
ariaLabel,
dialogId,
closeLabel,
title,
description,
onCancel,
@@ -34,40 +39,27 @@ export function MobileFilterSheet({
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
return <WorkspaceSideSheet
className={sheetClassName}
data-mobile-filter-sheet-id={sheetId}
variant="filter"
visible={visible}
ariaLabel={ariaLabel}
dialogId={dialogId}
closeLabel={closeLabel}
placement="bottom"
height={height}
aria-label={ariaLabel}
title={<div className="v2-mobile-filter-sheet-title"><strong>{title}</strong><span>{description}</span></div>}
title={title}
description={description}
icon={<IconFilter />}
badge="筛选范围"
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>}
footerNote="筛选条件仅在应用后更新当前结果"
secondaryActions={secondaryAction ? [secondaryAction] : []}
primaryAction={primaryAction}
>
{children}
</SideSheet>;
</WorkspaceSideSheet>;
}
type MobileFilterSheetSectionProps = {

View File

@@ -5,15 +5,16 @@ import { MobileFilterToggle } from './MobileFilterToggle';
describe('MobileFilterToggle', () => {
test('exposes the current filter summary and expanded state through a Semi button', () => {
const onToggle = vi.fn();
const { rerender } = render(<MobileFilterToggle summary="全部车辆 · 7 天" expanded={false} onToggle={onToggle} collapsedLabel="修改" />);
const { rerender } = render(<MobileFilterToggle summary="全部车辆 · 7 天" expanded={false} controls="vehicle-filter-dialog" onToggle={onToggle} collapsedLabel="修改" />);
const collapsed = screen.getByRole('button', { name: '修改筛选条件:全部车辆 · 7 天' });
expect(collapsed).toHaveClass('semi-button');
expect(collapsed).toHaveAttribute('aria-expanded', 'false');
expect(collapsed).toHaveAttribute('aria-controls', 'vehicle-filter-dialog');
fireEvent.click(collapsed);
expect(onToggle).toHaveBeenCalledTimes(1);
rerender(<MobileFilterToggle summary="已选 2 辆 · 7 天" expanded onToggle={onToggle} collapsedLabel="修改" />);
rerender(<MobileFilterToggle summary="已选 2 辆 · 7 天" expanded controls="vehicle-filter-dialog" onToggle={onToggle} collapsedLabel="修改" />);
expect(screen.getByRole('button', { name: '收起筛选条件:已选 2 辆 · 7 天' })).toHaveAttribute('aria-expanded', 'true');
});
});

View File

@@ -6,6 +6,7 @@ type MobileFilterToggleProps = {
summary: string;
expanded: boolean;
onToggle: () => void;
controls?: string;
expandedLabel?: string;
collapsedLabel?: string;
};
@@ -15,6 +16,7 @@ export function MobileFilterToggle({
summary,
expanded,
onToggle,
controls,
expandedLabel = '收起',
collapsedLabel = '展开'
}: MobileFilterToggleProps) {
@@ -26,6 +28,7 @@ export function MobileFilterToggle({
block
htmlType="button"
aria-expanded={expanded}
aria-controls={controls}
aria-label={`${actionLabel}${title}${summary}`}
onClick={onToggle}
>

View File

@@ -41,7 +41,7 @@ test('renders one accessible Semi configuration sheet with shared summary and ac
expect(onCancel).toHaveBeenCalledTimes(1);
});
test('exposes task, editor, help and action variants without changing the accessible dialog contract', () => {
test('exposes task, editor, help, action and filter variants without changing the accessible dialog contract', () => {
const view = render(<>
<WorkspaceSideSheet
visible
@@ -84,16 +84,28 @@ test('exposes task, editor, help and action variants without changing the access
>
<span></span>
</WorkspaceSideSheet>
<WorkspaceSideSheet
visible
variant="filter"
ariaLabel="范围筛选"
title="筛选"
description="应用后更新"
onCancel={() => undefined}
>
<span></span>
</WorkspaceSideSheet>
</>);
expect(screen.getByRole('dialog', { name: '后台任务' }).closest('.semi-sidesheet')).toHaveClass('v2-workspace-task-sidesheet');
expect(screen.getByRole('dialog', { name: '策略编辑' }).closest('.semi-sidesheet')).toHaveClass('v2-workspace-editor-sidesheet');
expect(screen.getByRole('dialog', { name: '页面帮助' }).closest('.semi-sidesheet')).toHaveClass('v2-workspace-help-sidesheet');
expect(screen.getByRole('dialog', { name: '快捷操作' }).closest('.semi-sidesheet')).toHaveClass('v2-workspace-action-sidesheet');
expect(screen.getByRole('dialog', { name: '范围筛选' }).closest('.semi-sidesheet')).toHaveClass('v2-workspace-filter-sidesheet');
expect(screen.getByText('任务内容')).toBeInTheDocument();
expect(screen.getByText('编辑内容')).toBeInTheDocument();
expect(screen.getByText('帮助内容')).toBeInTheDocument();
expect(screen.getByText('操作内容')).toBeInTheDocument();
expect(screen.getByText('筛选内容')).toBeInTheDocument();
expect(screen.getByTestId('help-icon').closest('.v2-workspace-config-title-icon')).toHaveAttribute('aria-hidden', 'true');
expect(view.baseElement).toHaveTextContent('查看生成进度');
expect(view.baseElement).toHaveTextContent('保存后生效');

View File

@@ -21,7 +21,7 @@ export type WorkspaceSideSheetBadgeColor = 'blue' | 'green' | 'orange' | 'red' |
export type WorkspaceSideSheetProps = {
className?: string;
variant?: 'config' | 'detail' | 'task' | 'editor' | 'help' | 'action';
variant?: 'config' | 'detail' | 'task' | 'editor' | 'help' | 'action' | 'filter';
visible: boolean;
ariaLabel: string;
closeLabel?: string;

View File

@@ -20599,14 +20599,14 @@
* Shared Semi UI configuration SideSheet: one hierarchy for strategy,
* column and source-policy work across desktop and mobile.
*/
.v2-workspace-config-sidesheet .semi-sidesheet-inner {
:is(.v2-workspace-config-sidesheet, .v2-workspace-filter-sidesheet) .semi-sidesheet-inner {
overflow: hidden;
border-left: 1px solid #d9e3ef;
background: #f5f7fa;
box-shadow: -22px 0 64px rgba(24, 43, 68, .16);
}
.v2-workspace-config-sidesheet .semi-sidesheet-header {
:is(.v2-workspace-config-sidesheet, .v2-workspace-filter-sidesheet) .semi-sidesheet-header {
min-height: 78px;
border-bottom: 1px solid #e1e8f1;
background:
@@ -20615,7 +20615,7 @@
padding: 14px 18px;
}
.v2-workspace-config-sidesheet .semi-sidesheet-title {
:is(.v2-workspace-config-sidesheet, .v2-workspace-filter-sidesheet) .semi-sidesheet-title {
min-width: 0;
}
@@ -20667,7 +20667,7 @@
font-weight: 700;
}
.v2-workspace-config-sidesheet .semi-sidesheet-body {
:is(.v2-workspace-config-sidesheet, .v2-workspace-filter-sidesheet) .semi-sidesheet-body {
min-height: 0;
overflow: hidden;
background: #f5f7fa;
@@ -20760,7 +20760,7 @@
scrollbar-gutter: stable;
}
.v2-workspace-config-sidesheet .semi-sidesheet-footer {
:is(.v2-workspace-config-sidesheet, .v2-workspace-filter-sidesheet) .semi-sidesheet-footer {
min-height: 64px;
border-top: 1px solid #e1e8f1;
background: rgba(255, 255, 255, .97);
@@ -20981,14 +20981,14 @@
padding: 0;
}
.v2-workspace-config-sidesheet.semi-sidesheet-bottom .semi-sidesheet-inner {
:is(.v2-workspace-config-sidesheet, .v2-workspace-filter-sidesheet).semi-sidesheet-bottom .semi-sidesheet-inner {
border: 1px solid #d8e2ed;
border-bottom: 0;
border-radius: 18px 18px 0 0;
box-shadow: 0 -18px 48px rgba(23, 43, 68, .17);
}
.v2-workspace-config-sidesheet.semi-sidesheet-bottom .semi-sidesheet-header {
:is(.v2-workspace-config-sidesheet, .v2-workspace-filter-sidesheet).semi-sidesheet-bottom .semi-sidesheet-header {
min-height: 68px;
padding: 10px 12px 10px 14px;
}
@@ -21032,7 +21032,7 @@
padding: 8px;
}
.v2-workspace-config-sidesheet.semi-sidesheet-bottom .semi-sidesheet-footer {
:is(.v2-workspace-config-sidesheet, .v2-workspace-filter-sidesheet).semi-sidesheet-bottom .semi-sidesheet-footer {
min-height: 58px;
padding: 7px 9px calc(7px + env(safe-area-inset-bottom));
}
@@ -21971,3 +21971,159 @@
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
/*
* Unified Semi UI filter workbench.
* Desktop advanced ranges and mobile discovery sheets now share one identity,
* one scroll owner and one explicit draft-to-apply action boundary.
*/
.v2-workspace-filter-sidesheet .semi-sidesheet-inner {
background: #f4f7fb;
}
.v2-workspace-filter-sidesheet .semi-sidesheet-header {
background:
radial-gradient(circle at 10% 0%, rgba(18, 104, 243, .1), transparent 38%),
linear-gradient(180deg, #fff 0%, #fbfcfe 100%);
}
.v2-workspace-filter-sidesheet .v2-workspace-config-title-icon {
border-color: #cfe0f7;
background: linear-gradient(145deg, #f4f8ff 0%, #e7f0ff 100%);
}
.v2-workspace-filter-sidesheet .v2-workspace-config-summary {
min-height: 74px;
}
.v2-workspace-filter-sidesheet .v2-workspace-config-summary strong {
font-size: 12px;
}
.v2-workspace-filter-sidesheet .v2-workspace-config-content {
padding: 10px;
}
.v2-workspace-filter-sidesheet .v2-alert-advanced-filter {
gap: 13px;
border: 1px solid #dce5ef;
border-radius: 12px;
background: #fff;
padding: 14px;
box-shadow: 0 8px 24px rgba(31, 53, 80, .05);
}
.v2-workspace-filter-sidesheet .v2-alert-advanced-filter label {
gap: 6px;
}
@media (max-width: 680px) {
.v2-workspace-filter-sidesheet.semi-sidesheet-bottom .semi-sidesheet-inner {
width: 100% !important;
max-width: 100%;
padding-bottom: env(safe-area-inset-bottom);
}
.v2-workspace-filter-sidesheet .v2-workspace-config-title {
gap: 8px;
}
.v2-workspace-filter-sidesheet .v2-workspace-config-title-icon {
width: 34px;
height: 34px;
border-radius: 10px;
font-size: 16px;
}
.v2-workspace-filter-sidesheet .v2-workspace-config-title strong {
font-size: 16px;
}
.v2-workspace-filter-sidesheet .v2-workspace-config-title small {
max-width: min(48vw, 210px);
}
.v2-workspace-filter-sidesheet .v2-workspace-config-content {
padding: 8px;
}
.v2-workspace-filter-sidesheet .v2-mobile-filter-sheet-section {
border-color: #dce5ef;
border-radius: 11px;
box-shadow: 0 7px 22px rgba(31, 53, 80, .045);
}
.v2-source-mobile-filter-sidesheet .v2-source-candidates.v2-vehicle-candidate-list {
position: static;
max-height: min(390px, 43dvh);
}
.v2-source-mobile-filter-empty {
display: grid;
min-height: 190px;
place-content: center;
justify-items: center;
gap: 7px;
border: 1px dashed #cad8e9;
border-radius: 12px;
background:
radial-gradient(circle at 50% 0%, rgba(18, 104, 243, .07), transparent 48%),
rgba(255, 255, 255, .72);
color: #7b8b9f;
padding: 20px;
text-align: center;
}
.v2-source-mobile-filter-empty > i {
display: grid;
width: 42px;
height: 42px;
place-items: center;
border: 1px solid #d4e2f5;
border-radius: 12px;
background: #eef5ff;
color: #1268f3;
font-size: 18px;
}
.v2-source-mobile-filter-empty > strong {
color: #2b4058;
font-size: 13px;
}
.v2-source-mobile-filter-empty > small {
max-width: 250px;
color: #7c8b9e;
font-size: 9px;
line-height: 1.55;
}
}
@media (max-width: 900px) and (max-height: 480px) and (orientation: landscape) {
.v2-workspace-filter-sidesheet.semi-sidesheet-bottom .semi-sidesheet-inner {
height: 100dvh !important;
max-height: 100dvh;
border-radius: 0;
}
.v2-workspace-filter-sidesheet.semi-sidesheet-bottom .semi-sidesheet-header {
min-height: 54px;
padding: 7px 10px 7px 12px;
}
.v2-workspace-filter-sidesheet .v2-workspace-config-title-icon,
.v2-workspace-filter-sidesheet .v2-workspace-config-title small,
.v2-workspace-filter-sidesheet .v2-workspace-config-title > .semi-tag,
.v2-workspace-filter-sidesheet .v2-mobile-filter-sheet-section > header {
display: none;
}
.v2-workspace-filter-sidesheet .v2-workspace-config-content {
padding: 7px;
}
.v2-source-mobile-filter-empty {
min-height: 88px;
padding: 10px;
}
}