feat(platform): promote alert events surface
This commit is contained in:
@@ -47,6 +47,9 @@ func (h *Handler) routes() {
|
|||||||
h.mux.HandleFunc("GET /api/quality/summary", h.handleQualitySummary)
|
h.mux.HandleFunc("GET /api/quality/summary", h.handleQualitySummary)
|
||||||
h.mux.HandleFunc("GET /api/quality/issues", h.handleQualityIssues)
|
h.mux.HandleFunc("GET /api/quality/issues", h.handleQualityIssues)
|
||||||
h.mux.HandleFunc("GET /api/quality/notification-plan", h.handleQualityNotificationPlan)
|
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)
|
h.mux.HandleFunc("GET /api/ops/health", h.handleOpsHealth)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -749,6 +749,9 @@ func TestHandlerHistoryMileageQualityOps(t *testing.T) {
|
|||||||
{"/api/statistics/online-vehicles?limit=10", "offlineDurationMinutes"},
|
{"/api/statistics/online-vehicles?limit=10", "offlineDurationMinutes"},
|
||||||
{"/api/quality/summary?limit=10", "issueVehicleCount"},
|
{"/api/quality/summary?limit=10", "issueVehicleCount"},
|
||||||
{"/api/quality/issues?limit=10", "sourceEndpoint"},
|
{"/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"},
|
{"/api/ops/health", "linkHealth"},
|
||||||
}
|
}
|
||||||
handler := NewHandler(NewService(NewMockStore()))
|
handler := NewHandler(NewService(NewMockStore()))
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export default function App() {
|
|||||||
initialRoute.page === 'mileage' ? mileageFiltersFromRoute(initialRoute) : {}
|
initialRoute.page === 'mileage' ? mileageFiltersFromRoute(initialRoute) : {}
|
||||||
);
|
);
|
||||||
const [qualityFilters, setQualityFilters] = useState<Record<string, string>>(
|
const [qualityFilters, setQualityFilters] = useState<Record<string, string>>(
|
||||||
initialRoute.page === 'quality' ? qualityFiltersFromRoute(initialRoute) : {}
|
initialRoute.page === 'quality' || initialRoute.page === 'alert-events' ? qualityFiltersFromRoute(initialRoute) : {}
|
||||||
);
|
);
|
||||||
const [linkIssueCount, setLinkIssueCount] = useState<number | null>(null);
|
const [linkIssueCount, setLinkIssueCount] = useState<number | null>(null);
|
||||||
const [activeAlertRuleCount, setActiveAlertRuleCount] = useState<number | null>(null);
|
const [activeAlertRuleCount, setActiveAlertRuleCount] = useState<number | null>(null);
|
||||||
@@ -140,7 +140,7 @@ export default function App() {
|
|||||||
if (route.page === 'mileage') {
|
if (route.page === 'mileage') {
|
||||||
setMileageFilters(mileageFiltersFromRoute(route));
|
setMileageFilters(mileageFiltersFromRoute(route));
|
||||||
}
|
}
|
||||||
if (route.page === 'quality') {
|
if (route.page === 'quality' || route.page === 'alert-events') {
|
||||||
setQualityFilters(qualityFiltersFromRoute(route));
|
setQualityFilters(qualityFiltersFromRoute(route));
|
||||||
}
|
}
|
||||||
setActiveProtocol(route.protocol ?? '');
|
setActiveProtocol(route.protocol ?? '');
|
||||||
@@ -177,7 +177,7 @@ export default function App() {
|
|||||||
replaceMileageHash(mileageFilters);
|
replaceMileageHash(mileageFilters);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (page === 'quality') {
|
if (page === 'quality' || page === 'alert-events') {
|
||||||
replaceQualityHash(qualityFilters);
|
replaceQualityHash(qualityFilters);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -287,7 +287,7 @@ export default function App() {
|
|||||||
if (filters.issueType) {
|
if (filters.issueType) {
|
||||||
issueFilters.issueType = filters.issueType;
|
issueFilters.issueType = filters.issueType;
|
||||||
}
|
}
|
||||||
replaceHash('quality', keyword, protocol, issueFilters);
|
replaceHash('alert-events', keyword, protocol, issueFilters);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateQualityFilters = (filters: Record<string, string> = {}) => {
|
const updateQualityFilters = (filters: Record<string, string> = {}) => {
|
||||||
@@ -297,7 +297,7 @@ export default function App() {
|
|||||||
|
|
||||||
const openQuality = (filters: Record<string, string> = {}) => {
|
const openQuality = (filters: Record<string, string> = {}) => {
|
||||||
setQualityFilters(filters);
|
setQualityFilters(filters);
|
||||||
setActivePage('quality');
|
setActivePage('alert-events');
|
||||||
replaceQualityHash(filters);
|
replaceQualityHash(filters);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -461,6 +461,10 @@ export default function App() {
|
|||||||
history: <History mode="trajectory" initialVin={analysisVin} initialProtocol={activeProtocol} initialTab={historyTab} initialFilters={historyFilters} onFiltersChange={updateHistoryFilters} onOpenVehicle={openVehicle} onOpenMileage={openMileageWithFilters} onOpenRaw={openRawWithFilters} />,
|
history: <History mode="trajectory" initialVin={analysisVin} initialProtocol={activeProtocol} initialTab={historyTab} initialFilters={historyFilters} onFiltersChange={updateHistoryFilters} onOpenVehicle={openVehicle} onOpenMileage={openMileageWithFilters} onOpenRaw={openRawWithFilters} />,
|
||||||
'history-query': <History mode="query" initialVin={analysisVin} initialProtocol={activeProtocol} initialTab={historyTab} initialFilters={historyFilters} onFiltersChange={updateHistoryFilters} onOpenVehicle={openVehicle} onOpenMileage={openMileageWithFilters} onOpenRaw={openRawWithFilters} />,
|
'history-query': <History mode="query" initialVin={analysisVin} initialProtocol={activeProtocol} initialTab={historyTab} initialFilters={historyFilters} onFiltersChange={updateHistoryFilters} onOpenVehicle={openVehicle} onOpenMileage={openMileageWithFilters} onOpenRaw={openRawWithFilters} />,
|
||||||
mileage: <Mileage initialVin={analysisVin} initialProtocol={activeProtocol} initialFilters={mileageFilters} onFiltersChange={updateMileageFilters} onOpenVehicle={openVehicle} onOpenHistory={openHistoryWithFilters} onOpenRaw={openRawWithFilters} />,
|
mileage: <Mileage initialVin={analysisVin} initialProtocol={activeProtocol} initialFilters={mileageFilters} onFiltersChange={updateMileageFilters} onOpenVehicle={openVehicle} onOpenHistory={openHistoryWithFilters} onOpenRaw={openRawWithFilters} />,
|
||||||
|
'alert-events': <Quality onOpenVehicle={openVehicle} onOpenRealtime={openRealtime} onOpenHistory={openHistoryWithFilters} onOpenRaw={openRawWithFilters} onOpenMileage={openMileageWithFilters} onOpenNotificationRules={() => 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: <Quality onOpenVehicle={openVehicle} onOpenRealtime={openRealtime} onOpenHistory={openHistoryWithFilters} onOpenRaw={openRawWithFilters} onOpenMileage={openMileageWithFilters} onOpenNotificationRules={() => navigatePage('notification-rules')} onHealthLoaded={(health) => {
|
quality: <Quality onOpenVehicle={openVehicle} onOpenRealtime={openRealtime} onOpenHistory={openHistoryWithFilters} onOpenRaw={openRawWithFilters} onOpenMileage={openMileageWithFilters} onOpenNotificationRules={() => navigatePage('notification-rules')} onHealthLoaded={(health) => {
|
||||||
setLinkIssueCount(health.linkHealth.filter((item) => item.status !== 'ok').length);
|
setLinkIssueCount(health.linkHealth.filter((item) => item.status !== 'ok').length);
|
||||||
setPlatformRelease(health.runtime?.platformRelease ?? '');
|
setPlatformRelease(health.runtime?.platformRelease ?? '');
|
||||||
|
|||||||
@@ -198,6 +198,37 @@ test('qualityNotificationPlan reads alert rules and priority issues from backend
|
|||||||
expect(result.priorityIssues[0].notificationText).toContain('告警通知');
|
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 () => {
|
test('onlineVehicleStatuses reads paged online vehicle status rows', async () => {
|
||||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -112,5 +112,8 @@ export const api = {
|
|||||||
qualitySummary: (params = new URLSearchParams()) => request<QualitySummary>(`/api/quality/summary?${params.toString()}`),
|
qualitySummary: (params = new URLSearchParams()) => request<QualitySummary>(`/api/quality/summary?${params.toString()}`),
|
||||||
qualityIssues: (params = new URLSearchParams()) => request<Page<QualityIssueRow>>(`/api/quality/issues?${params.toString()}`),
|
qualityIssues: (params = new URLSearchParams()) => request<Page<QualityIssueRow>>(`/api/quality/issues?${params.toString()}`),
|
||||||
qualityNotificationPlan: (params = new URLSearchParams()) => request<QualityNotificationPlan>(`/api/quality/notification-plan?${params.toString()}`),
|
qualityNotificationPlan: (params = new URLSearchParams()) => request<QualityNotificationPlan>(`/api/quality/notification-plan?${params.toString()}`),
|
||||||
|
alertEventSummary: (params = new URLSearchParams()) => request<QualitySummary>(`/api/alert-events/summary?${params.toString()}`),
|
||||||
|
alertEvents: (params = new URLSearchParams()) => request<Page<QualityIssueRow>>(`/api/alert-events?${params.toString()}`),
|
||||||
|
alertEventNotificationPlan: (params = new URLSearchParams()) => request<QualityNotificationPlan>(`/api/alert-events/notification-plan?${params.toString()}`),
|
||||||
opsHealth: () => request<OpsHealth>('/api/ops/health')
|
opsHealth: () => request<OpsHealth>('/api/ops/health')
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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({
|
expect(parseAppHash('#/quality?keyword=%E7%B2%A4A&protocol=VEHICLE_SERVICE&issueType=NO_SOURCE')).toEqual({
|
||||||
page: 'quality',
|
page: 'quality',
|
||||||
keyword: '粤A',
|
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');
|
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');
|
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', () => {
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { PageKey } from '../layout/AppShell';
|
import type { PageKey } from '../layout/AppShell';
|
||||||
|
|
||||||
const pageKeys = new Set<PageKey>(['dashboard', 'vehicles', 'map', 'realtime', 'detail', 'history', 'history-query', 'mileage', 'quality', 'notification-rules', 'ops-quality']);
|
const pageKeys = new Set<PageKey>(['dashboard', 'vehicles', 'map', 'realtime', 'detail', 'history', 'history-query', 'mileage', 'alert-events', 'quality', 'notification-rules', 'ops-quality']);
|
||||||
|
|
||||||
export type AppRoute = {
|
export type AppRoute = {
|
||||||
page?: PageKey;
|
page?: PageKey;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { useState } from 'react';
|
|||||||
import type { VehicleSourceConsistency, VehicleServiceStatus } from '../api/types';
|
import type { VehicleSourceConsistency, VehicleServiceStatus } from '../api/types';
|
||||||
import { isAMapConfigured } from '../config/appConfig';
|
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 = [
|
const navItems = [
|
||||||
{ itemKey: 'dashboard', text: '运营驾驶舱', icon: <IconHome /> },
|
{ itemKey: 'dashboard', text: '运营驾驶舱', icon: <IconHome /> },
|
||||||
@@ -27,7 +27,7 @@ const navItems = [
|
|||||||
{ itemKey: 'history', text: '轨迹回放', icon: <IconMapPin /> },
|
{ itemKey: 'history', text: '轨迹回放', icon: <IconMapPin /> },
|
||||||
{ itemKey: 'history-query', text: '历史查询', icon: <IconHistogram /> },
|
{ itemKey: 'history-query', text: '历史查询', icon: <IconHistogram /> },
|
||||||
{ itemKey: 'mileage', text: '统计分析', icon: <IconBarChartHStroked /> },
|
{ itemKey: 'mileage', text: '统计分析', icon: <IconBarChartHStroked /> },
|
||||||
{ itemKey: 'quality', text: '告警事件', icon: <IconHistogram /> },
|
{ itemKey: 'alert-events', text: '告警事件', icon: <IconHistogram /> },
|
||||||
{ itemKey: 'notification-rules', text: '通知规则', icon: <IconBell /> },
|
{ itemKey: 'notification-rules', text: '通知规则', icon: <IconBell /> },
|
||||||
{ itemKey: 'ops-quality', text: '运维质量', icon: <IconPulse /> }
|
{ itemKey: 'ops-quality', text: '运维质量', icon: <IconPulse /> }
|
||||||
];
|
];
|
||||||
@@ -93,6 +93,7 @@ export function AppShell({
|
|||||||
const amapConfigured = isAMapConfigured();
|
const amapConfigured = isAMapConfigured();
|
||||||
const alertLabel = alertButtonLabel(linkIssueCount, activeAlertRuleCount, p0AlertRuleCount);
|
const alertLabel = alertButtonLabel(linkIssueCount, activeAlertRuleCount, p0AlertRuleCount);
|
||||||
const alertClassName = alertButtonClassName(linkIssueCount, activeAlertRuleCount, p0AlertRuleCount);
|
const alertClassName = alertButtonClassName(linkIssueCount, activeAlertRuleCount, p0AlertRuleCount);
|
||||||
|
const selectedPage = activePage === 'quality' ? 'alert-events' : activePage;
|
||||||
|
|
||||||
const search = () => {
|
const search = () => {
|
||||||
const value = keyword.trim();
|
const value = keyword.trim();
|
||||||
@@ -113,7 +114,7 @@ export function AppShell({
|
|||||||
<span>车辆服务中台</span>
|
<span>车辆服务中台</span>
|
||||||
</div>
|
</div>
|
||||||
<Nav
|
<Nav
|
||||||
selectedKeys={[activePage]}
|
selectedKeys={[selectedPage]}
|
||||||
items={navItems}
|
items={navItems}
|
||||||
onSelect={({ itemKey }) => onChange(itemKey as PageKey)}
|
onSelect={({ itemKey }) => onChange(itemKey as PageKey)}
|
||||||
style={{ maxWidth: '100%' }}
|
style={{ maxWidth: '100%' }}
|
||||||
@@ -159,7 +160,7 @@ export function AppShell({
|
|||||||
size="small"
|
size="small"
|
||||||
aria-label={`顶部${alertLabel}`}
|
aria-label={`顶部${alertLabel}`}
|
||||||
className={alertClassName}
|
className={alertClassName}
|
||||||
onClick={() => onChange('quality')}
|
onClick={() => onChange('alert-events')}
|
||||||
>
|
>
|
||||||
{alertLabel}
|
{alertLabel}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ function priorityIssueNotificationText(row: QualityIssueRow) {
|
|||||||
`轨迹证据:${appURL(buildAppHash({ page: 'history', keyword: filters.keyword, protocol: filters.protocol, filters }))}`,
|
`轨迹证据:${appURL(buildAppHash({ page: 'history', keyword: filters.keyword, protocol: filters.protocol, filters }))}`,
|
||||||
`RAW证据:${appURL(buildAppHash({ page: 'history-query', keyword: rawFilters.keyword, protocol: rawFilters.protocol, filters: rawFilters }))}`,
|
`RAW证据:${appURL(buildAppHash({ page: 'history-query', keyword: rawFilters.keyword, protocol: rawFilters.protocol, filters: rawFilters }))}`,
|
||||||
`车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: lookup.key, protocol: row.protocol }))}`,
|
`车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: lookup.key, protocol: row.protocol }))}`,
|
||||||
`告警筛选:${appURL(buildAppHash({ page: 'quality', protocol: row.protocol, filters: { issueType: row.issueType } }))}`
|
`告警筛选:${appURL(buildAppHash({ page: 'alert-events', protocol: row.protocol, filters: { issueType: row.issueType } }))}`
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ test('exposes AMap operations shortcuts when map key is configured', async () =>
|
|||||||
fireEvent.click(screen.getByRole('button', { name: '顶部统计查询' }));
|
fireEvent.click(screen.getByRole('button', { name: '顶部统计查询' }));
|
||||||
expect(window.location.hash.startsWith('#/mileage')).toBe(true);
|
expect(window.location.hash.startsWith('#/mileage')).toBe(true);
|
||||||
fireEvent.click(await screen.findByRole('button', { name: '顶部告警事件正常' }));
|
fireEvent.click(await screen.findByRole('button', { name: '顶部告警事件正常' }));
|
||||||
expect(window.location.hash).toBe('#/quality');
|
expect(window.location.hash).toBe('#/alert-events');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows global alert pressure from notification plan in topbar', async () => {
|
test('shows global alert pressure from notification plan in topbar', async () => {
|
||||||
@@ -151,7 +151,7 @@ test('shows global alert pressure from notification plan in topbar', async () =>
|
|||||||
const alertButton = await screen.findByRole('button', { name: '顶部告警事件 P0 2 / 活跃 4' });
|
const alertButton = await screen.findByRole('button', { name: '顶部告警事件 P0 2 / 活跃 4' });
|
||||||
expect(alertButton).toHaveTextContent('告警事件 P0 2 / 活跃 4');
|
expect(alertButton).toHaveTextContent('告警事件 P0 2 / 活跃 4');
|
||||||
fireEvent.click(alertButton);
|
fireEvent.click(alertButton);
|
||||||
expect(window.location.hash).toBe('#/quality');
|
expect(window.location.hash).toBe('#/alert-events');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows vehicle service status distribution on dashboard', async () => {
|
test('shows vehicle service status distribution on dashboard', async () => {
|
||||||
@@ -488,7 +488,7 @@ test('dashboard exposes vehicle data center capability matrix', async () => {
|
|||||||
|
|
||||||
await renderDashboard();
|
await renderDashboard();
|
||||||
fireEvent.click(screen.getByRole('button', { name: '能力入口 告警事件触发与通知' }));
|
fireEvent.click(screen.getByRole('button', { name: '能力入口 告警事件触发与通知' }));
|
||||||
expect(window.location.hash).toBe('#/quality');
|
expect(window.location.hash).toBe('#/alert-events');
|
||||||
cleanup();
|
cleanup();
|
||||||
|
|
||||||
await renderDashboard();
|
await renderDashboard();
|
||||||
@@ -649,7 +649,7 @@ test('dashboard exposes vehicle command center map actions', async () => {
|
|||||||
expect(screen.getAllByText('有效定位 1').length).toBeGreaterThanOrEqual(1);
|
expect(screen.getAllByText('有效定位 1').length).toBeGreaterThanOrEqual(1);
|
||||||
expect(screen.getByText('降级/离线 2')).toBeInTheDocument();
|
expect(screen.getByText('降级/离线 2')).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole('button', { name: '处理高优先级告警' }));
|
fireEvent.click(screen.getByRole('button', { name: '处理高优先级告警' }));
|
||||||
expect(window.location.hash).toBe('#/quality?issueType=NO_SOURCE');
|
expect(window.location.hash).toBe('#/alert-events?issueType=NO_SOURCE');
|
||||||
|
|
||||||
cleanup();
|
cleanup();
|
||||||
window.history.replaceState(null, '', '/#/dashboard');
|
window.history.replaceState(null, '', '/#/dashboard');
|
||||||
@@ -744,7 +744,7 @@ test('dashboard shows vehicle service action queue', async () => {
|
|||||||
test.each([
|
test.each([
|
||||||
{ buttonName: '查看在线车辆', expectedHash: '#/vehicles?online=online' },
|
{ buttonName: '查看在线车辆', expectedHash: '#/vehicles?online=online' },
|
||||||
{ buttonName: '查看多源车辆', expectedHash: '#/vehicles?coverage=multi' },
|
{ buttonName: '查看多源车辆', expectedHash: '#/vehicles?coverage=multi' },
|
||||||
{ buttonName: '查看告警事件', expectedHash: '#/quality' }
|
{ buttonName: '查看告警事件', expectedHash: '#/alert-events' }
|
||||||
])('dashboard posture opens %s', async ({ buttonName, expectedHash }) => {
|
])('dashboard posture opens %s', async ({ buttonName, expectedHash }) => {
|
||||||
window.history.replaceState(null, '', '/#/dashboard');
|
window.history.replaceState(null, '', '/#/dashboard');
|
||||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
@@ -2874,7 +2874,7 @@ test('opens dashboard coverage from service status distribution', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('renders quality issues as vehicle-service governance labels', async () => {
|
test('renders quality issues as vehicle-service governance labels', async () => {
|
||||||
window.history.replaceState(null, '', '/#/quality');
|
window.history.replaceState(null, '', '/#/alert-events');
|
||||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
const path = String(input);
|
const path = String(input);
|
||||||
if (path.includes('/api/ops/health')) {
|
if (path.includes('/api/ops/health')) {
|
||||||
@@ -2949,7 +2949,7 @@ test('renders quality issues as vehicle-service governance labels', async () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('shows alert rule and notification policy workspace on quality page', async () => {
|
test('shows alert rule and notification policy workspace on quality page', async () => {
|
||||||
window.history.replaceState(null, '', '/#/quality');
|
window.history.replaceState(null, '', '/#/alert-events');
|
||||||
const writeText = vi.fn(() => Promise.resolve());
|
const writeText = vi.fn(() => Promise.resolve());
|
||||||
Object.defineProperty(navigator, 'clipboard', {
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -3045,7 +3045,7 @@ test('shows alert rule and notification policy workspace on quality page', async
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('shows actionable priority queue on quality page', async () => {
|
test('shows actionable priority queue on quality page', async () => {
|
||||||
window.history.replaceState(null, '', '/#/quality');
|
window.history.replaceState(null, '', '/#/alert-events');
|
||||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
const path = String(input);
|
const path = String(input);
|
||||||
if (path.includes('/api/ops/health')) {
|
if (path.includes('/api/ops/health')) {
|
||||||
@@ -3162,7 +3162,7 @@ test('shows actionable priority queue on quality page', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('uses backend quality notification plan on quality page', async () => {
|
test('uses backend quality notification plan on quality page', async () => {
|
||||||
window.history.replaceState(null, '', '/#/quality');
|
window.history.replaceState(null, '', '/#/alert-events');
|
||||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
const path = String(input);
|
const path = String(input);
|
||||||
if (path.includes('/api/ops/health')) {
|
if (path.includes('/api/ops/health')) {
|
||||||
@@ -3390,7 +3390,7 @@ test('renders ops quality as a standalone runtime health page', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('copies notification text from quality priority queue', async () => {
|
test('copies notification text from quality priority queue', async () => {
|
||||||
window.history.replaceState(null, '', '/#/quality');
|
window.history.replaceState(null, '', '/#/alert-events');
|
||||||
const writeText = vi.fn(() => Promise.resolve());
|
const writeText = vi.fn(() => Promise.resolve());
|
||||||
Object.defineProperty(navigator, 'clipboard', {
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -3475,11 +3475,11 @@ test('copies notification text from quality priority queue', async () => {
|
|||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('轨迹证据:http://localhost:3000/#/history?keyword=%E7%B2%A4A%E9%80%9A%E7%9F%A51&protocol=JT808&dateFrom=2026-07-03&dateTo=2026-07-04'));
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('轨迹证据:http://localhost:3000/#/history?keyword=%E7%B2%A4A%E9%80%9A%E7%9F%A51&protocol=JT808&dateFrom=2026-07-03&dateTo=2026-07-04'));
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('RAW证据:http://localhost:3000/#/history-query?keyword=%E7%B2%A4A%E9%80%9A%E7%9F%A51&protocol=JT808&tab=raw&dateFrom=2026-07-03&dateTo=2026-07-04&includeFields=true'));
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('RAW证据:http://localhost:3000/#/history-query?keyword=%E7%B2%A4A%E9%80%9A%E7%9F%A51&protocol=JT808&tab=raw&dateFrom=2026-07-03&dateTo=2026-07-04&includeFields=true'));
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('车辆服务:http://localhost:3000/#/detail?keyword=%E7%B2%A4A%E9%80%9A%E7%9F%A51&protocol=JT808'));
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('车辆服务:http://localhost:3000/#/detail?keyword=%E7%B2%A4A%E9%80%9A%E7%9F%A51&protocol=JT808'));
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('告警筛选:http://localhost:3000/#/quality'));
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('告警筛选:http://localhost:3000/#/alert-events'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('copies dispatch ticket from quality priority queue', async () => {
|
test('copies dispatch ticket from quality priority queue', async () => {
|
||||||
window.history.replaceState(null, '', '/#/quality');
|
window.history.replaceState(null, '', '/#/alert-events');
|
||||||
const writeText = vi.fn(() => Promise.resolve());
|
const writeText = vi.fn(() => Promise.resolve());
|
||||||
Object.defineProperty(navigator, 'clipboard', {
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -3570,7 +3570,7 @@ test('copies dispatch ticket from quality priority queue', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('copies priority queue notification digest on quality page', async () => {
|
test('copies priority queue notification digest on quality page', async () => {
|
||||||
window.history.replaceState(null, '', '/#/quality');
|
window.history.replaceState(null, '', '/#/alert-events');
|
||||||
const writeText = vi.fn(() => Promise.resolve());
|
const writeText = vi.fn(() => Promise.resolve());
|
||||||
Object.defineProperty(navigator, 'clipboard', {
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -3668,11 +3668,11 @@ test('copies priority queue notification digest on quality page', async () => {
|
|||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('确认平台转发'));
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('确认平台转发'));
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('2. [P1] 粤A汇总2 / 13307795426'));
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('2. [P1] 粤A汇总2 / 13307795426'));
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('维护身份绑定'));
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('维护身份绑定'));
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('告警筛选:http://localhost:3000/#/quality'));
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('告警筛选:http://localhost:3000/#/alert-events'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('copies notification text from regular quality issue row', async () => {
|
test('copies notification text from regular quality issue row', async () => {
|
||||||
window.history.replaceState(null, '', '/#/quality');
|
window.history.replaceState(null, '', '/#/alert-events');
|
||||||
const writeText = vi.fn(() => Promise.resolve());
|
const writeText = vi.fn(() => Promise.resolve());
|
||||||
Object.defineProperty(navigator, 'clipboard', {
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -3759,7 +3759,7 @@ test('copies notification text from regular quality issue row', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('opens realtime map context from quality priority queue', async () => {
|
test('opens realtime map context from quality priority queue', async () => {
|
||||||
window.history.replaceState(null, '', '/#/quality');
|
window.history.replaceState(null, '', '/#/alert-events');
|
||||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
const path = String(input);
|
const path = String(input);
|
||||||
if (path.includes('/api/ops/health')) {
|
if (path.includes('/api/ops/health')) {
|
||||||
@@ -3861,7 +3861,7 @@ test('opens realtime map context from quality priority queue', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('opens same-day trajectory evidence from quality priority queue', async () => {
|
test('opens same-day trajectory evidence from quality priority queue', async () => {
|
||||||
window.history.replaceState(null, '', '/#/quality');
|
window.history.replaceState(null, '', '/#/alert-events');
|
||||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
const path = String(input);
|
const path = String(input);
|
||||||
if (path.includes('/api/ops/health')) {
|
if (path.includes('/api/ops/health')) {
|
||||||
@@ -3954,7 +3954,7 @@ test('opens same-day trajectory evidence from quality priority queue', async ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('opens same-day raw evidence from quality priority queue', async () => {
|
test('opens same-day raw evidence from quality priority queue', async () => {
|
||||||
window.history.replaceState(null, '', '/#/quality');
|
window.history.replaceState(null, '', '/#/alert-events');
|
||||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
const path = String(input);
|
const path = String(input);
|
||||||
if (path.includes('/api/ops/health')) {
|
if (path.includes('/api/ops/health')) {
|
||||||
@@ -4048,7 +4048,7 @@ test('opens same-day raw evidence from quality priority queue', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('opens vehicle service from quality issue with issue source evidence', async () => {
|
test('opens vehicle service from quality issue with issue source evidence', async () => {
|
||||||
window.history.replaceState(null, '', '/#/quality');
|
window.history.replaceState(null, '', '/#/alert-events');
|
||||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
const path = String(input);
|
const path = String(input);
|
||||||
if (path.includes('/api/ops/health')) {
|
if (path.includes('/api/ops/health')) {
|
||||||
@@ -4148,7 +4148,7 @@ test('opens vehicle service from quality issue with issue source evidence', asyn
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('opens same-day history evidence from quality issue row', async () => {
|
test('opens same-day history evidence from quality issue row', async () => {
|
||||||
window.history.replaceState(null, '', '/#/quality');
|
window.history.replaceState(null, '', '/#/alert-events');
|
||||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
const path = String(input);
|
const path = String(input);
|
||||||
if (path.includes('/api/ops/health')) {
|
if (path.includes('/api/ops/health')) {
|
||||||
@@ -4224,7 +4224,7 @@ test('opens same-day history evidence from quality issue row', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('quality page surfaces escalation clock for priority issues', async () => {
|
test('quality page surfaces escalation clock for priority issues', async () => {
|
||||||
window.history.replaceState(null, '', '/#/quality');
|
window.history.replaceState(null, '', '/#/alert-events');
|
||||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
const path = String(input);
|
const path = String(input);
|
||||||
if (path.includes('/api/ops/health')) {
|
if (path.includes('/api/ops/health')) {
|
||||||
@@ -4311,7 +4311,7 @@ test('quality page surfaces escalation clock for priority issues', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('opens same-day raw evidence from quality issue row', async () => {
|
test('opens same-day raw evidence from quality issue row', async () => {
|
||||||
window.history.replaceState(null, '', '/#/quality');
|
window.history.replaceState(null, '', '/#/alert-events');
|
||||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
const path = String(input);
|
const path = String(input);
|
||||||
if (path.includes('/api/ops/health')) {
|
if (path.includes('/api/ops/health')) {
|
||||||
@@ -4498,7 +4498,7 @@ test('copies trajectory playback summary from history page', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('opens same-day mileage statistics from quality issue row', async () => {
|
test('opens same-day mileage statistics from quality issue row', async () => {
|
||||||
window.history.replaceState(null, '', '/#/quality');
|
window.history.replaceState(null, '', '/#/alert-events');
|
||||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
const path = String(input);
|
const path = String(input);
|
||||||
if (path.includes('/api/ops/health')) {
|
if (path.includes('/api/ops/health')) {
|
||||||
@@ -4629,7 +4629,7 @@ test('opens same-day mileage statistics from quality issue row', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('drills into quality issues by issue type', async () => {
|
test('drills into quality issues by issue type', async () => {
|
||||||
window.history.replaceState(null, '', '/#/quality');
|
window.history.replaceState(null, '', '/#/alert-events');
|
||||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
const path = String(input);
|
const path = String(input);
|
||||||
if (path.includes('/api/ops/health')) {
|
if (path.includes('/api/ops/health')) {
|
||||||
@@ -4695,7 +4695,7 @@ test('drills into quality issues by issue type', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('drills into quality issues by protocol bucket', async () => {
|
test('drills into quality issues by protocol bucket', async () => {
|
||||||
window.history.replaceState(null, '', '/#/quality');
|
window.history.replaceState(null, '', '/#/alert-events');
|
||||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
const path = String(input);
|
const path = String(input);
|
||||||
if (path.includes('/api/ops/health')) {
|
if (path.includes('/api/ops/health')) {
|
||||||
@@ -4761,11 +4761,11 @@ test('drills into quality issues by protocol bucket', async () => {
|
|||||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/quality/issues?protocol=JT808&limit=20&offset=0'), undefined);
|
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/quality/issues?protocol=JT808&limit=20&offset=0'), undefined);
|
||||||
});
|
});
|
||||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/quality/summary?protocol=JT808'), undefined);
|
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/quality/summary?protocol=JT808'), undefined);
|
||||||
expect(window.location.hash).toBe('#/quality?protocol=JT808');
|
expect(window.location.hash).toBe('#/alert-events?protocol=JT808');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('drills into quality issues by issue type bucket', async () => {
|
test('drills into quality issues by issue type bucket', async () => {
|
||||||
window.history.replaceState(null, '', '/#/quality');
|
window.history.replaceState(null, '', '/#/alert-events');
|
||||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
const path = String(input);
|
const path = String(input);
|
||||||
if (path.includes('/api/ops/health')) {
|
if (path.includes('/api/ops/health')) {
|
||||||
@@ -4831,11 +4831,11 @@ test('drills into quality issues by issue type bucket', async () => {
|
|||||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/quality/issues?issueType=VIN_MISSING&limit=20&offset=0'), undefined);
|
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/quality/issues?issueType=VIN_MISSING&limit=20&offset=0'), undefined);
|
||||||
});
|
});
|
||||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/quality/summary?issueType=VIN_MISSING'), undefined);
|
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/quality/summary?issueType=VIN_MISSING'), undefined);
|
||||||
expect(window.location.hash).toBe('#/quality?issueType=VIN_MISSING');
|
expect(window.location.hash).toBe('#/alert-events?issueType=VIN_MISSING');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('applies shareable quality filters from hash', async () => {
|
test('applies shareable quality filters from hash', async () => {
|
||||||
window.history.replaceState(null, '', '/#/quality?keyword=%E7%B2%A4A&protocol=VEHICLE_SERVICE&issueType=NO_SOURCE');
|
window.history.replaceState(null, '', '/#/alert-events?keyword=%E7%B2%A4A&protocol=VEHICLE_SERVICE&issueType=NO_SOURCE');
|
||||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
const path = String(input);
|
const path = String(input);
|
||||||
if (path.includes('/api/ops/health')) {
|
if (path.includes('/api/ops/health')) {
|
||||||
@@ -4894,7 +4894,7 @@ test('applies shareable quality filters from hash', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('shows and clears current quality filters', async () => {
|
test('shows and clears current quality filters', async () => {
|
||||||
window.history.replaceState(null, '', '/#/quality?keyword=%E7%B2%A4A&protocol=VEHICLE_SERVICE&issueType=NO_SOURCE');
|
window.history.replaceState(null, '', '/#/alert-events?keyword=%E7%B2%A4A&protocol=VEHICLE_SERVICE&issueType=NO_SOURCE');
|
||||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
const path = String(input);
|
const path = String(input);
|
||||||
if (path.includes('/api/ops/health')) {
|
if (path.includes('/api/ops/health')) {
|
||||||
@@ -4955,11 +4955,11 @@ test('shows and clears current quality filters', async () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/quality/issues?limit=20&offset=0'), undefined);
|
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/quality/issues?limit=20&offset=0'), undefined);
|
||||||
});
|
});
|
||||||
expect(window.location.hash).toBe('#/quality');
|
expect(window.location.hash).toBe('#/alert-events');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('copies shareable quality filter link', async () => {
|
test('copies shareable quality filter link', async () => {
|
||||||
window.history.replaceState(null, '', '/#/quality?keyword=%E7%B2%A4A&protocol=VEHICLE_SERVICE&issueType=NO_SOURCE');
|
window.history.replaceState(null, '', '/#/alert-events?keyword=%E7%B2%A4A&protocol=VEHICLE_SERVICE&issueType=NO_SOURCE');
|
||||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
Object.defineProperty(navigator, 'clipboard', {
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -5019,7 +5019,7 @@ test('copies shareable quality filter link', async () => {
|
|||||||
fireEvent.click(await screen.findByRole('button', { name: /复制筛选链接/ }));
|
fireEvent.click(await screen.findByRole('button', { name: /复制筛选链接/ }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('/#/quality?keyword=%E7%B2%A4A&protocol=VEHICLE_SERVICE&issueType=NO_SOURCE'));
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('/#/alert-events?keyword=%E7%B2%A4A&protocol=VEHICLE_SERVICE&issueType=NO_SOURCE'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -8585,7 +8585,7 @@ test('surfaces multi-source realtime consistency issues with quality drilldown',
|
|||||||
fireEvent.click(screen.getByRole('button', { name: '检查质量' }));
|
fireEvent.click(screen.getByRole('button', { name: '检查质量' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(window.location.hash).toBe('#/quality?keyword=VIN-RT-CONSISTENCY&protocol=GB32960');
|
expect(window.location.hash).toBe('#/alert-events?keyword=VIN-RT-CONSISTENCY&protocol=GB32960');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -8715,7 +8715,7 @@ test('opens quality issues from realtime vehicle row with source evidence', asyn
|
|||||||
fireEvent.click(screen.getAllByRole('button', { name: '质量问题' })[0]);
|
fireEvent.click(screen.getAllByRole('button', { name: '质量问题' })[0]);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(window.location.hash).toBe('#/quality?keyword=VIN-RT-QUALITY&protocol=JT808');
|
expect(window.location.hash).toBe('#/alert-events?keyword=VIN-RT-QUALITY&protocol=JT808');
|
||||||
});
|
});
|
||||||
expect(await screen.findByRole('heading', { name: '告警事件' })).toBeInTheDocument();
|
expect(await screen.findByRole('heading', { name: '告警事件' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -8773,7 +8773,7 @@ test('loads realtime vehicles from shareable source filter hash', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('opens realtime status from quality issue row with source evidence', async () => {
|
test('opens realtime status from quality issue row with source evidence', async () => {
|
||||||
window.history.replaceState(null, '', '/#/quality');
|
window.history.replaceState(null, '', '/#/alert-events');
|
||||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
const path = String(input);
|
const path = String(input);
|
||||||
if (path.includes('/api/ops/health')) {
|
if (path.includes('/api/ops/health')) {
|
||||||
@@ -9417,7 +9417,7 @@ test('opens quality issues from source-filtered vehicle list with source evidenc
|
|||||||
fireEvent.click(screen.getByRole('button', { name: '质量问题' }));
|
fireEvent.click(screen.getByRole('button', { name: '质量问题' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(window.location.hash).toBe('#/quality?keyword=VIN-LIST-QUALITY&protocol=JT808');
|
expect(window.location.hash).toBe('#/alert-events?keyword=VIN-LIST-QUALITY&protocol=JT808');
|
||||||
});
|
});
|
||||||
expect(await screen.findByRole('heading', { name: '告警事件' })).toBeInTheDocument();
|
expect(await screen.findByRole('heading', { name: '告警事件' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -9705,7 +9705,7 @@ test('opens quality governance from vehicle detail overview', async () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: '查看质量问题 3 项' }));
|
fireEvent.click(screen.getByRole('button', { name: '查看质量问题 3 项' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(window.location.hash).toBe('#/quality?keyword=VIN001');
|
expect(window.location.hash).toBe('#/alert-events?keyword=VIN001');
|
||||||
});
|
});
|
||||||
expect(await screen.findByRole('heading', { name: '告警事件' })).toBeInTheDocument();
|
expect(await screen.findByRole('heading', { name: '告警事件' })).toBeInTheDocument();
|
||||||
expect(screen.getByText('告警事件闭环')).toBeInTheDocument();
|
expect(screen.getByText('告警事件闭环')).toBeInTheDocument();
|
||||||
@@ -9714,7 +9714,7 @@ test('opens quality governance from vehicle detail overview', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('quality health storage card stays pending before ops health loads', async () => {
|
test('quality health storage card stays pending before ops health loads', async () => {
|
||||||
window.history.replaceState(null, '', '/#/quality');
|
window.history.replaceState(null, '', '/#/alert-events');
|
||||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
const path = String(input);
|
const path = String(input);
|
||||||
if (path.includes('/api/ops/health')) {
|
if (path.includes('/api/ops/health')) {
|
||||||
@@ -9741,7 +9741,7 @@ test('quality health storage card stays pending before ops health loads', async
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('quality health shows active connection capacity metric', async () => {
|
test('quality health shows active connection capacity metric', async () => {
|
||||||
window.history.replaceState(null, '', '/#/quality');
|
window.history.replaceState(null, '', '/#/alert-events');
|
||||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
const path = String(input);
|
const path = String(input);
|
||||||
return {
|
return {
|
||||||
@@ -9768,7 +9768,7 @@ test('quality health shows active connection capacity metric', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('quality health shows structured capacity findings', async () => {
|
test('quality health shows structured capacity findings', async () => {
|
||||||
window.history.replaceState(null, '', '/#/quality');
|
window.history.replaceState(null, '', '/#/alert-events');
|
||||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
const path = String(input);
|
const path = String(input);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -153,28 +153,33 @@ Returns daily mileage rows and aggregate mileage summary.
|
|||||||
|
|
||||||
### Alert Events
|
### Alert Events
|
||||||
|
|
||||||
Planned API surface:
|
Current API surface:
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /api/alerts/events?keyword=粤AG18312&severity=warning&status=open&limit=20&offset=0
|
GET /api/alert-events?keyword=粤AG18312&protocol=JT808&issueType=NO_SOURCE&limit=20&offset=0
|
||||||
GET /api/alerts/events/{id}
|
GET /api/alert-events/summary?keyword=粤AG18312&protocol=JT808&issueType=NO_SOURCE
|
||||||
PATCH /api/alerts/events/{id}
|
GET /api/alert-events/notification-plan?keyword=粤AG18312&protocol=JT808&issueType=NO_SOURCE&limit=20
|
||||||
GET /api/alerts/rules
|
|
||||||
GET /api/alerts/notifications?eventId=xxx&limit=20&offset=0
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Alert records should be vehicle-first and evidence-backed. Each event should include vehicle identity, issue type, severity, protocol/source when relevant, trigger time, last seen time, current status, evidence links, trace ID if available, and notification state.
|
`/api/quality/issues`, `/api/quality/summary`, and `/api/quality/notification-plan` remain compatibility aliases. New UI and integrations should use `/api/alert-events*` so the API language matches the vehicle data management center.
|
||||||
|
|
||||||
|
Alert event rows are vehicle-first and evidence-backed. Each event includes vehicle identity, issue type, severity, protocol/source when relevant, last seen time, source endpoint, detail, and links generated by the BFF/UI to realtime, trajectory, RAW, mileage, vehicle detail, or notification rule evidence.
|
||||||
|
|
||||||
Initial alert types:
|
Initial alert types:
|
||||||
|
|
||||||
- `SOURCE_OFFLINE`
|
- `NO_SOURCE`
|
||||||
- `NO_REALTIME_UPDATE`
|
- `VIN_MISSING`
|
||||||
- `LOCATION_MISSING`
|
- `LINK_GAP`
|
||||||
- `MILEAGE_DIVERGENT`
|
- `FIELD_MISSING`
|
||||||
- `RAW_PARSE_FAILURE`
|
- `CAPACITY_RISK`
|
||||||
- `BINDING_MISSING`
|
|
||||||
- `SOURCE_TIME_DIVERGENT`
|
Future durable alert records should extend this surface with event IDs, manual status, assignee, acknowledgement, escalation state, and notification logs:
|
||||||
- `QUEUE_BACKLOG`
|
|
||||||
|
```http
|
||||||
|
GET /api/alert-events/{id}
|
||||||
|
PATCH /api/alert-events/{id}
|
||||||
|
GET /api/alert-events/{id}/notifications?limit=20&offset=0
|
||||||
|
```
|
||||||
|
|
||||||
### Online And Completeness Statistics
|
### Online And Completeness Statistics
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user