feat(platform): add vehicle capability matrix
This commit is contained in:
@@ -392,7 +392,7 @@ export default function App() {
|
||||
};
|
||||
|
||||
const pages: Record<PageKey, JSX.Element> = {
|
||||
dashboard: <Dashboard onOpenVehicle={openVehicle} onOpenQuality={openQuality} onOpenRealtime={openRealtime} onOpenVehicles={openVehicles} />,
|
||||
dashboard: <Dashboard onOpenVehicle={openVehicle} onOpenQuality={openQuality} onOpenRealtime={openRealtime} onOpenVehicles={openVehicles} onOpenHistory={openHistoryWithFilters} onOpenMileage={openMileageWithFilters} />,
|
||||
vehicles: <Vehicles onOpenVehicle={openVehicle} onOpenQuality={openQuality} onFiltersChange={updateVehicleFilters} initialFilters={vehicleFilters} />,
|
||||
realtime: <Realtime onOpenVehicle={openVehicle} onOpenHistory={openHistoryForVehicle} onOpenQuality={openQuality} onFiltersChange={updateRealtimeFilters} initialFilters={realtimeFilters} />,
|
||||
detail: <VehicleDetail vin={activeVin} protocol={activeProtocol} onOpenRealtime={openRealtimeForVehicle} onOpenHistory={openHistoryForVehicle} onOpenRaw={openRawForVehicle} onOpenMileage={openMileageForVehicle} onOpenVehicles={openVehicles} onOpenQuality={openQuality} onQueryChange={updateVehicleDetailQuery} />,
|
||||
|
||||
@@ -112,12 +112,16 @@ export function Dashboard({
|
||||
onOpenVehicle,
|
||||
onOpenQuality,
|
||||
onOpenRealtime,
|
||||
onOpenVehicles
|
||||
onOpenVehicles,
|
||||
onOpenHistory,
|
||||
onOpenMileage
|
||||
}: {
|
||||
onOpenVehicle: (vin: string, protocol?: string) => void;
|
||||
onOpenQuality: (filters?: Record<string, string>) => void;
|
||||
onOpenRealtime: (filters?: Record<string, string>) => void;
|
||||
onOpenVehicles: (filters?: Record<string, string>) => void;
|
||||
onOpenHistory: (filters?: Record<string, string>) => void;
|
||||
onOpenMileage: (filters?: Record<string, string>) => void;
|
||||
}) {
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||
const [serviceSummary, setServiceSummary] = useState<VehicleServiceSummary | null>(null);
|
||||
@@ -239,6 +243,38 @@ export function Dashboard({
|
||||
online: row.online,
|
||||
title: `${row.plate || row.vin || '-'} ${row.primaryProtocol || ''} ${row.lastSeen || ''}`
|
||||
}));
|
||||
const capabilities = [
|
||||
{
|
||||
title: '实时监控',
|
||||
description: '按车辆聚合最新位置、在线状态和多来源实时字段,支持地图态势查看。',
|
||||
action: '查看在线车辆',
|
||||
onClick: () => onOpenRealtime({ online: 'online' })
|
||||
},
|
||||
{
|
||||
title: '轨迹回放',
|
||||
description: '围绕车辆回放历史位置轨迹,结合高德线路和 RAW 证据定位异常。',
|
||||
action: '打开轨迹',
|
||||
onClick: () => onOpenHistory()
|
||||
},
|
||||
{
|
||||
title: '历史数据查询',
|
||||
description: '查询位置历史、RAW 帧和解析字段,为车辆问题复盘提供证据链。',
|
||||
action: '查询历史',
|
||||
onClick: () => onOpenHistory()
|
||||
},
|
||||
{
|
||||
title: '告警事件触发与通知',
|
||||
description: '围绕断链、无来源、VIN 缺失、字段缺失形成告警和通知闭环。',
|
||||
action: '查看告警',
|
||||
onClick: () => onOpenQuality()
|
||||
},
|
||||
{
|
||||
title: '统计查询',
|
||||
description: '提供里程等运营统计查询,保证区间总值与每日统计口径闭合。',
|
||||
action: '查看统计',
|
||||
onClick: () => onOpenMileage()
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="vp-page">
|
||||
@@ -267,6 +303,21 @@ export function Dashboard({
|
||||
<Tag color={(summary?.kafkaLag ?? 0) > 0 ? 'orange' : 'green'}>Kafka Lag {formatLag(summary?.kafkaLag)}</Tag>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card bordered title="车辆业务能力矩阵" style={{ marginBottom: 16 }}>
|
||||
<div className="vp-capability-grid">
|
||||
{capabilities.map((item) => (
|
||||
<div key={item.title} className="vp-capability-item">
|
||||
<div>
|
||||
<Typography.Title heading={6} style={{ margin: 0 }}>{item.title}</Typography.Title>
|
||||
<Typography.Text type="secondary">{item.description}</Typography.Text>
|
||||
</div>
|
||||
<Button size="small" theme="light" type="primary" aria-label={`能力入口 ${item.title}`} onClick={item.onClick}>
|
||||
{item.action}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<Card
|
||||
bordered
|
||||
title="车辆态势指挥台"
|
||||
|
||||
@@ -699,6 +699,31 @@ body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vp-capability-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vp-capability-item {
|
||||
min-height: 154px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--vp-border);
|
||||
border-radius: var(--vp-radius);
|
||||
background: #fbfcff;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: space-between;
|
||||
}
|
||||
|
||||
.vp-capability-item .semi-typography {
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.vp-capability-item .semi-button {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.vp-vehicle-map-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 280px;
|
||||
@@ -775,6 +800,7 @@ body {
|
||||
@media (max-width: 900px) {
|
||||
.vp-evidence-grid,
|
||||
.vp-action-grid,
|
||||
.vp-capability-grid,
|
||||
.vp-conclusion-grid,
|
||||
.vp-monitor-layout,
|
||||
.vp-alert-flow,
|
||||
|
||||
@@ -281,6 +281,137 @@ test('dashboard presents one vehicle service operating posture', async () => {
|
||||
expect(screen.getByText('7 质量关注')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('dashboard exposes vehicle data center capability matrix', async () => {
|
||||
window.history.replaceState(null, '', '/#/dashboard');
|
||||
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: 0, capacityFindings: [], redisOnlineKeys: 0, tdengineWritable: true, mysqlWritable: true, runtime: { requestTimeoutMs: 5000 } },
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
}
|
||||
if (path.includes('/api/dashboard/summary')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
onlineVehicles: 3,
|
||||
activeToday: 4,
|
||||
frameToday: 1286320,
|
||||
issueVehicles: 7,
|
||||
kafkaLag: 0,
|
||||
protocols: [],
|
||||
serviceStatuses: [],
|
||||
linkHealth: []
|
||||
},
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
}
|
||||
if (path.includes('/api/vehicle-service/summary')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
totalVehicles: 1033,
|
||||
boundVehicles: 1024,
|
||||
onlineVehicles: 208,
|
||||
singleSourceVehicles: 391,
|
||||
multiSourceVehicles: 181,
|
||||
noDataVehicles: 461,
|
||||
identityRequiredVehicles: 9,
|
||||
serviceStatuses: [],
|
||||
protocols: [],
|
||||
missingSources: []
|
||||
},
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
}
|
||||
if (path.includes('/api/mileage/summary')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: { vehicleCount: 0, recordCount: 0, sourceCount: 0, totalMileageKm: 0, averageMileagePerVin: 0 },
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
}
|
||||
if (path.includes('/api/quality/summary')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: { issueVehicleCount: 0, issueRecordCount: 0, errorCount: 0, warningCount: 0, protocols: [], issueTypes: [] },
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
}
|
||||
if (path.includes('/api/quality/notification-plan')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: { rules: [], policies: [], priorityIssues: [], activeRuleCount: 0, p0RuleCount: 0 },
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: { items: [], total: 0, limit: 8, offset: 0 },
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
});
|
||||
|
||||
const renderDashboard = async () => {
|
||||
window.history.replaceState(null, '', '/#/dashboard');
|
||||
render(<App />);
|
||||
expect(await screen.findByText('车辆业务能力矩阵')).toBeInTheDocument();
|
||||
};
|
||||
|
||||
await renderDashboard();
|
||||
expect(screen.getAllByText('实时监控').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('轨迹回放').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('历史数据查询').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('告警事件触发与通知').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('统计查询').length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '能力入口 实时监控' }));
|
||||
expect(window.location.hash).toBe('#/realtime?online=online');
|
||||
cleanup();
|
||||
|
||||
await renderDashboard();
|
||||
fireEvent.click(screen.getByRole('button', { name: '能力入口 轨迹回放' }));
|
||||
expect(window.location.hash.startsWith('#/history')).toBe(true);
|
||||
cleanup();
|
||||
|
||||
await renderDashboard();
|
||||
fireEvent.click(screen.getByRole('button', { name: '能力入口 历史数据查询' }));
|
||||
expect(window.location.hash.startsWith('#/history')).toBe(true);
|
||||
cleanup();
|
||||
|
||||
await renderDashboard();
|
||||
fireEvent.click(screen.getByRole('button', { name: '能力入口 告警事件触发与通知' }));
|
||||
expect(window.location.hash).toBe('#/quality');
|
||||
cleanup();
|
||||
|
||||
await renderDashboard();
|
||||
fireEvent.click(screen.getByRole('button', { name: '能力入口 统计查询' }));
|
||||
expect(window.location.hash.startsWith('#/mileage')).toBe(true);
|
||||
});
|
||||
|
||||
test('dashboard exposes vehicle command center map actions', async () => {
|
||||
window.history.replaceState(null, '', '/#/dashboard');
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||
|
||||
Reference in New Issue
Block a user