refine Semi UI alert workflow

This commit is contained in:
lingniu
2026-07-18 22:58:07 +08:00
parent 2824c08eb6
commit a7297abdaf
3 changed files with 249 additions and 27 deletions

View File

@@ -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(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
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 () => {

View File

@@ -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 = <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 = <>
<label><span></span><Input prefix={<IconSearch />} value={draft.keyword} onChange={(value) => setDraft({ ...draft, keyword: value })} placeholder="车牌 / VIN / 规则名称" /></label>
<label><span id="alert-severity-label"></span><Select aria-labelledby="alert-severity-label" value={draft.severity} onChange={(value) => setDraft({ ...draft, severity: String(value) })} optionList={[{ value: '', label: '全部' }, { value: 'critical', label: '紧急' }, { value: 'major', label: '重要' }, { value: 'minor', label: '一般' }]} /></label>
<label><span id="alert-status-label"></span><Select aria-labelledby="alert-status-label" value={draft.status} onChange={(value) => setDraft({ ...draft, status: String(value) })} optionList={[{ value: '', label: '全部' }, ...Object.entries(statusLabels).map(([value, label]) => ({ value, label }))]} /></label>
</>;
const advancedFilterFields = <>
<label><span id="alert-rule-label"></span><Select aria-labelledby="alert-rule-label" value={draft.ruleId} onChange={(value) => setDraft({ ...draft, ruleId: String(value) })} optionList={[{ value: '', label: '全部规则' }, ...rules.map((rule) => ({ value: rule.id, label: rule.name }))]} /></label>
<label><span id="alert-protocol-label"></span><Select aria-labelledby="alert-protocol-label" value={draft.protocol} onChange={(value) => setDraft({ ...draft, protocol: String(value) })} optionList={[{ value: '', label: '全部协议' }, ...PROTOCOLS.map((item) => ({ value: item, label: item }))]} /></label>
<label><span></span><Input aria-label="告警起始时间" type="datetime-local" value={draft.dateFrom} onChange={(value) => setDraft({ ...draft, dateFrom: value })} /></label>
<label><span></span><Input aria-label="告警结束时间" type="datetime-local" value={draft.dateTo} onChange={(value) => setDraft({ ...draft, dateTo: value })} /></label>
</>;
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 <>
<WorkspaceFilterPanel
{mobileLayout ? <div className="v2-alert-mobile-discovery">
<MobileFilterToggle
title="事件筛选"
summary={activeFilterCount ? `已启用 ${activeFilterCount}` : '全部告警'}
expanded={mobileFiltersOpen}
expandedLabel="关闭"
collapsedLabel="修改"
onToggle={() => setFiltersCollapsed((value) => !value)}
/>
<SideSheet
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>}
onCancel={() => setFiltersCollapsed(true)}
footer={<div className="v2-alert-mobile-filter-footer"><Button theme="light" onClick={resetFilters}></Button><Button theme="solid" onClick={applyDraft}></Button></div>}
>
<form className="v2-alert-mobile-filter-form" onSubmit={submit}>
<section className="v2-alert-mobile-filter-section" aria-label="常用筛选">
<header><strong></strong><span></span></header>
<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>
<div className="v2-alert-mobile-filter-grid">{advancedFilterFields}</div>
</section>
</form>
</SideSheet>
</div> : <WorkspaceFilterPanel
className="v2-alert-filter-card"
title="事件筛选"
description="关键词、严重程度、处理状态与高级范围"
@@ -222,22 +273,15 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e
onToggle={() => setFiltersCollapsed((value) => !value)}
>
<form className="v2-alert-filter v2-alert-filter-primary" onSubmit={submit}>
<label><span></span><Input prefix={<IconSearch />} value={draft.keyword} onChange={(value) => setDraft({ ...draft, keyword: value })} placeholder="车牌 / VIN / 规则名称" /></label>
<label><span id="alert-severity-label"></span><Select aria-labelledby="alert-severity-label" value={draft.severity} onChange={(value) => setDraft({ ...draft, severity: String(value) })} optionList={[{ value: '', label: '全部' }, { value: 'critical', label: '紧急' }, { value: 'major', label: '重要' }, { value: 'minor', label: '一般' }]} /></label>
<label><span id="alert-status-label"></span><Select aria-labelledby="alert-status-label" value={draft.status} onChange={(value) => setDraft({ ...draft, status: String(value) })} optionList={[{ value: '', label: '全部' }, ...Object.entries(statusLabels).map(([value, label]) => ({ value, label }))]} /></label>
{coreFilterFields}
<Button className="v2-alert-more-filter" theme="light" htmlType="button" icon={<IconFilter />} aria-haspopup="dialog" aria-controls="v2-alert-advanced-filters" aria-expanded={advancedOpen} onClick={() => setAdvancedOpen(true)}>{advancedFilterCount ? ` · ${advancedFilterCount}` : ''}</Button>
<Button className="v2-primary-button" theme="solid" htmlType="submit"></Button>
<Button className="v2-secondary-button" theme="light" htmlType="button" onClick={resetFilters}></Button>
</form>
</WorkspaceFilterPanel>
</WorkspaceFilterPanel>}
{summary.isError ? <InlineError message={summary.error instanceof Error ? summary.error.message : '告警汇总读取失败'} onRetry={() => summary.refetch()} /> : null}
<Card className="v2-alert-kpis-card v2-alert-context-card" bodyStyle={{ padding: 0 }}>
<section className="v2-alert-kpis" aria-label="活跃告警概览">{[
{ 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) => {
<section className="v2-alert-kpis" aria-label="活跃告警概览">{primaryMetrics.map((item) => {
const value = Number(item.value ?? 0).toLocaleString('zh-CN');
return <MetricActionButton key={item.label} label={item.label} value={value} tone={item.tone} active={item.status !== 'notice' && filters.status === item.status} ariaLabel={item.status === 'notice' ? `查看未读通知,共 ${value}` : `筛选${item.label},共 ${value}`} onClick={() => item.status === 'notice' ? onTab('notifications') : quickStatus(item.status)} />;
})}</section>
@@ -273,24 +317,19 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e
</Card>
{!mobileLayout && selectedID ? inspector : null}
</div>
<SideSheet
{!mobileLayout ? <SideSheet
className="v2-alert-filter-sidesheet"
visible={advancedOpen}
aria-label="告警高级筛选"
placement={mobileLayout ? 'bottom' : 'right'}
width={mobileLayout ? undefined : 430}
height={mobileLayout ? 'min(72dvh, 620px)' : undefined}
placement="right"
width={430}
title={<div className="v2-alert-sheet-title"><strong></strong><span></span></div>}
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">
<label><span id="alert-rule-label"></span><Select aria-labelledby="alert-rule-label" value={draft.ruleId} onChange={(value) => setDraft({ ...draft, ruleId: String(value) })} optionList={[{ value: '', label: '全部规则' }, ...rules.map((rule) => ({ value: rule.id, label: rule.name }))]} /></label>
<label><span id="alert-protocol-label"></span><Select aria-labelledby="alert-protocol-label" value={draft.protocol} onChange={(value) => setDraft({ ...draft, protocol: String(value) })} optionList={[{ value: '', label: '全部协议' }, ...PROTOCOLS.map((item) => ({ value: item, label: item }))]} /></label>
<label><span></span><Input aria-label="告警起始时间" type="datetime-local" value={draft.dateFrom} onChange={(value) => setDraft({ ...draft, dateFrom: value })} /></label>
<label><span></span><Input aria-label="告警结束时间" type="datetime-local" value={draft.dateTo} onChange={(value) => setDraft({ ...draft, dateTo: value })} /></label>
</div>
<div className="v2-alert-advanced-filter">{advancedFilterFields}</div>
</SideSheet>
: null}
<SideSheet
className="v2-alert-detail-sidesheet"
visible={mobileLayout && Boolean(selectedID)}

View File

@@ -16748,3 +16748,171 @@
grid-column: 1 / -1;
}
}
/*
* Semi UI alert mobile workflow.
* Keep the event list as the primary task: all filters live in one focused
* bottom sheet and only actionable event states occupy the main metric rail.
*/
@media (max-width: 680px) {
.v2-alert-mobile-discovery {
display: grid;
min-width: 0;
flex: 0 0 auto;
gap: 8px;
}
.v2-alert-mobile-discovery > .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;
}
}