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}