feat(platform): streamline dashboard for vehicle service

This commit is contained in:
lingniu
2026-07-05 00:24:07 +08:00
parent 2ca656a3f3
commit 0613fe9f7b
3 changed files with 116 additions and 266 deletions

View File

@@ -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">
32960808 MQTT 线
32960808 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