diff --git a/vehicle-data-platform/apps/api/internal/platform/handler.go b/vehicle-data-platform/apps/api/internal/platform/handler.go index e447a205..3b664412 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler.go @@ -47,6 +47,9 @@ func (h *Handler) routes() { h.mux.HandleFunc("GET /api/quality/summary", h.handleQualitySummary) h.mux.HandleFunc("GET /api/quality/issues", h.handleQualityIssues) h.mux.HandleFunc("GET /api/quality/notification-plan", h.handleQualityNotificationPlan) + h.mux.HandleFunc("GET /api/alert-events/summary", h.handleQualitySummary) + h.mux.HandleFunc("GET /api/alert-events", h.handleQualityIssues) + h.mux.HandleFunc("GET /api/alert-events/notification-plan", h.handleQualityNotificationPlan) h.mux.HandleFunc("GET /api/ops/health", h.handleOpsHealth) } diff --git a/vehicle-data-platform/apps/api/internal/platform/handler_test.go b/vehicle-data-platform/apps/api/internal/platform/handler_test.go index 13bc8d54..0a98bebb 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -749,6 +749,9 @@ func TestHandlerHistoryMileageQualityOps(t *testing.T) { {"/api/statistics/online-vehicles?limit=10", "offlineDurationMinutes"}, {"/api/quality/summary?limit=10", "issueVehicleCount"}, {"/api/quality/issues?limit=10", "sourceEndpoint"}, + {"/api/alert-events/summary?limit=10", "issueVehicleCount"}, + {"/api/alert-events?limit=10", "sourceEndpoint"}, + {"/api/alert-events/notification-plan?limit=10", "priorityIssues"}, {"/api/ops/health", "linkHealth"}, } handler := NewHandler(NewService(NewMockStore())) diff --git a/vehicle-data-platform/apps/web/src/App.tsx b/vehicle-data-platform/apps/web/src/App.tsx index f1229e2d..85565d49 100644 --- a/vehicle-data-platform/apps/web/src/App.tsx +++ b/vehicle-data-platform/apps/web/src/App.tsx @@ -35,7 +35,7 @@ export default function App() { initialRoute.page === 'mileage' ? mileageFiltersFromRoute(initialRoute) : {} ); const [qualityFilters, setQualityFilters] = useState>( - initialRoute.page === 'quality' ? qualityFiltersFromRoute(initialRoute) : {} + initialRoute.page === 'quality' || initialRoute.page === 'alert-events' ? qualityFiltersFromRoute(initialRoute) : {} ); const [linkIssueCount, setLinkIssueCount] = useState(null); const [activeAlertRuleCount, setActiveAlertRuleCount] = useState(null); @@ -140,7 +140,7 @@ export default function App() { if (route.page === 'mileage') { setMileageFilters(mileageFiltersFromRoute(route)); } - if (route.page === 'quality') { + if (route.page === 'quality' || route.page === 'alert-events') { setQualityFilters(qualityFiltersFromRoute(route)); } setActiveProtocol(route.protocol ?? ''); @@ -177,7 +177,7 @@ export default function App() { replaceMileageHash(mileageFilters); return; } - if (page === 'quality') { + if (page === 'quality' || page === 'alert-events') { replaceQualityHash(qualityFilters); return; } @@ -287,7 +287,7 @@ export default function App() { if (filters.issueType) { issueFilters.issueType = filters.issueType; } - replaceHash('quality', keyword, protocol, issueFilters); + replaceHash('alert-events', keyword, protocol, issueFilters); }; const updateQualityFilters = (filters: Record = {}) => { @@ -297,7 +297,7 @@ export default function App() { const openQuality = (filters: Record = {}) => { setQualityFilters(filters); - setActivePage('quality'); + setActivePage('alert-events'); replaceQualityHash(filters); }; @@ -461,6 +461,10 @@ export default function App() { history: , 'history-query': , mileage: , + 'alert-events': navigatePage('notification-rules')} onHealthLoaded={(health) => { + setLinkIssueCount(health.linkHealth.filter((item) => item.status !== 'ok').length); + setPlatformRelease(health.runtime?.platformRelease ?? ''); + }} onNotificationPlanLoaded={applyNotificationPlanSummary} onFiltersChange={updateQualityFilters} initialFilters={qualityFilters} />, quality: navigatePage('notification-rules')} onHealthLoaded={(health) => { setLinkIssueCount(health.linkHealth.filter((item) => item.status !== 'ok').length); setPlatformRelease(health.runtime?.platformRelease ?? ''); diff --git a/vehicle-data-platform/apps/web/src/api/client.test.ts b/vehicle-data-platform/apps/web/src/api/client.test.ts index 61cffccf..49105ff1 100644 --- a/vehicle-data-platform/apps/web/src/api/client.test.ts +++ b/vehicle-data-platform/apps/web/src/api/client.test.ts @@ -198,6 +198,37 @@ test('qualityNotificationPlan reads alert rules and priority issues from backend expect(result.priorityIssues[0].notificationText).toContain('告警通知'); }); +test('alertEvents reads the vehicle alert event endpoint', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + data: { + items: [{ + vin: 'VIN001', + plate: '粤A001', + phone: '13307795425', + sourceEndpoint: '115.29.187.205:808', + protocol: 'JT808', + issueType: 'LINK_GAP', + severity: 'warning', + lastSeen: '2026-07-03 20:12:10', + detail: '来源间断' + }], + total: 1, + limit: 20, + offset: 0 + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response); + + const result = await api.alertEvents(new URLSearchParams({ protocol: 'JT808', limit: '20' })); + + expect(fetchMock).toHaveBeenCalledWith('/api/alert-events?protocol=JT808&limit=20', undefined); + expect(result.items[0].issueType).toBe('LINK_GAP'); +}); + test('onlineVehicleStatuses reads paged online vehicle status rows', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, diff --git a/vehicle-data-platform/apps/web/src/api/client.ts b/vehicle-data-platform/apps/web/src/api/client.ts index 9e746231..3b2f7337 100644 --- a/vehicle-data-platform/apps/web/src/api/client.ts +++ b/vehicle-data-platform/apps/web/src/api/client.ts @@ -112,5 +112,8 @@ export const api = { qualitySummary: (params = new URLSearchParams()) => request(`/api/quality/summary?${params.toString()}`), qualityIssues: (params = new URLSearchParams()) => request>(`/api/quality/issues?${params.toString()}`), qualityNotificationPlan: (params = new URLSearchParams()) => request(`/api/quality/notification-plan?${params.toString()}`), + alertEventSummary: (params = new URLSearchParams()) => request(`/api/alert-events/summary?${params.toString()}`), + alertEvents: (params = new URLSearchParams()) => request>(`/api/alert-events?${params.toString()}`), + alertEventNotificationPlan: (params = new URLSearchParams()) => request(`/api/alert-events/notification-plan?${params.toString()}`), opsHealth: () => request('/api/ops/health') }; 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 743e1d4b..0558019c 100644 --- a/vehicle-data-platform/apps/web/src/domain/appRoute.test.ts +++ b/vehicle-data-platform/apps/web/src/domain/appRoute.test.ts @@ -48,7 +48,18 @@ describe('parseAppHash', () => { }); }); - test('parses quality governance filters from hash query', () => { + test('parses alert event filters from hash query', () => { + expect(parseAppHash('#/alert-events?keyword=%E7%B2%A4A&protocol=VEHICLE_SERVICE&issueType=NO_SOURCE')).toEqual({ + page: 'alert-events', + keyword: '粤A', + protocol: 'VEHICLE_SERVICE', + filters: { + issueType: 'NO_SOURCE' + } + }); + }); + + test('keeps legacy quality governance hash compatible', () => { expect(parseAppHash('#/quality?keyword=%E7%B2%A4A&protocol=VEHICLE_SERVICE&issueType=NO_SOURCE')).toEqual({ page: 'quality', keyword: '粤A', @@ -110,7 +121,11 @@ describe('buildAppHash', () => { expect(buildAppHash({ page: 'vehicles', filters: { coverage: 'multi', serviceStatus: 'degraded', archiveStatus: 'incomplete', archiveMissing: 'phone', missingProtocol: 'YUTONG_MQTT' } })).toBe('#/vehicles?coverage=multi&serviceStatus=degraded&archiveStatus=incomplete&archiveMissing=phone&missingProtocol=YUTONG_MQTT'); }); - test('builds shareable quality hash with filters', () => { + test('builds shareable alert events hash with filters', () => { + expect(buildAppHash({ page: 'alert-events', keyword: '粤A', protocol: 'VEHICLE_SERVICE', filters: { issueType: 'NO_SOURCE' } })).toBe('#/alert-events?keyword=%E7%B2%A4A&protocol=VEHICLE_SERVICE&issueType=NO_SOURCE'); + }); + + test('keeps building legacy quality hash when requested', () => { 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'); }); @@ -127,6 +142,6 @@ describe('buildAppHash', () => { }); test('builds page-only hash when keyword is empty', () => { - expect(buildAppHash({ page: 'quality', keyword: '' })).toBe('#/quality'); + expect(buildAppHash({ page: 'alert-events', keyword: '' })).toBe('#/alert-events'); }); }); diff --git a/vehicle-data-platform/apps/web/src/domain/appRoute.ts b/vehicle-data-platform/apps/web/src/domain/appRoute.ts index 13f718d0..42643528 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', 'map', 'realtime', 'detail', 'history', 'history-query', 'mileage', 'quality', 'notification-rules', 'ops-quality']); +const pageKeys = new Set(['dashboard', 'vehicles', 'map', 'realtime', 'detail', 'history', 'history-query', 'mileage', 'alert-events', 'quality', 'notification-rules', 'ops-quality']); 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 96b9703b..5eebfed7 100644 --- a/vehicle-data-platform/apps/web/src/layout/AppShell.tsx +++ b/vehicle-data-platform/apps/web/src/layout/AppShell.tsx @@ -16,7 +16,7 @@ import { useState } from 'react'; import type { VehicleSourceConsistency, VehicleServiceStatus } from '../api/types'; import { isAMapConfigured } from '../config/appConfig'; -export type PageKey = 'dashboard' | 'vehicles' | 'map' | 'realtime' | 'detail' | 'history' | 'history-query' | 'mileage' | 'quality' | 'notification-rules' | 'ops-quality'; +export type PageKey = 'dashboard' | 'vehicles' | 'map' | 'realtime' | 'detail' | 'history' | 'history-query' | 'mileage' | 'alert-events' | 'quality' | 'notification-rules' | 'ops-quality'; const navItems = [ { itemKey: 'dashboard', text: '运营驾驶舱', icon: }, @@ -27,7 +27,7 @@ const navItems = [ { itemKey: 'history', text: '轨迹回放', icon: }, { itemKey: 'history-query', text: '历史查询', icon: }, { itemKey: 'mileage', text: '统计分析', icon: }, - { itemKey: 'quality', text: '告警事件', icon: }, + { itemKey: 'alert-events', text: '告警事件', icon: }, { itemKey: 'notification-rules', text: '通知规则', icon: }, { itemKey: 'ops-quality', text: '运维质量', icon: } ]; @@ -93,6 +93,7 @@ export function AppShell({ const amapConfigured = isAMapConfigured(); const alertLabel = alertButtonLabel(linkIssueCount, activeAlertRuleCount, p0AlertRuleCount); const alertClassName = alertButtonClassName(linkIssueCount, activeAlertRuleCount, p0AlertRuleCount); + const selectedPage = activePage === 'quality' ? 'alert-events' : activePage; const search = () => { const value = keyword.trim(); @@ -113,7 +114,7 @@ export function AppShell({ 车辆服务中台