feat(platform): refocus dashboard on vehicle operations

This commit is contained in:
lingniu
2026-07-05 00:03:12 +08:00
parent 6888f1a598
commit 2ca656a3f3
4 changed files with 265 additions and 113 deletions

View File

@@ -2,7 +2,7 @@ import { Button, Card, Col, Form, Row, Select, Space, Spin, Table, Tag, Toast, T
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, SourceReadinessPlan, SourceReadinessRow, VehicleCoverageRow, VehicleRealtimeRow, VehicleServiceSummary } from '../api/types';
import type { DashboardSummary, LinkHealth, OpsHealth, ProtocolStat, QualityIssueRow, ServiceStatusStat, VehicleCoverageRow, VehicleRealtimeRow, VehicleServiceSummary } from '../api/types';
import { PageHeader } from '../components/PageHeader';
import { SourceStatusTags } from '../components/SourceStatusTags';
import { StatusTag } from '../components/StatusTag';
@@ -93,15 +93,6 @@ function formatProtocolRate(row: ProtocolStat) {
return `${Math.round((row.online / row.total) * 100)}%`;
}
function normalizeSourceReadiness(plan: SourceReadinessPlan | null): SourceReadinessPlan | null {
return plan && Array.isArray(plan.sources) ? plan : null;
}
function sourceReadinessColor(row: SourceReadinessRow): 'green' | 'orange' | 'red' {
if (row.severity === 'error') return 'red';
return row.severity === 'warning' ? 'orange' : 'green';
}
function rowServiceStatus(row: { serviceStatus?: { title: string; severity: string }; onlineSourceCount: number; sourceCount: number }) {
if (row.serviceStatus) {
return {
@@ -268,7 +259,6 @@ export function Dashboard({
const [locations, setLocations] = useState<VehicleRealtimeRow[]>([]);
const [qualityIssues, setQualityIssues] = useState<QualityIssueRow[]>([]);
const [opsHealth, setOpsHealth] = useState<OpsHealth | null>(null);
const [sourceReadiness, setSourceReadiness] = useState<SourceReadinessPlan | null>(null);
const [loading, setLoading] = useState(true);
const [coverageLoading, setCoverageLoading] = useState(false);
const [coverageServiceStatusTitle, setCoverageServiceStatusTitle] = useState('');
@@ -308,8 +298,7 @@ export function Dashboard({
api.vehicleCoverage(new URLSearchParams({ limit: '8' })).then((page) => setCoverage(page.items)),
api.vehicleRealtime(new URLSearchParams({ limit: '8' })).then((page) => setLocations(page.items)),
api.alertEvents(new URLSearchParams({ limit: '5' })).then((page) => setQualityIssues(page.items)),
api.opsHealth().then(setOpsHealth),
api.sourceReadiness().then((plan) => setSourceReadiness(normalizeSourceReadiness(plan)))
api.opsHealth().then(setOpsHealth)
];
Promise.allSettled(tasks)
.then((results) => {
@@ -394,6 +383,66 @@ export function Dashboard({
online: row.online,
title: `${row.plate || row.vin || '-'} ${row.primaryProtocol || ''} ${row.lastSeen || ''}`
}));
const customerHeroActions = [
{
title: '实时车辆地图',
value: `${commandLocatedCount.toLocaleString()} 辆有定位`,
detail: '按车查看实时位置、在线状态、速度和最新上报时间。',
action: '进入地图',
onClick: () => onOpenMap({ online: 'online' })
},
{
title: '轨迹回放',
value: `${formatCount(summary?.activeToday)} 今日活跃`,
detail: '选择车辆和时间范围,回放历史路线并核对断点。',
action: '回放轨迹',
onClick: () => onOpenHistory()
},
{
title: '里程统计',
value: `${formatCount(serviceSummary?.totalVehicles)} 辆可统计`,
detail: '按自定义时间查询区间里程、日里程和异常记录。',
action: '查看统计',
onClick: () => onOpenMileage({})
},
{
title: '历史数据导出',
value: `${formatCount(summary?.frameToday)} 今日数据`,
detail: '按车辆、时间和字段裁剪查询历史位置与 RAW 证据。',
action: '查询导出',
onClick: () => onOpenHistory({ tab: 'raw', includeFields: 'true' })
}
];
const customerHealthItems = [
{
label: '在线车辆',
value: formatCount(serviceSummary?.onlineVehicles ?? summary?.onlineVehicles),
detail: vehicleServiceOnlineText(serviceSummary, summary),
color: 'green' as const,
onClick: () => onOpenVehicles({ online: 'online' })
},
{
label: '有效定位',
value: commandLocatedCount.toLocaleString(),
detail: `当前抽样 ${locations.length.toLocaleString()} 辆,定位可用于地图和回放。`,
color: commandLocatedCount > 0 ? 'blue' as const : 'orange' as const,
onClick: () => onOpenMap({ online: 'online' })
},
{
label: '需要关注',
value: formatCount(summary?.issueVehicles),
detail: highPriorityIssue ? `${qualityIssueLabel(highPriorityIssue.issueType)} / ${priorityIssueVehicleLabel(highPriorityIssue)}` : '暂无高优先级告警。',
color: (summary?.issueVehicles ?? 0) > 0 ? 'orange' as const : 'green' as const,
onClick: () => onOpenQuality()
},
{
label: '时间范围监控',
value: formatCount(summary?.activeToday),
detail: '支持按任意时间窗查询轨迹、历史数据、里程和导出。',
color: 'blue' as const,
onClick: () => onOpenHistory()
}
];
const capabilities = [
{
title: '实时地图',
@@ -765,36 +814,6 @@ export function Dashboard({
].filter(Boolean);
copyText(lines.join('\n'), '运营交接摘要');
};
const copySourceReadinessHandoff = () => {
const plan = sourceReadiness;
if (!plan?.sources?.length) {
Toast.warning('暂无数据源就绪度数据');
return;
}
const lines = [
'【三源接入到车辆服务交接摘要】',
`车辆规模:${formatCount(plan.onlineVehicles)} / ${formatCount(plan.totalVehicles)} 在线`,
`接入运行:连接 ${formatCount(plan.activeConnections)}Redis 在线 Key ${formatCount(plan.redisOnlineKeys)}Kafka Lag ${formatLag(plan.kafkaLag)}`,
`平台版本:${plan.platformRelease || '-'}`,
'',
...plan.sources.map((item, index) => [
`${index + 1}. ${item.protocol} - ${item.status}`,
` 角色:${item.role}`,
` 在线:${formatCount(item.online)} / ${formatCount(item.total)},在线率 ${item.onlineRate.toLocaleString(undefined, { maximumFractionDigits: 1 })}%`,
` 缺失车辆:${formatCount(item.missingVehicles)}`,
` 证据:${item.evidence}`,
` 动作:${item.action}`,
` 验收:${item.acceptance}`,
` 实时:${appURL(item.realtimeHash)}`,
` 车辆:${appURL(item.vehiclesHash)}`,
` RAW${appURL(item.historyHash)}`,
` 告警:${appURL(item.alertHash)}`
].join('\n')),
'',
`驾驶舱:${appURL(buildAppHash({ page: 'dashboard' }))}`
];
copyText(lines.join('\n'), '三源接入交接摘要');
};
const copyScenarioBlueprint = () => {
const lines = [
'【车辆数据中台功能蓝图】',
@@ -968,8 +987,59 @@ export function Dashboard({
return (
<div className="vp-page">
<PageHeader title="运营驾驶舱" description="三个数据源最终汇总为一个车辆服务,统一承载实时监控、轨迹回放、历史查询、告警通知、统计查询和链路质量" />
<PageHeader title="车辆运营监控台" description="面向客户的车辆地图、实时状态、轨迹回放、里程统计、历史查询和数据导出入口" />
<Spin spinning={loading}>
<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.Text type="secondary">
32960808 MQTT 线
</Typography.Text>
<Space wrap>
<Button theme="solid" type="primary" onClick={() => onOpenMap({ online: 'online' })}></Button>
<Button theme="light" type="primary" onClick={() => onOpenHistory()}></Button>
<Button theme="light" type="primary" onClick={() => onOpenMileage({})}></Button>
<Button theme="light" onClick={exportDashboardSnapshot}></Button>
</Space>
</div>
<VehicleMap
points={commandMapPoints}
maxFallbackPoints={80}
heightClassName="vp-customer-hero-map-canvas"
fallbackLabel="显示车辆实时坐标预览"
/>
</div>
<div className="vp-customer-hero-side">
<div className="vp-customer-hero-side-head">
<Tag color={amapConfigured ? 'green' : 'orange'}>{amapConfigured ? '地图可用' : '地图待配置'}</Tag>
<Tag color={(summary?.issueVehicles ?? 0) > 0 ? 'orange' : 'green'}>{formatCount(summary?.issueVehicles)} </Tag>
</div>
<div className="vp-customer-health-grid">
{customerHealthItems.map((item) => (
<button key={item.label} type="button" className="vp-customer-health-item" onClick={item.onClick} aria-label={`车辆运营 ${item.label} ${item.value}`}>
<Tag color={item.color}>{item.label}</Tag>
<strong>{item.value}</strong>
<span>{item.detail}</span>
</button>
))}
</div>
</div>
</Card>
<Card bordered title="客户常用工作流" style={{ marginBottom: 16 }}>
<div className="vp-customer-workflow-grid">
{customerHeroActions.map((item) => (
<button key={item.title} type="button" className="vp-customer-workflow-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>
<strong>{item.value}</strong>
<Tag color="blue">{item.action}</Tag>
</button>
))}
</div>
</Card>
<div className="vp-kpi-grid">
{kpis.map((item) => (
<Card key={item.label} bordered className="vp-kpi-card" bodyStyle={{ padding: 0 }}>
@@ -995,52 +1065,6 @@ export function Dashboard({
<Button size="small" theme="light" type="primary" onClick={exportDashboardSnapshot}> CSV</Button>
</Space>
</Card>
<Card
bordered
title={<Space wrap><span></span><Button size="small" theme="light" disabled={!sourceReadiness?.sources?.length} onClick={copySourceReadinessHandoff}></Button></Space>}
style={{ marginBottom: 16 }}
>
<div className="vp-source-readiness-grid">
{(sourceReadiness?.sources ?? []).map((item) => (
<div key={item.protocol} className="vp-source-readiness-item">
<div className="vp-source-readiness-head">
<Space wrap>
<Tag color={sourceReadinessColor(item)}>{item.protocol}</Tag>
<Tag color={sourceReadinessColor(item)}>{item.status}</Tag>
</Space>
<Typography.Text type="secondary">{item.role}</Typography.Text>
</div>
<div className="vp-source-readiness-metrics">
<div>
<span>线</span>
<strong>{formatCount(item.online)} / {formatCount(item.total)}</strong>
</div>
<div>
<span>线</span>
<strong>{item.onlineRate.toLocaleString(undefined, { maximumFractionDigits: 1 })}%</strong>
</div>
<div>
<span></span>
<strong>{formatCount(item.missingVehicles)}</strong>
</div>
</div>
<Typography.Text type="secondary">{item.evidence}</Typography.Text>
<div className="vp-source-readiness-actions">
<Button size="small" theme="light" onClick={() => { window.location.hash = item.realtimeHash; }}></Button>
<Button size="small" theme="light" onClick={() => { window.location.hash = item.vehiclesHash; }}></Button>
<Button size="small" theme="light" onClick={() => { window.location.hash = item.historyHash; }}>RAW</Button>
<Button size="small" theme="light" type={item.severity === 'ok' ? 'tertiary' : 'warning'} onClick={() => { window.location.hash = item.alertHash; }}></Button>
</div>
</div>
))}
{sourceReadiness?.sources?.length ? null : (
<div className="vp-source-readiness-empty">
<Tag color="grey"></Tag>
<Typography.Text type="secondary"> 32960808 MQTT </Typography.Text>
</div>
)}
</div>
</Card>
<Card bordered title="地图运营能力" style={{ marginBottom: 16 }}>
<div className="vp-map-ops-board">
<div className="vp-map-ops-readiness">