462 lines
23 KiB
TypeScript
462 lines
23 KiB
TypeScript
import { Banner, Button, Card, Descriptions, Form, Select, Space, Table, Tabs, Tag, Toast, Typography } from '@douyinfe/semi-ui';
|
||
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
import { api } from '../api/client';
|
||
import type { QualityIssueRow, VehicleDetail as VehicleDetailData, VehicleSourceStatus } from '../api/types';
|
||
import { PageHeader } from '../components/PageHeader';
|
||
import { StatusTag } from '../components/StatusTag';
|
||
import { isLikelyVIN } from '../domain/vehicleLookup';
|
||
|
||
type VehicleQuery = {
|
||
keyword: string;
|
||
protocol?: string;
|
||
};
|
||
|
||
const sourceCapabilities: Array<{ key: keyof Pick<VehicleSourceStatus, 'hasRealtime' | 'hasHistory' | 'hasRaw' | 'hasMileage'>; label: string }> = [
|
||
{ key: 'hasRealtime', label: '实时' },
|
||
{ key: 'hasHistory', label: '历史' },
|
||
{ key: 'hasRaw', label: 'RAW' },
|
||
{ key: 'hasMileage', label: '里程' }
|
||
];
|
||
|
||
const coverageStatusText: Record<string, string> = {
|
||
online: '全部在线',
|
||
partial: '部分在线',
|
||
offline: '全部离线',
|
||
no_data: '暂无来源'
|
||
};
|
||
|
||
function isFiniteNumber(value: unknown): value is number {
|
||
return typeof value === 'number' && Number.isFinite(value);
|
||
}
|
||
|
||
function formatCompactNumber(value?: number, suffix = '') {
|
||
if (!isFiniteNumber(value)) return '-';
|
||
return `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })}${suffix}`;
|
||
}
|
||
|
||
function parseTimeMs(value?: string) {
|
||
if (!value) return undefined;
|
||
const ms = Date.parse(value);
|
||
return Number.isFinite(ms) ? ms : undefined;
|
||
}
|
||
|
||
function formatSeconds(value?: number) {
|
||
if (!isFiniteNumber(value)) return '-';
|
||
return `${Math.round(value)} 秒`;
|
||
}
|
||
|
||
export function VehicleDetail({
|
||
vin,
|
||
protocol,
|
||
onOpenHistory,
|
||
onOpenMileage,
|
||
onQueryChange
|
||
}: {
|
||
vin: string;
|
||
protocol?: string;
|
||
onOpenHistory: (vin: string, protocol?: string) => void;
|
||
onOpenMileage: (vin: string, protocol?: string) => void;
|
||
onQueryChange?: (keyword: string, protocol?: string) => void;
|
||
}) {
|
||
const [query, setQuery] = useState<VehicleQuery>({ keyword: vin, protocol });
|
||
const [detail, setDetail] = useState<VehicleDetailData | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const requestSeq = useRef(0);
|
||
|
||
const load = (nextQuery = query) => {
|
||
const keyword = nextQuery.keyword.trim();
|
||
if (!keyword) {
|
||
Toast.warning('请输入 VIN / 车牌 / 手机号');
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
const params = new URLSearchParams({ keyword });
|
||
if (nextQuery.protocol?.trim()) {
|
||
params.set('protocol', nextQuery.protocol.trim());
|
||
}
|
||
const seq = requestSeq.current + 1;
|
||
requestSeq.current = seq;
|
||
api.vehicleDetail(params)
|
||
.then((nextDetail) => {
|
||
if (requestSeq.current === seq) {
|
||
setDetail(nextDetail);
|
||
}
|
||
})
|
||
.catch((error: Error) => {
|
||
if (requestSeq.current === seq) {
|
||
Toast.error(error.message);
|
||
}
|
||
})
|
||
.finally(() => {
|
||
if (requestSeq.current === seq) {
|
||
setLoading(false);
|
||
}
|
||
});
|
||
};
|
||
|
||
useEffect(() => {
|
||
const nextQuery = { keyword: vin, protocol };
|
||
setQuery(nextQuery);
|
||
load(nextQuery);
|
||
}, [vin, protocol]);
|
||
|
||
const identity = detail?.identity;
|
||
const summary = detail?.realtimeSummary;
|
||
const latest = detail?.realtime?.[0];
|
||
const resolution = detail?.resolution;
|
||
const overview = detail?.serviceOverview;
|
||
const resolvedVIN = resolution?.vin || detail?.vin || summary?.vin || identity?.vin || latest?.vin || query.keyword;
|
||
const hasResolvedVIN = resolution?.resolved ?? detail?.lookupResolved ?? isLikelyVIN(resolvedVIN);
|
||
const displayVIN = hasResolvedVIN ? resolvedVIN : '-';
|
||
const displayLookupKey = resolution?.lookupKey || detail?.lookupKey || query.keyword;
|
||
const protocols = useMemo(() => resolution?.protocols?.length ? resolution.protocols : detail?.sources ?? [], [detail?.sources, resolution?.protocols]);
|
||
const latestRaw = detail?.raw?.items?.[0];
|
||
const qualityCount = detail?.quality?.total ?? 0;
|
||
const online = resolution?.online || summary?.online || identity?.online || false;
|
||
const lastSeen = resolution?.lastSeen || summary?.lastSeen || latest?.lastSeen || '-';
|
||
const serviceStatus = detail?.serviceStatus;
|
||
const realtimeRows = detail?.realtime ?? [];
|
||
const realtimeSources = new Set(realtimeRows.map((row) => row.protocol).filter(Boolean));
|
||
const sourceCount = Math.max(detail?.sourceStatus?.length ?? 0, realtimeSources.size, summary?.sourceCount ?? 0, protocols.length);
|
||
const onlineSourceCount = detail?.sourceStatus?.length
|
||
? detail.sourceStatus.filter((source) => source.online).length
|
||
: summary?.onlineSourceCount ?? 0;
|
||
const locatedSourceCount = realtimeRows.filter((row) => isFiniteNumber(row.longitude) && isFiniteNumber(row.latitude)).length;
|
||
const mileageValues = realtimeRows.map((row) => row.totalMileageKm).filter(isFiniteNumber);
|
||
const mileageDelta = mileageValues.length > 1 ? Math.max(...mileageValues) - Math.min(...mileageValues) : undefined;
|
||
const timeValues = [
|
||
...realtimeRows.map((row) => parseTimeMs(row.lastSeen)),
|
||
...(detail?.sourceStatus ?? []).map((source) => parseTimeMs(source.lastSeen))
|
||
].filter(isFiniteNumber);
|
||
const sourceTimeDeltaSeconds = timeValues.length > 1 ? (Math.max(...timeValues) - Math.min(...timeValues)) / 1000 : undefined;
|
||
const consistency = detail?.sourceConsistency;
|
||
const consistencySourceCount = consistency?.sourceCount ?? sourceCount;
|
||
const consistencyOnlineSourceCount = consistency?.onlineSourceCount ?? onlineSourceCount;
|
||
const consistencyLocatedSourceCount = consistency?.locatedSourceCount ?? locatedSourceCount;
|
||
const consistencyMileageDelta = consistency?.mileageDeltaKm ?? mileageDelta;
|
||
const consistencyTimeDeltaSeconds = consistency?.sourceTimeDeltaSeconds ?? sourceTimeDeltaSeconds;
|
||
const activeProtocol = query.protocol?.trim() ?? '';
|
||
const scopeText = activeProtocol ? `单一来源:${activeProtocol}` : '全部来源聚合';
|
||
const formKey = `${query.keyword}-${query.protocol ?? ''}`;
|
||
const switchSource = (nextProtocol = '') => {
|
||
const nextQuery = { keyword: resolvedVIN || query.keyword, protocol: nextProtocol };
|
||
setQuery(nextQuery);
|
||
onQueryChange?.(nextQuery.keyword, nextQuery.protocol);
|
||
load(nextQuery);
|
||
};
|
||
const qualityTable = (
|
||
<Table
|
||
loading={loading}
|
||
pagination={false}
|
||
rowKey={(row?: QualityIssueRow) => `${row?.protocol ?? ''}-${row?.issueType ?? ''}-${row?.lastSeen ?? ''}`}
|
||
dataSource={detail?.quality?.items ?? []}
|
||
columns={[
|
||
{ title: '来源', dataIndex: 'protocol', width: 130 },
|
||
{ title: '问题', dataIndex: 'issueType', width: 150 },
|
||
{ title: '级别', width: 110, render: (_: unknown, row: QualityIssueRow) => <Tag color={row.severity === 'error' ? 'red' : 'orange'}>{row.severity}</Tag> },
|
||
{ title: '最后时间', dataIndex: 'lastSeen', width: 190 },
|
||
{ title: '说明', dataIndex: 'detail' }
|
||
]}
|
||
/>
|
||
);
|
||
|
||
return (
|
||
<div className="vp-page">
|
||
<PageHeader title="车辆服务" description="以 VIN 为主对象聚合身份、实时、历史、RAW 和里程,协议仅作为数据来源" />
|
||
<Card bordered>
|
||
<Form key={formKey} initValues={query} layout="horizontal" onSubmit={(values) => {
|
||
const nextQuery = { keyword: String(values.keyword ?? ''), protocol: String(values.protocol ?? '') };
|
||
setQuery(nextQuery);
|
||
onQueryChange?.(nextQuery.keyword, nextQuery.protocol);
|
||
load(nextQuery);
|
||
}}>
|
||
<Form.Input field="keyword" label="车辆关键词" placeholder="VIN / 车牌 / 手机号" style={{ width: 280 }} />
|
||
<Form.Select field="protocol" label="数据来源" placeholder="全部来源" style={{ width: 180 }}>
|
||
<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>
|
||
<Space>
|
||
<Button icon={<IconSearch />} htmlType="submit" theme="solid" type="primary">查询车辆</Button>
|
||
<Button icon={<IconRefresh />} onClick={() => load(query)} loading={loading}>刷新</Button>
|
||
<Button disabled={!hasResolvedVIN} onClick={() => onOpenHistory(resolvedVIN, activeProtocol)}>查看历史</Button>
|
||
<Button disabled={!hasResolvedVIN} onClick={() => onOpenMileage(resolvedVIN, activeProtocol)}>查看里程</Button>
|
||
</Space>
|
||
</Form>
|
||
</Card>
|
||
|
||
<div className="vp-scope-bar">
|
||
<span className="vp-scope-label">当前查看范围</span>
|
||
<Tag color={activeProtocol ? 'blue' : 'green'}>{scopeText}</Tag>
|
||
<Typography.Text type="tertiary">
|
||
{activeProtocol ? '下方实时、历史、RAW 和里程仅展示该来源返回的数据。' : '下方数据按车辆聚合展示全部可用来源。'}
|
||
</Typography.Text>
|
||
</div>
|
||
|
||
{serviceStatus ? (
|
||
<div className={`vp-service-status vp-service-status-${serviceStatus.severity}`}>
|
||
<span className="vp-scope-label">车辆服务状态</span>
|
||
<Tag color={serviceStatus.severity === 'ok' ? 'green' : serviceStatus.severity === 'error' ? 'red' : 'orange'}>
|
||
{serviceStatus.title}
|
||
</Tag>
|
||
<Typography.Text type="tertiary">{serviceStatus.detail}</Typography.Text>
|
||
</div>
|
||
) : null}
|
||
|
||
{overview ? (
|
||
<Card bordered title="车辆服务概览" style={{ marginTop: 16 }}>
|
||
<Descriptions
|
||
row
|
||
data={[
|
||
{ key: '服务车辆', value: [overview.plate, overview.vin].filter(Boolean).join(' / ') || '-' },
|
||
{ key: '在线来源', value: `${overview.onlineSourceCount}/${overview.sourceCount}` },
|
||
{ key: '覆盖状态', value: <Tag color={overview.coverageStatus === 'online' ? 'green' : overview.coverageStatus === 'partial' ? 'orange' : 'grey'}>{coverageStatusText[overview.coverageStatus] ?? overview.coverageStatus}</Tag> },
|
||
{ key: '主来源', value: overview.primaryProtocol || '-' },
|
||
{ key: '最后上报', value: overview.lastSeen || '-' },
|
||
{ key: '数据规模', value: `历史 ${overview.historyCount} / RAW ${overview.rawCount} / 里程 ${overview.mileageCount}` },
|
||
{ key: '实时来源', value: overview.realtimeCount },
|
||
{ key: '质量问题', value: <Tag color={overview.qualityIssueCount > 0 ? 'orange' : 'green'}>{overview.qualityIssueCount}</Tag> }
|
||
]}
|
||
/>
|
||
</Card>
|
||
) : null}
|
||
|
||
<Card bordered style={{ marginTop: 16 }}>
|
||
<div className="vp-vehicle-summary">
|
||
<Descriptions
|
||
row
|
||
data={[
|
||
{ key: '查询关键词', value: displayLookupKey },
|
||
{ key: 'VIN', value: displayVIN },
|
||
{ key: '解析状态', value: <Tag color={hasResolvedVIN ? 'green' : 'orange'}>{hasResolvedVIN ? '已解析 VIN' : '未解析到 VIN'}</Tag> },
|
||
{ key: '车牌', value: resolution?.plate || summary?.plate || identity?.plate || latest?.plate || '-' },
|
||
{ key: '手机号', value: resolution?.phone || summary?.phone || identity?.phone || '-' },
|
||
{ key: 'OEM', value: resolution?.oem || summary?.oem || identity?.oem || '-' },
|
||
{ key: '在线', value: <StatusTag status={online ? 'ok' : 'offline'} /> },
|
||
{
|
||
key: '数据来源',
|
||
value: protocols.length > 0 ? <Space>{protocols.map((item) => <Tag key={item} color={item === summary?.primaryProtocol ? 'blue' : 'grey'}>{item}</Tag>)}</Space> : '-'
|
||
},
|
||
{ key: '在线来源', value: summary ? `${summary.onlineSourceCount}/${summary.sourceCount}` : '-' },
|
||
{ key: '最后位置时间', value: lastSeen },
|
||
{ key: '最新 RAW 时间', value: latestRaw?.serverTime ?? '-' },
|
||
{ key: '质量问题', value: <Tag color={qualityCount > 0 ? 'orange' : 'green'}>{qualityCount > 0 ? `${qualityCount} 项` : '无'}</Tag> }
|
||
]}
|
||
/>
|
||
</div>
|
||
{!hasResolvedVIN ? (
|
||
<Banner
|
||
type="warning"
|
||
bordered
|
||
title="身份绑定待处理"
|
||
description={`车辆关键词 ${displayLookupKey} 暂未解析到 VIN,请维护车辆身份绑定后再查看历史、里程和跨来源数据。`}
|
||
style={{ marginTop: 12 }}
|
||
/>
|
||
) : null}
|
||
</Card>
|
||
|
||
{hasResolvedVIN ? (
|
||
<>
|
||
<Card bordered title="数据来源覆盖" style={{ marginTop: 16 }}>
|
||
{detail?.sourceStatus?.length ? (
|
||
<>
|
||
<div className="vp-source-toolbar">
|
||
<Space>
|
||
<Tag color={activeProtocol ? 'grey' : 'blue'}>{activeProtocol ? `当前 ${activeProtocol}` : '当前 全部来源'}</Tag>
|
||
<Button size="small" disabled={!activeProtocol} onClick={() => switchSource('')}>全部来源</Button>
|
||
</Space>
|
||
</div>
|
||
<div className="vp-source-grid">
|
||
{detail.sourceStatus.map((source) => (
|
||
<div key={source.protocol} className={`vp-source-card${activeProtocol === source.protocol ? ' vp-source-card-active' : ''}`}>
|
||
<div className="vp-source-card-head">
|
||
<Space>
|
||
<Tag color={source.protocol === summary?.primaryProtocol ? 'blue' : 'grey'}>{source.protocol}</Tag>
|
||
<StatusTag status={source.online ? 'ok' : 'offline'} />
|
||
</Space>
|
||
<Typography.Text type="tertiary">{source.lastSeen || '无上报'}</Typography.Text>
|
||
</div>
|
||
<Space spacing={4} wrap>
|
||
{sourceCapabilities.map((item) => (
|
||
<Tag key={item.key} color={source[item.key] ? 'green' : 'grey'}>
|
||
{item.label}{source[item.key] ? '有' : '无'}
|
||
</Tag>
|
||
))}
|
||
</Space>
|
||
<div className="vp-source-card-actions">
|
||
<Space spacing={6} wrap>
|
||
<Button
|
||
size="small"
|
||
theme={activeProtocol === source.protocol ? 'solid' : 'light'}
|
||
type={activeProtocol === source.protocol ? 'primary' : 'tertiary'}
|
||
disabled={activeProtocol === source.protocol}
|
||
onClick={() => switchSource(source.protocol)}
|
||
>
|
||
仅看 {source.protocol}
|
||
</Button>
|
||
<Button
|
||
size="small"
|
||
theme="light"
|
||
type="tertiary"
|
||
disabled={!source.hasHistory}
|
||
onClick={() => onOpenHistory(resolvedVIN, source.protocol)}
|
||
>
|
||
查看 {source.protocol} 历史
|
||
</Button>
|
||
<Button
|
||
size="small"
|
||
theme="light"
|
||
type="tertiary"
|
||
disabled={!source.hasMileage}
|
||
onClick={() => onOpenMileage(resolvedVIN, source.protocol)}
|
||
>
|
||
查看 {source.protocol} 里程
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<Typography.Text type="secondary">暂未查询到该车辆的数据来源覆盖。</Typography.Text>
|
||
)}
|
||
</Card>
|
||
|
||
<Card bordered title="跨来源一致性" style={{ marginTop: 16 }}>
|
||
<Descriptions
|
||
row
|
||
data={[
|
||
{ key: '来源覆盖', value: consistencySourceCount > 0 ? `${consistencySourceCount} 个来源` : '-' },
|
||
{ key: '在线来源', value: consistencySourceCount > 0 ? `${consistencyOnlineSourceCount}/${consistencySourceCount} 在线` : '-' },
|
||
{ key: '位置覆盖', value: consistencyLocatedSourceCount > 0 ? `${consistencyLocatedSourceCount} 个来源有位置` : '暂无位置' },
|
||
{ key: '里程差异', value: formatCompactNumber(consistencyMileageDelta, ' km') },
|
||
{ key: '来源时间差', value: formatSeconds(consistencyTimeDeltaSeconds) },
|
||
{ key: '车辆服务视角', value: activeProtocol ? `当前只看 ${activeProtocol}` : '三类来源合并为同一车辆服务' }
|
||
]}
|
||
/>
|
||
</Card>
|
||
|
||
<Card bordered title="来源诊断明细" style={{ marginTop: 16 }}>
|
||
<Table
|
||
loading={loading}
|
||
pagination={false}
|
||
rowKey="protocol"
|
||
dataSource={detail?.sourceStatus ?? []}
|
||
columns={[
|
||
{ title: '来源', dataIndex: 'protocol', width: 130 },
|
||
{ title: '在线', width: 100, render: (_: unknown, row: VehicleSourceStatus) => <StatusTag status={row.online ? 'ok' : 'offline'} /> },
|
||
{ title: '最后时间', dataIndex: 'lastSeen', width: 190 },
|
||
{ title: '实时', width: 90, render: (_: unknown, row: VehicleSourceStatus) => <Tag color={row.hasRealtime ? 'green' : 'grey'}>{row.hasRealtime ? '有' : '无'}</Tag> },
|
||
{ title: '历史', width: 90, render: (_: unknown, row: VehicleSourceStatus) => <Tag color={row.hasHistory ? 'green' : 'grey'}>{row.hasHistory ? '有' : '无'}</Tag> },
|
||
{ title: 'RAW', width: 90, render: (_: unknown, row: VehicleSourceStatus) => <Tag color={row.hasRaw ? 'green' : 'grey'}>{row.hasRaw ? '有' : '无'}</Tag> },
|
||
{ title: '里程', width: 90, render: (_: unknown, row: VehicleSourceStatus) => <Tag color={row.hasMileage ? 'green' : 'grey'}>{row.hasMileage ? '有' : '无'}</Tag> }
|
||
]}
|
||
/>
|
||
</Card>
|
||
</>
|
||
) : null}
|
||
|
||
{!hasResolvedVIN ? (
|
||
<Card bordered style={{ marginTop: 16 }}>
|
||
<Tabs>
|
||
<Tabs.TabPane tab="质量问题" itemKey="quality">
|
||
{qualityTable}
|
||
</Tabs.TabPane>
|
||
</Tabs>
|
||
</Card>
|
||
) : (
|
||
<Card bordered style={{ marginTop: 16 }}>
|
||
<Tabs>
|
||
<Tabs.TabPane tab="实时状态" itemKey="realtime">
|
||
<div className="vp-realtime-strip">
|
||
{[
|
||
{ label: '最新来源', value: summary?.primaryProtocol || '-' },
|
||
{ label: '速度 km/h', value: summary ? summary.speedKmh : '-' },
|
||
{ label: 'SOC %', value: summary ? summary.socPercent : '-' },
|
||
{ label: '总里程 km', value: summary ? summary.totalMileageKm : '-' },
|
||
{ label: '经纬度', value: summary ? `${summary.longitude}, ${summary.latitude}` : '-' },
|
||
{ label: '最后时间', value: lastSeen }
|
||
].map((item) => (
|
||
<div key={item.label} className="vp-realtime-strip-item">
|
||
<div className="vp-realtime-strip-label">{item.label}</div>
|
||
<div className="vp-realtime-strip-value">{item.value}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<Table
|
||
loading={loading}
|
||
pagination={false}
|
||
rowKey="protocol"
|
||
dataSource={detail?.realtime ?? []}
|
||
columns={[
|
||
{ title: '来源', dataIndex: 'protocol', width: 130 },
|
||
{ title: '经度', dataIndex: 'longitude', width: 120 },
|
||
{ title: '纬度', dataIndex: 'latitude', width: 120 },
|
||
{ title: '速度 km/h', dataIndex: 'speedKmh', width: 120 },
|
||
{ title: 'SOC %', dataIndex: 'socPercent', width: 120 },
|
||
{ title: '总里程 km', dataIndex: 'totalMileageKm', width: 130 },
|
||
{ title: '最后时间', dataIndex: 'lastSeen', width: 190 }
|
||
]}
|
||
/>
|
||
</Tabs.TabPane>
|
||
<Tabs.TabPane tab="历史位置" itemKey="history">
|
||
<Table
|
||
loading={loading}
|
||
pagination={false}
|
||
rowKey="deviceTime"
|
||
dataSource={detail?.history?.items ?? []}
|
||
columns={[
|
||
{ title: '来源', dataIndex: 'protocol', width: 130 },
|
||
{ title: '经度', dataIndex: 'longitude', width: 120 },
|
||
{ title: '纬度', dataIndex: 'latitude', width: 120 },
|
||
{ title: '速度 km/h', dataIndex: 'speedKmh', width: 120 },
|
||
{ title: '总里程 km', dataIndex: 'totalMileageKm', width: 130 },
|
||
{ title: '设备时间', dataIndex: 'deviceTime', width: 190 },
|
||
{ title: '入库时间', dataIndex: 'serverTime', width: 190 }
|
||
]}
|
||
/>
|
||
</Tabs.TabPane>
|
||
<Tabs.TabPane tab="RAW 字段" itemKey="raw">
|
||
<Table
|
||
loading={loading}
|
||
pagination={false}
|
||
rowKey="id"
|
||
dataSource={detail?.raw?.items ?? []}
|
||
columns={[
|
||
{ title: '来源', dataIndex: 'protocol', width: 130 },
|
||
{ title: '帧类型', dataIndex: 'frameType', width: 190 },
|
||
{ title: '大小 B', dataIndex: 'rawSizeBytes', width: 100 },
|
||
{ title: '设备时间', dataIndex: 'deviceTime', width: 190 },
|
||
{ title: '入库时间', dataIndex: 'serverTime', width: 190 }
|
||
]}
|
||
/>
|
||
<Typography.Text type="tertiary">最新 RAW 解析字段</Typography.Text>
|
||
<pre className="vp-json">{JSON.stringify(latestRaw?.parsedFields ?? {}, null, 2)}</pre>
|
||
</Tabs.TabPane>
|
||
<Tabs.TabPane tab="里程" itemKey="mileage">
|
||
<Table
|
||
loading={loading}
|
||
pagination={false}
|
||
rowKey="date"
|
||
dataSource={detail?.mileage?.items ?? []}
|
||
columns={[
|
||
{ title: '日期', dataIndex: 'date', width: 130 },
|
||
{ title: '来源', dataIndex: 'source', width: 130 },
|
||
{ title: '起始里程', dataIndex: 'startMileageKm', width: 130 },
|
||
{ title: '结束里程', dataIndex: 'endMileageKm', width: 130 },
|
||
{ title: '日里程', dataIndex: 'dailyMileageKm', width: 130 }
|
||
]}
|
||
/>
|
||
</Tabs.TabPane>
|
||
<Tabs.TabPane tab="质量问题" itemKey="quality">
|
||
{qualityTable}
|
||
</Tabs.TabPane>
|
||
</Tabs>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|