Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx

1502 lines
69 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');
}
function vehicleCustomerLookupText({
filters,
rows,
total,
summary
}: {
filters: Record<string, string>;
rows: VehicleCoverageRow[];
total: number;
summary: VehicleCoverageSummary | null;
}) {
const serviceMode = filters.keyword?.trim() ? '单车服务' : '车辆池';
const scopeHash = buildAppHash({ page: 'vehicles', keyword: filters.keyword, protocol: filters.protocol, filters });
const singleVehicleFilters = filters.keyword?.trim()
? { keyword: filters.keyword.trim(), ...(filters.protocol ? { protocol: filters.protocol } : {}) }
: {};
const mapFilters = { ...filters, online: filters.online || 'online' };
const lines = [
'【客户查车路径】',
`服务模式:${serviceMode}`,
`当前筛选:${vehicleFilterSummary(filters).join('') || '全部车辆'}`,
`当前页:${rows.length.toLocaleString()} 辆 / 当前筛选 ${total.toLocaleString()}`,
`在线车辆:${(summary?.onlineVehicles ?? 0).toLocaleString()};待绑定:${(summary?.unboundVehicles ?? 0).toLocaleString()};暂无来源:${(summary?.noDataVehicles ?? 0).toLocaleString()};档案不完整:${(summary?.archiveIncompleteVehicles ?? 0).toLocaleString()}`,
'',
serviceMode === '单车服务'
? '推荐路径:先看车辆档案和实时监控,再按需要进入轨迹回放、统计查询、历史数据导出。'
: '推荐路径:先筛在线/离线/无来源/待绑定车辆,再进入地图态势或导出处置清单。',
`车辆服务:${appURL(scopeHash)}`,
`实时监控:${appURL(buildAppHash({ page: 'realtime', ...singleVehicleFilters }))}`,
`车辆地图:${appURL(buildAppHash({ page: 'map', keyword: filters.keyword, protocol: filters.protocol, filters: mapFilters }))}`,
`轨迹回放:${appURL(buildAppHash({ page: 'history', ...singleVehicleFilters, filters: { tab: 'location' } }))}`,
`统计查询:${appURL(buildAppHash({ page: 'mileage', ...singleVehicleFilters }))}`,
`历史数据:${appURL(buildAppHash({ page: 'history', ...singleVehicleFilters, filters: { tab: 'raw', includeFields: 'true' } }))}`
];
return lines.join('\n');
}
function vehicleCustomerServicePackageText({
filters,
rows,
total,
summary
}: {
filters: Record<string, string>;
rows: VehicleCoverageRow[];
total: number;
summary: VehicleCoverageSummary | null;
}) {
const scope = filters.keyword?.trim() ? `单车 ${filters.keyword.trim()}` : '当前车辆池';
const singleVehicleFilters = filters.keyword?.trim()
? { keyword: filters.keyword.trim(), ...(filters.protocol ? { protocol: filters.protocol } : {}) }
: {};
const mapFilters = { ...filters, online: filters.online || 'online' };
const pageVerdicts = currentPageVerdictStats(rows).map((item) => `${item.status} ${item.count.toLocaleString()}`).join('') || '-';
const pageActions = currentPageActionStats(rows).map((item) => `${item.label} ${item.count.toLocaleString()}`).join('') || '-';
const missingSources = (summary?.missingSources ?? [])
.filter((item) => item.count > 0)
.map((item) => `${item.protocol} ${item.count.toLocaleString()}`)
.join('') || '-';
return [
'【客户车辆服务交付包】',
`服务范围:${scope}`,
`当前筛选:${vehicleFilterSummary(filters).join('') || '全部车辆'}`,
`车辆规模:当前页 ${rows.length.toLocaleString()} 辆 / 当前筛选 ${total.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()}`,
`当前页判读:${pageVerdicts}`,
`建议动作:${pageActions}`,
`缺失来源:${missingSources}`,
'',
'客户入口:',
`车辆地图:${appURL(buildAppHash({ page: 'map', keyword: filters.keyword, protocol: filters.protocol, filters: mapFilters }))}`,
`实时监控:${appURL(buildAppHash({ page: 'realtime', ...singleVehicleFilters, filters: filters.keyword?.trim() ? undefined : { ...filters, online: filters.online || 'online' } }))}`,
`轨迹回放:${appURL(buildAppHash({ page: 'history', ...singleVehicleFilters, filters: { tab: 'location' } }))}`,
`统计查询:${appURL(buildAppHash({ page: 'mileage', ...singleVehicleFilters }))}`,
`告警通知:${appURL(buildAppHash({ page: 'alert-events', ...singleVehicleFilters, filters }))}`,
`历史数据导出:${appURL(buildAppHash({ page: 'history', ...singleVehicleFilters, filters: { tab: 'raw', includeFields: 'true' } }))}`,
'',
'交付口径:',
'1. 客户先看地图与实时状态,判断车辆是否在线和位置是否可信',
'2. 单车问题进入轨迹回放和统计查询,确认时间窗内发生了什么',
'3. 异常车辆进入告警通知,带上原始记录和历史数据作为证据',
'4. 对外交付时优先使用车辆口径,协议来源只作为证据说明'
].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 copyCustomerLookupPath = () => {
copyText(vehicleCustomerLookupText({ filters, rows, total: pagination.total, summary }), '客户查车路径');
};
const copyCustomerServicePackage = () => {
copyText(vehicleCustomerServicePackageText({ filters, rows, total: pagination.total, summary }), '客户车辆服务交付包');
};
const customerLookupSteps = [
{
label: '车辆身份',
value: filters.keyword?.trim() || 'VIN / 车牌 / 手机号',
detail: '确认车辆归属、车牌、手机号和 OEM客户问题先落到一辆车。'
},
{
label: '在线状态',
value: `${(summary?.onlineVehicles ?? 0).toLocaleString()} 在线`,
detail: '判断车辆是否仍在上报,离线车辆进入断链和来源证据排查。'
},
{
label: '轨迹与位置',
value: filters.keyword?.trim() ? '可回放' : '先选车辆',
detail: '单车进入轨迹回放,车辆池进入地图态势查看位置分布。'
},
{
label: '数据交付',
value: `${rows.length.toLocaleString()} 当前页`,
detail: '导出车辆清单再进入历史页交付轨迹、RAW 和字段证据。'
}
];
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]);
const primaryAction = actionQueue[0];
const vehicleAssetTasks = [
{
title: '最近上报',
value: `${(summary?.onlineVehicles ?? 0).toLocaleString()} 在线`,
detail: '按最近仍在上报的车辆进入实时监控和地图态势。',
action: '只看在线',
color: 'green' as const,
onClick: () => applyFilters({ ...filters, online: 'online' })
},
{
title: '待关注车辆',
value: `${((summary?.noDataVehicles ?? 0) + (summary?.unboundVehicles ?? 0)).toLocaleString()} 待处理`,
detail: '无来源、未绑定或来源缺失的车辆先进入处置队列。',
action: '查看异常',
color: ((summary?.noDataVehicles ?? 0) + (summary?.unboundVehicles ?? 0)) > 0 ? 'orange' as const : 'green' as const,
onClick: () => applyFilters(primaryAction ? { ...filters, ...primaryAction.filters } : { ...filters, serviceStatus: 'degraded' })
},
{
title: '身份待维护',
value: `${(summary?.unboundVehicles ?? 0).toLocaleString()} 未绑定`,
detail: '复制当前页身份维护清单用于补齐车牌、手机号、VIN 和 OEM。',
action: '维护清单',
color: (summary?.unboundVehicles ?? 0) > 0 || (summary?.archiveIncompleteVehicles ?? 0) > 0 ? 'orange' as const : 'green' as const,
onClick: copyIdentityMaintenanceList
},
{
title: '报表导出',
value: `${rows.length.toLocaleString()} 当前页`,
detail: '导出车辆资产清单,后续按车辆进入轨迹、统计和历史证据。',
action: '导出车辆',
color: rows.length > 0 ? 'blue' as const : 'grey' as const,
onClick: exportVehicles
}
];
const vehicleAssetOperations = [
{
title: '资产健康',
value: `${(summary?.totalVehicles ?? pagination.total).toLocaleString()} 车辆`,
detail: '当前筛选车辆池可继续按车牌、手机号、OEM、在线状态和服务状态缩小范围。',
action: '查看车辆池',
color: 'blue' as const,
onClick: () => applyFilters(filters)
},
{
title: '在线车辆',
value: `${(summary?.onlineVehicles ?? 0).toLocaleString()} 在线`,
detail: '优先进入仍在上报的车辆,查看实时位置、最后时间和来源证据。',
action: '打开实时',
color: (summary?.onlineVehicles ?? 0) > 0 ? 'green' as const : 'orange' as const,
onClick: () => onOpenRealtime?.({ ...serviceScopeFilters, online: 'online' })
},
{
title: '待处理',
value: `${((summary?.noDataVehicles ?? 0) + (summary?.unboundVehicles ?? 0)).toLocaleString()} 待处理`,
detail: '无来源和未绑定车辆优先处理,否则客户无法稳定查车、看位置和出报表。',
action: '异常优先',
color: ((summary?.noDataVehicles ?? 0) + (summary?.unboundVehicles ?? 0)) > 0 ? 'orange' as const : 'green' as const,
onClick: () => applyFilters(primaryAction ? { ...filters, ...primaryAction.filters } : { ...filters, serviceStatus: 'no_data' })
},
{
title: '服务交付',
value: `${rows.length.toLocaleString()} 当前页`,
detail: '把当前车辆池打包成客户能用的地图、实时、轨迹、统计、告警和导出入口。',
action: '复制交付包',
color: rows.length > 0 ? 'blue' as const : 'grey' as const,
onClick: copyCustomerServicePackage
}
];
const vehicleDecisionItems = [
{
title: '在线车辆',
value: `${(summary?.onlineVehicles ?? 0).toLocaleString()}`,
detail: '先看当前仍在上报的车辆,直接进入地图和实时状态。',
color: 'green' as const,
primaryAction: '实时监控',
secondaryAction: '车辆地图',
onPrimary: () => onOpenRealtime?.({ ...serviceScopeFilters, online: 'online' }),
onSecondary: () => onOpenMap?.({ ...serviceScopeFilters, online: 'online' }),
disabled: !onOpenRealtime
},
{
title: '异常车辆',
value: `${(summary?.noDataVehicles ?? 0).toLocaleString()} 无来源`,
detail: primaryAction ? primaryAction.detail : '当前没有必须立即处理的车辆服务事项。',
color: (summary?.noDataVehicles ?? 0) > 0 || primaryAction ? 'orange' as const : 'green' as const,
primaryAction: primaryAction?.label ?? '查看结果',
secondaryAction: '告警事件',
onPrimary: () => primaryAction ? applyFilters({ ...filters, ...primaryAction.filters }) : applyFilters(filters),
onSecondary: () => onOpenQuality?.(serviceScopeFilters),
disabled: !primaryAction
},
{
title: '单车服务',
value: filters.keyword?.trim() || '输入车辆',
detail: filters.keyword?.trim() ? '当前已进入单车范围,可查看车辆详情、轨迹和里程。' : '输入 VIN、车牌或手机号后进入完整车辆服务。',
color: filters.keyword?.trim() ? 'blue' as const : 'grey' as const,
primaryAction: '车辆详情',
secondaryAction: '轨迹回放',
onPrimary: () => filters.keyword?.trim() && onOpenVehicle(filters.keyword.trim(), filters.protocol),
onSecondary: () => onOpenHistory?.({ ...serviceScopeFilters, tab: 'location' }),
disabled: !filters.keyword?.trim()
},
{
title: '数据交付',
value: `${rows.length.toLocaleString()} 当前页`,
detail: '导出当前车辆清单继续进入历史页交付轨迹、RAW 和字段证据。',
color: rows.length > 0 ? 'blue' as const : 'grey' as const,
primaryAction: '导出清单',
secondaryAction: '历史导出',
onPrimary: exportVehicles,
onSecondary: () => onOpenHistory?.({ ...serviceScopeFilters, tab: 'raw', includeFields: 'true' }),
disabled: rows.length === 0
}
];
const servicePackageItems = [
{
label: '车辆地图',
value: `${(summary?.onlineVehicles ?? 0).toLocaleString()} 在线`,
detail: '先看当前筛选车辆的位置分布和在线状态。',
color: 'blue' as const,
action: '打开地图',
disabled: !onOpenMap,
onClick: () => onOpenMap?.({ ...serviceScopeFilters, online: 'online' })
},
{
label: '轨迹回放',
value: filters.keyword?.trim() ? '单车时间线' : '先选车辆',
detail: filters.keyword?.trim() ? '按当前车辆回放定位轨迹和断点。' : '客户追问某车时,从车辆列表进入单车回放。',
color: filters.keyword?.trim() ? 'green' as const : 'grey' as const,
action: '回放轨迹',
disabled: !onOpenHistory || !filters.keyword?.trim(),
onClick: () => onOpenHistory?.({ ...serviceScopeFilters, tab: 'location' })
},
{
label: '统计查询',
value: filters.keyword?.trim() ? '区间复核' : '单车后可用',
detail: '核对时间窗里程、日里程和累计口径,支撑 BI 与客户问询。',
color: filters.keyword?.trim() ? 'orange' as const : 'grey' as const,
action: '统计查询',
disabled: !onOpenMileage || !filters.keyword?.trim(),
onClick: () => onOpenMileage?.(serviceScopeFilters)
},
{
label: '告警通知',
value: `${(summary?.noDataVehicles ?? 0).toLocaleString()} 无来源`,
detail: '异常车辆进入告警闭环,确认责任方、通知渠道和证据。',
color: (summary?.noDataVehicles ?? 0) > 0 ? 'orange' as const : 'green' as const,
action: '查看告警',
disabled: !onOpenQuality,
onClick: () => onOpenQuality?.(serviceScopeFilters)
},
{
label: '历史导出',
value: `${rows.length.toLocaleString()} 当前页`,
detail: '对外交付车辆清单、轨迹、RAW 和字段证据。',
color: rows.length > 0 ? 'blue' as const : 'grey' as const,
action: '导出清单',
disabled: rows.length === 0,
onClick: exportVehicles
}
];
const vehicleCustomerQuestions = [
{
question: '客户要找某辆车怎么办?',
answer: filters.keyword?.trim() ? filters.keyword.trim() : '先输入 VIN/车牌/手机号',
evidence: filters.keyword?.trim() ? '已进入单车范围' : `当前筛选 ${pagination.total.toLocaleString()}`,
action: '聚焦找车',
color: filters.keyword?.trim() ? 'green' as const : 'blue' as const,
disabled: false,
onClick: () => document.querySelector<HTMLInputElement>('input[name="keyword"]')?.focus()
},
{
question: '现在有哪些车在线?',
answer: `${(summary?.onlineVehicles ?? 0).toLocaleString()} 辆在线`,
evidence: filters.online === 'online' ? '已筛在线车辆' : '可切换到在线车辆池',
action: '只看在线',
color: (summary?.onlineVehicles ?? 0) > 0 ? 'green' as const : 'orange' as const,
disabled: false,
onClick: () => applyFilters({ ...filters, online: 'online' })
},
{
question: '哪些车暂时不能服务?',
answer: `${((summary?.noDataVehicles ?? 0) + (summary?.unboundVehicles ?? 0)).toLocaleString()} 辆待处理`,
evidence: `无来源 ${(summary?.noDataVehicles ?? 0).toLocaleString()} / 待绑定 ${(summary?.unboundVehicles ?? 0).toLocaleString()}`,
action: '异常车辆',
color: (summary?.noDataVehicles ?? 0) > 0 || (summary?.unboundVehicles ?? 0) > 0 ? 'orange' as const : 'green' as const,
disabled: false,
onClick: () => primaryAction ? applyFilters({ ...filters, ...primaryAction.filters }) : applyFilters({ ...filters, serviceStatus: 'no_data' })
},
{
question: '车辆清单能交付吗?',
answer: rows.length > 0 ? `${rows.length.toLocaleString()} 辆当前页` : '暂无可交付车辆',
evidence: `筛选总计 ${pagination.total.toLocaleString()}`,
action: '复制交付包',
color: rows.length > 0 ? 'blue' as const : 'grey' as const,
disabled: false,
onClick: () => copyCustomerServicePackage()
}
];
const customerVehicleServiceItems = [
{
title: '车辆池',
value: `${(summary?.totalVehicles ?? pagination.total).toLocaleString()} 车辆`,
detail: '按 VIN、车牌、手机号、OEM 和服务状态找到客户要看的车。',
action: '找车',
color: 'blue' as const,
disabled: false,
onClick: () => document.querySelector<HTMLInputElement>('input[name="keyword"]')?.focus()
},
{
title: '实时地图',
value: `${(summary?.onlineVehicles ?? 0).toLocaleString()} 在线`,
detail: '直接查看当前在线车辆的位置分布、最后上报和在线状态。',
action: '打开地图',
color: (summary?.onlineVehicles ?? 0) > 0 ? 'green' as const : 'orange' as const,
disabled: !onOpenMap,
onClick: () => onOpenMap?.({ ...serviceScopeFilters, online: 'online' })
},
{
title: '轨迹回放',
value: filters.keyword?.trim() ? '单车时间窗' : '先选车辆',
detail: filters.keyword?.trim() ? '围绕当前车辆复盘历史位置和断点。' : '先选中一辆车,再进入时间窗轨迹回放。',
action: '回放',
color: filters.keyword?.trim() ? 'green' as const : 'grey' as const,
disabled: !onOpenHistory || !filters.keyword?.trim(),
onClick: () => onOpenHistory?.({ ...serviceScopeFilters, tab: 'location' })
},
{
title: '统计查询',
value: filters.keyword?.trim() ? '区间统计' : '单车后可用',
detail: '按车辆和时间窗核对里程、位置和业务统计口径。',
action: '查询统计',
color: filters.keyword?.trim() ? 'orange' as const : 'grey' as const,
disabled: !onOpenMileage || !filters.keyword?.trim(),
onClick: () => onOpenMileage?.(serviceScopeFilters)
},
{
title: '告警通知',
value: `${((summary?.noDataVehicles ?? 0) + (summary?.unboundVehicles ?? 0)).toLocaleString()} 待处理`,
detail: '把不可服务车辆、离线断链和身份缺口进入告警处置。',
action: '查看告警',
color: ((summary?.noDataVehicles ?? 0) + (summary?.unboundVehicles ?? 0)) > 0 ? 'orange' as const : 'green' as const,
disabled: !onOpenQuality,
onClick: () => onOpenQuality?.(serviceScopeFilters)
}
];
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="围绕车辆完成实时监控、地图查看、轨迹回放、统计查询和数据导出,协议来源只作为证据过滤条件" />
<section className="vp-customer-vehicle-service-hub" aria-label="客户车辆服务台">
<div className="vp-customer-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() || `${(summary?.totalVehicles ?? pagination.total).toLocaleString()} 辆车`}</strong>
<span></span>
</div>
<div className="vp-customer-vehicle-service-grid">
{customerVehicleServiceItems.map((item) => (
<button
key={item.title}
type="button"
className="vp-customer-vehicle-service-item"
disabled={item.disabled}
onClick={item.onClick}
aria-label={`客户车辆服务台 ${item.title} ${item.value} ${item.action}`}
>
<Tag color={item.color}>{item.title}</Tag>
<strong>{item.value}</strong>
<span>{item.detail}</span>
<em>{item.action}</em>
</button>
))}
</div>
</section>
<section className="vp-vehicle-asset-console" aria-label="车辆资产运营台">
<div className="vp-vehicle-asset-console-summary">
<Space wrap>
<Tag color="blue"></Tag>
<Tag color={filters.keyword?.trim() ? 'green' : 'grey'}>{filters.keyword?.trim() ? '单车范围' : '车辆池'}</Tag>
<Tag color={((summary?.noDataVehicles ?? 0) + (summary?.unboundVehicles ?? 0)) > 0 ? 'orange' : 'green'}>
{((summary?.noDataVehicles ?? 0) + (summary?.unboundVehicles ?? 0)).toLocaleString()}
</Tag>
</Space>
<strong></strong>
<span>线</span>
</div>
<div className="vp-vehicle-asset-console-grid">
{vehicleAssetOperations.map((item) => (
<button
key={item.title}
type="button"
className="vp-vehicle-asset-console-item"
onClick={item.onClick}
aria-label={`车辆资产运营台 ${item.title} ${item.value} ${item.action}`}
>
<Tag color={item.color}>{item.title}</Tag>
<strong>{item.value}</strong>
<span>{item.detail}</span>
<em>{item.action}</em>
</button>
))}
</div>
</section>
<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 title="客户查车路径" style={{ marginTop: 16 }}>
<div className="vp-vehicle-lookup-strip">
<div className="vp-vehicle-lookup-head">
<div>
<Space wrap>
<Tag color={filters.keyword?.trim() ? 'green' : 'blue'}>{filters.keyword?.trim() ? '单车服务' : '车辆池服务'}</Tag>
<Tag color="grey">{vehicleFilterSummary(filters).join(' / ') || '全部车辆'}</Tag>
</Space>
<strong>{filters.keyword?.trim() ? '围绕一辆车完成监控、回放、统计和导出' : '从车辆池筛出客户要看的车,再进入单车服务'}</strong>
<span></span>
</div>
<Space wrap>
<Button size="small" theme="solid" type="primary" disabled={!onOpenMap} onClick={() => onOpenMap?.({ ...serviceScopeFilters, online: 'online' })}></Button>
<Button size="small" disabled={!onOpenHistory || !filters.keyword?.trim()} onClick={() => onOpenHistory?.({ ...serviceScopeFilters, tab: 'location' })}></Button>
<Button size="small" icon={<IconCopy />} onClick={copyCustomerLookupPath}></Button>
</Space>
</div>
<div className="vp-vehicle-lookup-grid">
{customerLookupSteps.map((step) => (
<div key={step.label} className="vp-vehicle-lookup-item">
<Tag color="blue">{step.label}</Tag>
<strong>{step.value}</strong>
<span>{step.detail}</span>
</div>
))}
</div>
</div>
</Card>
<Card bordered title="车辆资产作业栏" style={{ marginTop: 16 }}>
<div className="vp-vehicle-asset-board">
<div className="vp-vehicle-asset-summary">
<Space wrap>
<Tag color="blue"></Tag>
<Tag color={filters.keyword?.trim() ? 'green' : 'grey'}>{filters.keyword?.trim() ? '单车' : '车辆池'}</Tag>
</Space>
<strong></strong>
<span></span>
</div>
<div className="vp-vehicle-asset-grid">
{vehicleAssetTasks.map((item) => (
<button
key={item.title}
type="button"
className="vp-vehicle-asset-item"
onClick={item.onClick}
aria-label={`车辆资产作业 ${item.title} ${item.action}`}
>
<Tag color={item.color}>{item.title}</Tag>
<strong>{item.value}</strong>
<span>{item.detail}</span>
<em>{item.action}</em>
</button>
))}
</div>
</div>
</Card>
<Card bordered title="车辆客户常问" style={{ marginTop: 16 }}>
<div className="vp-vehicle-question-board">
<div className="vp-vehicle-question-summary">
<Space wrap>
<Tag color="blue"></Tag>
<Tag color={filters.keyword?.trim() ? 'green' : 'grey'}>{filters.keyword?.trim() ? '单车范围' : '车辆池'}</Tag>
<Tag color={(summary?.noDataVehicles ?? 0) > 0 || (summary?.unboundVehicles ?? 0) > 0 ? 'orange' : 'green'}>
{((summary?.noDataVehicles ?? 0) + (summary?.unboundVehicles ?? 0)).toLocaleString()}
</Tag>
</Space>
<strong>线</strong>
<span>线</span>
</div>
<div className="vp-vehicle-question-grid">
{vehicleCustomerQuestions.map((item) => (
<button
key={item.question}
type="button"
className="vp-vehicle-question-item"
disabled={item.disabled}
onClick={item.onClick}
aria-label={`车辆客户常问 ${item.question} ${item.action}`}
>
<Tag color={item.color}>{item.question}</Tag>
<strong>{item.answer}</strong>
<span>{item.evidence}</span>
<em>{item.action}</em>
</button>
))}
</div>
</div>
</Card>
<Card bordered title="车辆服务决策板" style={{ marginTop: 16 }}>
<div className="vp-vehicle-decision-grid">
{vehicleDecisionItems.map((item) => (
<div key={item.title} className="vp-vehicle-decision-item">
<div className="vp-vehicle-decision-head">
<Tag color={item.color}>{item.title}</Tag>
<strong>{item.value}</strong>
</div>
<span>{item.detail}</span>
<Space wrap>
<Button
size="small"
theme="solid"
type="primary"
disabled={item.disabled}
aria-label={`车辆服务决策 ${item.title} ${item.primaryAction}`}
onClick={item.onPrimary}
>
{item.primaryAction}
</Button>
<Button
size="small"
theme="light"
type="primary"
disabled={item.title === '单车服务' && item.disabled}
aria-label={`车辆服务决策 ${item.title} ${item.secondaryAction}`}
onClick={item.onSecondary}
>
{item.secondaryAction}
</Button>
</Space>
</div>
))}
</div>
</Card>
<Card bordered title="客户车辆服务交付包" style={{ marginTop: 16 }}>
<div className="vp-vehicle-package-board">
<div className="vp-vehicle-package-summary">
<Space wrap>
<Tag color="blue"></Tag>
<Tag color={filters.keyword?.trim() ? 'green' : 'grey'}>{filters.keyword?.trim() ? '单车包' : '车辆池包'}</Tag>
</Space>
<strong>{filters.keyword?.trim() || `${(summary?.totalVehicles ?? pagination.total).toLocaleString()} 辆车`}</strong>
<span>
GB32960808 MQTT
</span>
<Space wrap>
<Button size="small" theme="solid" type="primary" icon={<IconCopy />} onClick={copyCustomerServicePackage}></Button>
<Button size="small" disabled={!onOpenMap} onClick={() => onOpenMap?.({ ...serviceScopeFilters, online: 'online' })}></Button>
<Button size="small" disabled={!onOpenQuality} onClick={() => onOpenQuality?.(serviceScopeFilters)}></Button>
</Space>
</div>
<div className="vp-vehicle-package-grid">
{servicePackageItems.map((item) => (
<button
key={item.label}
type="button"
className="vp-vehicle-package-item"
disabled={item.disabled}
aria-label={`客户车辆服务交付 ${item.label} ${item.action}`}
onClick={item.onClick}
>
<Tag color={item.color}>{item.label}</Tag>
<strong>{item.value}</strong>
<span>{item.detail}</span>
<em>{item.action}</em>
</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>
);
}