Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx
2026-07-04 04:25:29 +08:00

340 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, useState } from 'react';
import { api } from '../api/client';
import type { DashboardSummary, LinkHealth, ProtocolStat, QualityIssueRow, ServiceStatusStat, VehicleCoverageRow, VehicleRealtimeRow, VehicleServiceSummary } from '../api/types';
import { PageHeader } from '../components/PageHeader';
import { StatusTag } from '../components/StatusTag';
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: '部分来源离线',
offline: '车辆离线',
no_data: '暂无数据来源',
identity_required: '身份未绑定'
};
function formatLag(value?: number | null) {
return value == null ? '未接入' : value.toLocaleString();
}
function formatCount(value?: number | null) {
return value == null ? '0' : value.toLocaleString();
}
function rowServiceStatus(row: { serviceStatus?: { title: string; severity: string }; onlineSourceCount: number; sourceCount: number }) {
if (row.serviceStatus) {
return {
label: row.serviceStatus.title,
color: row.serviceStatus.severity === 'ok' ? 'green' as const : row.serviceStatus.severity === 'error' ? 'red' as const : 'orange' as const
};
}
if (row.onlineSourceCount <= 0) {
return { label: '车辆离线', color: 'red' as const };
}
if (row.onlineSourceCount < row.sourceCount) {
return { label: '部分来源离线', color: 'orange' as const };
}
return { label: '服务正常', color: 'green' as const };
}
function sourceEvidenceText(row: { onlineSourceCount: number; sourceCount: number }) {
return `${row.onlineSourceCount}/${row.sourceCount} 来源在线`;
}
export function Dashboard({ onOpenVehicle, onOpenQuality, onOpenVehicles }: { onOpenVehicle: (vin: string) => void; onOpenQuality: () => void; onOpenVehicles: (filters?: Record<string, string>) => void }) {
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [serviceSummary, setServiceSummary] = useState<VehicleServiceSummary | null>(null);
const [coverage, setCoverage] = useState<VehicleCoverageRow[]>([]);
const [locations, setLocations] = useState<VehicleRealtimeRow[]>([]);
const [qualityIssues, setQualityIssues] = useState<QualityIssueRow[]>([]);
const [loading, setLoading] = useState(true);
const [coverageLoading, setCoverageLoading] = useState(false);
const [coverageServiceStatusTitle, setCoverageServiceStatusTitle] = useState('');
const loadCoverage = (values?: Record<string, string>) => {
setCoverageLoading(true);
setCoverageServiceStatusTitle(values?.serviceStatus ? serviceStatusTitle[values.serviceStatus] ?? values.serviceStatus : '');
const params = new URLSearchParams({ limit: '8' });
if (values?.keyword) params.set('keyword', values.keyword);
if (values?.coverage) params.set('coverage', values.coverage);
if (values?.online) params.set('online', values.online);
if (values?.bindingStatus) params.set('bindingStatus', values.bindingStatus);
if (values?.serviceStatus) params.set('serviceStatus', values.serviceStatus);
api.vehicleCoverage(params)
.then((page) => setCoverage(page.items))
.catch((error: Error) => Toast.error(error.message))
.finally(() => setCoverageLoading(false));
};
useEffect(() => {
Promise.all([
api.dashboardSummary(),
api.vehicleServiceSummary(),
api.vehicleCoverage(new URLSearchParams({ limit: '8' })),
api.vehicleRealtime(new URLSearchParams({ limit: '8' })),
api.qualityIssues(new URLSearchParams({ limit: '5' }))
])
.then(([nextSummary, nextServiceSummary, coveragePage, locationPage, qualityPage]) => {
setSummary(nextSummary);
setServiceSummary(nextServiceSummary);
setCoverage(coveragePage.items);
setLocations(locationPage.items);
setQualityIssues(qualityPage.items);
})
.catch((error: Error) => Toast.error(error.message))
.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' } }
];
return (
<div className="vp-page">
<PageHeader title="总览工作台" description="以车辆服务为中心汇总在线状态、数据来源覆盖、质量问题和链路健康" />
<Spin spinning={loading}>
<div className="vp-kpi-grid">
{kpis.map((item) => (
<Card key={item.label} bordered className="vp-kpi-card" bodyStyle={{ padding: 0 }}>
<button className="vp-kpi-button" type="button" onClick={() => onOpenVehicles(item.filters)} aria-label={`${item.label} ${item.value}`}>
<div className="vp-kpi-value">{item.value}</div>
<div className="vp-kpi-label">{item.label}</div>
</button>
</Card>
))}
</div>
<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) => `${Math.round((row.online / row.total) * 100)}%`
}
]}
/>
</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
style={{ marginTop: 16 }}
>
<Table
pagination={false}
rowKey={(row?: QualityIssueRow) => `${row?.protocol ?? ''}-${row?.issueType ?? ''}-${row?.lastSeen ?? ''}-${row?.detail ?? ''}`}
dataSource={qualityIssues}
columns={[
{ title: 'VIN', dataIndex: 'vin', width: 160 },
{ title: '车牌', dataIndex: 'plate', width: 110 },
{ title: '手机号', dataIndex: 'phone', width: 130 },
{ title: '来源地址', dataIndex: 'sourceEndpoint', width: 180 },
{ title: '来源', dataIndex: 'protocol', width: 110 },
{ title: '问题', dataIndex: 'issueType', width: 140 },
{ title: '级别', width: 90, render: (_: unknown, row: QualityIssueRow) => <Tag color={row.severity === 'error' ? 'red' : 'orange'}>{row.severity}</Tag> },
{ title: '最后时间', dataIndex: 'lastSeen', width: 170 },
{
title: '操作',
width: 130,
render: (_: unknown, row: QualityIssueRow) => {
const lookup = qualityIssueVehicleLookup(row);
return <Button disabled={!lookup.key} onClick={() => onOpenVehicle(lookup.key)}>{lookup.label}</Button>;
}
}
]}
/>
</Card>
<Card title="车辆服务覆盖" bordered style={{ marginTop: 16 }}>
{coverageServiceStatusTitle ? (
<div className="vp-scope-bar" style={{ marginBottom: 12 }}>
<Tag color="blue">{coverageServiceStatusTitle}</Tag>
</div>
) : null}
<Form layout="horizontal" onSubmit={(values) => loadCoverage(values as Record<string, string>)} style={{ marginBottom: 12 }}>
<Form.Input field="keyword" label="关键词" placeholder="VIN / 车牌 / 手机号 / OEM" style={{ width: 240 }} />
<Form.Select field="coverage" label="来源覆盖" placeholder="全部" style={{ width: 130 }}>
<Select.Option value="single"></Select.Option>
<Select.Option value="multi"></Select.Option>
</Form.Select>
<Form.Select field="online" label="在线" placeholder="全部" style={{ width: 130 }}>
<Select.Option value="online">线</Select.Option>
<Select.Option value="offline">线</Select.Option>
</Form.Select>
<Form.Select field="bindingStatus" label="绑定" placeholder="全部" style={{ width: 130 }}>
<Select.Option value="bound"></Select.Option>
<Select.Option value="unbound"></Select.Option>
</Form.Select>
<Form.Select field="serviceStatus" label="服务状态" placeholder="全部" style={{ width: 150 }} data-testid="dashboard-service-status-filter">
<Select.Option value="healthy"></Select.Option>
<Select.Option value="degraded">线</Select.Option>
<Select.Option value="offline">线</Select.Option>
<Select.Option value="no_data"></Select.Option>
<Select.Option value="identity_required"></Select.Option>
</Form.Select>
<Space>
<Button htmlType="submit" theme="solid" type="primary"></Button>
<Button onClick={() => loadCoverage()}></Button>
</Space>
</Form>
<Table
loading={coverageLoading}
pagination={false}
rowKey="vin"
dataSource={coverage}
columns={[
{ title: '车牌', dataIndex: 'plate', width: 110 },
{ title: 'VIN', dataIndex: 'vin', width: 190 },
{
title: '来源证据',
width: 240,
render: (_: unknown, row: VehicleCoverageRow) => (
<Space spacing={4} wrap>
{row.protocols.map((protocol) => <Tag key={protocol} color="blue">{protocol}</Tag>)}
</Space>
)
},
{
title: '证据覆盖',
width: 130,
render: (_: unknown, row: VehicleCoverageRow) => sourceEvidenceText(row)
},
{
title: '服务状态',
width: 130,
render: (_: unknown, row: VehicleCoverageRow) => {
const status = rowServiceStatus(row);
return <Tag color={status.color}>{status.label}</Tag>;
}
},
{ title: '在线', width: 90, render: (_: unknown, row: VehicleCoverageRow) => <StatusTag status={row.online ? 'ok' : 'offline'} /> },
{ title: '绑定', width: 90, render: (_: unknown, row: VehicleCoverageRow) => <Tag color={row.bindingStatus === 'bound' ? 'green' : 'orange'}>{row.bindingStatus === 'bound' ? '已绑定' : '未绑定'}</Tag> },
{ title: '最后时间', dataIndex: 'lastSeen', width: 170 },
{ title: '操作', width: 110, render: (_: unknown, row: VehicleCoverageRow) => <Button onClick={() => onOpenVehicle(row.vin)}></Button> }
]}
/>
</Card>
<Row gutter={16} style={{ marginTop: 16 }}>
<Col span={12}>
<Card title="实时位置预览" bordered>
<div className="vp-map" style={{ height: 260 }}>
{locations.map((row, index) => (
<span
key={row.vin}
className="vp-map-dot"
title={`${row.plate} ${row.primaryProtocol}`}
style={{ left: `${18 + index * 13}%`, top: `${24 + (index % 4) * 15}%` }}
/>
))}
</div>
</Card>
</Col>
<Col span={12}>
<Card title="最新车辆" bordered>
<Table
pagination={false}
rowKey="vin"
dataSource={locations}
columns={[
{ title: '车牌', dataIndex: 'plate' },
{ title: 'VIN', dataIndex: 'vin' },
{
title: '来源证据',
render: (_: unknown, row: VehicleRealtimeRow) => (
<Space spacing={4} wrap>
{row.protocols.map((protocol) => <Tag key={protocol} color={protocol === row.primaryProtocol ? 'blue' : 'grey'}>{protocol}</Tag>)}
</Space>
)
},
{
title: '服务状态',
render: (_: unknown, row: VehicleRealtimeRow) => {
const status = rowServiceStatus(row);
return <Tag color={status.color}>{status.label}</Tag>;
}
},
{ title: '最后时间', dataIndex: 'lastSeen' },
{ title: '操作', width: 110, render: (_: unknown, row: VehicleRealtimeRow) => <Button onClick={() => onOpenVehicle(row.vin)}></Button> }
]}
/>
</Card>
</Col>
</Row>
</Spin>
</div>
);
}