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 b8be5c88..1401cc7b 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,11 +1,17 @@ import { describe, expect, it } from 'vitest'; -import { alertValue, canAct, formatAlertTime, ruleCondition, thresholdText } from './alert'; +import { alertDeltaText, alertValue, canAct, formatAlertTime, ruleCondition, thresholdText } from './alert'; describe('alert domain helpers', () => { it('keeps trigger evidence and duration explicit', () => { const event = { triggerValue: 96, threshold: 80, thresholdHigh: 0, operator: 'gt', unit: 'km/h', durationSec: 60 }; expect(alertValue(event)).toBe('96 km/h'); expect(thresholdText(event)).toBe('> 80 km/h,持续 60 秒'); + expect(alertDeltaText(event)).toBe('+16 km/h'); + }); + + it('describes low-bound and non-numeric trigger outcomes without inventing a delta', () => { + expect(alertDeltaText({ triggerValue: 12, threshold: 20, operator: 'lt', unit: '%' })).toBe('+8 %'); + expect(alertDeltaText({ triggerValue: 1, threshold: 1, operator: 'changed', unit: '' })).toBe('规则已命中'); }); it('enforces valid workflow transitions', () => { 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 01993f6a..ac5d4f3b 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/alert.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/alert.ts @@ -35,6 +35,17 @@ export function thresholdText(event: Pick) { + if (!Number.isFinite(event.triggerValue) || !Number.isFinite(event.threshold)) return '规则已命中'; + const delta = event.operator === 'gt' || event.operator === 'gte' + ? event.triggerValue - event.threshold + : event.operator === 'lt' || event.operator === 'lte' + ? event.threshold - event.triggerValue + : Number.NaN; + if (!Number.isFinite(delta)) return '规则已命中'; + return `+${formatZhNumber(Number(Math.max(0, delta).toFixed(2)), 2)} ${event.unit}`.trim(); +} + export function ruleCondition(rule: AlertRule, labels: Record = metricLabels) { const threshold = rule.operator === 'changed' ? '状态变化' : rule.operator === 'between' || rule.operator === 'outside' ? `${operatorLabels[rule.operator]} ${rule.threshold}–${rule.thresholdHigh}` : rule.valueType === 'boolean' ? (rule.booleanThreshold ? '是' : '否') : `${operatorLabels[rule.operator] ?? rule.operator} ${rule.threshold}`; return `${labels[rule.metric] ?? rule.metric} ${threshold}${rule.durationSec ? ` · ${rule.durationSec} 秒` : ''}`; 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 9c4776f1..fc0d871f 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 @@ -163,6 +163,10 @@ test('clears old alert rows and inspector evidence when the event scope changes' 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(screen.getByLabelText('告警事件上下文')).toHaveTextContent('数据来源JT808触发时间07-16 12:00:00处理记录1 条'); + expect(screen.getByLabelText('告警触发证据')).toHaveTextContent('触发值90 km/h阈值条件> 80 km/h,持续 60 秒超限幅度+10 km/h'); + expect(screen.getByText('1 条记录').closest('.semi-tag')).toBeInTheDocument(); + expect(screen.getByText('可操作').closest('.semi-tag')).toBeInTheDocument(); expect(view.container.querySelector('.v2-alert-technical-collapse.semi-collapse')).toBeInTheDocument(); expect(screen.getByText('技术详情')).toBeInTheDocument(); expect(view.container.querySelector('.v2-alert-inspector-heading')).toHaveClass('v2-workspace-panel-header'); @@ -233,12 +237,15 @@ test('renders only selectable Semi alert cards on mobile', async () => { 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(action).toHaveAttribute('aria-expanded', 'false'); + expect(action).toHaveTextContent('超限幅度+10 km/h'); + expect(action).toHaveTextContent('核验并处置'); fireEvent.click(action); expect(action).toHaveAttribute('aria-pressed', 'true'); expect(action).toHaveAttribute('aria-expanded', 'true'); expect(await screen.findByRole('dialog', { name: '告警事件详情' })).toBeInTheDocument(); expect(document.querySelector('.v2-alert-detail-sidesheet')).toHaveClass('semi-sidesheet-bottom'); expect(document.querySelector('.v2-alert-detail-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(86dvh, 760px)' }); + expect(screen.getByLabelText('告警触发证据')).toHaveTextContent('超限幅度+10 km/h'); expect(await screen.findByText('mobile-event')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '关闭告警详情' })); expect(screen.queryByText('mobile-event')).not.toBeInTheDocument(); 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 6522a486..f933ad99 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx @@ -5,7 +5,7 @@ import { FormEvent, memo, useCallback, useEffect, useMemo, useState } from 'reac 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, alertValue, canAct, formatAlertTime, operatorLabels, ruleCondition, severityLabels, statusLabels, thresholdText } from '../domain/alert'; +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 { SegmentedTabs } from '../shared/SegmentedTabs'; @@ -114,23 +114,30 @@ const AlertEventTable = memo(function AlertEventTable({ rows, selectedID, compac 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="查看触发证据、状态时间线和处置动作。" />; - return {sheet ? null :