Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx
2026-07-05 01:36:46 +08:00

926 lines
40 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 { buildAppHash } from '../domain/appRoute';
import { buildCsv, downloadCsv, type CsvColumn } from '../domain/csvExport';
import { summarizeVehicleService, type VehicleServiceVerdict } 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 rowServiceVerdict(row: VehicleCoverageRow): VehicleServiceVerdict {
return summarizeVehicleService({
bindingStatus: row.bindingStatus,
sourceCount: row.sourceCount,
onlineSourceCount: row.onlineSourceCount,
missingProtocols: row.missingProtocols,
mileageDeltaKm: row.sourceConsistency?.mileageDeltaKm
});
}
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');
}
function appURL(hash: string) {
return `${window.location.origin}${window.location.pathname}${hash}`;
}
function vehicleDispatchListText({
filters,
rows,
total
}: {
filters: Record<string, string>;
rows: VehicleCoverageRow[];
total: number;
}) {
const lines = [
'【车辆处置清单】',
`当前筛选:${vehicleFilterSummary(filters).join('') || '全部车辆'}`,
`当前页:${rows.length.toLocaleString()} 辆 / 总计 ${total.toLocaleString()}`,
''
];
rows.forEach((row, index) => {
const action = vehicleActionRecommendation(row);
const status = vehicleServiceStatus(row);
const protocol = primaryRowProtocol(row, filters.protocol);
const identity = [
row.plate ? `车牌 ${row.plate}` : '车牌 -',
row.vin ? `VIN ${row.vin}` : 'VIN -',
row.phone ? `手机号 ${row.phone}` : '手机号 -',
row.oem ? `OEM ${row.oem}` : 'OEM -'
].join(' / ');
lines.push(
`${index + 1}. ${identity}`,
` 状态:${status.label};在线:${row.online ? '在线' : '离线'};来源:${sourceDiagnosisText(row)};最后时间:${row.lastSeen || '-'}`,
` 建议动作:${action.label}`,
` 车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: row.vin, protocol }))}`,
` 实时监控:${appURL(buildAppHash({ page: 'realtime', keyword: row.vin, protocol }))}`,
` 轨迹回放:${appURL(buildAppHash({ page: 'history', keyword: row.vin, protocol }))}`,
` 告警事件:${appURL(buildAppHash({ page: 'alert-events', keyword: row.vin, protocol }))}`
);
});
return lines.join('\n');
}
function vehicleIdentityMaintenanceText({
filters,
rows,
total
}: {
filters: Record<string, string>;
rows: VehicleCoverageRow[];
total: number;
}) {
const maintenanceRows = rows.filter((row) => row.bindingStatus !== 'bound' || vehicleArchiveMissingLabels(row).length > 0);
const lines = [
'【车辆身份维护清单】',
`当前筛选:${vehicleFilterSummary(filters).join('') || '全部车辆'}`,
`当前页待维护:${maintenanceRows.length.toLocaleString()} 辆 / 当前筛选 ${total.toLocaleString()}`,
''
];
maintenanceRows.forEach((row, index) => {
const missingLabels = vehicleArchiveMissingLabels(row);
const lookupKeys = [
row.vin ? `vin=${row.vin}` : '',
row.plate ? `plate=${row.plate}` : '',
row.phone ? `phone=${row.phone}` : '',
row.oem ? `oem=${row.oem}` : ''
].filter(Boolean).join('') || '暂无可用关联键';
const action = row.bindingStatus !== 'bound'
? '先维护 vehicle_identity_binding 的车牌/手机号到 VIN 映射,再复核实时来源是否归并到车辆服务。'
: `补齐档案字段:${missingLabels.join('、')}`;
lines.push(
`${index + 1}. ${row.plate || '-'} / ${row.vin || '-'} / ${row.phone || '-'} / ${row.oem || '-'}`,
` 状态:${vehicleServiceStatus(row).label};绑定:${row.bindingStatus === 'bound' ? '已绑定' : '未绑定'};档案缺项:${missingLabels.join('、') || '无'}`,
` 关联键:${lookupKeys}`,
` 建议动作:${action}`,
` 车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: row.vin, protocol: primaryRowProtocol(row, filters.protocol) }))}`
);
});
return lines.join('\n');
}
type VehicleActionRecommendation = {
label: string;
color: 'green' | 'orange' | 'red';
filters: Record<string, string> | null;
};
type PageVerdictStat = {
status: string;
count: number;
color: 'green' | 'orange' | 'red';
risk: string;
nextStep: string;
};
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 currentPageVerdictStats(rows: VehicleCoverageRow[]): PageVerdictStat[] {
const stats = new Map<string, PageVerdictStat>();
for (const row of rows) {
const verdict = rowServiceVerdict(row);
const current = stats.get(verdict.status);
if (current) {
current.count += 1;
continue;
}
stats.set(verdict.status, {
status: verdict.status,
count: 1,
color: verdict.color,
risk: verdict.risk,
nextStep: verdict.nextStep
});
}
const order = ['不可服务', '降级可服务', '可服务'];
return [...stats.values()].sort((left, right) => order.indexOf(left.status) - order.indexOf(right.status));
}
function currentPageActionStats(rows: VehicleCoverageRow[]) {
const stats = new Map<string, { label: string; count: number; color: 'green' | 'orange' | 'red'; filters: Record<string, string> | null }>();
for (const row of rows) {
const action = vehicleActionRecommendation(row);
const current = stats.get(action.label);
if (current) {
current.count += 1;
continue;
}
stats.set(action.label, { label: action.label, count: 1, color: action.color, filters: action.filters });
}
return [...stats.values()].sort((left, right) => right.count - left.count);
}
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,
onOpenMap,
onOpenRealtime,
onOpenHistory,
onOpenMileage,
onFiltersChange,
initialFilters = {}
}: {
onOpenVehicle: (vin: string, protocol?: string) => void;
onOpenQuality?: (filters: Record<string, string>) => void;
onOpenMap?: (filters: Record<string, string>) => void;
onOpenRealtime?: (filters: Record<string, string>) => void;
onOpenHistory?: (filters: Record<string, string>) => void;
onOpenMileage?: (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 pageVerdictStats = useMemo(() => currentPageVerdictStats(rows), [rows]);
const pageActionStats = useMemo(() => currentPageActionStats(rows), [rows]);
const serviceScopeFilters = useMemo(() => {
const nextFilters: Record<string, string> = {};
if (filters.keyword?.trim()) nextFilters.keyword = filters.keyword.trim();
if (filters.protocol?.trim()) nextFilters.protocol = filters.protocol.trim();
return nextFilters;
}, [filters.keyword, filters.protocol]);
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 }), '治理摘要');
};
const copyDispatchList = () => {
if (rows.length === 0) {
Toast.warning('当前没有可复制的车辆处置清单');
return;
}
copyText(vehicleDispatchListText({ filters, rows, total: pagination.total }), '车辆处置清单');
};
const copyIdentityMaintenanceList = () => {
const maintenanceRows = rows.filter((row) => row.bindingStatus !== 'bound' || vehicleArchiveMissingLabels(row).length > 0);
if (maintenanceRows.length === 0) {
Toast.warning('当前页没有需要维护身份或档案的车辆');
return;
}
copyText(vehicleIdentityMaintenanceText({ filters, rows, total: pagination.total }), '车辆身份维护清单');
};
const serviceDeskActions = useMemo(() => [
{
label: '实时监控',
value: `${(summary?.onlineVehicles ?? 0).toLocaleString()} 在线`,
detail: '查看车辆在线、速度、最后上报和来源状态。',
color: 'green' as const,
disabled: !onOpenRealtime,
onClick: () => onOpenRealtime?.({ ...serviceScopeFilters, online: 'online' })
},
{
label: '车辆地图',
value: '地图态势',
detail: '把当前筛选车辆带入实时地图,优先看位置分布。',
color: 'blue' as const,
disabled: !onOpenMap,
onClick: () => onOpenMap?.({ ...serviceScopeFilters, online: 'online' })
},
{
label: '轨迹回放',
value: filters.keyword?.trim() ? '单车轨迹' : '选择车辆',
detail: filters.keyword?.trim() ? '按当前车辆进入历史轨迹复盘。' : '先选择车辆后进入精确轨迹回放。',
color: filters.keyword?.trim() ? 'blue' as const : 'grey' as const,
disabled: !onOpenHistory || !filters.keyword?.trim(),
onClick: () => onOpenHistory?.({ ...serviceScopeFilters, tab: 'location' })
},
{
label: '里程统计',
value: filters.keyword?.trim() ? '区间核对' : '车辆口径',
detail: filters.keyword?.trim() ? '按当前车辆核对区间里程和日里程。' : '输入车辆后查看里程闭合统计。',
color: filters.keyword?.trim() ? 'green' as const : 'grey' as const,
disabled: !onOpenMileage || !filters.keyword?.trim(),
onClick: () => onOpenMileage?.(serviceScopeFilters)
},
{
label: '数据导出',
value: `${rows.length.toLocaleString()} 当前页`,
detail: '导出当前页车辆清单,后续可继续导出轨迹和原始记录。',
color: rows.length > 0 ? 'blue' as const : 'grey' as const,
disabled: rows.length === 0,
onClick: exportVehicles
}
], [exportVehicles, filters.keyword, onOpenHistory, onOpenMap, onOpenMileage, onOpenRealtime, rows.length, serviceScopeFilters, summary?.onlineVehicles]);
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="围绕车辆完成实时监控、地图查看、轨迹回放、里程统计和数据导出,协议来源只作为证据过滤条件" />
<Card bordered title="车辆服务台">
<div className="vp-vehicle-service-desk">
<div className="vp-vehicle-service-summary">
<Space wrap>
<Tag color="blue"></Tag>
<Tag color={filters.keyword?.trim() ? 'green' : 'grey'}>{filters.keyword?.trim() ? '单车服务' : '车辆池'}</Tag>
</Space>
<strong>{filters.keyword?.trim() || '全部车辆'}</strong>
<span>
{(summary?.totalVehicles ?? pagination.total).toLocaleString()} 线 {(summary?.onlineVehicles ?? 0).toLocaleString()}
</span>
<Space wrap>
<Button size="small" theme="solid" type="primary" disabled={!onOpenRealtime} onClick={() => onOpenRealtime?.({ ...serviceScopeFilters, online: 'online' })}>线</Button>
<Button size="small" disabled={!onOpenMap} onClick={() => onOpenMap?.({ ...serviceScopeFilters, online: 'online' })}></Button>
<Button size="small" onClick={copyDispatchList}></Button>
</Space>
</div>
<div className="vp-vehicle-service-grid">
{serviceDeskActions.map((item) => (
<button
key={item.label}
className="vp-vehicle-service-action"
type="button"
disabled={item.disabled}
onClick={item.onClick}
>
<Tag color={item.color}>{item.label}</Tag>
<strong>{item.value}</strong>
<span>{item.detail}</span>
</button>
))}
</div>
</div>
</Card>
<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>
<Button size="small" theme="light" onClick={copyDispatchList}></Button>
<Button size="small" theme="light" onClick={copyIdentityMaintenanceList}></Button>
</Space>
)}
style={{ marginTop: 16 }}
>
{rows.length > 0 ? (
<div className="vp-current-service-board">
<div>
<div className="vp-current-service-title"></div>
<div className="vp-current-service-grid">
{pageVerdictStats.map((item) => (
<div key={item.status} className="vp-current-service-item">
<Space spacing={6} wrap>
<Tag color={item.color}>{item.status}</Tag>
<Tag color="grey">{item.count.toLocaleString()} </Tag>
</Space>
<div className="vp-current-service-risk">{item.risk}</div>
<div className="vp-current-service-next">{item.nextStep}</div>
</div>
))}
</div>
</div>
<div>
<div className="vp-current-service-title"></div>
<div className="vp-current-action-list">
{pageActionStats.map((item) => (
item.filters ? (
<Button
key={item.label}
size="small"
theme="light"
type={item.color === 'red' ? 'danger' : 'warning'}
onClick={() => applyFilters({ ...filters, ...item.filters })}
>
{item.label} {item.count.toLocaleString()}
</Button>
) : (
<Tag key={item.label} color={item.color}>{item.label} {item.count.toLocaleString()}</Tag>
)
))}
</div>
</div>
</div>
) : null}
<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>
);
}