feat(platform): streamline dashboard for vehicle service
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
import { Button, Card, Col, Form, Row, Select, Space, Spin, Table, Tag, Toast, Typography } from '@douyinfe/semi-ui';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import type { DashboardSummary, LinkHealth, OpsHealth, ProtocolStat, QualityIssueRow, ServiceStatusStat, VehicleCoverageRow, VehicleRealtimeRow, VehicleServiceSummary } from '../api/types';
|
||||
import type { DashboardSummary, OpsHealth, QualityIssueRow, VehicleCoverageRow, VehicleRealtimeRow, VehicleServiceSummary } from '../api/types';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { SourceStatusTags } from '../components/SourceStatusTags';
|
||||
import { StatusTag } from '../components/StatusTag';
|
||||
@@ -13,20 +12,6 @@ import { buildCsv, downloadCsv, type CsvColumn } from '../domain/csvExport';
|
||||
import { qualityIssueLabel, qualityProtocolLabel } from '../domain/qualityIssue';
|
||||
import { qualityIssueVehicleLookup } from '../domain/vehicleLookup';
|
||||
|
||||
const statusColor: Record<string, 'green' | 'orange' | 'red' | 'grey'> = {
|
||||
ok: 'green',
|
||||
warning: 'orange',
|
||||
error: 'red'
|
||||
};
|
||||
|
||||
const serviceStatusColor: Record<string, 'green' | 'orange' | 'red' | 'grey'> = {
|
||||
healthy: 'green',
|
||||
degraded: 'orange',
|
||||
offline: 'red',
|
||||
no_data: 'orange',
|
||||
identity_required: 'orange'
|
||||
};
|
||||
|
||||
const serviceStatusTitle: Record<string, string> = {
|
||||
healthy: '服务正常',
|
||||
degraded: '来源不完整',
|
||||
@@ -86,13 +71,6 @@ function vehicleServiceOnlineText(serviceSummary: VehicleServiceSummary | null,
|
||||
return `${onlineVehicles.toLocaleString()} / ${totalVehicles.toLocaleString()} 在线`;
|
||||
}
|
||||
|
||||
function formatProtocolRate(row: ProtocolStat) {
|
||||
if (!Number.isFinite(row.total) || row.total <= 0) {
|
||||
return '0%';
|
||||
}
|
||||
return `${Math.round((row.online / row.total) * 100)}%`;
|
||||
}
|
||||
|
||||
function rowServiceStatus(row: { serviceStatus?: { title: string; severity: string }; onlineSourceCount: number; sourceCount: number }) {
|
||||
if (row.serviceStatus) {
|
||||
return {
|
||||
@@ -311,16 +289,6 @@ export function Dashboard({
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const kpis: Array<{ label: string; value: string; filters: Record<string, string> }> = [
|
||||
{ label: '总车辆', value: formatCount(serviceSummary?.totalVehicles), filters: {} },
|
||||
{ label: '在线车辆', value: formatCount(serviceSummary?.onlineVehicles ?? summary?.onlineVehicles), filters: { online: 'online' } },
|
||||
{ label: '单源车辆', value: formatCount(serviceSummary?.singleSourceVehicles), filters: { coverage: 'single' } },
|
||||
{ label: '多源车辆', value: formatCount(serviceSummary?.multiSourceVehicles), filters: { coverage: 'multi' } },
|
||||
{ label: '暂无来源车辆', value: formatCount(serviceSummary?.noDataVehicles), filters: { serviceStatus: 'no_data' } },
|
||||
{ label: '身份未绑定', value: formatCount(serviceSummary?.identityRequiredVehicles), filters: { serviceStatus: 'identity_required' } },
|
||||
{ label: '档案不完整', value: formatCount(serviceSummary?.archiveIncompleteVehicles), filters: { archiveStatus: 'incomplete' } }
|
||||
];
|
||||
const missingSourceCounts = new Map((serviceSummary?.missingSources ?? []).map((item) => [item.protocol, item.count]));
|
||||
const serviceActionQueue = useMemo<Array<{ label: string; count: number; filters: Record<string, string>; detail: string }>>(() => {
|
||||
const items: Array<{ label: string; count: number; filters: Record<string, string>; detail: string }> = [];
|
||||
if ((serviceSummary?.noDataVehicles ?? 0) > 0) {
|
||||
@@ -370,6 +338,15 @@ export function Dashboard({
|
||||
const commandOnlineCount = locations.filter((row) => row.online).length;
|
||||
const commandLocatedCount = locations.filter(hasValidCoordinate).length;
|
||||
const commandDegradedCount = locations.filter((row) => row.onlineSourceCount <= 0 || row.onlineSourceCount < row.sourceCount).length;
|
||||
const kpis: Array<{ label: string; value: string; filters: Record<string, string> }> = [
|
||||
{ label: '总车辆', value: formatCount(serviceSummary?.totalVehicles), filters: {} },
|
||||
{ label: '在线车辆', value: formatCount(serviceSummary?.onlineVehicles ?? summary?.onlineVehicles), filters: { online: 'online' } },
|
||||
{ label: '今日活跃', value: formatCount(summary?.activeToday), filters: { online: 'online' } },
|
||||
{ label: '有效定位', value: commandLocatedCount.toLocaleString(), filters: { online: 'online' } },
|
||||
{ label: '告警车辆', value: formatCount(summary?.issueVehicles), filters: { serviceStatus: 'degraded' } },
|
||||
{ label: '今日数据', value: formatCount(summary?.frameToday), filters: {} },
|
||||
{ label: '档案不完整', value: formatCount(serviceSummary?.archiveIncompleteVehicles), filters: { archiveStatus: 'incomplete' } }
|
||||
];
|
||||
const highPriorityIssue = qualityIssues.find((item) => item.severity === 'error') ?? qualityIssues[0];
|
||||
const highPriorityLookup = highPriorityIssue ? qualityIssueVehicleLookup(highPriorityIssue) : undefined;
|
||||
const highPriorityEvidenceFilters = highPriorityIssue ? priorityIssueEvidenceFilters(highPriorityIssue) : undefined;
|
||||
@@ -647,8 +624,8 @@ export function Dashboard({
|
||||
{
|
||||
title: '历史数据查询',
|
||||
value: `${formatCount(summary?.frameToday)} 今日帧`,
|
||||
meta: `Kafka Lag ${formatLag(summary?.kafkaLag)}`,
|
||||
color: (summary?.kafkaLag ?? 0) > 0 ? 'orange' as const : 'blue' as const,
|
||||
meta: '字段裁剪与导出',
|
||||
color: 'blue' as const,
|
||||
detail: '查询位置历史、RAW 帧和解析字段,为车辆服务提供可追溯证据。',
|
||||
actions: [
|
||||
{ label: '查询历史数据', onClick: () => onOpenHistory({ tab: 'location' }) },
|
||||
@@ -758,7 +735,7 @@ export function Dashboard({
|
||||
{
|
||||
title: '历史数据查询',
|
||||
objective: '围绕车辆查询位置、RAW、解析字段,给 BI、运维和业务复盘提供证据。',
|
||||
evidence: `TDengine / 今日帧 ${formatCount(summary?.frameToday)} / Kafka Lag ${formatLag(summary?.kafkaLag)}`,
|
||||
evidence: `今日数据 ${formatCount(summary?.frameToday)} / 支持字段裁剪与导出`,
|
||||
sla: '优先返回必要字段,避免大 JSON 拖慢查询',
|
||||
primaryAction: '历史查询',
|
||||
secondaryAction: '字段证据',
|
||||
@@ -778,7 +755,7 @@ export function Dashboard({
|
||||
{
|
||||
title: '统计查询',
|
||||
objective: '按车辆口径查询里程等指标,保证区间统计和日统计可闭合。',
|
||||
evidence: `车辆口径 ${formatCount(serviceSummary?.totalVehicles)} / 多源覆盖 ${formatCount(serviceSummary?.multiSourceVehicles)}`,
|
||||
evidence: `车辆口径 ${formatCount(serviceSummary?.totalVehicles)} / 区间统计可追溯轨迹证据`,
|
||||
sla: '统计值必须能追溯轨迹和 RAW 证据',
|
||||
primaryAction: '统计查询',
|
||||
secondaryAction: '车辆中心',
|
||||
@@ -798,9 +775,7 @@ export function Dashboard({
|
||||
`今日帧量:${formatCount(summary?.frameToday)}`,
|
||||
`实时地图有效定位:${commandLocatedCount.toLocaleString()}`,
|
||||
`告警车辆:${formatCount(summary?.issueVehicles)}`,
|
||||
`Kafka Lag:${formatLag(summary?.kafkaLag)}`,
|
||||
`高德地图:Web JS ${amapConfigured ? '已配置' : '待配置'} / 服务端 API ${amapApiConfigured ? '已配置' : '待配置'} / 安全代理 ${amapSecurityProxyEnabled ? '已启用' : '未启用'}`,
|
||||
`链路健康:${unhealthyLinks.length > 0 ? unhealthyLinks.map((item) => `${item.name}=${item.status}`).join(';') : '正常'}`,
|
||||
`优先动作:${priorityAction ? `${priorityAction.label} ${priorityAction.count.toLocaleString()}辆 - ${priorityAction.detail}` : '暂无待办'}`,
|
||||
highPriorityIssue ? `最高告警:${highPriorityIssue.severity === 'error' ? 'P0' : 'P1'} ${qualityIssueLabel(highPriorityIssue.issueType)} / ${priorityIssueVehicleLabel(highPriorityIssue)} / ${highPriorityIssue.lastSeen || '-'}` : '最高告警:暂无',
|
||||
highPriorityIssue ? `告警详情:${highPriorityIssue.detail || '-'}` : '',
|
||||
@@ -890,21 +865,9 @@ export function Dashboard({
|
||||
},
|
||||
{
|
||||
section: '车辆服务',
|
||||
item: '多源覆盖车辆',
|
||||
value: formatCount(serviceSummary?.multiSourceVehicles),
|
||||
detail: '同一车辆可由多个协议交叉验证'
|
||||
},
|
||||
{
|
||||
section: '车辆服务',
|
||||
item: '单源车辆',
|
||||
value: formatCount(serviceSummary?.singleSourceVehicles),
|
||||
detail: '仅有一个协议来源,需要持续补齐'
|
||||
},
|
||||
{
|
||||
section: '车辆服务',
|
||||
item: '暂无来源车辆',
|
||||
value: formatCount(serviceSummary?.noDataVehicles),
|
||||
detail: '车辆档案存在但暂无实时来源'
|
||||
item: '今日活跃',
|
||||
value: formatCount(summary?.activeToday),
|
||||
detail: '当天有有效上报或历史记录的车辆服务对象'
|
||||
},
|
||||
{
|
||||
section: '实时监控',
|
||||
@@ -922,7 +885,7 @@ export function Dashboard({
|
||||
section: '历史数据',
|
||||
item: '今日帧量',
|
||||
value: formatCount(summary?.frameToday),
|
||||
detail: `Kafka Lag ${formatLag(summary?.kafkaLag)}`
|
||||
detail: '历史查询和导出可按车辆、时间、字段裁剪'
|
||||
},
|
||||
{
|
||||
section: '告警事件',
|
||||
@@ -930,12 +893,6 @@ export function Dashboard({
|
||||
value: formatCount(summary?.issueVehicles),
|
||||
detail: highPriorityIssue ? `${qualityIssueLabel(highPriorityIssue.issueType)} / ${priorityIssueVehicleLabel(highPriorityIssue)}` : '暂无最高优先级告警'
|
||||
},
|
||||
{
|
||||
section: '链路健康',
|
||||
item: '异常链路',
|
||||
value: unhealthyLinks.length.toLocaleString(),
|
||||
detail: unhealthyLinks.length > 0 ? unhealthyLinks.map((item) => `${item.name}=${item.status}`).join(';') : '正常'
|
||||
},
|
||||
{
|
||||
section: '地图能力',
|
||||
item: '高德地图',
|
||||
@@ -949,14 +906,6 @@ export function Dashboard({
|
||||
detail: priorityAction?.detail ?? '当前没有必须立即处理的车辆服务事项'
|
||||
}
|
||||
];
|
||||
(serviceSummary?.protocols ?? summary?.protocols ?? []).forEach((item) => {
|
||||
rows.push({
|
||||
section: '协议来源',
|
||||
item: item.protocol,
|
||||
value: `${item.online.toLocaleString()} / ${item.total.toLocaleString()}`,
|
||||
detail: `在线率 ${formatProtocolRate(item)}`
|
||||
});
|
||||
});
|
||||
(serviceSummary?.serviceStatuses ?? summary?.serviceStatuses ?? []).forEach((item) => {
|
||||
rows.push({
|
||||
section: '服务状态',
|
||||
@@ -965,14 +914,6 @@ export function Dashboard({
|
||||
detail: serviceStatusTitle[item.status] || item.status
|
||||
});
|
||||
});
|
||||
(summary?.linkHealth ?? []).forEach((item) => {
|
||||
rows.push({
|
||||
section: '链路健康',
|
||||
item: item.name,
|
||||
value: item.status,
|
||||
detail: item.detail ?? ''
|
||||
});
|
||||
});
|
||||
return rows;
|
||||
};
|
||||
const exportDashboardSnapshot = () => {
|
||||
@@ -992,9 +933,9 @@ export function Dashboard({
|
||||
<Card bordered className="vp-customer-hero" bodyStyle={{ padding: 0 }}>
|
||||
<div className="vp-customer-hero-map">
|
||||
<div className="vp-customer-hero-copy">
|
||||
<Typography.Title heading={3} style={{ margin: 0 }}>先看车,再看数据来源</Typography.Title>
|
||||
<Typography.Title heading={3} style={{ margin: 0 }}>先看车辆服务</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
车辆是主对象。32960、808 和宇通 MQTT 只是证据来源,客户每天需要先确认车辆在哪里、是否在线、今天跑了多少、异常能否追溯和导出。
|
||||
车辆是主对象。32960、808 和宇通 MQTT 只是数据来源,客户每天需要先确认车辆在哪里、是否在线、今天跑了多少、异常能否追溯和导出。
|
||||
</Typography.Text>
|
||||
<Space wrap>
|
||||
<Button theme="solid" type="primary" onClick={() => onOpenMap({ online: 'online' })}>打开实时地图</Button>
|
||||
@@ -1050,21 +991,42 @@ export function Dashboard({
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Card bordered title="统一车辆服务入口" style={{ marginBottom: 16 }}>
|
||||
<Card bordered title="车辆服务入口" style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<Tag color="blue">{vehicleServiceOnlineText(serviceSummary, summary)}</Tag>
|
||||
<Button size="small" theme="light" type="primary" onClick={() => onOpenVehicles({ online: 'online' })}>查看在线车辆</Button>
|
||||
<Tag color="green">{formatCount(serviceSummary?.multiSourceVehicles)} 多源覆盖</Tag>
|
||||
<Button size="small" theme="light" type="primary" onClick={() => onOpenVehicles({ coverage: 'multi' })}>查看多源车辆</Button>
|
||||
<Tag color="blue">有效定位 {commandLocatedCount.toLocaleString()}</Tag>
|
||||
<Button size="small" theme="light" type="primary" onClick={() => onOpenMap({ online: 'online' })}>实时地图</Button>
|
||||
<Tag color="blue">今日活跃 {formatCount(summary?.activeToday)}</Tag>
|
||||
<Button size="small" theme="light" type="primary" onClick={() => onOpenHistory()}>轨迹回放</Button>
|
||||
<Tag color="blue">今日数据 {formatCount(summary?.frameToday)}</Tag>
|
||||
<Button size="small" theme="light" type="primary" onClick={() => onOpenHistory({ tab: 'raw', includeFields: 'true' })}>历史查询导出</Button>
|
||||
<Tag color={(summary?.issueVehicles ?? 0) > 0 ? 'orange' : 'green'}>
|
||||
{formatCount(summary?.issueVehicles)} 告警事件
|
||||
</Tag>
|
||||
<Button size="small" theme="light" type={(summary?.issueVehicles ?? 0) > 0 ? 'warning' : 'tertiary'} onClick={() => onOpenQuality()}>查看告警事件</Button>
|
||||
<Tag color={(summary?.kafkaLag ?? 0) > 0 ? 'orange' : 'green'}>Kafka Lag {formatLag(summary?.kafkaLag)}</Tag>
|
||||
<Button size="small" theme="solid" type="primary" onClick={copyOperationsHandoff}>复制运营交接摘要</Button>
|
||||
<Button size="small" theme="light" type="primary" onClick={exportDashboardSnapshot}>导出驾驶舱 CSV</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card bordered title="自定义时间监控" style={{ marginBottom: 16 }}>
|
||||
<div className="vp-time-monitor-grid">
|
||||
{[
|
||||
{ title: '今天车辆轨迹', detail: '查看当天活跃车辆的轨迹、速度和里程断点。', action: '查今天', onClick: () => onOpenHistory() },
|
||||
{ title: '历史时间窗', detail: '按任意起止时间查询位置历史、RAW 字段和证据。', action: '自定义查询', onClick: () => onOpenHistory({ tab: 'raw', includeFields: 'true' }) },
|
||||
{ title: '区间里程', detail: '按车辆和时间范围核对区间里程、日统计和异常点。', action: '统计查询', onClick: () => onOpenMileage() },
|
||||
{ title: '告警复盘', detail: '按车辆或事件回放告警发生前后的轨迹和数据证据。', action: '告警事件', onClick: () => onOpenQuality() }
|
||||
].map((item) => (
|
||||
<button key={item.title} type="button" className="vp-time-monitor-item" onClick={item.onClick} aria-label={`时间监控 ${item.title}`}>
|
||||
<div>
|
||||
<Typography.Title heading={6} style={{ margin: 0 }}>{item.title}</Typography.Title>
|
||||
<Typography.Text type="secondary">{item.detail}</Typography.Text>
|
||||
</div>
|
||||
<Tag color="blue">{item.action}</Tag>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<Card bordered title="地图运营能力" style={{ marginBottom: 16 }}>
|
||||
<div className="vp-map-ops-board">
|
||||
<div className="vp-map-ops-readiness">
|
||||
@@ -1100,7 +1062,7 @@ export function Dashboard({
|
||||
<div className="vp-focus-service-main">
|
||||
<Tag color="blue">{focusVehicle.reason}</Tag>
|
||||
<Typography.Title heading={5} style={{ margin: '8px 0 4px' }}>{focusVehicle.label}</Typography.Title>
|
||||
<Typography.Text type="secondary">把协议来源作为证据,把实时、轨迹、RAW、统计和告警集中到同一辆车处理。</Typography.Text>
|
||||
<Typography.Text type="secondary">把数据来源作为证据,把实时、轨迹、RAW、统计和告警集中到同一辆车处理。</Typography.Text>
|
||||
</div>
|
||||
<div className="vp-focus-service-evidence">
|
||||
{[
|
||||
@@ -1165,26 +1127,6 @@ export function Dashboard({
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<Card
|
||||
bordered
|
||||
title={<Space><span>数据流转作业链</span><Button size="small" onClick={copyDataFlowBlueprint}>复制流转图</Button></Space>}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<div className="vp-data-flow-grid">
|
||||
{dataFlowStages.map((item) => (
|
||||
<button key={item.stage} className="vp-data-flow-item" type="button" onClick={item.onClick} aria-label={`数据流转 ${item.title}`}>
|
||||
<div className="vp-data-flow-head">
|
||||
<span>{item.stage}</span>
|
||||
<Tag color="blue">{item.owner}</Tag>
|
||||
</div>
|
||||
<Typography.Title heading={6} style={{ margin: 0 }}>{item.title}</Typography.Title>
|
||||
<div className="vp-data-flow-evidence">{item.evidence}</div>
|
||||
<Typography.Text type="secondary">{item.detail}</Typography.Text>
|
||||
<Tag color="grey">{item.action}</Tag>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<Card
|
||||
bordered
|
||||
title={<Space><span>车联网场景导航</span><Button size="small" onClick={copyScenarioBlueprint}>复制功能蓝图</Button></Space>}
|
||||
@@ -1211,31 +1153,6 @@ export function Dashboard({
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<Card bordered title="全链路值班闭环" style={{ marginBottom: 16 }}>
|
||||
<div className="vp-operation-flow">
|
||||
{workflowSteps.map((item, index) => (
|
||||
<div key={item.title} className="vp-operation-step">
|
||||
<div className="vp-operation-index">{index + 1}</div>
|
||||
<div className="vp-operation-content">
|
||||
<Space spacing={8} align="center">
|
||||
<Typography.Title heading={6} style={{ margin: 0 }}>{item.title}</Typography.Title>
|
||||
<Tag color={item.color}>{item.value}</Tag>
|
||||
</Space>
|
||||
<Typography.Text type="secondary">{item.detail}</Typography.Text>
|
||||
<Button
|
||||
size="small"
|
||||
theme="light"
|
||||
type="primary"
|
||||
aria-label={`闭环入口 ${item.title}`}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
{item.action}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
{highPriorityIssue ? (
|
||||
<Card bordered title="最高优先级告警" style={{ marginBottom: 16 }}>
|
||||
<div className="vp-priority-alert">
|
||||
@@ -1377,89 +1294,6 @@ export function Dashboard({
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Card title="车辆服务状态" bordered>
|
||||
<Table
|
||||
pagination={false}
|
||||
dataSource={serviceSummary?.serviceStatuses ?? summary?.serviceStatuses ?? []}
|
||||
columns={[
|
||||
{
|
||||
title: '状态',
|
||||
render: (_: unknown, row: ServiceStatusStat) => <Tag color={serviceStatusColor[row.status] ?? 'grey'}>{row.title}</Tag>
|
||||
},
|
||||
{ title: '车辆数', dataIndex: 'count' },
|
||||
{
|
||||
title: '操作',
|
||||
width: 90,
|
||||
render: (_: unknown, row: ServiceStatusStat) => (
|
||||
<Button
|
||||
aria-label={`查看${row.title}`}
|
||||
icon={<IconSearch />}
|
||||
size="small"
|
||||
onClick={() => loadCoverage({ serviceStatus: row.status })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card title="来源证据在线分布" bordered>
|
||||
<Table
|
||||
pagination={false}
|
||||
dataSource={serviceSummary?.protocols ?? summary?.protocols ?? []}
|
||||
columns={[
|
||||
{ title: '来源证据', dataIndex: 'protocol' },
|
||||
{ title: '在线', dataIndex: 'online' },
|
||||
{ title: '总数', dataIndex: 'total' },
|
||||
{
|
||||
title: '在线率',
|
||||
render: (_: unknown, row: ProtocolStat) => formatProtocolRate(row)
|
||||
},
|
||||
{
|
||||
title: '缺失车辆',
|
||||
render: (_: unknown, row: ProtocolStat) => {
|
||||
const missingCount = missingSourceCounts.get(row.protocol);
|
||||
if (missingCount == null) {
|
||||
return '-';
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
aria-label={`查看缺 ${row.protocol}`}
|
||||
size="small"
|
||||
onClick={() => loadCoverage({ missingProtocol: row.protocol })}
|
||||
>
|
||||
{formatCount(missingCount)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card title="链路健康" bordered>
|
||||
<Table
|
||||
pagination={false}
|
||||
dataSource={summary?.linkHealth ?? []}
|
||||
columns={[
|
||||
{ title: '链路', dataIndex: 'name' },
|
||||
{
|
||||
title: '状态',
|
||||
render: (_: unknown, row: LinkHealth) => <Tag color={statusColor[row.status] ?? 'grey'}>{row.status}</Tag>
|
||||
},
|
||||
{ title: '说明', dataIndex: 'detail' }
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<Card title="实时积压" bordered style={{ marginTop: 16 }}>
|
||||
<Typography.Text>Kafka 当前消费积压:{formatLag(summary?.kafkaLag)}</Typography.Text>
|
||||
</Card>
|
||||
<Card
|
||||
title={<Space><span>质量问题预览</span><Button size="small" onClick={() => onOpenQuality()}>查看全部</Button></Space>}
|
||||
bordered
|
||||
|
||||
@@ -365,6 +365,34 @@ body {
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
.vp-time-monitor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vp-time-monitor-item {
|
||||
min-height: 132px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--vp-border);
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
display: grid;
|
||||
align-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.vp-time-monitor-item:hover,
|
||||
.vp-time-monitor-item:focus-visible {
|
||||
border-color: rgba(22, 100, 255, 0.45);
|
||||
background: #f7fbff;
|
||||
box-shadow: var(--vp-shadow-sm);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.vp-result-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
@@ -2486,6 +2514,7 @@ button.vp-realtime-command-item:focus-visible {
|
||||
.vp-map-ops-board,
|
||||
.vp-map-ops-readiness,
|
||||
.vp-map-ops-work,
|
||||
.vp-time-monitor-grid,
|
||||
.vp-current-service-board,
|
||||
.vp-current-service-grid,
|
||||
.vp-realtime-command-board,
|
||||
|
||||
@@ -424,9 +424,9 @@ test('shows vehicle service status distribution on dashboard', async () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText('车辆服务状态')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('来源不完整').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('39')).toBeInTheDocument();
|
||||
expect(await screen.findByText('车辆服务覆盖')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('服务状态').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('质量问题预览')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('dashboard renders vehicle service summary metrics', async () => {
|
||||
@@ -495,18 +495,12 @@ test('dashboard renders vehicle service summary metrics', async () => {
|
||||
|
||||
expect(await screen.findByText('总车辆')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('1,033').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('单源车辆')).toBeInTheDocument();
|
||||
expect(screen.getByText('391')).toBeInTheDocument();
|
||||
expect(screen.getByText('多源车辆')).toBeInTheDocument();
|
||||
expect(screen.getByText('181')).toBeInTheDocument();
|
||||
expect(screen.getByText('暂无来源车辆')).toBeInTheDocument();
|
||||
expect(screen.getByText('461')).toBeInTheDocument();
|
||||
expect(screen.getByText('身份未绑定')).toBeInTheDocument();
|
||||
expect(screen.getByText('今日活跃')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('有效定位').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('告警车辆')).toBeInTheDocument();
|
||||
expect(screen.getByText('今日数据')).toBeInTheDocument();
|
||||
expect(screen.getByText('档案不完整')).toBeInTheDocument();
|
||||
expect(screen.getByText('366')).toBeInTheDocument();
|
||||
expect(screen.getByText('来源证据在线分布')).toBeInTheDocument();
|
||||
expect(screen.getByText('YUTONG_MQTT')).toBeInTheDocument();
|
||||
expect(screen.getByText('0%')).toBeInTheDocument();
|
||||
expect(screen.queryByText('NaN%')).not.toBeInTheDocument();
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/vehicle-service/summary', undefined);
|
||||
});
|
||||
@@ -593,9 +587,9 @@ test('dashboard presents one vehicle service operating posture', async () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText('统一车辆服务入口')).toBeInTheDocument();
|
||||
expect(await screen.findByText('车辆服务入口')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('208 / 1,033 在线').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('181 多源覆盖')).toBeInTheDocument();
|
||||
expect(screen.getByText('今日活跃 4')).toBeInTheDocument();
|
||||
expect(screen.getByText('7 告警事件')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -792,24 +786,24 @@ test('dashboard exposes vehicle data center capability matrix', async () => {
|
||||
|
||||
await renderDashboard();
|
||||
expect(screen.getByText('车辆运营监控台')).toBeInTheDocument();
|
||||
expect(screen.getByText('先看车,再看数据来源')).toBeInTheDocument();
|
||||
expect(screen.getByText('先看车辆服务')).toBeInTheDocument();
|
||||
expect(screen.getByText('客户常用工作流')).toBeInTheDocument();
|
||||
expect(screen.getByText('实时车辆地图')).toBeInTheDocument();
|
||||
expect(screen.getByText('历史数据导出')).toBeInTheDocument();
|
||||
expect(screen.getByText('车辆服务入口')).toBeInTheDocument();
|
||||
expect(screen.getByText('自定义时间监控')).toBeInTheDocument();
|
||||
expect(screen.getByText('今天车辆轨迹')).toBeInTheDocument();
|
||||
expect(screen.getByText('历史时间窗')).toBeInTheDocument();
|
||||
expect(screen.getByText('区间里程')).toBeInTheDocument();
|
||||
expect(screen.getByText('告警复盘')).toBeInTheDocument();
|
||||
expect(screen.getByText('车辆服务作业台')).toBeInTheDocument();
|
||||
expect(screen.getByText('地图运营能力')).toBeInTheDocument();
|
||||
expect(screen.getByText('数据流转作业链')).toBeInTheDocument();
|
||||
expect(screen.getByText('车联网场景导航')).toBeInTheDocument();
|
||||
expect(screen.getByText('高德 Web JS')).toBeInTheDocument();
|
||||
expect(screen.getByText('服务端 API')).toBeInTheDocument();
|
||||
expect(screen.getByText('安全代理')).toBeInTheDocument();
|
||||
expect(screen.getByText('安全码暴露')).toBeInTheDocument();
|
||||
expect(screen.getByText('协议接入')).toBeInTheDocument();
|
||||
expect(screen.getByText('统一解析')).toBeInTheDocument();
|
||||
expect(screen.getByText('实时投影')).toBeInTheDocument();
|
||||
expect(screen.getByText('车辆归并')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('历史证据').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('运营闭环')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('已配置').length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getByText('已启用')).toBeInTheDocument();
|
||||
expect(screen.getByText('未暴露')).toBeInTheDocument();
|
||||
@@ -822,7 +816,6 @@ test('dashboard exposes vehicle data center capability matrix', async () => {
|
||||
expect(screen.getByText('告警事件与通知')).toBeInTheDocument();
|
||||
expect(screen.getByText('区间闭合复核')).toBeInTheDocument();
|
||||
expect(screen.getByText('高德轨迹证据')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Kafka Lag 0').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('1,033 车辆口径')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('在线 208').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('在线态势 208').length).toBeGreaterThanOrEqual(1);
|
||||
@@ -840,9 +833,6 @@ test('dashboard exposes vehicle data center capability matrix', async () => {
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('高德地图:Web JS 已配置;服务端 API 已配置;安全代理 已启用;安全码未暴露'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('1. 实时监控'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('SLA:目标 0-1 秒内进入实时视图'));
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制流转图' }));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【车辆数据中台数据流转图】'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('03. 实时投影 / Redis KV'));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '客户工作流 实时车辆地图' }));
|
||||
expect(window.location.hash).toBe('#/map?online=online');
|
||||
@@ -867,14 +857,14 @@ test('dashboard exposes vehicle data center capability matrix', async () => {
|
||||
cleanup();
|
||||
|
||||
await renderDashboard();
|
||||
fireEvent.click(screen.getByRole('button', { name: '数据流转 实时投影' }));
|
||||
expect(window.location.hash).toBe('#/realtime?online=online');
|
||||
fireEvent.click(screen.getByRole('button', { name: '时间监控 历史时间窗' }));
|
||||
expect(window.location.hash.startsWith('#/history-query')).toBe(true);
|
||||
expect(new URLSearchParams(window.location.hash.split('?')[1] ?? '').get('includeFields')).toBe('true');
|
||||
cleanup();
|
||||
|
||||
await renderDashboard();
|
||||
fireEvent.click(screen.getByRole('button', { name: '数据流转 统一解析' }));
|
||||
expect(window.location.hash.startsWith('#/history-query')).toBe(true);
|
||||
expect(new URLSearchParams(window.location.hash.split('?')[1] ?? '').get('includeFields')).toBe('true');
|
||||
fireEvent.click(screen.getByRole('button', { name: '时间监控 区间里程' }));
|
||||
expect(window.location.hash.startsWith('#/mileage')).toBe(true);
|
||||
cleanup();
|
||||
|
||||
await renderDashboard();
|
||||
@@ -1172,7 +1162,7 @@ test('dashboard shows vehicle service action queue', async () => {
|
||||
|
||||
test.each([
|
||||
{ buttonName: '查看在线车辆', expectedHash: '#/vehicles?online=online' },
|
||||
{ buttonName: '查看多源车辆', expectedHash: '#/vehicles?coverage=multi' },
|
||||
{ buttonName: '实时地图', expectedHash: '#/map?online=online' },
|
||||
{ buttonName: '查看告警事件', expectedHash: '#/alert-events' }
|
||||
])('dashboard posture opens %s', async ({ buttonName, expectedHash }) => {
|
||||
window.history.replaceState(null, '', '/#/dashboard');
|
||||
@@ -1335,18 +1325,16 @@ test('dashboard exposes end-to-end operations workflow entries', async () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText('全链路值班闭环')).toBeInTheDocument();
|
||||
expect(screen.getByText('接入巡检')).toBeInTheDocument();
|
||||
expect(screen.getByText('实时态势')).toBeInTheDocument();
|
||||
expect(screen.getByText('轨迹复盘')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('历史证据').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('告警事件').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('统计复核')).toBeInTheDocument();
|
||||
expect(await screen.findByText('自定义时间监控')).toBeInTheDocument();
|
||||
expect(screen.getByText('今天车辆轨迹')).toBeInTheDocument();
|
||||
expect(screen.getByText('历史时间窗')).toBeInTheDocument();
|
||||
expect(screen.getByText('区间里程')).toBeInTheDocument();
|
||||
expect(screen.getByText('告警复盘')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '闭环入口 轨迹复盘' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '时间监控 今天车辆轨迹' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.hash).toBe('#/history?keyword=LB9A32A24R0LS1426');
|
||||
expect(window.location.hash.startsWith('#/history')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1459,7 +1447,7 @@ test('dashboard surfaces highest priority quality issue with evidence shortcuts'
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText('重点车辆服务')).toBeInTheDocument();
|
||||
expect(screen.getByText('把协议来源作为证据,把实时、轨迹、RAW、统计和告警集中到同一辆车处理。')).toBeInTheDocument();
|
||||
expect(screen.getByText('把数据来源作为证据,把实时、轨迹、RAW、统计和告警集中到同一辆车处理。')).toBeInTheDocument();
|
||||
expect(screen.getByText('实时证据')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('轨迹证据').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('告警证据')).toBeInTheDocument();
|
||||
@@ -1611,7 +1599,6 @@ test('dashboard copies highest priority quality issue notification text', async
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('在线车辆:88 / 1,033'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('今日活跃:96'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('今日帧量:1,286,320'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('Kafka Lag:0'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('最高告警:P0 VIN 缺失 / 粤A告警1 / 13307795425 / 2026-07-03 20:12:10'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('实时监控:http://localhost:3000/#/realtime?online=online'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('实时地图:http://localhost:3000/#/map?online=online'));
|
||||
@@ -1695,11 +1682,11 @@ test('dashboard keeps core vehicle service metrics when preview requests fail',
|
||||
|
||||
expect(await screen.findByText('总车辆')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('1,033').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('单源车辆')).toBeInTheDocument();
|
||||
expect(screen.getByText('391')).toBeInTheDocument();
|
||||
expect(screen.getByText('今日活跃')).toBeInTheDocument();
|
||||
expect(screen.getByText('告警车辆')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('opens dashboard coverage from missing source distribution', async () => {
|
||||
test('opens dashboard coverage from service action queue', async () => {
|
||||
window.history.replaceState(null, '', '/#/dashboard');
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||
const path = String(input);
|
||||
@@ -1755,14 +1742,11 @@ test('opens dashboard coverage from missing source distribution', async () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText('缺失车辆')).toBeInTheDocument();
|
||||
expect(screen.getByText('983')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看缺 YUTONG_MQTT' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '补齐 YUTONG_MQTT 来源 983' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('missingProtocol=YUTONG_MQTT'), undefined);
|
||||
});
|
||||
expect(screen.getByText('当前筛选:缺 YUTONG_MQTT')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('opens vehicle list filtered by service summary KPI', async () => {
|
||||
@@ -1811,12 +1795,12 @@ test('opens vehicle list filtered by service summary KPI', async () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /暂无来源车辆/ }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /告警车辆/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicles/coverage?limit=20&offset=0&serviceStatus=no_data'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicles/coverage?limit=20&offset=0&serviceStatus=degraded'), undefined);
|
||||
});
|
||||
expect(window.location.hash).toBe('#/vehicles?serviceStatus=no_data');
|
||||
expect(window.location.hash).toBe('#/vehicles?serviceStatus=degraded');
|
||||
});
|
||||
|
||||
test('shows vehicle service result summary on vehicle list filters', async () => {
|
||||
@@ -3362,7 +3346,10 @@ test('opens dashboard coverage from service status distribution', async () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '查看来源不完整' }));
|
||||
expect(await screen.findByText('车辆服务覆盖')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('dashboard-service-status-filter'));
|
||||
fireEvent.click(await screen.findByText('来源不完整'));
|
||||
fireEvent.click(screen.getByRole('button', { name: '筛选' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('serviceStatus=degraded'), undefined);
|
||||
|
||||
Reference in New Issue
Block a user