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 84e22caa..95aebfe1 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 @@ -35,3 +35,30 @@ test('preserves an unsubmitted event filter draft across alert tab URL changes', expect(await screen.findByPlaceholderText('车牌 / VIN / 规则名称')).toHaveValue('保留筛选草稿'); }); + +test('loads only event dependencies on the default alert tab', async () => { + mocks.alertSummaryV2.mockResolvedValue({ active: 0, unprocessed: 0, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' }); + mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 20, offset: 0 }); + mocks.alertRulesV2.mockResolvedValue([]); + mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + + await screen.findByText('当前筛选条件没有告警事件'); + expect(mocks.alertRulesV2).toHaveBeenCalledTimes(1); + expect(mocks.metricCatalog).not.toHaveBeenCalled(); + expect(mocks.alertNotificationsV2).toHaveBeenCalledTimes(1); + expect(mocks.alertNotificationsV2.mock.calls[0][0].toString()).toBe('unreadOnly=true&limit=100'); +}); + +test('loads one full notification query on a direct notifications entry', async () => { + mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + + await screen.findByText('仅站内通道具备真实送达与已读状态'); + expect(mocks.alertRulesV2).not.toHaveBeenCalled(); + expect(mocks.metricCatalog).not.toHaveBeenCalled(); + expect(mocks.alertNotificationsV2).toHaveBeenCalledTimes(1); + expect(mocks.alertNotificationsV2.mock.calls[0][0].toString()).toBe('limit=100'); +}); 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 8e3f52e0..543a33f9 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx @@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { FormEvent, memo, 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 } from '../../api/types'; +import type { AlertEvent, AlertNotification, AlertQuery, AlertRule, AlertRuleInput, AlertStatus, MetricDefinition, Page } from '../../api/types'; import { actionLabels, alertValue, canAct, formatAlertTime, operatorLabels, ruleCondition, severityLabels, statusLabels, thresholdText } from '../domain/alert'; import { InlineError } from '../shared/AsyncState'; import { usePlatformSession } from '../auth/AuthGate'; @@ -109,19 +109,33 @@ function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: Metri ; } -function NotificationsWorkspace({ editable }: { editable: boolean }) { - const queryClient = useQueryClient(); const notifications = useQuery({ queryKey: ['alert-notifications-v2', 'all'], queryFn: ({ signal }) => api.alertNotificationsV2(new URLSearchParams({ limit: '100' }), signal), staleTime: 5_000, gcTime: QUERY_MEMORY.summaryGcTime }); +type NotificationsQuery = { + data?: Page; + error: Error | null; + isError: boolean; + refetch: () => unknown; +}; + +function NotificationsWorkspace({ editable, notifications }: { editable: boolean; notifications: NotificationsQuery }) { + const queryClient = useQueryClient(); const read = useMutation({ mutationFn: api.readAlertNotificationsV2, onSuccess: async () => { await Promise.all([queryClient.invalidateQueries({ queryKey: ['alert-notifications-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-summary-v2'] })]); } }); - return
站内通知仅站内通道具备真实送达与已读状态
{editable ? : 只读}
{notifications.isError ? notifications.refetch()} /> : null}
{notifications.data?.items.map((item) =>
{item.title}

{item.content}

{formatAlertTime(item.createdAt)} · {item.read ? '已读' : '未读'}
{editable && !item.read ? : null}
)}
外部通知通道短信(SMS)— 预留 / 未启用邮件(Email)— 预留 / 未启用企业微信(WeCom)— 预留 / 未启用
; + return
站内通知仅站内通道具备真实送达与已读状态
{editable ? : 只读}
{notifications.isError ? notifications.refetch()} /> : null}
{notifications.data?.items.map((item) =>
{item.title}

{item.content}

{formatAlertTime(item.createdAt)} · {item.read ? '已读' : '未读'}
{editable && !item.read ? : null}
)}
外部通知通道短信(SMS)— 预留 / 未启用邮件(Email)— 预留 / 未启用企业微信(WeCom)— 预留 / 未启用
; } 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(['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 [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 }); const metrics = useQuery({ queryKey: ['metric-catalog-v2'], queryFn: ({ signal }) => api.metricCatalog(signal), staleTime: 300_000, enabled: admin }); const notices = useQuery({ queryKey: ['alert-notifications-v2', 'unread'], queryFn: ({ signal }) => api.alertNotificationsV2(new URLSearchParams({ unreadOnly: 'true', limit: '100' }), signal), staleTime: 5_000, gcTime: QUERY_MEMORY.summaryGcTime }); + 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: '100' }), signal), staleTime: 5_000, gcTime: QUERY_MEMORY.summaryGcTime, enabled: activeTab !== 'notifications' }); + const notifications = useQuery({ queryKey: ['alert-notifications-v2', 'all'], queryFn: ({ signal }) => api.alertNotificationsV2(new URLSearchParams({ limit: '100' }), signal), staleTime: 5_000, gcTime: QUERY_MEMORY.summaryGcTime, enabled: activeTab === 'notifications' }); + const unread = activeTab === 'notifications' + ? notifications.data?.items.filter((item) => !item.read).length ?? notices.data?.total ?? 0 + : 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 activeTab = tab === 'rules' && !admin ? 'events' : tab; - return

告警中心

统一监控告警事件,快速发现并处置车辆运行异常,保留数据质量与处置证据。

{rules.isError ? rules.refetch()} /> : null}{activeTab === 'rules' && metrics.isError ? metrics.refetch()} /> : null}{activeTab === 'events' ? : activeTab === 'rules' ? : }
; + return

告警中心

统一监控告警事件,快速发现并处置车辆运行异常,保留数据质量与处置证据。

{activeTab !== 'notifications' && rules.isError ? rules.refetch()} /> : null}{activeTab === 'rules' && metrics.isError ? metrics.refetch()} /> : null}{activeTab === 'events' ? : activeTab === 'rules' ? : }
; } diff --git a/vehicle-data-platform/docs/frontend-production-readiness.md b/vehicle-data-platform/docs/frontend-production-readiness.md index 3231a1e3..8df06467 100644 --- a/vehicle-data-platform/docs/frontend-production-readiness.md +++ b/vehicle-data-platform/docs/frontend-production-readiness.md @@ -92,6 +92,8 @@ A production route-memory loop did not prove a retained application leak: after Excel export previously produced two independent 940 KB ExcelJS assets because the worker imported the browser download module while that module also retained a main-thread workbook fallback. Workbook construction now lives in a worker-only runtime module; unsupported legacy browsers receive an upgrade message instead of freezing the page with a main-thread build. The production output contains one ExcelJS asset, the Statistics route chunk is smaller, and `scripts/verify-build.mjs` makes exactly one ExcelJS asset plus one mileage worker a mandatory `pnpm build` gate. +The Alert Center previously mounted every tab's data dependencies together: the default event view fetched the metric catalog even though only the rule editor consumes it, while a direct notifications entry fetched both the unread-only collection and the full notification collection. Queries are now gated by the active workspace. Events load events, summary, rule names and the unread badge; rules add the metric catalog only when opened; notifications load one full collection and derive the unread badge from it. This follows TanStack Query's lazy-query `enabled` model and preserves Grafana's separation between alert triage, rule configuration and notification handling: , . Request-count tests protect both the default event route and a direct notifications entry. + The navigation and progressive-loading direction follows mature observability systems: persistent global controls, collapsible sections and loading only the content needed for the current task. Elastic documents these dashboard interaction and panel-organization patterns here: and . ### Remaining audit queue