From c060317fb0e1799d298c692f2a5af4981dc845e2 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 4 Jul 2026 16:27:37 +0800 Subject: [PATCH] feat(platform): add notification rules workspace --- vehicle-data-platform/apps/web/src/App.tsx | 10 +- .../apps/web/src/domain/appRoute.test.ts | 11 ++ .../apps/web/src/domain/appRoute.ts | 2 +- .../apps/web/src/layout/AppShell.tsx | 9 +- .../apps/web/src/pages/NotificationRules.tsx | 177 ++++++++++++++++++ .../apps/web/src/pages/Quality.tsx | 4 +- .../apps/web/src/test/App.test.tsx | 76 ++++++++ vehicle-data-platform/docs/product-spec.md | 7 +- 8 files changed, 287 insertions(+), 9 deletions(-) create mode 100644 vehicle-data-platform/apps/web/src/pages/NotificationRules.tsx diff --git a/vehicle-data-platform/apps/web/src/App.tsx b/vehicle-data-platform/apps/web/src/App.tsx index b6fc2d5a..42331ce4 100644 --- a/vehicle-data-platform/apps/web/src/App.tsx +++ b/vehicle-data-platform/apps/web/src/App.tsx @@ -7,6 +7,7 @@ import { AppShell, type PageKey } from './layout/AppShell'; import { Dashboard } from './pages/Dashboard'; import { History } from './pages/History'; import { Mileage } from './pages/Mileage'; +import { NotificationRules } from './pages/NotificationRules'; import { Quality } from './pages/Quality'; import { Realtime } from './pages/Realtime'; import { VehicleDetail } from './pages/VehicleDetail'; @@ -153,6 +154,10 @@ export default function App() { replaceQualityHash(qualityFilters); return; } + if (page === 'notification-rules') { + replaceHash(page); + return; + } if (page === 'vehicles') { replaceVehicleHash(vehicleFilters); return; @@ -406,10 +411,11 @@ export default function App() { detail: , history: , mileage: , - quality: { + quality: navigatePage('notification-rules')} onHealthLoaded={(health) => { setLinkIssueCount(health.linkHealth.filter((item) => item.status !== 'ok').length); setPlatformRelease(health.runtime?.platformRelease ?? ''); - }} onFiltersChange={updateQualityFilters} initialFilters={qualityFilters} /> + }} onFiltersChange={updateQualityFilters} initialFilters={qualityFilters} />, + 'notification-rules': }; return ( diff --git a/vehicle-data-platform/apps/web/src/domain/appRoute.test.ts b/vehicle-data-platform/apps/web/src/domain/appRoute.test.ts index c52ea507..145680e9 100644 --- a/vehicle-data-platform/apps/web/src/domain/appRoute.test.ts +++ b/vehicle-data-platform/apps/web/src/domain/appRoute.test.ts @@ -48,6 +48,13 @@ describe('parseAppHash', () => { }); }); + test('parses notification rules page', () => { + expect(parseAppHash('#/notification-rules')).toEqual({ + page: 'notification-rules', + filters: {} + }); + }); + test('ignores unknown pages', () => { expect(parseAppHash('#/unknown?keyword=VIN001')).toEqual({}); }); @@ -70,6 +77,10 @@ describe('buildAppHash', () => { expect(buildAppHash({ page: 'quality', keyword: '粤A', protocol: 'VEHICLE_SERVICE', filters: { issueType: 'NO_SOURCE' } })).toBe('#/quality?keyword=%E7%B2%A4A&protocol=VEHICLE_SERVICE&issueType=NO_SOURCE'); }); + test('builds notification rules page hash', () => { + expect(buildAppHash({ page: 'notification-rules' })).toBe('#/notification-rules'); + }); + test('builds page-only hash when keyword is empty', () => { expect(buildAppHash({ page: 'quality', keyword: '' })).toBe('#/quality'); }); diff --git a/vehicle-data-platform/apps/web/src/domain/appRoute.ts b/vehicle-data-platform/apps/web/src/domain/appRoute.ts index 95bb36bb..e4b00285 100644 --- a/vehicle-data-platform/apps/web/src/domain/appRoute.ts +++ b/vehicle-data-platform/apps/web/src/domain/appRoute.ts @@ -1,6 +1,6 @@ import type { PageKey } from '../layout/AppShell'; -const pageKeys = new Set(['dashboard', 'vehicles', 'realtime', 'detail', 'history', 'mileage', 'quality']); +const pageKeys = new Set(['dashboard', 'vehicles', 'realtime', 'detail', 'history', 'mileage', 'quality', 'notification-rules']); export type AppRoute = { page?: PageKey; diff --git a/vehicle-data-platform/apps/web/src/layout/AppShell.tsx b/vehicle-data-platform/apps/web/src/layout/AppShell.tsx index 7e314c71..72a1ab89 100644 --- a/vehicle-data-platform/apps/web/src/layout/AppShell.tsx +++ b/vehicle-data-platform/apps/web/src/layout/AppShell.tsx @@ -7,14 +7,15 @@ import { IconMapPin, IconSearch, IconServer, - IconSetting + IconSetting, + IconBell } from '@douyinfe/semi-icons'; import type { ReactNode } from 'react'; import { useState } from 'react'; import type { VehicleSourceConsistency, VehicleServiceStatus } from '../api/types'; import { isAMapConfigured } from '../config/appConfig'; -export type PageKey = 'dashboard' | 'vehicles' | 'realtime' | 'detail' | 'history' | 'mileage' | 'quality'; +export type PageKey = 'dashboard' | 'vehicles' | 'realtime' | 'detail' | 'history' | 'mileage' | 'quality' | 'notification-rules'; const navItems = [ { itemKey: 'dashboard', text: '运营驾驶舱', icon: }, @@ -23,7 +24,8 @@ const navItems = [ { itemKey: 'detail', text: '车辆档案', icon: }, { itemKey: 'history', text: '轨迹回放', icon: }, { itemKey: 'mileage', text: '统计分析', icon: }, - { itemKey: 'quality', text: '告警事件', icon: } + { itemKey: 'quality', text: '告警事件', icon: }, + { itemKey: 'notification-rules', text: '通知规则', icon: } ]; function linkHealthClassName(count: number | null) { @@ -116,6 +118,7 @@ export function AppShell({ + } + > +
+ {[ + { label: '触发', value: `${activeRuleCount} 类规则`, detail: '规则从车辆服务质量、来源断链、字段缺失和容量风险中生成。' }, + { label: '通知', value: `${policies.length} 套策略`, detail: '策略定义目标人群、渠道、升级窗口和验收口径。' }, + { label: '闭环', value: `${p0RuleCount} 类 P0`, detail: 'P0 问题必须有证据链接、恢复时间和验收结果。' } + ].map((item) => ( +
+ {item.label} +
{item.value}
+
{item.detail}
+
+ ))} +
+ + + + + pagination={false} + dataSource={rules} + rowKey="issueType" + columns={[ + { title: '规则', width: 180, render: (_: unknown, row: QualityAlertRule) => ruleTitle(row) }, + { title: '级别', width: 90, render: (_: unknown, row: QualityAlertRule) => {row.level} }, + { title: '责任团队', width: 130, dataIndex: 'owner' }, + { title: '当前命中', width: 110, render: (_: unknown, row: QualityAlertRule) => Number(row.count ?? 0).toLocaleString() }, + { title: '触发条件', dataIndex: 'trigger' }, + { title: '通知动作', dataIndex: 'notify' }, + { title: 'SLA', width: 120, dataIndex: 'sla' } + ]} + /> + + + +
+ {policies.map((policy: QualityNotificationPolicy) => ( +
+
+ + {policy.name} + {policy.target} + +
{policy.condition}
+
验收:{policy.acceptanceCriteria || '-'}
+
+ + {formatEscalationMinutes(policy.escalationMinutes)} + {policy.channel} + +
+ ))} +
+
+ + ); +} diff --git a/vehicle-data-platform/apps/web/src/pages/Quality.tsx b/vehicle-data-platform/apps/web/src/pages/Quality.tsx index 62361697..936d54af 100644 --- a/vehicle-data-platform/apps/web/src/pages/Quality.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Quality.tsx @@ -495,6 +495,7 @@ export function Quality({ onOpenHistory, onOpenRaw, onOpenMileage, + onOpenNotificationRules, onHealthLoaded, onFiltersChange, initialFilters = {} @@ -504,6 +505,7 @@ export function Quality({ onOpenHistory?: (filters: Record) => void; onOpenRaw?: (filters: Record) => void; onOpenMileage?: (filters: Record) => void; + onOpenNotificationRules?: () => void; onHealthLoaded?: (health: OpsHealth) => void; onFiltersChange?: (filters: Record) => void; initialFilters?: Record; @@ -794,7 +796,7 @@ export function Quality({ />
- + 告警触发规则{onOpenNotificationRules ? : null}}> pagination={false} dataSource={rules} diff --git a/vehicle-data-platform/apps/web/src/test/App.test.tsx b/vehicle-data-platform/apps/web/src/test/App.test.tsx index f7badd4e..3ceee7b9 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -3183,6 +3183,82 @@ test('uses backend quality notification plan on quality page', async () => { expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/quality/notification-plan?limit=20'), undefined); }); +test('renders notification rules as a standalone operations page', async () => { + window.history.replaceState(null, '', '/#/notification-rules'); + const writeText = vi.fn(() => Promise.resolve()); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText } + }); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const path = String(input); + if (path.includes('/api/ops/health')) { + return { + ok: true, + json: async () => ({ + data: { + linkHealth: [], + kafkaLag: 0, + activeConnections: 1000, + capacityFindings: [], + redisOnlineKeys: 800, + tdengineWritable: true, + mysqlWritable: true, + runtime: { requestTimeoutMs: 5000, platformRelease: 'platform-rules-test' } + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/quality/notification-plan')) { + return { + ok: true, + json: async () => ({ + data: { + summary: { issueVehicleCount: 5, issueRecordCount: 8, errorCount: 2, warningCount: 6, protocols: [], issueTypes: [{ name: 'NO_SOURCE', count: 3 }] }, + rules: [ + { issueType: 'NO_SOURCE', title: '无来源规则', level: 'P0', owner: '平台接入', trigger: '车辆无任何来源证据', notify: '立即通知接入运维', sla: '30 分钟确认', count: 3 }, + { issueType: 'FIELD_MISSING', title: '字段缺失规则', level: 'P1', owner: '协议解析', trigger: '核心字段缺失', notify: '进入每日治理清单', sla: '当日闭环', count: 1 } + ], + policies: [ + { name: 'P0 实时中断', target: '接入运维 + 业务责任人', channel: '邮件 / 企业微信', condition: '无来源、VIN 缺失、存储不可写', escalationMinutes: 30, acceptanceCriteria: '来源恢复并持续 10 分钟' }, + { name: 'P1 数据质量', target: '协议解析 + 数据治理', channel: '每日汇总邮件', condition: '字段缺失、链路间断', escalationMinutes: 120, acceptanceCriteria: '核心字段恢复解析' } + ], + priorityIssues: [], + activeRuleCount: 2, + p0RuleCount: 1 + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + return { + ok: true, + json: async () => ({ + data: { items: [], total: 0, limit: 20, offset: 0 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + }); + + render(); + + expect(await screen.findByRole('heading', { name: '通知规则' })).toBeInTheDocument(); + expect(screen.getByText('无来源规则')).toBeInTheDocument(); + expect(screen.getByText('车辆无任何来源证据')).toBeInTheDocument(); + expect(screen.getByText('P0 实时中断')).toBeInTheDocument(); + expect(screen.getByText('接入运维 + 业务责任人')).toBeInTheDocument(); + expect(screen.getByText('platform-rules-test')).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/quality/notification-plan?limit=50'), undefined); + + fireEvent.click(screen.getByRole('button', { name: '复制通知规则Runbook' })); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【通知规则Runbook】')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('无来源规则 / P0 / 平台接入')); +}); + test('copies notification text from quality priority queue', async () => { window.history.replaceState(null, '', '/#/quality'); const writeText = vi.fn(() => Promise.resolve()); diff --git a/vehicle-data-platform/docs/product-spec.md b/vehicle-data-platform/docs/product-spec.md index 15412062..1edc9a93 100644 --- a/vehicle-data-platform/docs/product-spec.md +++ b/vehicle-data-platform/docs/product-spec.md @@ -21,8 +21,9 @@ The platform navigation should use vehicle-service language rather than protocol 5. Trajectory Replay: historical location playback with AMap polyline, playback marker, controls, and table evidence. 6. History Query: historical locations, raw frames, and flattened parsed fields with pagination and field trimming. 7. Alert Events: alert trigger records, affected vehicles, rule evidence, manual state, notification state, and escalation readiness. -8. Statistics: daily/range mileage, online rate, offline duration, data completeness, and source consistency. -9. Ops Quality: GB32960, JT808, Yutong MQTT, Kafka, NATS, Redis, TDengine, MySQL, AMap, gateway, and runtime health. +8. Notification Rules: alert trigger rules, notification targets, channels, escalation windows, acceptance criteria, and copyable runbooks. +9. Statistics: daily/range mileage, online rate, offline duration, data completeness, and source consistency. +10. Ops Quality: GB32960, JT808, Yutong MQTT, Kafka, NATS, Redis, TDengine, MySQL, AMap, gateway, and runtime health. See `docs/vehicle-platform-blueprint.md` for the detailed page-level blueprint and phased implementation plan. @@ -83,6 +84,8 @@ Alert events are first-class product records, but notification delivery can be i Alerts should cover source offline, no realtime update, location missing, mileage abnormality, raw parse failure, binding missing, and cross-source inconsistency. +Notification rules are managed separately from alert records. Alert Events answers "what is happening now"; Notification Rules answers "who should be notified, when to escalate, and how to verify recovery." A rule should always expose trigger condition, owner, channel, escalation window, and acceptance criteria. + ## Statistics Scope Statistics must stay vehicle-first and evidence-backed: