From a7297abdafceba46c00115f7bb12199a7a114f3f Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 18 Jul 2026 22:58:07 +0800 Subject: [PATCH] refine Semi UI alert workflow --- .../apps/web/src/v2/pages/AlertsPage.test.tsx | 25 ++- .../apps/web/src/v2/pages/AlertsPage.tsx | 83 ++++++--- .../apps/web/src/v2/styles/workspace.css | 168 ++++++++++++++++++ 3 files changed, 249 insertions(+), 27 deletions(-) diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.test.tsx index acd43638..43cbd051 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.test.tsx @@ -254,6 +254,9 @@ test('renders only selectable Semi alert cards on mobile', async () => { expect(view.container.querySelector('.v2-alert-table-scroll.is-mobile-scroll')).toHaveAttribute('tabindex', '0'); expect(view.container.querySelector('.v2-alert-table-scroll.is-mobile-scroll')).toHaveAttribute('aria-label', '告警事件列表,可上下滚动'); expect(view.container.querySelector('.v2-alert-event-table')).not.toBeInTheDocument(); + expect(view.container.querySelectorAll('.v2-alert-kpis .v2-metric-action')).toHaveLength(3); + expect(screen.queryByRole('button', { name: /查看未读通知/ })).not.toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /站内通知/ })).toBeInTheDocument(); expect(action).toHaveAttribute('aria-expanded', 'false'); expect(action).toHaveTextContent('超限幅度+10 km/h'); expect(action.querySelector('.v2-alert-protocol-tag.semi-tag')).toHaveTextContent('JT/T 808'); @@ -298,7 +301,7 @@ test('keeps the primary alert query compact and applies advanced filters from a expect(screen.getByRole('button', { name: /更多筛选 · 1/ })).toHaveAttribute('aria-expanded', 'false'); }); -test('uses the shared Semi bottom SideSheet pattern for mobile advanced filters', async () => { +test('uses one focused Semi bottom SideSheet for the complete mobile event filter', async () => { layout.mobile = true; mocks.alertSummaryV2.mockResolvedValue({ active: 0, unprocessed: 0, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' }); mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 20, offset: 0 }); @@ -308,11 +311,23 @@ test('uses the shared Semi bottom SideSheet pattern for mobile advanced filters' render(); await screen.findByText('当前筛选条件没有告警事件'); - fireEvent.click(screen.getByRole('button', { name: /更多筛选/ })); + const trigger = screen.getByRole('button', { name: /修改事件筛选/ }); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(trigger); - expect(await screen.findByRole('dialog', { name: '告警高级筛选' })).toBeInTheDocument(); - expect(document.querySelector('.v2-alert-filter-sidesheet')).toHaveClass('semi-sidesheet-bottom'); - expect(document.querySelector('.v2-alert-filter-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(72dvh, 620px)' }); + const dialog = await screen.findByRole('dialog', { name: '告警事件筛选' }); + expect(document.querySelector('.v2-alert-mobile-filter-sidesheet')).toHaveClass('semi-sidesheet-bottom'); + expect(document.querySelector('.v2-alert-mobile-filter-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(82dvh, 690px)' }); + expect(within(dialog).getByRole('textbox', { name: /关键词/ })).toBeInTheDocument(); + expect(within(dialog).getByRole('combobox', { name: '协议' })).toBeInTheDocument(); + expect(within(dialog).getByLabelText('告警起始时间')).toBeInTheDocument(); + expect(within(dialog).getByLabelText('告警结束时间')).toBeInTheDocument(); + fireEvent.click(within(dialog).getByRole('combobox', { name: '协议' })); + fireEvent.click(await screen.findByText('JT808')); + fireEvent.click(within(dialog).getByRole('button', { name: '应用并查询' })); + + await waitFor(() => expect(mocks.alertEventsV2).toHaveBeenLastCalledWith(expect.objectContaining({ protocol: 'JT808' }), expect.anything())); + expect(screen.getByRole('button', { name: /修改事件筛选:已启用 1 项/ })).toHaveAttribute('aria-expanded', 'false'); }); test('keeps mobile rule selection focused and opens editing in a Semi bottom SideSheet', async () => { diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx index 4be28515..0ce5bc8a 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx @@ -8,6 +8,7 @@ import type { AlertEvent, AlertQuery, AlertRule, AlertRuleInput, AlertStatus, Me import { actionLabels, alertDeltaText, alertValue, canAct, formatAlertTime, operatorLabels, ruleCondition, severityLabels, statusLabels, thresholdText } from '../domain/alert'; import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState'; import { MetricActionButton } from '../shared/MetricActionButton'; +import { MobileFilterToggle } from '../shared/MobileFilterToggle'; import { ProtocolTag } from '../shared/ProtocolTag'; import { SegmentedTabs } from '../shared/SegmentedTabs'; import { TablePagination } from '../shared/TablePagination'; @@ -188,7 +189,9 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e const closeDetail = () => { setSelection(undefined); setNote(''); }; const selectEvent = useCallback((id: string) => setSelection({ scope: eventScope, id }), [eventScope]); const selectedEvent = detail.data ?? rows.find((row) => row.id === selectedID); - useSideSheetA11y(advancedOpen, '.v2-alert-filter-sidesheet', 'v2-alert-advanced-filters', '告警高级筛选', '关闭告警高级筛选'); + 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', '告警事件筛选', '关闭告警事件筛选'); useSideSheetA11y(mobileLayout && Boolean(selectedID), '.v2-alert-detail-sidesheet', 'v2-alert-detail', '告警事件详情', '关闭告警详情'); const applyDraft = () => { setFilters(draft); @@ -201,6 +204,7 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e setFilters(EMPTY_FILTERS); setOffset(0); setAdvancedOpen(false); + setFiltersCollapsed(true); }; const quickStatus = (status: string) => { const next = { ...filters, status }; setDraft(next); setFilters(next); setOffset(0); }; const totalPages = Math.max(1, Math.ceil((events.data?.total ?? 0) / limit)); @@ -210,8 +214,55 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e const advancedFilterCount = [filters.ruleId, filters.protocol, filters.dateFrom, filters.dateTo].filter(Boolean).length; const allEventCount = Number(sums?.active ?? 0) + Number(sums?.recovered ?? 0) + Number(sums?.closed ?? 0) + Number(sums?.ignored ?? 0); const inspector = { const event = detail.data; if (event) action.mutate({ name, event }); }} onClose={closeDetail} />; + const coreFilterFields = <> + + + ; + const advancedFilterFields = <> + + + + ; + const primaryMetrics = [ + { label: '全部事件', value: allEventCount, tone: 'active', status: '' }, + { label: '未处理', value: sums?.unprocessed, tone: 'unprocessed', status: 'unprocessed' }, + { label: '处理中', value: sums?.processing, tone: 'processing', status: 'processing' }, + ...(mobileLayout ? [] : [{ label: '未读通知', value: unread, tone: 'notice', status: 'notice' }]) + ]; return <> - + setFiltersCollapsed((value) => !value)} + /> + 筛选告警事件车辆、状态、规则与时间范围} + onCancel={() => setFiltersCollapsed(true)} + footer={
} + > +
+
+
常用条件快速定位车辆与待处置事件
+
{coreFilterFields}
+
+
+
精确范围按规则、协议和触发时间进一步收窄
+
{advancedFilterFields}
+
+
+
+ : setFiltersCollapsed((value) => !value)} >
- - + {coreFilterFields}
-
+
} {summary.isError ? summary.refetch()} /> : null} -
{[ - { label: '全部事件', value: allEventCount, tone: 'active', status: '' }, - { label: '未处理', value: sums?.unprocessed, tone: 'unprocessed', status: 'unprocessed' }, - { label: '处理中', value: sums?.processing, tone: 'processing', status: 'processing' }, - { label: '未读通知', value: unread, tone: 'notice', status: 'notice' } - ].map((item) => { +
{primaryMetrics.map((item) => { const value = Number(item.value ?? 0).toLocaleString('zh-CN'); return item.status === 'notice' ? onTab('notifications') : quickStatus(item.status)} />; })}
@@ -273,24 +317,19 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e {!mobileLayout && selectedID ? inspector : null} - 更多筛选按规则、协议和触发时间收窄告警范围} onCancel={() => setAdvancedOpen(false)} footer={
} > -
- - - -
+
{advancedFilterFields}
+ : null} .v2-mobile-filter-toggle.semi-button { + min-height: 58px; + margin: 0; + } + + .v2-alert-mobile-filter-sidesheet .semi-sidesheet-inner { + width: 100vw !important; + overflow: hidden; + border-radius: 18px 18px 0 0; + background: #f4f7fb; + box-shadow: 0 -20px 56px rgba(25, 45, 72, .2); + } + + .v2-alert-mobile-filter-sidesheet.semi-sidesheet-bottom .semi-sidesheet-header { + min-height: 68px; + border-bottom: 1px solid #dfe7f0; + background: rgba(255, 255, 255, .98); + padding: 11px 14px; + } + + .v2-alert-mobile-filter-sidesheet.semi-sidesheet-bottom .semi-sidesheet-body { + overflow: auto; + background: #f4f7fb; + padding: 12px; + overscroll-behavior: contain; + } + + .v2-alert-mobile-filter-sidesheet.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-alert-mobile-filter-form { + display: grid; + gap: 10px; + } + + .v2-alert-mobile-filter-section { + display: grid; + gap: 12px; + border: 1px solid #dfe7f0; + border-radius: 12px; + background: #fff; + padding: 12px; + box-shadow: 0 8px 24px rgba(31, 53, 80, .055); + } + + .v2-alert-mobile-filter-section > header { + display: flex; + min-width: 0; + flex-direction: column; + gap: 3px; + } + + .v2-alert-mobile-filter-section > header > strong { + color: #2b4058; + font-size: 13px; + line-height: 1.3; + } + + .v2-alert-mobile-filter-section > header > span { + color: #7c8b9e; + font-size: 10px; + line-height: 1.45; + } + + .v2-alert-mobile-filter-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 11px 9px; + } + + .v2-alert-mobile-filter-grid.is-primary > label:first-child, + .v2-alert-mobile-filter-grid > label:nth-last-child(-n + 2) { + grid-column: 1 / -1; + } + + .v2-alert-mobile-filter-grid > label { + display: grid; + min-width: 0; + gap: 6px; + color: #5f7188; + font-size: 11px; + font-weight: 650; + } + + .v2-alert-mobile-filter-grid > label > .semi-input-wrapper, + .v2-alert-mobile-filter-grid > label > .semi-select { + width: 100%; + min-height: 44px; + border-radius: 9px; + background: #fff; + } + + .v2-alert-mobile-filter-grid .semi-input, + .v2-alert-mobile-filter-grid .semi-select-selection-text { + font-size: 14px; + } + + .v2-alert-mobile-filter-footer { + display: grid; + width: 100%; + grid-template-columns: minmax(0, .72fr) minmax(0, 1.28fr); + gap: 9px; + } + + .v2-alert-mobile-filter-footer > .semi-button { + width: 100%; + min-height: 42px; + border-radius: 9px; + font-weight: 700; + } + + .v2-alert-context-card.semi-card { + min-height: 96px; + } + + .v2-alert-context-card .v2-alert-kpis { + height: 62px; + min-height: 62px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .v2-alert-context-card .v2-alert-kpis .v2-metric-action, + .v2-alert-context-card .v2-alert-kpis .v2-metric-action-content { + min-height: 62px; + } + + .v2-alert-context-card .v2-alert-kpis .v2-metric-action { + padding: 6px 4px; + } + + .v2-alert-context-card .v2-alert-kpis .v2-metric-action-content { + gap: 5px; + } + + .v2-alert-context-card .v2-alert-kpis small { + color: #687991; + font-size: 10px; + font-weight: 650; + } + + .v2-alert-context-card .v2-alert-kpis strong { + color: #28415f; + font-size: 18px; + } + + .v2-alert-context-card .v2-alert-secondary-states { + min-height: 34px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + padding: 3px 4px; + } +}