Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx
2026-07-19 01:02:39 +08:00

572 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, 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';
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';
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
import { detailTriggerRow } from '../shared/detailTriggerRow';
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 };
const EMPTY_FILTERS: Filters = { keyword: '', severity: '', status: '', ruleId: '', protocol: '', dateFrom: '', dateTo: '' };
const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'];
const NUMERIC_OPERATORS = ['gt', 'gte', 'lt', 'lte', 'eq', 'neq', 'between', 'outside'];
const BOOLEAN_OPERATORS = ['eq', 'neq', 'changed'];
type RuleTemplate = { id: string; title: string; summary: string; draft: AlertRuleInput };
const RULE_TEMPLATES: RuleTemplate[] = [
{
id: 'offline-10h',
title: '离线超过 10 小时',
summary: '全部协议 · 自动恢复 · 每小时最多提醒一次',
draft: {
id: '', name: '车辆离线超过 10 小时', description: '车辆任一有效来源持续 10 小时未上报时告警。',
severity: 'major', valueType: 'numeric', metric: 'freshness_sec', operator: 'gt', threshold: 36_000, thresholdHigh: 0,
durationSec: 0, recoveryOperator: 'lte', recoveryThreshold: 300, repeatIntervalSec: 3_600,
scopeProtocols: [...PROTOCOLS], scopeVins: [], scopeOems: [], scopeModels: [], scopeCompanies: [],
notificationChannels: ['in_app'], enabled: true, version: 0
}
},
{
id: 'hydrogen-concentration',
title: '最高氢浓度',
summary: 'GB32960 · 阈值由车型或厂家标准确定',
draft: {
id: '', name: '最高氢浓度超限', description: '监控 GB32960 燃料电池系统最高氢浓度;保存前须按车型、厂家和安全规范确认百分比阈值。',
severity: 'critical', valueType: 'numeric', metric: 'hydrogen_concentration_percent', operator: 'gt', threshold: Number.NaN, thresholdHigh: 0,
durationSec: 0, recoveryOperator: '', recoveryThreshold: 0, repeatIntervalSec: 600,
scopeProtocols: ['GB32960'], scopeVins: [], scopeOems: [], scopeModels: [], scopeCompanies: [],
notificationChannels: ['in_app'], enabled: true, version: 0
}
}
];
function SeverityTag({ severity }: Pick<AlertEvent, 'severity'>) {
const color = severity === 'critical' ? 'red' : severity === 'major' ? 'orange' : 'amber';
return <Tag className={`v2-alert-severity is-${severity}`} color={color} type="light" size="small"><i />{severityLabels[severity]}</Tag>;
}
function StatusTag({ status }: Pick<AlertEvent, 'status'>) {
const color = status === 'unprocessed' ? 'red' : status === 'processing' ? 'blue' : status === 'recovered' ? 'green' : 'grey';
return <Tag className={`v2-alert-status is-${status}`} color={color} type="light" size="small">{statusLabels[status]}</Tag>;
}
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.plate || event.vin} ${event.ruleName}`} />
},
{ title: '严重程度', dataIndex: 'severity', width: 90, render: (_: AlertEvent['severity'], event: AlertEvent) => <SeverityTag severity={event.severity} /> },
{
title: '车牌 / VIN', dataIndex: 'plate', width: 142,
render: (_: string, event: AlertEvent) => <div className="v2-alert-event-vehicle"><strong>{event.plate || '未绑定车牌'}</strong><small>{event.vin}</small></div>
},
{ title: '规则', dataIndex: 'ruleName', width: 170, render: (value: string) => <span className="v2-alert-event-rule" title={value}>{value}</span> },
{ title: '协议', dataIndex: 'protocol', width: 112, render: (value: string) => <ProtocolTag className="v2-alert-protocol-tag" protocol={value} compact /> },
{ 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> },
];
return compact ? allColumns.filter((column) => column.dataIndex !== 'evidence' && column.dataIndex !== 'location') : allColumns;
}, [compact, onSelect, selectedID]);
return <Table
className={`v2-alert-event-table${compact ? ' is-compact' : ''}`}
columns={columns}
dataSource={rows}
rowKey="id"
pagination={false}
empty={null}
onRow={(event) => event ? detailTriggerRow({
className: selectedID === event.id ? 'is-selected' : '',
expanded: selectedID === event.id,
label: `查看 ${event.plate || event.vin} ${event.ruleName} 告警详情`,
testId: `alert-row-${event.id}`,
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>;
const actionCount = event.actions?.length ?? 0;
return <Card className={`v2-alert-inspector${sheet ? ' is-sheet' : ''}`} bodyStyle={{ padding: 0 }}>
{!sheet ? <WorkspacePanelHeader className="v2-alert-inspector-heading" title="事件处置" description={event.ruleName} actions={<><span className="v2-alert-inspector-status"><SeverityTag severity={event.severity} /></span><Button className="v2-alert-inspector-close" theme="borderless" icon={<IconClose />} aria-label="关闭告警详情" onClick={onClose} /></>} /> : null}
<div className="v2-alert-inspector-content">
<Card className="v2-alert-detail-card v2-alert-focus-card" bodyStyle={{ padding: 0 }} aria-label="告警处置摘要">
<header className="v2-alert-focus-identity">
<span><small></small><strong>{event.plate || '未绑定车牌'}</strong><code>{event.vin}</code></span>
<span className="v2-alert-focus-current"><small></small><StatusTag status={event.status} /></span>
</header>
<div className="v2-alert-focus-facts" aria-label="告警事件上下文">
<span><small></small><strong><ProtocolTag className="v2-alert-protocol-tag" protocol={event.protocol} compact unknownLabel="未知协议" /></strong></span>
<span><small></small><strong><AlertTime value={event.triggeredAt} /></strong></span>
<span><small></small><strong>{actionCount} </strong></span>
</div>
<div className="v2-alert-evidence" aria-label="告警触发证据">
<Card className="v2-alert-evidence-value is-trigger"><small></small><strong>{alertValue(event)}</strong></Card>
<Card className="v2-alert-evidence-value"><small></small><strong>{thresholdText(event)}</strong></Card>
<Card className="v2-alert-evidence-value is-delta"><small></small><strong>{alertDeltaText(event)}</strong></Card>
</div>
<div className="v2-alert-focus-rule"><Tag color="grey" type="light" size="small"></Tag><span>{event.ruleName}</span></div>
</Card>
<Card className="v2-alert-detail-card v2-alert-disposition-card" title={<span className="v2-alert-detail-title"><strong></strong><Tag color={editable ? 'green' : 'grey'} type="light" size="small">{editable ? '可操作' : '只读'}</Tag></span>} headerLine aria-label="告警处置操作">
{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>}
</Card>
<Card className="v2-alert-detail-card v2-alert-progress-card" title={<span className="v2-alert-detail-title"><strong></strong><Tag color={actionCount ? 'blue' : 'amber'} type="light" size="small">{actionCount ? `${actionCount} 条记录` : '待处置'}</Tag></span>} 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} · <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>
<Collapse className="v2-alert-technical-collapse" keepDOM>
<Collapse.Panel
itemKey="technical-evidence"
header={<span className="v2-alert-technical-heading"><strong></strong><small></small></span>}
>
<Descriptions className="v2-alert-descriptions" align="left" size="small" data={[
{ key: '事件 ID', value: <code>{event.id}</code> },
{ key: '来源事件 ID', value: event.sourceEventId || '—' },
{ key: '规则 / 版本', value: `${event.ruleId} / v${event.ruleVersion}` },
{ key: '事件 / 接收', value: <><AlertTime value={event.eventAt} /> / <AlertTime value={event.receivedAt} /></> },
{ key: '恢复时间', value: <AlertTime value={event.recoveredAt} /> },
{ key: '处理人', value: event.handler || '—' }
]} />
</Collapse.Panel>
</Collapse>
</div>
<nav className="v2-alert-links"><Link to={`/vehicles/${encodeURIComponent(event.vin)}`}></Link><Link to={`/tracks?vin=${encodeURIComponent(event.vin)}`}></Link><Link to={`/history?vin=${encodeURIComponent(event.vin)}`}></Link></nav>
</Card>;
}
function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, editable, onTab }: { filters: Filters; draft: Filters; setDraft: (next: Filters) => void; setFilters: (next: Filters) => void; rules: AlertRule[]; unread: number; editable: boolean; onTab: (tab: Tab) => void }) {
const [offset, setOffset] = useState(0);
const [limit, setLimit] = useState(20);
const [selection, setSelection] = useState<{ scope: string; id: string }>();
const [note, setNote] = useState('');
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
const [advancedOpen, setAdvancedOpen] = useState(false);
const queryClient = useQueryClient();
const mobileLayout = useMobileLayout();
const query: AlertQuery = useMemo(() => ({ ...Object.fromEntries(Object.entries(filters).filter(([, value]) => value)), limit, offset }), [filters, limit, offset]);
const baseQuery = useMemo(() => ({ ...query, limit: undefined, offset: undefined }), [query]);
const summaryQuery = useMemo(() => {
const scope = { ...baseQuery };
delete scope.status;
return scope;
}, [baseQuery]);
const eventScope = useMemo(() => queryScopeKey(baseQuery), [baseQuery]);
const summary = useQuery({ queryKey: ['alert-summary-v2', summaryQuery], queryFn: ({ signal }) => api.alertSummaryV2(summaryQuery, signal), staleTime: 8_000, gcTime: QUERY_MEMORY.summaryGcTime });
const events = useQuery<Page<AlertEvent>>({ queryKey: ['alert-events-v2', eventScope, limit, offset], queryFn: ({ signal }) => api.alertEventsV2(query, signal), placeholderData: retainPreviousPageWithinScope<Page<AlertEvent>>(eventScope), staleTime: 5_000, gcTime: QUERY_MEMORY.highVolumeGcTime });
const rows = events.data?.items ?? [];
const selectedID = selection?.scope === eventScope ? selection.id : '';
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);
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);
setOffset(0);
setFiltersCollapsed(true);
};
const submit = (e: FormEvent) => { e.preventDefault(); applyDraft(); };
const resetFilters = () => {
setDraft(EMPTY_FILTERS);
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));
const page = Math.floor(offset / limit) + 1;
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 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 = mobileLayout
? [
{ label: '待处置', value: sums?.unprocessed, tone: 'unprocessed', status: 'unprocessed' },
{ label: '处理中', value: sums?.processing, tone: 'processing', status: 'processing' },
{ label: '全部事件', value: allEventCount, tone: 'active', status: '' }
]
: [
{ 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' }
];
return <>
{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="关键词、严重程度、处理状态与高级范围"
mobileSummary={activeFilterCount ? `已启用 ${activeFilterCount}` : '全部告警'}
expanded={!filtersCollapsed}
status={activeFilterCount ? `${activeFilterCount} 项已启用` : '全部告警'}
statusColor={activeFilterCount ? 'blue' : 'grey'}
onToggle={() => setFiltersCollapsed((value) => !value)}
>
<form className="v2-alert-filter v2-alert-filter-primary" onSubmit={submit}>
{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>}
{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="活跃告警概览">{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>
{!mobileLayout ? <nav className="v2-alert-secondary-states" aria-label="已结束告警状态">{
[
{ label: '已恢复', value: sums?.recovered, status: 'recovered' },
{ label: '已关闭', value: sums?.closed, status: 'closed' },
{ label: '已忽略', value: sums?.ignored, status: 'ignored' }
].map((item) => <Button key={item.status} theme="borderless" type="tertiary" aria-pressed={filters.status === item.status} onClick={() => quickStatus(item.status)}><span>{item.label}</span><b>{Number(item.value ?? 0).toLocaleString('zh-CN')}</b></Button>)
}</nav> : null}
</Card>
{events.isError ? <InlineError message={events.error instanceof Error ? events.error.message : '告警事件读取失败'} onRetry={() => events.refetch()} /> : null}
<div className={`v2-alert-workspace${selectedID && !mobileLayout ? ' is-inspector-open' : ''}`}>
<Card className="v2-alert-table-card" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
title="告警事件"
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>}
/>
<div
className={`v2-alert-table-scroll${mobileLayout ? ' is-mobile-scroll' : ''}`}
tabIndex={mobileLayout ? 0 : undefined}
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={() => 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><ProtocolTag className="v2-alert-protocol-tag" protocol={event.protocol} compact /></dd></div><div><dt></dt><dd>{alertDeltaText(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>
<footer><TablePagination page={page} totalPages={totalPages} info={`${(events.data?.total ?? 0).toLocaleString('zh-CN')}`} onPageChange={(next) => setOffset((next - 1) * limit)} pageSize={limit} onPageSizeChange={(next) => { setLimit(next); setOffset(0); }} pageSizeOptions={[{ value: 20, label: '20 条/页' }, { value: 50, label: '50 条/页' }]} /></footer>
</Card>
{!mobileLayout && selectedID ? inspector : null}
</div>
{!mobileLayout ? <SideSheet
className="v2-alert-filter-sidesheet"
visible={advancedOpen}
aria-label="告警高级筛选"
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">{advancedFilterFields}</div>
</SideSheet>
: null}
<SideSheet
className="v2-alert-detail-sidesheet"
visible={mobileLayout && Boolean(selectedID)}
aria-label="告警事件详情"
placement="bottom"
height="min(90dvh, 800px)"
title={<div className="v2-alert-sheet-title"><strong></strong><span>{selectedEvent ? `${selectedEvent.plate || selectedEvent.vin} · ${selectedEvent.ruleName}` : '证据、状态与处置履历'}</span></div>}
onCancel={closeDetail}
>
{mobileLayout && selectedID ? <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} sheet /> : null}
</SideSheet>
</>;
}
function emptyRule(): AlertRuleInput { return { id: '', name: '', description: '', severity: 'major', valueType: 'numeric', metric: 'speed_kmh', operator: 'gt', threshold: 80, thresholdHigh: 100, durationSec: 60, recoveryOperator: 'lte', recoveryThreshold: 75, repeatIntervalSec: 600, scopeProtocols: [], scopeVins: [], scopeOems: [], scopeModels: [], scopeCompanies: [], notificationChannels: ['in_app'], enabled: true, version: 0 }; }
function ruleDraft(rule: AlertRule): AlertRuleInput {
return {
id: rule.id, name: rule.name, description: rule.description, severity: rule.severity, valueType: rule.valueType,
metric: rule.metric, operator: rule.operator, threshold: rule.threshold, thresholdHigh: rule.thresholdHigh, booleanThreshold: rule.booleanThreshold,
durationSec: rule.durationSec, recoveryOperator: rule.recoveryOperator, recoveryThreshold: rule.recoveryThreshold,
repeatIntervalSec: rule.repeatIntervalSec, scopeProtocols: [...(rule.scopeProtocols ?? [])], scopeVins: [...(rule.scopeVins ?? [])], scopeOems: [...(rule.scopeOems ?? [])], scopeModels: [...(rule.scopeModels ?? [])], scopeCompanies: [...(rule.scopeCompanies ?? [])],
notificationChannels: [...(rule.notificationChannels ?? ['in_app'])], enabled: rule.enabled, version: rule.version
};
}
function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: MetricDefinition[] }) {
const queryClient = useQueryClient();
const mobileLayout = useMobileLayout();
const [selectedID, setSelectedID] = useState('');
const [draft, setDraft] = useState<AlertRuleInput>(emptyRule());
const [editorOpen, setEditorOpen] = useState(false);
useEffect(() => { if (!selectedID && rules[0]) { setSelectedID(rules[0].id); setDraft(ruleDraft(rules[0])); } }, [rules, selectedID]);
const save = useMutation({ mutationFn: api.saveAlertRuleV2, onSuccess: async (rule) => { setSelectedID(rule.id); setDraft(ruleDraft(rule)); await queryClient.invalidateQueries({ queryKey: ['alert-rules-v2'] }); } });
const toggle = useMutation({ mutationFn: (rule: AlertRule) => api.setAlertRuleEnabledV2(rule.id, { version: rule.version, enabled: !rule.enabled }), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['alert-rules-v2'] }); } });
useSideSheetA11y(mobileLayout && editorOpen, '.v2-alert-rule-editor-sidesheet', 'v2-alert-rule-editor', '告警规则编辑', '关闭告警规则编辑');
const mutationError = save.error ?? toggle.error;
const operators = draft.valueType === 'boolean' ? BOOLEAN_OPERATORS : NUMERIC_OPERATORS;
const availableMetrics = metrics.filter((metric) => metric.alertable && metric.valueType === draft.valueType);
const catalogLabels = Object.fromEntries(metrics.map((metric) => [metric.key, metric.label]));
const setList = (key: 'scopeProtocols' | 'scopeVins' | 'scopeOems' | 'scopeModels' | 'scopeCompanies', value: string) => setDraft({ ...draft, [key]: value.split(',').map((item) => item.trim()).filter(Boolean) });
const openNewRule = () => {
setSelectedID('__new__');
setDraft(emptyRule());
if (mobileLayout) setEditorOpen(true);
};
const openRule = (rule: AlertRule) => {
setSelectedID(rule.id);
setDraft(ruleDraft(rule));
if (mobileLayout) setEditorOpen(true);
};
const applyTemplate = (template: RuleTemplate) => {
setSelectedID('__new__');
setDraft({ ...template.draft, scopeProtocols: [...template.draft.scopeProtocols], notificationChannels: [...template.draft.notificationChannels] });
};
const severityColor = (severity: AlertRule['severity']) => severity === 'critical' ? 'red' : severity === 'major' ? 'orange' : 'amber';
const toggleButton = draft.version ? <Button
htmlType="button"
theme="light"
disabled={toggle.isPending || save.isPending}
onClick={() => {
const current = rules.find((item) => item.id === draft.id);
if (current) toggle.mutate(current);
}}
>{toggle.isPending ? '更新中' : draft.enabled ? '停用规则' : '启用规则'}</Button> : null;
const editor = <Card className="v2-alert-rule-editor" bodyStyle={{ padding: 0 }}>
<form className="v2-alert-rule-editor-form" onSubmit={(event) => { event.preventDefault(); save.mutate(draft); }}>
{!mobileLayout ? <WorkspacePanelHeader
className="v2-alert-rule-editor-heading"
title={draft.version ? '编辑规则' : '新建规则'}
description="数值、状态与主数据范围均纳入版本审计"
actions={toggleButton}
/> : <div className={`v2-alert-rule-sheet-status${draft.version ? '' : ' is-new'}`}>
<span>
<Tag size="small" color={draft.version ? draft.enabled ? 'green' : 'grey' : 'blue'} type="light">{draft.version ? draft.enabled ? '已启用' : '已停用' : '新规则'}</Tag>
<small>{draft.version ? `v${draft.version} · 配置变更写入版本审计` : '保存后生成首个可审计版本'}</small>
</span>
{toggleButton}
</div>}
{!draft.version ? <section className="v2-alert-rule-templates" aria-label="告警规则模板">
<div><strong></strong><span></span></div>
<nav>{RULE_TEMPLATES.map((template) => {
const available = metrics.some((metric) => metric.key === template.draft.metric && metric.alertable);
return <Button
className="v2-alert-template-card"
key={template.id}
theme="borderless"
type="tertiary"
disabled={!available}
onClick={() => applyTemplate(template)}
>
<span className="v2-alert-template-icon"><IconAlarm /></span>
<span className="v2-alert-template-copy"><b>{template.title}</b><small>{available ? template.summary : '当前字段目录尚未启用此指标'}</small></span>
<Tag size="small" color={available ? 'blue' : 'grey'} type="light">{available ? (template.draft.scopeProtocols.join(' / ') || '全部协议') : '不可用'}</Tag>
</Button>;
})}</nav>
</section> : null}
<div className="v2-rule-form-grid">
<label><span></span><Input required maxLength={80} value={draft.name} onChange={(value) => setDraft({ ...draft, name: value })} /></label>
<label><span></span><Select value={draft.severity} onChange={(value) => setDraft({ ...draft, severity: String(value) as AlertRuleInput['severity'] })} optionList={[{ value: 'critical', label: '紧急' }, { value: 'major', label: '重要' }, { value: 'minor', label: '一般' }]} /></label>
<label><span></span><Select value={draft.valueType} onChange={(value) => { const valueType = String(value) as AlertRuleInput['valueType']; const metric = metrics.find((item) => item.alertable && item.valueType === valueType)?.key ?? ''; setDraft({ ...draft, valueType, operator: valueType === 'boolean' ? 'eq' : 'gt', metric }); }} optionList={[{ value: 'numeric', label: '数值' }, { value: 'boolean', label: '布尔' }]} /></label>
<label><span></span><Select disabled={!availableMetrics.length} value={draft.metric} onChange={(value) => setDraft({ ...draft, metric: String(value) })} optionList={availableMetrics.map((metric) => ({ value: metric.key, label: `${metric.label}${metric.unit ? ` (${metric.unit})` : ''}` }))} /></label>
<label><span></span><Select value={draft.operator} onChange={(value) => { const operator = String(value); setDraft({ ...draft, operator, durationSec: operator === 'changed' ? 0 : draft.durationSec }); }} optionList={operators.map((value) => ({ value, label: operatorLabels[value] }))} /></label>
{draft.operator === 'changed' ? <label><span></span><Input value="false ↔ true" disabled /></label> : draft.valueType === 'boolean' ? <label><span></span><Select value={draft.booleanThreshold ? 'true' : 'false'} onChange={(value) => setDraft({ ...draft, booleanThreshold: value === 'true' })} optionList={[{ value: 'true', label: '是' }, { value: 'false', label: '否' }]} /></label> : <label><span>{draft.operator === 'between' || draft.operator === 'outside' ? '区间下限' : '触发阈值'}</span><Input required type="number" step="0.01" placeholder={draft.metric === 'hydrogen_concentration_percent' ? '请按厂家标准填写百分比' : undefined} value={Number.isFinite(draft.threshold) ? String(draft.threshold) : ''} onChange={(value) => setDraft({ ...draft, threshold: value === '' ? Number.NaN : Number(value) })} /></label>}
{draft.operator === 'between' || draft.operator === 'outside' ? <label><span></span><Input type="number" step="0.1" value={String(draft.thresholdHigh)} onChange={(value) => setDraft({ ...draft, thresholdHigh: Number(value) })} /></label> : null}
<label><span></span><Input type="number" min="0" max="86400" disabled={draft.operator === 'changed'} value={String(draft.durationSec)} onChange={(value) => setDraft({ ...draft, durationSec: Number(value) })} /></label>
<label><span></span><Select value={draft.recoveryOperator} onChange={(value) => setDraft({ ...draft, recoveryOperator: String(value) })} optionList={[{ value: '', label: '未配置' }, ...['gt', 'gte', 'lt', 'lte', 'eq', 'neq'].map((value) => ({ value, label: operatorLabels[value] }))]} /></label>
<label><span></span><Input type="number" step="0.1" value={String(draft.recoveryThreshold)} onChange={(value) => setDraft({ ...draft, recoveryThreshold: Number(value) })} /></label>
<label><span></span><Input type="number" min="0" max="604800" value={String(draft.repeatIntervalSec)} onChange={(value) => setDraft({ ...draft, repeatIntervalSec: Number(value) })} /></label>
<label className="is-wide"><span></span><Input value={draft.scopeProtocols.join(',')} onChange={(value) => setList('scopeProtocols', value)} /></label>
<label className="is-wide"><span> VIN </span><Input value={draft.scopeVins.join(',')} onChange={(value) => setList('scopeVins', value)} /></label>
<label className="is-wide"><span></span><Input value={draft.scopeOems.join(',')} onChange={(value) => setList('scopeOems', value)} /></label>
<label className="is-wide"><span></span><Input value={draft.scopeModels.join(',')} onChange={(value) => setList('scopeModels', value)} /></label>
<label className="is-wide"><span></span><Input value={draft.scopeCompanies.join(',')} onChange={(value) => setList('scopeCompanies', value)} /></label>
<label className="is-wide"><span></span><TextArea maxCount={500} autosize={{ minRows: 3, maxRows: 7 }} value={draft.description} onChange={(value) => setDraft({ ...draft, description: value })} /></label>
</div>
<footer><div><b></b><span> / </span></div>{mutationError ? <em>{mutationError.message}</em> : null}<Button className="v2-primary-button" theme="solid" htmlType="submit" disabled={save.isPending || toggle.isPending}>{save.isPending ? '保存中' : '保存规则'}</Button></footer>
</form>
</Card>;
return <div className={`v2-alert-rules${mobileLayout ? ' is-mobile' : ''}`}>
<Card className="v2-alert-rule-list" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
className="v2-alert-rule-list-heading"
title="规则配置"
description={rules.length ? `${rules.length} 条可审计规则` : '尚未创建规则'}
actions={<Button theme="light" type="primary" icon={<IconPlus />} aria-haspopup={mobileLayout ? 'dialog' : undefined} aria-expanded={mobileLayout ? editorOpen && selectedID === '__new__' : undefined} onClick={openNewRule}></Button>}
/>
<div className="v2-alert-rule-items" role="region" tabIndex={0} aria-label={mobileLayout ? '告警规则列表' : '告警规则列表,可左右滚动选择'}>
{rules.map((rule) => <Button
className={`v2-alert-rule-item${selectedID === rule.id ? ' is-selected' : ''}`}
key={rule.id}
theme="borderless"
type="tertiary"
aria-pressed={selectedID === rule.id}
aria-haspopup={mobileLayout ? 'dialog' : undefined}
aria-expanded={mobileLayout ? editorOpen && selectedID === rule.id : undefined}
onClick={() => openRule(rule)}
>
<span className={`v2-alert-rule-dot is-${rule.severity}`} aria-hidden="true" />
<span className="v2-alert-rule-copy">
<span><strong>{rule.name}</strong><Tag size="small" color={severityColor(rule.severity)} type="light">{severityLabels[rule.severity]}</Tag></span>
<small>{ruleCondition(rule, catalogLabels)} · v{rule.version}</small>
</span>
<Tag className="v2-alert-rule-state" size="small" color={rule.enabled ? 'green' : 'grey'} type="light">{rule.enabled ? '已启用' : '已停用'}</Tag>
</Button>)}
{!rules.length ? <Empty className="v2-alert-rule-empty" title="暂无告警规则" description="新建规则后可从常用模板开始,也可直接配置自定义条件。" /> : null}
</div>
</Card>
{!mobileLayout ? editor : null}
{mobileLayout ? <SideSheet
className="v2-alert-rule-editor-sidesheet"
visible={editorOpen}
aria-label="告警规则编辑"
placement="bottom"
height="min(92dvh, 820px)"
title={<div className="v2-alert-sheet-title"><strong>{draft.version ? '编辑规则' : '新建规则'}</strong><span>{draft.name || '配置触发条件、范围与通知策略'}</span></div>}
onCancel={() => setEditorOpen(false)}
>
{editorOpen ? editor : null}
</SideSheet> : null}
</div>;
}
function NotificationsWorkspace({ editable }: { editable: boolean }) {
const queryClient = useQueryClient();
const mobileLayout = useMobileLayout();
const [offset, setOffset] = useState(0);
const [limit, setLimit] = useState(20);
const notifications = useQuery({
queryKey: ['alert-notifications-v2', 'all', limit, offset],
queryFn: ({ signal }) => api.alertNotificationsV2(new URLSearchParams({ limit: String(limit), offset: String(offset) }), signal),
staleTime: 5_000,
gcTime: QUERY_MEMORY.highVolumeGcTime
});
const read = useMutation({ mutationFn: api.readAlertNotificationsV2, onSuccess: async () => { await Promise.all([queryClient.invalidateQueries({ queryKey: ['alert-notifications-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-summary-v2'] })]); } });
const items = notifications.data?.items ?? [];
const total = notifications.data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / limit));
const page = Math.floor(offset / limit) + 1;
useEffect(() => {
if (offset > 0 && offset >= total && !notifications.isPending) setOffset(Math.max(0, (totalPages - 1) * limit));
}, [limit, notifications.isPending, offset, total, totalPages]);
const totalTag = <Tag className="v2-alert-notification-total" color="blue" type="light" size="small">{total.toLocaleString('zh-CN')} </Tag>;
return <Card className="v2-alert-notifications" bodyStyle={{ padding: 0 }}><WorkspacePanelHeader className="v2-alert-notification-heading" title={mobileLayout ? <span className="v2-alert-notification-mobile-title">{totalTag}</span> : '站内通知'} description="仅站内通道具备真实送达与已读状态" meta={mobileLayout ? undefined : totalTag} actions={editable ? <Button theme="light" type="primary" disabled={read.isPending || !items.some((item) => !item.read)} onClick={() => read.mutate(items.filter((item) => !item.read).map((item) => item.id))}>{read.isPending ? '正在更新' : mobileLayout ? '本页已读' : '本页全部已读'}</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"><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>
<Collapse className="v2-alert-channel-collapse" keepDOM>
<Collapse.Panel itemKey="external-channels" header={<span className="v2-alert-channel-heading"><span><strong></strong><small></small></span><Tag color="grey" type="light" size="small">3 · </Tag></span>}>
<Descriptions className="v2-alert-channel-descriptions" align="left" size="small" data={[
{ key: '短信SMS', value: <Tag color="grey" type="light" size="small"> · </Tag> },
{ key: '邮件Email', value: <Tag color="grey" type="light" size="small"> · </Tag> },
{ key: '企业微信WeCom', value: <Tag color="grey" type="light" size="small"> · </Tag> }
]} />
</Collapse.Panel>
</Collapse>
</Card>;
}
export default function AlertsPage() {
const { session } = usePlatformSession(); const operator = canOperate(session); const admin = canAdminister(session);
const [params, setParams] = useSearchParams(); const initialTab = (params.get('tab') as Tab) || 'events'; const [tab, setTabState] = useState<Tab>(['events', 'rules', 'notifications'].includes(initialTab) ? initialTab : 'events');
const initialFilters: Filters = { ...EMPTY_FILTERS, keyword: params.get('vin') ?? params.get('keyword') ?? '', severity: params.get('severity') ?? '', status: params.get('status') ?? '', ruleId: params.get('ruleId') ?? '', protocol: params.get('protocol') ?? '' };
const activeTab = tab === 'rules' && !admin ? 'events' : tab;
const [filters, setFilterState] = useState(initialFilters); const [eventDraft, setEventDraft] = useState(initialFilters);
const rules = useQuery({ queryKey: ['alert-rules-v2'], queryFn: ({ signal }) => api.alertRulesV2(signal), staleTime: 30_000, gcTime: QUERY_MEMORY.summaryGcTime, enabled: activeTab !== 'notifications' });
const metrics = useQuery({ queryKey: ['metric-catalog-v2'], queryFn: ({ signal }) => api.metricCatalog(signal), staleTime: 300_000, enabled: activeTab === 'rules' });
const notices = useQuery({ queryKey: ['alert-notifications-v2', 'unread'], queryFn: ({ signal }) => api.alertNotificationsV2(new URLSearchParams({ unreadOnly: 'true', limit: '1' }), signal), staleTime: 5_000, gcTime: QUERY_MEMORY.summaryGcTime });
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: '规则配置', icon: <IconSetting /> }] : []), { key: 'notifications' as const, label: '站内通知', icon: <IconBell />, count: unread || undefined }];
return <div className={`v2-alert-page is-${activeTab}`}>
<WorkspaceCommandBar
className="v2-alert-navigation v2-alert-command-bar"
ariaLabel="告警中心工作区"
title="告警处置工作台"
description="核验事件证据、治理规则并追踪通知状态"
status={unread ? `${unread.toLocaleString('zh-CN')} 条未读` : '通知已读'}
statusColor={unread ? 'orange' : 'green'}
meta={<Typography.Text type="tertiary">{operator ? '可处置事件' : '只读查看'}</Typography.Text>}
actions={<SegmentedTabs className="v2-alert-tabs" variant="filled" ariaLabel="告警中心分类" value={activeTab} items={tabs} onChange={setTab} />}
/>
{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>;
}