647 lines
28 KiB
TypeScript
647 lines
28 KiB
TypeScript
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>
|
||
);
|
||
}
|