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

579 lines
28 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, useMemo, 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 { SourceStatusTags } from '../components/SourceStatusTags';
import { StatusTag } from '../components/StatusTag';
import { VehicleMap, type VehicleMapPoint } from '../components/VehicleMap';
import { isAMapConfigured } from '../config/appConfig';
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: '身份未绑定'
};
const missingProtocolTitle: Record<string, string> = {
GB32960: '缺 GB32960',
JT808: '缺 JT808',
YUTONG_MQTT: '缺 YUTONG_MQTT'
};
function formatLag(value?: number | null) {
return value == null ? '未接入' : value.toLocaleString();
}
function formatCount(value?: number | null) {
return value == null ? '0' : value.toLocaleString();
}
function vehicleServiceOnlineText(serviceSummary: VehicleServiceSummary | null, summary: DashboardSummary | null) {
const onlineVehicles = serviceSummary?.onlineVehicles ?? summary?.onlineVehicles;
const totalVehicles = serviceSummary?.totalVehicles;
if (onlineVehicles == null || totalVehicles == null) {
return '未接入';
}
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 {
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} 来源在线`;
}
function hasValidCoordinate(row: VehicleRealtimeRow) {
return Number.isFinite(row.longitude) && Number.isFinite(row.latitude) && row.longitude !== 0 && row.latitude !== 0;
}
function sourceConsistencyAction(row: VehicleCoverageRow, onFilter: (filters: Record<string, string>) => void) {
const consistency = row.sourceConsistency;
if (!consistency) {
return <Tag color="grey"></Tag>;
}
const color = consistency.severity === 'ok' ? 'green' as const : consistency.severity === 'error' ? 'red' as const : 'orange' as const;
const label = consistency.title || consistency.status;
if ((consistency.missingProtocols ?? []).length > 0) {
return (
<Button
size="small"
theme="light"
type={color === 'red' ? 'danger' : color === 'orange' ? 'warning' : 'primary'}
onClick={() => onFilter({ serviceStatus: 'degraded', missingProtocol: consistency.missingProtocols[0] })}
>
{label}
</Button>
);
}
return <Tag color={color}>{label}</Tag>;
}
export function Dashboard({
onOpenVehicle,
onOpenQuality,
onOpenRealtime,
onOpenVehicles
}: {
onOpenVehicle: (vin: string, protocol?: string) => void;
onOpenQuality: (filters?: Record<string, string>) => void;
onOpenRealtime: (filters?: Record<string, string>) => 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 [coverageFilters, setCoverageFilters] = useState<Record<string, string>>({});
const amapConfigured = isAMapConfigured();
const loadCoverage = (values?: Record<string, string>) => {
const nextValues = values ?? {};
setCoverageLoading(true);
setCoverageFilters(nextValues);
const scopeTitle = [
nextValues.serviceStatus ? serviceStatusTitle[nextValues.serviceStatus] ?? nextValues.serviceStatus : '',
nextValues.missingProtocol ? missingProtocolTitle[nextValues.missingProtocol] ?? `${nextValues.missingProtocol}` : ''
].filter(Boolean).join(' / ');
setCoverageServiceStatusTitle(scopeTitle);
const params = new URLSearchParams({ limit: '8' });
if (nextValues.keyword) params.set('keyword', nextValues.keyword);
if (nextValues.coverage) params.set('coverage', nextValues.coverage);
if (nextValues.missingProtocol) params.set('missingProtocol', nextValues.missingProtocol);
if (nextValues.online) params.set('online', nextValues.online);
if (nextValues.bindingStatus) params.set('bindingStatus', nextValues.bindingStatus);
if (nextValues.serviceStatus) params.set('serviceStatus', nextValues.serviceStatus);
api.vehicleCoverage(params)
.then((page) => setCoverage(page.items))
.catch((error: Error) => Toast.error(error.message))
.finally(() => setCoverageLoading(false));
};
useEffect(() => {
const tasks = [
api.dashboardSummary().then(setSummary),
api.vehicleServiceSummary().then(setServiceSummary),
api.vehicleCoverage(new URLSearchParams({ limit: '8' })).then((page) => setCoverage(page.items)),
api.vehicleRealtime(new URLSearchParams({ limit: '8' })).then((page) => setLocations(page.items)),
api.qualityIssues(new URLSearchParams({ limit: '5' })).then((page) => setQualityIssues(page.items))
];
Promise.allSettled(tasks)
.then((results) => {
const failed = results.find((result) => result.status === 'rejected');
if (failed?.status === 'rejected') {
const reason = failed.reason;
Toast.error(reason instanceof Error ? reason.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' } },
{ 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) {
items.push({
label: '确认平台转发',
count: serviceSummary?.noDataVehicles ?? 0,
filters: { serviceStatus: 'no_data' },
detail: '车辆没有形成任何来源证据,优先确认上游平台是否持续转发。'
});
}
if ((serviceSummary?.identityRequiredVehicles ?? 0) > 0) {
items.push({
label: '维护身份绑定',
count: serviceSummary?.identityRequiredVehicles ?? 0,
filters: { serviceStatus: 'identity_required' },
detail: '已有数据但无法稳定归并到 VIN会影响车辆服务聚合。'
});
}
if ((serviceSummary?.archiveIncompleteVehicles ?? 0) > 0) {
items.push({
label: '完善车辆档案',
count: serviceSummary?.archiveIncompleteVehicles ?? 0,
filters: { archiveStatus: 'incomplete' },
detail: '车辆缺少车牌、手机号或 OEM 等基础档案,影响后续运营查询和治理。'
});
}
for (const field of serviceSummary?.archiveMissingFields ?? []) {
if (field.count <= 0) continue;
items.push({
label: `补齐${field.title}`,
count: field.count,
filters: { archiveMissing: field.field },
detail: `${field.title}会影响车辆档案检索、绑定确认和运营侧筛选。`
});
}
for (const source of serviceSummary?.missingSources ?? []) {
if (source.count <= 0) continue;
items.push({
label: `补齐 ${source.protocol} 来源`,
count: source.count,
filters: { serviceStatus: 'degraded', missingProtocol: source.protocol },
detail: `${source.protocol} 来源缺失会降低跨来源定位、里程和实时判断可信度。`
});
}
return items;
}, [serviceSummary]);
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 highPriorityIssue = qualityIssues.find((item) => item.severity === 'error') ?? qualityIssues[0];
const commandMapPoints: VehicleMapPoint[] = locations.map((row, index) => ({
id: row.vin || `${row.primaryProtocol || 'source'}-${index}`,
label: row.plate || row.vin || 'unknown',
longitude: row.longitude,
latitude: row.latitude,
online: row.online,
title: `${row.plate || row.vin || '-'} ${row.primaryProtocol || ''} ${row.lastSeen || ''}`
}));
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>
<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={(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>
</Space>
</Card>
<Card
bordered
title="车辆态势指挥台"
style={{ marginBottom: 16 }}
>
<div className="vp-monitor-layout">
<div className="vp-monitor-map">
<div className="vp-monitor-map-header">
<Space wrap>
<Tag color={amapConfigured ? 'green' : 'orange'}>
{amapConfigured ? '高德地图配置就绪' : '高德地图待配置'}
</Tag>
<Tag color="green">线 {commandOnlineCount.toLocaleString()} / {locations.length.toLocaleString()}</Tag>
<Tag color="blue"> {commandLocatedCount.toLocaleString()}</Tag>
<Tag color={commandDegradedCount > 0 ? 'orange' : 'green'}>/线 {commandDegradedCount.toLocaleString()}</Tag>
</Space>
</div>
<VehicleMap
points={commandMapPoints}
maxFallbackPoints={80}
fallbackLabel="高德地图未配置,显示车辆态势坐标预览"
/>
</div>
<div className="vp-monitor-side">
<div className="vp-monitor-metric">
<Tag color="green"></Tag>
<div className="vp-monitor-metric-value">{commandOnlineCount.toLocaleString()}</div>
<Typography.Text type="secondary">线</Typography.Text>
</div>
<div className="vp-monitor-metric">
<Tag color={commandDegradedCount > 0 ? 'orange' : 'green'}></Tag>
<div className="vp-monitor-metric-value">{commandDegradedCount.toLocaleString()}</div>
<Typography.Text type="secondary">线</Typography.Text>
</div>
<Space vertical align="start">
<Button theme="solid" type="primary" onClick={() => onOpenRealtime({ online: 'online' })}></Button>
<Button
disabled={!highPriorityIssue?.issueType}
theme="light"
type="warning"
onClick={() => onOpenQuality(highPriorityIssue?.issueType ? { issueType: highPriorityIssue.issueType } : {})}
>
</Button>
<Button theme="light" onClick={() => onOpenVehicles({ serviceStatus: 'degraded' })}></Button>
</Space>
</div>
</div>
</Card>
{serviceActionQueue.length > 0 ? (
<Card bordered title="车辆服务处置队列" style={{ marginBottom: 16 }}>
<div className="vp-action-grid">
{serviceActionQueue.map((item) => (
<div key={`${item.label}-${item.count}`} className="vp-action-item">
<div>
<Tag color="orange">{item.label} {item.count.toLocaleString()}</Tag>
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 8 }}>{item.detail}</Typography.Text>
</div>
<Button size="small" theme="light" type="warning" onClick={() => onOpenVehicles(item.filters)}>
{item.label} {item.count.toLocaleString()}
</Button>
</div>
))}
</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
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, row.protocol)}>{lookup.label}</Button>;
}
}
]}
/>
</Card>
<Card
title={<Space><span></span><Button size="small" onClick={() => onOpenVehicles(coverageFilters)}></Button></Space>}
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="missingProtocol" label="缺失来源" placeholder="全部" style={{ width: 170 }} data-testid="dashboard-missing-protocol-filter">
<Select.Option value="GB32960"> GB32960</Select.Option>
<Select.Option value="JT808"> JT808</Select.Option>
<Select.Option value="YUTONG_MQTT"> YUTONG_MQTT</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: 300,
render: (_: unknown, row: VehicleCoverageRow) => (
<SourceStatusTags sourceStatus={row.sourceStatus} protocols={row.protocols} lastSeen={row.lastSeen} />
)
},
{
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: 140,
render: (_: unknown, row: VehicleCoverageRow) => sourceConsistencyAction(row, loadCoverage)
},
{ 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, row.primaryProtocol)}></Button> }
]}
/>
</Card>
</Col>
</Row>
</Spin>
</div>
);
}