Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx
2026-07-04 18:13:14 +08:00

647 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 { IconCopy } from '@douyinfe/semi-icons';
import { Button, Card, Form, Select, Space, Table, Tag, Toast } from '@douyinfe/semi-ui';
import { useEffect, useMemo, useState } from 'react';
import { api } from '../api/client';
import type { VehicleCoverageRow, VehicleCoverageSummary } from '../api/types';
import { DataEmpty } from '../components/DataEmpty';
import { PageHeader } from '../components/PageHeader';
import { SourceStatusTags } from '../components/SourceStatusTags';
import { StatusTag } from '../components/StatusTag';
import { buildCsv, downloadCsv, type CsvColumn } from '../domain/csvExport';
import { summarizeVehicleService } from '../domain/vehicleService';
function vehicleServiceStatus(row: VehicleCoverageRow) {
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
};
}
const verdict = summarizeVehicleService({
bindingStatus: row.bindingStatus,
sourceCount: row.sourceCount,
onlineSourceCount: row.onlineSourceCount,
missingProtocols: row.missingProtocols,
mileageDeltaKm: row.sourceConsistency?.mileageDeltaKm
});
return { label: verdict.status, color: verdict.color };
}
function sourceEvidenceText(row: VehicleCoverageRow) {
return `${row.onlineSourceCount}/${row.sourceCount} 来源在线`;
}
function primaryRowProtocol(row: VehicleCoverageRow, fallbackProtocol?: string) {
return (fallbackProtocol || row.sourceStatus?.find((source) => source.online)?.protocol || row.protocols?.[0] || '').trim();
}
function rowWorkflowFilters(row: VehicleCoverageRow, fallbackProtocol?: string) {
return {
keyword: row.vin,
...(primaryRowProtocol(row, fallbackProtocol) ? { protocol: primaryRowProtocol(row, fallbackProtocol) } : {})
};
}
function vehicleArchiveCompleteness(row: VehicleCoverageRow) {
const fields = [row.vin, row.plate, row.phone, row.oem];
const completed = fields.filter((item) => Boolean(String(item ?? '').trim())).length;
return { completed, total: fields.length, label: `${completed}/${fields.length}` };
}
function vehicleArchiveMissingLabels(row: VehicleCoverageRow) {
return [
{ value: row.vin, label: '缺VIN' },
{ value: row.plate, label: '缺车牌' },
{ value: row.phone, label: '缺手机号' },
{ value: row.oem, label: '缺OEM' }
].filter((item) => !String(item.value ?? '').trim()).map((item) => item.label);
}
const coverageLabel: Record<string, string> = {
single: '单源',
multi: '多源'
};
const serviceStatusLabel: Record<string, string> = {
healthy: '服务正常',
degraded: '来源不完整',
offline: '车辆离线',
no_data: '暂无数据来源',
identity_required: '身份未绑定'
};
const onlineLabel: Record<string, string> = {
online: '在线',
offline: '离线'
};
const bindingStatusLabel: Record<string, string> = {
bound: '已绑定',
unbound: '未绑定'
};
const archiveStatusLabel: Record<string, string> = {
complete: '完整',
incomplete: '不完整'
};
const archiveMissingLabel: Record<string, string> = {
plate: '缺车牌',
phone: '缺手机号',
oem: '缺OEM'
};
function sourceDiagnosisText(row: VehicleCoverageRow) {
const parts = [sourceEvidenceText(row)];
if ((row.missingProtocols ?? []).length > 0) {
parts.push(`${row.missingProtocols.join('、')}`);
} else if (row.sourceCount > 0) {
parts.push('来源完整');
} else {
parts.push('暂无来源');
}
return parts.join('');
}
const vehicleExportColumns: CsvColumn<VehicleCoverageRow>[] = [
{ title: 'VIN', value: (row) => row.vin },
{ title: '车牌', value: (row) => row.plate },
{ title: '手机号', value: (row) => row.phone },
{ title: 'OEM', value: (row) => row.oem },
{ title: '来源', value: (row) => row.protocols?.join('|') },
{ title: '缺失来源', value: (row) => row.missingProtocols?.join('|') },
{ title: '来源覆盖', value: (row) => sourceEvidenceText(row) },
{ title: '服务状态', value: (row) => vehicleServiceStatus(row).label },
{ title: '诊断摘要', value: (row) => sourceDiagnosisText(row) },
{ title: '档案完整度', value: (row) => vehicleArchiveCompleteness(row).label },
{ title: '档案缺项', value: (row) => vehicleArchiveMissingLabels(row).join('|') },
{ title: '在线', value: (row) => row.online ? '在线' : '离线' },
{ title: '最后在线', value: (row) => row.lastSeen },
{ title: '绑定状态', value: (row) => row.bindingStatus },
{ title: '来源一致性', value: (row) => row.sourceConsistency?.title ?? '' }
];
function exportFileName(filters: Record<string, string>) {
const keyword = filters.keyword?.trim() || 'all';
const protocol = filters.protocol?.trim() || 'all-source';
return `vehicle-coverage-${keyword}-${protocol}.csv`;
}
type VehicleActionQueueItem = {
label: string;
count: number;
filters: Record<string, string>;
color: 'orange' | 'red';
priority: 'P0' | 'P1';
detail: string;
};
function vehicleFilterSummary(filters: Record<string, string>) {
return [
filters.keyword ? `关键词:${filters.keyword}` : '',
filters.protocol ? `数据来源:${filters.protocol}` : '',
filters.coverage ? `来源覆盖:${coverageLabel[filters.coverage] ?? filters.coverage}` : '',
filters.missingProtocol ? `缺失来源:${filters.missingProtocol}` : '',
filters.serviceStatus ? `服务状态:${serviceStatusLabel[filters.serviceStatus] ?? filters.serviceStatus}` : '',
filters.online ? `在线:${onlineLabel[filters.online] ?? filters.online}` : '',
filters.bindingStatus ? `绑定:${bindingStatusLabel[filters.bindingStatus] ?? filters.bindingStatus}` : '',
filters.archiveStatus ? `档案:${archiveStatusLabel[filters.archiveStatus] ?? filters.archiveStatus}` : '',
filters.archiveMissing ? `档案缺项:${archiveMissingLabel[filters.archiveMissing] ?? filters.archiveMissing}` : ''
].filter(Boolean);
}
function vehicleGovernanceSummaryText({
filters,
summary,
actionQueue
}: {
filters: Record<string, string>;
summary: VehicleCoverageSummary | null;
actionQueue: VehicleActionQueueItem[];
}) {
const archiveMissing = (summary?.archiveMissingFields ?? [])
.filter((item) => item.count > 0)
.map((item) => `${item.title} ${item.count.toLocaleString()}`)
.join('、') || '-';
const missingSources = (summary?.missingSources ?? [])
.filter((item) => item.count > 0)
.map((item) => `${item.protocol} ${item.count.toLocaleString()}`)
.join('、') || '-';
const actions = actionQueue.length > 0
? actionQueue.map((item) => `[${item.priority}] ${item.label} ${item.count.toLocaleString()}${item.detail}`).join('\n')
: '暂无处置项';
return [
'【车辆治理摘要】',
`当前筛选:${vehicleFilterSummary(filters).join('') || '全部车辆'}`,
`车辆总数:${(summary?.totalVehicles ?? 0).toLocaleString()},在线:${(summary?.onlineVehicles ?? 0).toLocaleString()}`,
`单源:${(summary?.singleSourceVehicles ?? 0).toLocaleString()},多源:${(summary?.multiSourceVehicles ?? 0).toLocaleString()},暂无来源:${(summary?.noDataVehicles ?? 0).toLocaleString()}`,
`待绑定:${(summary?.unboundVehicles ?? 0).toLocaleString()},档案不完整:${(summary?.archiveIncompleteVehicles ?? 0).toLocaleString()}`,
`档案缺项:${archiveMissing}`,
`缺失来源:${missingSources}`,
'处置队列:',
actions
].join('\n');
}
type VehicleActionRecommendation = {
label: string;
color: 'green' | 'orange' | 'red';
filters: Record<string, string> | null;
};
function vehicleActionRecommendation(row: VehicleCoverageRow): VehicleActionRecommendation {
const verdict = summarizeVehicleService({
bindingStatus: row.bindingStatus,
sourceCount: row.sourceCount,
onlineSourceCount: row.onlineSourceCount,
missingProtocols: row.missingProtocols,
mileageDeltaKm: row.sourceConsistency?.mileageDeltaKm
});
if (row.bindingStatus !== 'bound') {
return { label: '维护身份绑定', color: verdict.color, filters: { bindingStatus: 'unbound' } };
}
if ((row.missingProtocols ?? []).length > 0) {
return { label: `补齐 ${row.missingProtocols.join('、')} 来源`, color: verdict.color, filters: { missingProtocol: row.missingProtocols[0] } };
}
if (row.sourceCount <= 0) {
return { label: '确认平台转发配置', color: verdict.color, filters: { serviceStatus: 'no_data' } };
}
if (row.onlineSourceCount <= 0) {
return { label: '排查离线链路', color: verdict.color, filters: { online: 'offline' } };
}
return { label: '持续观察', color: verdict.color, filters: null };
}
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>;
}
async function copyText(value: string, label: string) {
try {
await navigator.clipboard.writeText(value);
Toast.success(`已复制${label}`);
} catch {
Toast.error(`复制${label}失败`);
}
}
async function copyVehicleShareURL() {
await copyText(`${window.location.origin}${window.location.pathname}${window.location.hash}`, '筛选链接');
}
export function Vehicles({
onOpenVehicle,
onOpenQuality,
onOpenRealtime,
onOpenHistory,
onFiltersChange,
initialFilters = {}
}: {
onOpenVehicle: (vin: string, protocol?: string) => void;
onOpenQuality?: (filters: Record<string, string>) => void;
onOpenRealtime?: (filters: Record<string, string>) => void;
onOpenHistory?: (filters: Record<string, string>) => void;
onFiltersChange?: (filters: Record<string, string>) => void;
initialFilters?: Record<string, string>;
}) {
const [rows, setRows] = useState<VehicleCoverageRow[]>([]);
const [summary, setSummary] = useState<VehicleCoverageSummary | null>(null);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<Record<string, string>>(initialFilters);
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 });
const resultSummary = useMemo<Array<{ label: string; value: string; filters: Record<string, string> }>>(() => {
const items: Array<{ label: string; value: string; filters: Record<string, string> }> = [
{ label: '过滤车辆', value: (summary?.totalVehicles ?? pagination.total).toLocaleString(), filters: {} },
{ label: '在线车辆', value: (summary?.onlineVehicles ?? 0).toLocaleString(), filters: { online: 'online' } },
{ label: '单源车辆', value: (summary?.singleSourceVehicles ?? 0).toLocaleString(), filters: { coverage: 'single' } },
{ label: '多源车辆', value: (summary?.multiSourceVehicles ?? 0).toLocaleString(), filters: { coverage: 'multi' } },
{ label: '暂无来源车辆', value: (summary?.noDataVehicles ?? 0).toLocaleString(), filters: { serviceStatus: 'no_data' } },
{ label: '待绑定', value: (summary?.unboundVehicles ?? 0).toLocaleString(), filters: { bindingStatus: 'unbound' } },
{ label: '档案不完整', value: (summary?.archiveIncompleteVehicles ?? 0).toLocaleString(), filters: { archiveStatus: 'incomplete' } }
];
for (const field of summary?.archiveMissingFields ?? []) {
if (field.count <= 0) continue;
items.push({
label: field.title,
value: field.count.toLocaleString(),
filters: { archiveMissing: field.field }
});
}
for (const source of summary?.missingSources ?? []) {
if (source.count <= 0) continue;
items.push({
label: `${source.protocol}`,
value: source.count.toLocaleString(),
filters: { missingProtocol: source.protocol }
});
}
return items;
}, [pagination.total, summary]);
const actionQueue = useMemo<VehicleActionQueueItem[]>(() => {
const items: VehicleActionQueueItem[] = [];
const unboundCount = summary?.unboundVehicles ?? 0;
if (unboundCount > 0) {
items.push({
label: '维护身份绑定',
count: unboundCount,
filters: { bindingStatus: 'unbound' },
color: 'orange',
priority: 'P0',
detail: '已有数据但无法归并到 VIN先补绑定再看跨来源服务。'
});
}
const noDataCount = summary?.noDataVehicles ?? 0;
if (noDataCount > 0) {
items.push({
label: '确认平台转发',
count: noDataCount,
filters: { serviceStatus: 'no_data' },
color: 'orange',
priority: 'P0',
detail: '车辆没有任何来源证据,优先确认平台转发、端口和订阅配置。'
});
}
const archiveIncompleteCount = summary?.archiveIncompleteVehicles ?? 0;
if (archiveIncompleteCount > 0) {
items.push({
label: '完善车辆档案',
count: archiveIncompleteCount,
filters: { archiveStatus: 'incomplete' },
color: 'orange',
priority: 'P1',
detail: '车辆缺少车牌、手机号或 OEM 等基础档案,影响后续运营查询和治理。'
});
}
for (const field of summary?.archiveMissingFields ?? []) {
if (field.count <= 0) continue;
items.push({
label: `补齐${field.title}`,
count: field.count,
filters: { archiveMissing: field.field },
color: 'orange',
priority: 'P1',
detail: `${field.title}会影响车辆档案检索、绑定确认和运营侧筛选。`
});
}
for (const source of summary?.missingSources ?? []) {
if (source.count <= 0) continue;
items.push({
label: `补齐 ${source.protocol} 来源`,
count: source.count,
filters: { missingProtocol: source.protocol },
color: 'orange',
priority: 'P1',
detail: `${source.protocol} 来源缺失会降低跨来源定位、里程和实时判断可信度。`
});
}
return items;
}, [summary]);
const filterSummary = vehicleFilterSummary(filters);
const load = (values: Record<string, string> = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => {
setLoading(true);
const params = new URLSearchParams({ limit: String(pageSize), offset: String((page - 1) * pageSize) });
if (values?.keyword) params.set('keyword', values.keyword);
if (values?.protocol) params.set('protocol', values.protocol);
if (values?.coverage) params.set('coverage', values.coverage);
if (values?.missingProtocol) params.set('missingProtocol', values.missingProtocol);
if (values?.online) params.set('online', values.online);
if (values?.bindingStatus) params.set('bindingStatus', values.bindingStatus);
if (values?.archiveStatus) params.set('archiveStatus', values.archiveStatus);
if (values?.archiveMissing) params.set('archiveMissing', values.archiveMissing);
if (values?.serviceStatus) params.set('serviceStatus', values.serviceStatus);
const summaryParams = new URLSearchParams(params);
summaryParams.delete('limit');
summaryParams.delete('offset');
Promise.all([api.vehicleCoverage(params), api.vehicleCoverageSummary(summaryParams)])
.then(([nextPage, nextSummary]) => {
setRows(nextPage.items);
setSummary(nextSummary);
setPagination({ currentPage: page, pageSize, total: nextPage.total });
})
.catch((error: Error) => Toast.error(error.message))
.finally(() => setLoading(false));
};
const applyFilters = (nextFilters: Record<string, string>) => {
setFilters(nextFilters);
onFiltersChange?.(nextFilters);
load(nextFilters, 1, pagination.pageSize);
};
const exportVehicles = () => {
if (rows.length === 0) {
Toast.warning('当前没有可导出的车辆结果');
return;
}
downloadCsv(exportFileName(filters), buildCsv(vehicleExportColumns, rows));
Toast.success(`已导出 ${rows.length} 条车辆结果`);
};
const copyGovernanceSummary = () => {
copyText(vehicleGovernanceSummaryText({ filters, summary, actionQueue }), '治理摘要');
};
useEffect(() => {
setFilters(initialFilters);
load(initialFilters, 1, pagination.pageSize);
}, [JSON.stringify(initialFilters)]);
const columns = useMemo(
() => [
{ title: 'VIN', dataIndex: 'vin', width: 190 },
{ title: '车牌', dataIndex: 'plate', width: 120 },
{ title: '手机号', dataIndex: 'phone', width: 130 },
{ title: 'OEM', dataIndex: 'oem', width: 120 },
{
title: '档案完整度',
width: 190,
render: (_: unknown, row: VehicleCoverageRow) => {
const archive = vehicleArchiveCompleteness(row);
const missingLabels = vehicleArchiveMissingLabels(row);
return (
<Space spacing={4} wrap>
<Tag color={archive.completed === archive.total ? 'green' : 'orange'}>{archive.label}</Tag>
{missingLabels.map((label) => (
<Tag key={label} color="orange">{label}</Tag>
))}
</Space>
);
}
},
{
title: '车辆服务状态',
width: 130,
render: (_: unknown, row: VehicleCoverageRow) => {
const status = vehicleServiceStatus(row);
return <Tag color={status.color}>{status.label}</Tag>;
}
},
{
title: '来源证据',
width: 300,
render: (_: unknown, row: VehicleCoverageRow) => (
<SourceStatusTags sourceStatus={row.sourceStatus} protocols={row.protocols} lastSeen={row.lastSeen} />
)
},
{
title: '缺失来源',
width: 220,
render: (_: unknown, row: VehicleCoverageRow) => (
<Space spacing={4} wrap>
{(row.missingProtocols ?? []).length > 0
? row.missingProtocols.map((protocol) => (
<Button
key={protocol}
size="small"
theme="light"
type="warning"
onClick={() => applyFilters({ ...filters, missingProtocol: protocol })}
>
{protocol}
</Button>
))
: <Tag color="green"></Tag>}
</Space>
)
},
{
title: '来源一致性',
width: 140,
render: (_: unknown, row: VehicleCoverageRow) => sourceConsistencyAction(row, (nextFilters) => applyFilters({ ...filters, ...nextFilters }))
},
{ title: '诊断摘要', width: 240, render: (_: unknown, row: VehicleCoverageRow) => sourceDiagnosisText(row) },
{
title: '建议动作',
width: 180,
render: (_: unknown, row: VehicleCoverageRow) => {
const action = vehicleActionRecommendation(row);
if (action.filters) {
return (
<Button size="small" theme="light" type={action.color === 'red' ? 'danger' : 'warning'} onClick={() => applyFilters({ ...filters, ...action.filters })}>
{action.label}
</Button>
);
}
return <Tag color={action.color}>{action.label}</Tag>;
}
},
{ title: '证据覆盖', width: 120, render: (_: unknown, row: VehicleCoverageRow) => sourceEvidenceText(row) },
{ title: '在线', width: 90, render: (_: unknown, row: VehicleCoverageRow) => <StatusTag status={row.online ? 'ok' : 'offline'} /> },
{ title: '最后在线', dataIndex: 'lastSeen', width: 170 },
{ title: '绑定', width: 90, render: (_: unknown, row: VehicleCoverageRow) => <Tag color={row.bindingStatus === 'bound' ? 'green' : 'orange'}>{row.bindingStatus === 'bound' ? '已绑定' : '未绑定'}</Tag> },
{
title: '操作',
width: 300,
render: (_: unknown, row: VehicleCoverageRow) => (
<Space wrap>
<Button disabled={!row.vin || !onOpenRealtime} onClick={() => onOpenRealtime?.(rowWorkflowFilters(row, filters.protocol))}></Button>
<Button disabled={!row.vin || !onOpenHistory} onClick={() => onOpenHistory?.(rowWorkflowFilters(row, filters.protocol))}></Button>
<Button onClick={() => onOpenVehicle(row.vin, primaryRowProtocol(row, filters.protocol))}></Button>
<Button disabled={!row.vin || !onOpenQuality} onClick={() => onOpenQuality?.(rowWorkflowFilters(row, filters.protocol))}></Button>
</Space>
)
}
],
[filters, onOpenHistory, onOpenQuality, onOpenRealtime, onOpenVehicle]
);
return (
<div className="vp-page">
<PageHeader title="车辆服务" description="以 VIN 为主对象维护车辆身份、数据来源覆盖、在线状态和绑定状态" />
<Card bordered>
<Form key={JSON.stringify(filters)} initValues={filters} layout="horizontal" onSubmit={(values) => {
const nextFilters = values as Record<string, string>;
applyFilters(nextFilters);
}}>
<Form.Input field="keyword" label="关键词" placeholder="VIN / 车牌 / 手机号 / OEM" style={{ width: 260 }} />
<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>
<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="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="serviceStatus" label="服务状态" placeholder="全部" style={{ width: 150 }} data-testid="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>
<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="archiveStatus" label="档案" placeholder="全部" style={{ width: 130 }}>
<Select.Option value="complete"></Select.Option>
<Select.Option value="incomplete"></Select.Option>
</Form.Select>
<Form.Select field="archiveMissing" label="档案缺项" placeholder="全部" style={{ width: 140 }}>
<Select.Option value="plate"></Select.Option>
<Select.Option value="phone"></Select.Option>
<Select.Option value="oem">OEM</Select.Option>
</Form.Select>
<Space>
<Button htmlType="submit" theme="solid" type="primary"></Button>
<Button icon={<IconCopy />} onClick={copyVehicleShareURL}></Button>
<Button onClick={() => {
applyFilters({});
}}></Button>
</Space>
</Form>
</Card>
{filterSummary.length > 0 ? (
<Card bordered title="当前车辆筛选" style={{ marginTop: 16 }}>
<Space wrap>
{filterSummary.map((item) => (
<Tag key={item} color="blue">{item}</Tag>
))}
<Button size="small" onClick={() => applyFilters({})}></Button>
</Space>
</Card>
) : null}
<Card
bordered
title={(
<Space>
<span></span>
<Button size="small" theme="light" onClick={copyGovernanceSummary}></Button>
</Space>
)}
style={{ marginTop: 16 }}
>
<div className="vp-result-summary-grid">
{resultSummary.map((item) => (
<button
key={item.label}
className="vp-result-summary-item vp-result-summary-button"
type="button"
aria-label={`${item.label} ${item.value}`}
onClick={() => applyFilters({ ...filters, ...item.filters })}
>
<div className="vp-result-summary-value">{item.value}</div>
<div className="vp-result-summary-label">{item.label}</div>
</button>
))}
</div>
</Card>
{actionQueue.length > 0 ? (
<Card bordered title="处置队列" style={{ marginTop: 16 }}>
<div className="vp-action-grid">
{actionQueue.map((item) => (
<div key={`${item.label}-${item.count}`} className="vp-action-item">
<div>
<Space spacing={6} wrap>
<Tag color={item.priority === 'P0' ? 'red' : 'orange'}>{item.priority}</Tag>
<Tag color={item.color}>{item.label} {item.count.toLocaleString()}</Tag>
</Space>
<div style={{ marginTop: 8 }}>{item.detail}</div>
</div>
<Button size="small" theme="light" type={item.color === 'red' ? 'danger' : 'warning'} onClick={() => applyFilters({ ...filters, ...item.filters })}>
{item.label} {item.count.toLocaleString()}
</Button>
</div>
))}
</div>
</Card>
) : null}
<Card bordered style={{ marginTop: 16 }}>
{rows.length === 0 && !loading ? (
<DataEmpty />
) : (
<>
<div className="vp-table-toolbar">
<Space wrap>
<Tag color="blue"> {rows.length.toLocaleString()} </Tag>
<Tag color="grey"> {pagination.total.toLocaleString()} </Tag>
<Button size="small" onClick={exportVehicles}> CSV</Button>
</Space>
</div>
<Table
loading={loading}
rowKey="vin"
dataSource={rows}
columns={columns}
pagination={{
currentPage: pagination.currentPage,
pageSize: pagination.pageSize,
total: pagination.total,
showSizeChanger: true,
onPageChange: (page) => load(filters, page, pagination.pageSize),
onPageSizeChange: (pageSize) => load(filters, 1, pageSize)
}}
/>
</>
)}
</Card>
</div>
);
}