1648 lines
76 KiB
TypeScript
1648 lines
76 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 { 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)
|
||
}
|
||
];
|
||
const totalVehicleCount = summary?.totalVehicles ?? pagination.total;
|
||
const onlineRate = totalVehicleCount > 0 ? `${(((summary?.onlineVehicles ?? 0) / totalVehicleCount) * 100).toFixed(1)}%` : '-';
|
||
const currentPageServiceableCount = rows.filter((row) => row.bindingStatus === 'bound' && row.sourceCount > 0).length;
|
||
const pendingVehicleCount = (summary?.noDataVehicles ?? 0) + (summary?.unboundVehicles ?? 0);
|
||
const fleetCommandItems = [
|
||
{
|
||
label: '在线率',
|
||
value: onlineRate,
|
||
detail: `${(summary?.onlineVehicles ?? 0).toLocaleString()} / ${totalVehicleCount.toLocaleString()} 辆在线,先进入地图看车辆分布。`,
|
||
action: '车辆地图',
|
||
color: (summary?.onlineVehicles ?? 0) > 0 ? 'green' as const : 'orange' as const,
|
||
disabled: !onOpenMap,
|
||
onClick: () => onOpenMap?.({ ...serviceScopeFilters, online: 'online' })
|
||
},
|
||
{
|
||
label: '可服务车辆',
|
||
value: `${currentPageServiceableCount.toLocaleString()} 辆`,
|
||
detail: '当前页可归并到车辆服务的车辆,可直接查看实时状态和来源证据。',
|
||
action: '实时监控',
|
||
color: currentPageServiceableCount > 0 ? 'blue' as const : 'orange' as const,
|
||
disabled: !onOpenRealtime,
|
||
onClick: () => onOpenRealtime?.({ ...serviceScopeFilters, online: 'online' })
|
||
},
|
||
{
|
||
label: '待处理车辆',
|
||
value: `${pendingVehicleCount.toLocaleString()} 辆`,
|
||
detail: '无来源或未绑定车辆优先进入告警闭环,避免客户查不到车。',
|
||
action: '告警通知',
|
||
color: pendingVehicleCount > 0 ? 'orange' as const : 'green' as const,
|
||
disabled: !onOpenQuality,
|
||
onClick: () => onOpenQuality?.(serviceScopeFilters)
|
||
},
|
||
{
|
||
label: '当前交付',
|
||
value: `${rows.length.toLocaleString()} 辆`,
|
||
detail: '把当前筛选结果导出为车辆清单,再按时间窗交付轨迹和历史证据。',
|
||
action: '数据导出',
|
||
color: rows.length > 0 ? 'blue' as const : 'grey' as const,
|
||
disabled: rows.length === 0,
|
||
onClick: exportVehicles
|
||
},
|
||
{
|
||
label: '轨迹复盘',
|
||
value: filters.keyword?.trim() ? '单车可回放' : '先选车辆',
|
||
detail: filters.keyword?.trim() ? '当前已进入单车范围,可直接复盘历史轨迹。' : '先锁定 VIN、车牌或手机号,再进入轨迹回放。',
|
||
action: '轨迹回放',
|
||
color: filters.keyword?.trim() ? 'green' as const : 'grey' as const,
|
||
disabled: !onOpenHistory || !filters.keyword?.trim(),
|
||
onClick: () => onOpenHistory?.({ ...serviceScopeFilters, tab: 'location' })
|
||
}
|
||
];
|
||
const amapServiceFoundationItems = [
|
||
{
|
||
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() ? '按当前车辆进入轨迹回放,解释时间窗内怎么跑。' : '从车辆池先锁定 VIN、车牌或手机号,再回放轨迹。',
|
||
action: '回放轨迹',
|
||
color: filters.keyword?.trim() ? 'green' as const : 'grey' as const,
|
||
disabled: !onOpenHistory || !filters.keyword?.trim(),
|
||
onClick: () => onOpenHistory?.({ ...serviceScopeFilters, tab: 'location' })
|
||
},
|
||
{
|
||
title: '围栏告警',
|
||
value: `${pendingVehicleCount.toLocaleString()} 待处理`,
|
||
detail: '围绕车辆位置、断链、身份缺口和后续围栏规则,把异常转成通知闭环。',
|
||
action: '告警通知',
|
||
color: pendingVehicleCount > 0 ? 'orange' as const : 'green' as const,
|
||
disabled: !onOpenQuality,
|
||
onClick: () => onOpenQuality?.(serviceScopeFilters)
|
||
},
|
||
{
|
||
title: '数据交付',
|
||
value: `${rows.length.toLocaleString()} 当前页`,
|
||
detail: '把车辆清单、地图筛选、轨迹证据和历史数据导出给客户或运营人员。',
|
||
action: '导出清单',
|
||
color: rows.length > 0 ? 'blue' as const : 'grey' as const,
|
||
disabled: rows.length === 0,
|
||
onClick: exportVehicles
|
||
}
|
||
];
|
||
|
||
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-fleet-command-center" aria-label="客户车队指挥台">
|
||
<div className="vp-fleet-command-copy">
|
||
<Space wrap>
|
||
<Tag color="blue">客户车队指挥台</Tag>
|
||
<Tag color={filters.keyword?.trim() ? 'green' : 'grey'}>{filters.keyword?.trim() ? '单车服务' : '车辆池'}</Tag>
|
||
<Tag color={pendingVehicleCount > 0 ? 'orange' : 'green'}>{pendingVehicleCount.toLocaleString()} 待处理</Tag>
|
||
</Space>
|
||
<strong>从车辆池直接判断运营状态:哪些车在线、哪些车可服务、哪些车要回放、统计、导出或触发告警。</strong>
|
||
<span>
|
||
车队指挥台把实时地图、轨迹回放、里程统计、历史导出和告警通知放在同一组入口里;协议来源只保留为证据和筛选条件。
|
||
</span>
|
||
</div>
|
||
<div className="vp-fleet-command-grid">
|
||
{fleetCommandItems.map((item) => (
|
||
<button
|
||
key={item.label}
|
||
type="button"
|
||
className="vp-fleet-command-item"
|
||
disabled={item.disabled}
|
||
onClick={item.onClick}
|
||
aria-label={`客户车队指挥台 ${item.label} ${item.value} ${item.action}`}
|
||
>
|
||
<Tag color={item.color}>{item.label}</Tag>
|
||
<strong>{item.value}</strong>
|
||
<span>{item.detail}</span>
|
||
<em>{item.action}</em>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
<section className="vp-vehicle-amap-foundation" aria-label="高德地图车辆服务底座">
|
||
<div className="vp-vehicle-amap-foundation-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-amap-foundation-grid">
|
||
{amapServiceFoundationItems.map((item) => (
|
||
<button
|
||
key={item.title}
|
||
type="button"
|
||
className="vp-vehicle-amap-foundation-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-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>
|
||
把车辆地图、实时监控、轨迹回放、统计查询、告警通知和历史导出放在同一份服务包里,客户不用理解 GB32960、808 或 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>
|
||
);
|
||
}
|