polish alert operations workspace

This commit is contained in:
lingniu
2026-07-18 11:24:24 +08:00
parent 622c4398ec
commit 0802b1989a
6 changed files with 86 additions and 22 deletions

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { alertValue, canAct, ruleCondition, thresholdText } from './alert';
import { alertValue, canAct, formatAlertTime, ruleCondition, thresholdText } from './alert';
describe('alert domain helpers', () => {
it('keeps trigger evidence and duration explicit', () => {
@@ -23,4 +23,8 @@ describe('alert domain helpers', () => {
expect(thresholdText({ threshold: 20, thresholdHigh: 80, operator: 'between', unit: '%', durationSec: 30 })).toBe('区间内 2080 %,持续 30 秒');
expect(ruleCondition({ valueType: 'boolean', metric: 'alarm_active', operator: 'changed', durationSec: 0 } as never)).toBe('协议告警位 状态变化');
});
it('renders operational timestamps in the shared Shanghai timezone', () => {
expect(formatAlertTime('2026-07-18T02:33:00Z')).toBe('07-18 10:33:00');
});
});

View File

@@ -1,7 +1,15 @@
import type { AlertEvent, AlertRule, AlertSeverity, AlertStatus } from '../../api/types';
import { formatZhNumber } from './formatters';
const alertTimeFormatter = new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
const alertTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
timeZone: 'Asia/Shanghai'
});
export const severityLabels: Record<AlertSeverity, string> = { critical: '紧急', major: '重要', minor: '一般' };
export const statusLabels: Record<AlertStatus, string> = { unprocessed: '未处理', processing: '处理中', recovered: '已恢复', closed: '已关闭', ignored: '已忽略' };

View File

@@ -140,8 +140,12 @@ test('clears old alert rows and inspector evidence when the event scope changes'
expect(view.container.querySelectorAll('.v2-alert-secondary-states .semi-button')).toHaveLength(3);
expect(screen.getByRole('button', { name: '查看未读通知,共 0 条' })).toHaveClass('is-notice');
expect(view.container.querySelector('.v2-alert-event-table.semi-table-wrapper')).toBeInTheDocument();
expect(view.container.querySelector('.v2-alert-event-table.semi-table-wrapper')).not.toHaveClass('is-compact');
expect(screen.getByRole('columnheader', { name: '触发 / 阈值' })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: '位置' })).toBeInTheDocument();
expect(view.container.querySelector('.v2-alert-table-scroll > table')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-alert-mobile-list')).not.toBeInTheDocument();
expect(view.container.querySelector('time[datetime="2026-07-16T04:00:00Z"]')).toHaveTextContent('07-16 12:00:00');
const desktopAlertRow = screen.getByTestId('alert-row-old-event');
expect(desktopAlertRow).toHaveAttribute('role', 'button');
expect(desktopAlertRow).toHaveAttribute('aria-expanded', 'false');
@@ -150,6 +154,10 @@ test('clears old alert rows and inspector evidence when the event scope changes'
expect(await screen.findByText('old-event')).toBeInTheDocument();
expect(view.container.querySelector('.v2-alert-inspector.semi-card')).toBeInTheDocument();
expect(view.container.querySelector('.v2-alert-workspace')).toHaveClass('is-inspector-open');
expect(view.container.querySelector('.v2-alert-event-table.semi-table-wrapper')).toHaveClass('is-compact');
expect(screen.queryByRole('columnheader', { name: '触发 / 阈值' })).not.toBeInTheDocument();
expect(screen.queryByRole('columnheader', { name: '位置' })).not.toBeInTheDocument();
expect(screen.getByText('已展开处置详情 · 点击其他事件快速切换')).toBeInTheDocument();
expect(desktopAlertRow).toHaveAttribute('aria-expanded', 'true');
expect(view.container.querySelector('.v2-alert-focus-card[aria-label="告警处置摘要"]')).toHaveClass('v2-alert-detail-card');
expect(view.container.querySelector('.v2-alert-technical-collapse.semi-collapse')).toBeInTheDocument();
@@ -160,6 +168,9 @@ test('clears old alert rows and inspector evidence when the event scope changes'
expect(screen.getByText('规则首次命中')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '关闭告警详情' }));
expect(view.container.querySelector('.v2-alert-inspector')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-alert-event-table.semi-table-wrapper')).not.toHaveClass('is-compact');
expect(screen.getByRole('columnheader', { name: '触发 / 阈值' })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: '位置' })).toBeInTheDocument();
expect(desktopAlertRow).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(desktopAlertRow);
expect(await screen.findByText('old-event')).toBeInTheDocument();
@@ -269,6 +280,7 @@ test('creates auditable drafts from simple offline and hydrogen rule templates',
expect(view.container.querySelector('.v2-alert-rule-editor')).toHaveClass('semi-card');
expect(view.container.querySelector('.v2-alert-rule-list-heading')).toHaveClass('v2-workspace-panel-header');
expect(view.container.querySelector('.v2-alert-rule-editor-heading')).toHaveClass('v2-workspace-panel-header');
expect(screen.getByRole('region', { name: '告警规则列表,可左右滚动选择' })).toHaveAttribute('tabindex', '0');
expect(ruleItem).toHaveClass('semi-button', 'v2-alert-rule-item');
await waitFor(() => expect(ruleItem).toHaveAttribute('aria-pressed', 'true'));
expect(within(ruleItem).getByText('重要').closest('.semi-tag')).toBeTruthy();

View File

@@ -1,7 +1,7 @@
import { IconAlarm, IconBell, IconChevronRight, IconClose, IconFilter, IconPlus, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FormEvent, useEffect, useMemo, useState } from 'react';
import { FormEvent, memo, useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { AlertEvent, AlertQuery, AlertRule, AlertRuleInput, AlertStatus, MetricDefinition, Page } from '../../api/types';
@@ -63,11 +63,18 @@ function StatusTag({ status }: Pick<AlertEvent, 'status'>) {
return <Tag className={`v2-alert-status is-${status}`} color={color} type="light" size="small">{statusLabels[status]}</Tag>;
}
function AlertEventTable({ rows, selectedID, onSelect }: { rows: AlertEvent[]; selectedID: string; onSelect: (id: string) => void }) {
const columns = useMemo(() => [
function AlertTime({ value, className = '' }: { value?: string; className?: string }) {
const classes = ['v2-alert-time', className].filter(Boolean).join(' ');
if (!value) return <span className={classes}></span>;
return <time className={classes} dateTime={value} title={`${value} · 上海时间`}>{formatAlertTime(value)}</time>;
}
const AlertEventTable = memo(function AlertEventTable({ rows, selectedID, compact, onSelect }: { rows: AlertEvent[]; selectedID: string; compact: boolean; onSelect: (id: string) => void }) {
const columns = useMemo(() => {
const allColumns = [
{
title: '', dataIndex: 'selection', width: 42,
render: (_: unknown, event: AlertEvent) => <Radio name="alert-event" checked={selectedID === event.id} onChange={() => onSelect(event.id)} aria-label={`选择 ${event.ruleName}`} />
render: (_: unknown, event: AlertEvent) => <Radio name="alert-event" checked={selectedID === event.id} onChange={() => onSelect(event.id)} aria-label={`选择 ${event.plate || event.vin} ${event.ruleName}`} />
},
{ title: '严重程度', dataIndex: 'severity', width: 90, render: (_: AlertEvent['severity'], event: AlertEvent) => <SeverityTag severity={event.severity} /> },
{
@@ -76,17 +83,19 @@ function AlertEventTable({ rows, selectedID, onSelect }: { rows: AlertEvent[]; s
},
{ title: '规则', dataIndex: 'ruleName', width: 170, render: (value: string) => <span className="v2-alert-event-rule" title={value}>{value}</span> },
{ title: '协议', dataIndex: 'protocol', width: 92, render: (value: string) => value || '—' },
{ title: '触发时间', dataIndex: 'triggeredAt', width: 132, render: (value: string) => formatAlertTime(value) },
{ title: '触发时间', dataIndex: 'triggeredAt', width: 132, render: (value: string) => <AlertTime value={value} /> },
{ title: '状态', dataIndex: 'status', width: 90, render: (_: AlertEvent['status'], event: AlertEvent) => <StatusTag status={event.status} /> },
{
title: '触发 / 阈值', dataIndex: 'evidence', width: 164,
render: (_: unknown, event: AlertEvent) => <div className="v2-alert-event-evidence"><strong>{alertValue(event)}</strong><small>{thresholdText(event)}</small></div>
},
{ title: '位置', dataIndex: 'location', width: 180, render: (value: string) => <span className="v2-alert-event-location" title={value}>{value || '—'}</span> },
], [onSelect, selectedID]);
];
return compact ? allColumns.filter((column) => column.dataIndex !== 'evidence' && column.dataIndex !== 'location') : allColumns;
}, [compact, onSelect, selectedID]);
return <Table
className="v2-alert-event-table"
className={`v2-alert-event-table${compact ? ' is-compact' : ''}`}
columns={columns}
dataSource={rows}
rowKey="id"
@@ -100,7 +109,7 @@ function AlertEventTable({ rows, selectedID, onSelect }: { rows: AlertEvent[]; s
onOpen: () => onSelect(event.id)
}) : ({})}
/>;
}
});
function EventInspector({ event, note, acting, actionError, editable, onNote, onAction, onClose, sheet = false }: { event?: AlertEvent; note: string; acting: boolean; actionError?: string; editable: boolean; onNote: (value: string) => void; onAction: (action: 'acknowledge' | 'close' | 'ignore') => void; onClose: () => void; sheet?: boolean }) {
if (!event) return <Card className="v2-alert-inspector" bodyStyle={{ padding: 0 }}><Empty className="v2-alert-inspector-empty" image={<IconAlarm />} title="选择告警事件" description="查看触发证据、状态时间线和处置动作。" /></Card>;
@@ -112,13 +121,13 @@ function EventInspector({ event, note, acting, actionError, editable, onNote, on
<Tag color="blue" type="light" size="small">{event.protocol || '未知协议'}</Tag>
</header>
<div className="v2-alert-focus-facts">
<span><small></small><strong>{formatAlertTime(event.triggeredAt)}</strong></span>
<span><small></small><strong><AlertTime value={event.triggeredAt} /></strong></span>
<span><small></small><strong>{statusLabels[event.status]}</strong></span>
</div>
<div className="v2-alert-evidence"><Card className="v2-alert-evidence-value is-trigger"><small></small><strong>{alertValue(event)}</strong></Card><Tag color="grey" type="light" size="small">VS</Tag><Card className="v2-alert-evidence-value"><small></small><strong>{thresholdText(event)}</strong></Card></div>
</Card>
<Card className="v2-alert-detail-card v2-alert-progress-card" title={<strong></strong>} headerLine>
{event.actions?.length ? <Timeline className="v2-alert-timeline" aria-label="告警处理进度">{event.actions.map((item, index) => <Timeline.Item key={item.id} type={index === event.actions!.length - 1 ? 'ongoing' : 'default'} time={<span>{item.actor} · {formatAlertTime(item.createdAt)}</span>}><strong>{actionLabels[item.action] ?? item.action}</strong>{item.note ? <p>{item.note}</p> : null}</Timeline.Item>)}</Timeline> : <Empty className="v2-alert-timeline-empty" title="暂无处理记录" description="事件被确认、关闭或忽略后,将在这里形成审计履历。" />}
{event.actions?.length ? <Timeline className="v2-alert-timeline" aria-label="告警处理进度">{event.actions.map((item, index) => <Timeline.Item key={item.id} type={index === event.actions!.length - 1 ? 'ongoing' : 'default'} time={<span>{item.actor} · <AlertTime value={item.createdAt} /></span>}><strong>{actionLabels[item.action] ?? item.action}</strong>{item.note ? <p>{item.note}</p> : null}</Timeline.Item>)}</Timeline> : <Empty className="v2-alert-timeline-empty" title="暂无处理记录" description="事件被确认、关闭或忽略后,将在这里形成审计履历。" />}
</Card>
<Card className="v2-alert-detail-card v2-alert-disposition-card" title={<strong></strong>} headerLine>
{editable ? <><TextArea maxCount={200} autosize={{ minRows: 2, maxRows: 5 }} placeholder="请输入处置说明(选填)" value={note} onChange={onNote} />{actionError ? <p className="v2-alert-action-error">{actionError}</p> : null}<div className="v2-alert-actions"><Button theme="solid" className="is-primary" disabled={acting || !canAct(event.status, 'acknowledge')} onClick={() => onAction('acknowledge')}></Button><Button theme="light" disabled={acting || !canAct(event.status, 'close')} onClick={() => onAction('close')}></Button><Button theme="borderless" disabled={acting || !canAct(event.status, 'ignore')} onClick={() => onAction('ignore')}></Button></div></> : <p className="v2-role-notice"></p>}
@@ -132,8 +141,8 @@ function EventInspector({ event, note, acting, actionError, editable, onNote, on
{ key: '事件 ID', value: <code>{event.id}</code> },
{ key: '来源事件 ID', value: event.sourceEventId || '—' },
{ key: '规则 / 版本', value: `${event.ruleId} / v${event.ruleVersion}` },
{ key: '事件 / 接收', value: `${formatAlertTime(event.eventAt)} / ${formatAlertTime(event.receivedAt)}` },
{ key: '恢复时间', value: formatAlertTime(event.recoveredAt) },
{ key: '事件 / 接收', value: <><AlertTime value={event.eventAt} /> / <AlertTime value={event.receivedAt} /></> },
{ key: '恢复时间', value: <AlertTime value={event.recoveredAt} /> },
{ key: '处理人', value: event.handler || '—' }
]} />
</Collapse.Panel>
@@ -167,6 +176,7 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e
const detail = useQuery({ queryKey: ['alert-event-v2', selectedID], queryFn: ({ signal }) => api.alertEventV2(selectedID, signal), enabled: Boolean(selectedID), staleTime: 3_000, gcTime: QUERY_MEMORY.summaryGcTime });
const action = useMutation({ mutationFn: ({ name, event }: { name: 'acknowledge' | 'close' | 'ignore'; event: AlertEvent }) => api.actOnAlertV2(event.id, { version: event.version, action: name, note }), onSuccess: async (event) => { setNote(''); queryClient.setQueryData(['alert-event-v2', event.id], event); await Promise.all([queryClient.invalidateQueries({ queryKey: ['alert-events-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-summary-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-notifications-v2'] })]); } });
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', '告警高级筛选', '关闭告警高级筛选');
useSideSheetA11y(mobileLayout && Boolean(selectedID), '.v2-alert-detail-sidesheet', 'v2-alert-detail', '告警事件详情', '关闭告警详情');
@@ -234,7 +244,7 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e
<Card className="v2-alert-table-card" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
title="告警事件"
description="点击事件查看触发证据、处理进度与处置动作"
description={selectedID && !mobileLayout ? '已展开处置详情 · 点击其他事件快速切换' : '点击事件查看触发证据、处理进度与处置动作'}
meta={`${(events.data?.total ?? 0).toLocaleString('zh-CN')}`}
actions={<Button theme="borderless" icon={<IconRefresh />} loading={events.isFetching} onClick={() => Promise.all([events.refetch(), summary.refetch(), ...(selectedID ? [detail.refetch()] : [])])}></Button>}
/>
@@ -244,8 +254,8 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e
aria-label={mobileLayout ? '告警事件列表,可上下滚动' : undefined}
>
{mobileLayout
? <div className="v2-alert-mobile-list">{rows.map((event) => <Card key={event.id} className={`v2-alert-mobile-card${selectedID === event.id ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}><Button theme="borderless" type="tertiary" className="v2-alert-mobile-action" aria-pressed={selectedID === event.id} aria-expanded={selectedID === event.id} aria-label={`查看 ${event.plate || event.vin} ${event.ruleName} 告警详情`} onClick={() => setSelection({ scope: eventScope, id: event.id })}><span className="v2-alert-mobile-card-content"><header><strong>{event.plate || '未绑定车牌'}</strong><span><SeverityTag severity={event.severity} /><StatusTag status={event.status} /></span></header><p>{event.ruleName}</p><dl><div><dt></dt><dd>{formatAlertTime(event.triggeredAt)}</dd></div><div><dt></dt><dd>{event.protocol || '—'}</dd></div><div><dt> / </dt><dd>{alertValue(event)} / {thresholdText(event)}</dd></div></dl><footer><IconChevronRight /></footer></span></Button></Card>)}</div>
: <AlertEventTable rows={rows} selectedID={selectedID} onSelect={(id) => setSelection({ scope: eventScope, id })} />}
? <div className="v2-alert-mobile-list">{rows.map((event) => <Card key={event.id} className={`v2-alert-mobile-card${selectedID === event.id ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}><Button theme="borderless" type="tertiary" className="v2-alert-mobile-action" aria-pressed={selectedID === event.id} aria-expanded={selectedID === event.id} aria-label={`查看 ${event.plate || event.vin} ${event.ruleName} 告警详情`} onClick={() => selectEvent(event.id)}><span className="v2-alert-mobile-card-content"><header><strong>{event.plate || '未绑定车牌'}</strong><span><SeverityTag severity={event.severity} /><StatusTag status={event.status} /></span></header><p>{event.ruleName}</p><dl><div><dt></dt><dd><AlertTime value={event.triggeredAt} /></dd></div><div><dt></dt><dd>{event.protocol || '—'}</dd></div><div><dt> / </dt><dd>{alertValue(event)} / {thresholdText(event)}</dd></div></dl><footer><IconChevronRight /></footer></span></Button></Card>)}</div>
: <AlertEventTable rows={rows} selectedID={selectedID} compact={Boolean(selectedID)} onSelect={selectEvent} />}
{events.isFetching ? <PanelLoading className="v2-alert-loading" title="正在更新事件…" description="告警列表返回后会自动更新。" compact={Boolean(rows.length)} /> : null}
{!events.isFetching && !rows.length ? <PanelEmpty className="v2-alert-empty" title="当前筛选条件没有告警事件" description="调整车辆、严重程度、状态或时间范围后重试。" /> : null}
</div>
@@ -318,7 +328,7 @@ function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: Metri
description={rules.length ? `${rules.length} 条可审计规则` : '尚未创建规则'}
actions={<Button theme="light" type="primary" icon={<IconPlus />} onClick={() => { setSelectedID('__new__'); setDraft(emptyRule()); }}></Button>}
/>
<div className="v2-alert-rule-items">
<div className="v2-alert-rule-items" role="region" tabIndex={0} aria-label="告警规则列表,可左右滚动选择">
{rules.map((rule) => <Button
className={`v2-alert-rule-item${selectedID === rule.id ? ' is-selected' : ''}`}
key={rule.id}
@@ -409,7 +419,7 @@ function NotificationsWorkspace({ editable }: { editable: boolean }) {
return <Card className="v2-alert-notifications" bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="站内通知" description="仅站内通道具备真实送达与已读状态" meta={`${total.toLocaleString('zh-CN')}`} actions={editable ? <Button theme="light" disabled={read.isPending || !items.some((item) => !item.read)} onClick={() => read.mutate(items.filter((item) => !item.read).map((item) => item.id))}>{read.isPending ? '正在更新' : '本页全部已读'}</Button> : <span className="v2-role-badge"></span>} />{notifications.isError ? <InlineError message={notifications.error?.message ?? '站内通知读取失败'} onRetry={() => notifications.refetch()} /> : null}{read.isError ? <InlineError message={read.error instanceof Error ? read.error.message : '通知状态更新失败'} /> : null}
<div className="v2-alert-notification-list">{notifications.isPending ? <div className="v2-alert-notification-loading"><Spin size="middle" tip="正在读取站内通知" /></div> : items.length ? <CardGroup className="v2-alert-notification-cards" type="grid" spacing={0}>{items.map((item) => <Card className={`v2-alert-notification-card${item.read ? ' is-read' : ' is-unread'}`} key={item.id} title={<span className="v2-alert-notification-title"><SeverityTag severity={item.severity} /><span title={item.title}>{item.title}</span></span>} headerExtraContent={<Tag color={item.read ? 'grey' : 'orange'} type="light" size="small">{item.read ? '已读' : '未读'}</Tag>} headerLine bodyStyle={{ padding: 0 }}>
<p className="v2-alert-notification-content" title={item.content}>{item.content}</p>
<footer><Typography.Text type="tertiary">{formatAlertTime(item.createdAt)}</Typography.Text>{editable && !item.read ? <Button theme="borderless" disabled={read.isPending} onClick={() => read.mutate([item.id])}>{read.isPending ? '更新中' : '标为已读'}</Button> : null}</footer>
<footer><Typography.Text type="tertiary"><AlertTime value={item.createdAt} /></Typography.Text>{editable && !item.read ? <Button theme="borderless" disabled={read.isPending} onClick={() => read.mutate([item.id])}>{read.isPending ? '更新中' : '标为已读'}</Button> : null}</footer>
</Card>)}</CardGroup> : !notifications.isError ? <Empty className="v2-alert-notification-empty" image={<IconBell />} title="暂无站内通知" description="告警触发或状态变更后,通知会在这里形成可追溯记录。" /> : null}</div>
<footer className="v2-alert-notification-pagination"><TablePagination page={page} totalPages={totalPages} info={`${total.toLocaleString('zh-CN')} 条 · 本页 ${items.length.toLocaleString('zh-CN')}`} disabled={notifications.isFetching} onPageChange={(next) => setOffset((next - 1) * limit)} pageSize={limit} pageSizeLabel="每页通知数" onPageSizeChange={(next) => { setLimit(next); setOffset(0); }} pageSizeOptions={[{ value: 20, label: '20 条/页' }, { value: 50, label: '50 条/页' }]} /></footer>
<Card className="v2-alert-channel-card" title={<strong></strong>} headerLine bodyStyle={{ padding: 0 }}><Descriptions className="v2-alert-channel-descriptions" align="left" size="small" data={[
@@ -432,6 +442,6 @@ export default function AlertsPage() {
const unread = notices.data?.total ?? 0;
const setTab = (next: Tab) => { setTabState(next); const copy = new URLSearchParams(params); copy.set('tab', next); setParams(copy, { replace: true }); };
const setFilters = (next: Filters) => { setFilterState(next); const copy = new URLSearchParams(); if (tab !== 'events') copy.set('tab', tab); Object.entries(next).forEach(([key, value]) => { if (value) copy.set(key, value); }); setParams(copy, { replace: true }); };
const tabs = [{ key: 'events' as const, label: '告警事件', icon: <IconAlarm /> }, ...(admin ? [{ key: 'rules' as const, label: '规则配置' }] : []), { key: 'notifications' as const, label: '站内通知', icon: <IconBell />, count: unread || undefined }];
const tabs = [{ key: 'events' as const, label: '告警事件', icon: <IconAlarm /> }, ...(admin ? [{ key: 'rules' as const, label: '规则配置', icon: <IconSetting /> }] : []), { key: 'notifications' as const, label: '站内通知', icon: <IconBell />, count: unread || undefined }];
return <div className={`v2-alert-page is-${activeTab}`}><div className="v2-alert-navigation"><SegmentedTabs className="v2-alert-tabs" variant="filled" ariaLabel="告警中心分类" value={activeTab} items={tabs} onChange={setTab} /><div className="v2-alert-navigation-meta"><Typography.Text type="tertiary">{operator ? '可处置事件' : '只读查看'}</Typography.Text><Tag color={unread ? 'orange' : 'green'} type="light" size="small">{unread ? `${unread.toLocaleString('zh-CN')} 条未读` : '通知已读'}</Tag></div></div>{activeTab !== 'notifications' && rules.isError ? <InlineError message={rules.error.message} onRetry={() => rules.refetch()} /> : null}{activeTab === 'rules' && metrics.isError ? <InlineError message={metrics.error.message} onRetry={() => metrics.refetch()} /> : null}{activeTab === 'events' ? <EventWorkspace filters={filters} draft={eventDraft} setDraft={setEventDraft} setFilters={setFilters} rules={rules.data ?? []} unread={unread} editable={operator} onTab={setTab} /> : activeTab === 'rules' ? <RulesWorkspace rules={rules.data ?? []} metrics={metrics.data?.metrics ?? []} /> : <NotificationsWorkspace editable={operator} />}</div>;
}

View File

@@ -166,7 +166,7 @@ describe('V2 production entry', () => {
expect(corePageSources.AlertsPage).toContain('className="v2-alert-navigation"');
expect(corePageSources.AlertsPage).toContain('<SideSheet\n className="v2-alert-filter-sidesheet"');
expect(corePageSources.AlertsPage).toContain('<SideSheet\n className="v2-alert-detail-sidesheet"');
expect(corePageSources.AlertsPage).toContain('className="v2-alert-event-table"');
expect(corePageSources.AlertsPage).toContain('className={`v2-alert-event-table');
expect(corePageSources.AlertsPage).toContain('detailTriggerRow({');
expect(corePageSources.AlertsPage).not.toContain('<table><thead><tr><th />');
expect(corePageSources.AlertsPage).toContain('className="v2-alert-filter-card"');

View File

@@ -6713,6 +6713,14 @@
font-size: 12px;
}
.v2-alert-event-table.semi-table-wrapper {
min-width: 1102px;
}
.v2-alert-event-table.is-compact.semi-table-wrapper {
min-width: 758px;
}
.v2-alert-event-evidence {
display: grid;
min-width: 0;
@@ -7475,6 +7483,28 @@
max-height: 270px;
}
.v2-alert-rule-items {
scrollbar-color: #9dacc0 transparent;
scrollbar-width: thin;
}
.v2-alert-rule-items:focus-visible {
outline: 2px solid rgba(18, 104, 243, .34);
outline-offset: -2px;
}
.v2-alert-rule-items::-webkit-scrollbar {
display: block;
height: 8px;
}
.v2-alert-rule-items::-webkit-scrollbar-thumb {
border: 2px solid transparent;
border-radius: 999px;
background: #9dacc0;
background-clip: padding-box;
}
.v2-alert-rule-editor.semi-card,
.v2-alert-rule-editor-form {
overflow: visible;