diff --git a/vehicle-data-platform/apps/web/src/v2/domain/alert.test.ts b/vehicle-data-platform/apps/web/src/v2/domain/alert.test.ts index ba939b52..b8be5c88 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/alert.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/alert.test.ts @@ -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('区间内 20–80 %,持续 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'); + }); }); diff --git a/vehicle-data-platform/apps/web/src/v2/domain/alert.ts b/vehicle-data-platform/apps/web/src/v2/domain/alert.ts index 8fa8d13d..01993f6a 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/alert.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/alert.ts @@ -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 = { critical: '紧急', major: '重要', minor: '一般' }; export const statusLabels: Record = { unprocessed: '未处理', processing: '处理中', recovered: '已恢复', closed: '已关闭', ignored: '已忽略' }; 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 7b97a78d..7c6c5970 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 @@ -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(); 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 1087d949..a2ff78a3 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx @@ -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) { return {statusLabels[status]}; } -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 ; + return ; +} + +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) => onSelect(event.id)} aria-label={`选择 ${event.ruleName}`} /> + render: (_: unknown, event: AlertEvent) => onSelect(event.id)} aria-label={`选择 ${event.plate || event.vin} ${event.ruleName}`} /> }, { title: '严重程度', dataIndex: 'severity', width: 90, render: (_: AlertEvent['severity'], event: AlertEvent) => }, { @@ -76,17 +83,19 @@ function AlertEventTable({ rows, selectedID, onSelect }: { rows: AlertEvent[]; s }, { title: '规则', dataIndex: 'ruleName', width: 170, render: (value: string) => {value} }, { 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) => }, { title: '状态', dataIndex: 'status', width: 90, render: (_: AlertEvent['status'], event: AlertEvent) => }, { title: '触发 / 阈值', dataIndex: 'evidence', width: 164, render: (_: unknown, event: AlertEvent) =>
{alertValue(event)}{thresholdText(event)}
}, { title: '位置', dataIndex: 'location', width: 180, render: (value: string) => {value || '—'} }, - ], [onSelect, selectedID]); + ]; + return compact ? allColumns.filter((column) => column.dataIndex !== 'evidence' && column.dataIndex !== 'location') : allColumns; + }, [compact, onSelect, selectedID]); return 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 } title="选择告警事件" description="查看触发证据、状态时间线和处置动作。" />; @@ -112,13 +121,13 @@ function EventInspector({ event, note, acting, actionError, editable, onNote, on {event.protocol || '未知协议'}
- 触发时间{formatAlertTime(event.triggeredAt)} + 触发时间 当前状态{statusLabels[event.status]}
触发值{alertValue(event)}VS阈值条件{thresholdText(event)}
处理进度} headerLine> - {event.actions?.length ? {event.actions.map((item, index) => {item.actor} · {formatAlertTime(item.createdAt)}}>{actionLabels[item.action] ?? item.action}{item.note ?

{item.note}

: null}
)}
: } + {event.actions?.length ? {event.actions.map((item, index) => {item.actor} · }>{actionLabels[item.action] ?? item.action}{item.note ?

{item.note}

: null}
)}
: }
处置与备注} headerLine> {editable ? <>