926 lines
40 KiB
TypeScript
926 lines
40 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');
|
||
}
|
||
|
||
type VehicleActionRecommendation = {
|
||
label: string;
|
||
color: 'green' | 'orange' | 'red';
|
||
filters: Record<string, string> | null;
|
||
};
|
||
|
||
type PageVerdictStat = {
|
||
status: string;
|
||
count: number;
|
||
color: 'green' | 'orange' | 'red';
|
||
risk: string;
|
||
nextStep: string;
|
||
};
|
||
|
||
function vehicleActionRecommendation(row: VehicleCoverageRow): VehicleActionRecommendation {
|
||
const verdict = summarizeVehicleService({
|
||
bindingStatus: row.bindingStatus,
|
||
sourceCount: row.sourceCount,
|
||
onlineSourceCount: row.onlineSourceCount,
|
||
missingProtocols: row.missingProtocols,
|
||
mileageDeltaKm: row.sourceConsistency?.mileageDeltaKm
|
||
});
|
||
if (row.bindingStatus !== 'bound') {
|
||
return { label: '维护身份绑定', color: verdict.color, filters: { bindingStatus: 'unbound' } };
|
||
}
|
||
if ((row.missingProtocols ?? []).length > 0) {
|
||
return { label: `补齐 ${row.missingProtocols.join('、')} 来源`, color: verdict.color, filters: { missingProtocol: row.missingProtocols[0] } };
|
||
}
|
||
if (row.sourceCount <= 0) {
|
||
return { label: '确认平台转发配置', color: verdict.color, filters: { serviceStatus: 'no_data' } };
|
||
}
|
||
if (row.onlineSourceCount <= 0) {
|
||
return { label: '排查离线链路', color: verdict.color, filters: { online: 'offline' } };
|
||
}
|
||
return { label: '持续观察', color: verdict.color, filters: null };
|
||
}
|
||
|
||
function currentPageVerdictStats(rows: VehicleCoverageRow[]): PageVerdictStat[] {
|
||
const stats = new Map<string, PageVerdictStat>();
|
||
for (const row of rows) {
|
||
const verdict = rowServiceVerdict(row);
|
||
const current = stats.get(verdict.status);
|
||
if (current) {
|
||
current.count += 1;
|
||
continue;
|
||
}
|
||
stats.set(verdict.status, {
|
||
status: verdict.status,
|
||
count: 1,
|
||
color: verdict.color,
|
||
risk: verdict.risk,
|
||
nextStep: verdict.nextStep
|
||
});
|
||
}
|
||
const order = ['不可服务', '降级可服务', '可服务'];
|
||
return [...stats.values()].sort((left, right) => order.indexOf(left.status) - order.indexOf(right.status));
|
||
}
|
||
|
||
function currentPageActionStats(rows: VehicleCoverageRow[]) {
|
||
const stats = new Map<string, { label: string; count: number; color: 'green' | 'orange' | 'red'; filters: Record<string, string> | null }>();
|
||
for (const row of rows) {
|
||
const action = vehicleActionRecommendation(row);
|
||
const current = stats.get(action.label);
|
||
if (current) {
|
||
current.count += 1;
|
||
continue;
|
||
}
|
||
stats.set(action.label, { label: action.label, count: 1, color: action.color, filters: action.filters });
|
||
}
|
||
return [...stats.values()].sort((left, right) => right.count - left.count);
|
||
}
|
||
|
||
function sourceConsistencyAction(row: VehicleCoverageRow, onFilter: (filters: Record<string, string>) => void) {
|
||
const consistency = row.sourceConsistency;
|
||
if (!consistency) {
|
||
return <Tag color="grey">未诊断</Tag>;
|
||
}
|
||
const color = consistency.severity === 'ok' ? 'green' as const : consistency.severity === 'error' ? 'red' as const : 'orange' as const;
|
||
const label = consistency.title || consistency.status;
|
||
if ((consistency.missingProtocols ?? []).length > 0) {
|
||
return (
|
||
<Button
|
||
size="small"
|
||
theme="light"
|
||
type={color === 'red' ? 'danger' : color === 'orange' ? 'warning' : 'primary'}
|
||
onClick={() => onFilter({ serviceStatus: 'degraded', missingProtocol: consistency.missingProtocols[0] })}
|
||
>
|
||
{label}
|
||
</Button>
|
||
);
|
||
}
|
||
return <Tag color={color}>{label}</Tag>;
|
||
}
|
||
|
||
async function copyText(value: string, label: string) {
|
||
try {
|
||
await navigator.clipboard.writeText(value);
|
||
Toast.success(`已复制${label}`);
|
||
} catch {
|
||
Toast.error(`复制${label}失败`);
|
||
}
|
||
}
|
||
|
||
async function copyVehicleShareURL() {
|
||
await copyText(`${window.location.origin}${window.location.pathname}${window.location.hash}`, '筛选链接');
|
||
}
|
||
|
||
export function Vehicles({
|
||
onOpenVehicle,
|
||
onOpenQuality,
|
||
onOpenMap,
|
||
onOpenRealtime,
|
||
onOpenHistory,
|
||
onOpenMileage,
|
||
onFiltersChange,
|
||
initialFilters = {}
|
||
}: {
|
||
onOpenVehicle: (vin: string, protocol?: string) => void;
|
||
onOpenQuality?: (filters: Record<string, string>) => void;
|
||
onOpenMap?: (filters: Record<string, string>) => void;
|
||
onOpenRealtime?: (filters: Record<string, string>) => void;
|
||
onOpenHistory?: (filters: Record<string, string>) => void;
|
||
onOpenMileage?: (filters: Record<string, string>) => void;
|
||
onFiltersChange?: (filters: Record<string, string>) => void;
|
||
initialFilters?: Record<string, string>;
|
||
}) {
|
||
const [rows, setRows] = useState<VehicleCoverageRow[]>([]);
|
||
const [summary, setSummary] = useState<VehicleCoverageSummary | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [filters, setFilters] = useState<Record<string, string>>(initialFilters);
|
||
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 });
|
||
const resultSummary = useMemo<Array<{ label: string; value: string; filters: Record<string, string> }>>(() => {
|
||
const items: Array<{ label: string; value: string; filters: Record<string, string> }> = [
|
||
{ label: '过滤车辆', value: (summary?.totalVehicles ?? pagination.total).toLocaleString(), filters: {} },
|
||
{ label: '在线车辆', value: (summary?.onlineVehicles ?? 0).toLocaleString(), filters: { online: 'online' } },
|
||
{ label: '单源车辆', value: (summary?.singleSourceVehicles ?? 0).toLocaleString(), filters: { coverage: 'single' } },
|
||
{ label: '多源车辆', value: (summary?.multiSourceVehicles ?? 0).toLocaleString(), filters: { coverage: 'multi' } },
|
||
{ label: '暂无来源车辆', value: (summary?.noDataVehicles ?? 0).toLocaleString(), filters: { serviceStatus: 'no_data' } },
|
||
{ label: '待绑定', value: (summary?.unboundVehicles ?? 0).toLocaleString(), filters: { bindingStatus: 'unbound' } },
|
||
{ label: '档案不完整', value: (summary?.archiveIncompleteVehicles ?? 0).toLocaleString(), filters: { archiveStatus: 'incomplete' } }
|
||
];
|
||
for (const field of summary?.archiveMissingFields ?? []) {
|
||
if (field.count <= 0) continue;
|
||
items.push({
|
||
label: field.title,
|
||
value: field.count.toLocaleString(),
|
||
filters: { archiveMissing: field.field }
|
||
});
|
||
}
|
||
for (const source of summary?.missingSources ?? []) {
|
||
if (source.count <= 0) continue;
|
||
items.push({
|
||
label: `缺 ${source.protocol}`,
|
||
value: source.count.toLocaleString(),
|
||
filters: { missingProtocol: source.protocol }
|
||
});
|
||
}
|
||
return items;
|
||
}, [pagination.total, summary]);
|
||
const actionQueue = useMemo<VehicleActionQueueItem[]>(() => {
|
||
const items: VehicleActionQueueItem[] = [];
|
||
const unboundCount = summary?.unboundVehicles ?? 0;
|
||
if (unboundCount > 0) {
|
||
items.push({
|
||
label: '维护身份绑定',
|
||
count: unboundCount,
|
||
filters: { bindingStatus: 'unbound' },
|
||
color: 'orange',
|
||
priority: 'P0',
|
||
detail: '已有数据但无法归并到 VIN,先补绑定再看跨来源服务。'
|
||
});
|
||
}
|
||
const noDataCount = summary?.noDataVehicles ?? 0;
|
||
if (noDataCount > 0) {
|
||
items.push({
|
||
label: '确认平台转发',
|
||
count: noDataCount,
|
||
filters: { serviceStatus: 'no_data' },
|
||
color: 'orange',
|
||
priority: 'P0',
|
||
detail: '车辆没有任何来源证据,优先确认平台转发、端口和订阅配置。'
|
||
});
|
||
}
|
||
const archiveIncompleteCount = summary?.archiveIncompleteVehicles ?? 0;
|
||
if (archiveIncompleteCount > 0) {
|
||
items.push({
|
||
label: '完善车辆档案',
|
||
count: archiveIncompleteCount,
|
||
filters: { archiveStatus: 'incomplete' },
|
||
color: 'orange',
|
||
priority: 'P1',
|
||
detail: '车辆缺少车牌、手机号或 OEM 等基础档案,影响后续运营查询和治理。'
|
||
});
|
||
}
|
||
for (const field of summary?.archiveMissingFields ?? []) {
|
||
if (field.count <= 0) continue;
|
||
items.push({
|
||
label: `补齐${field.title}`,
|
||
count: field.count,
|
||
filters: { archiveMissing: field.field },
|
||
color: 'orange',
|
||
priority: 'P1',
|
||
detail: `${field.title}会影响车辆档案检索、绑定确认和运营侧筛选。`
|
||
});
|
||
}
|
||
for (const source of summary?.missingSources ?? []) {
|
||
if (source.count <= 0) continue;
|
||
items.push({
|
||
label: `补齐 ${source.protocol} 来源`,
|
||
count: source.count,
|
||
filters: { missingProtocol: source.protocol },
|
||
color: 'orange',
|
||
priority: 'P1',
|
||
detail: `${source.protocol} 来源缺失会降低跨来源定位、里程和实时判断可信度。`
|
||
});
|
||
}
|
||
return items;
|
||
}, [summary]);
|
||
const filterSummary = vehicleFilterSummary(filters);
|
||
const pageVerdictStats = useMemo(() => currentPageVerdictStats(rows), [rows]);
|
||
const pageActionStats = useMemo(() => currentPageActionStats(rows), [rows]);
|
||
const serviceScopeFilters = useMemo(() => {
|
||
const nextFilters: Record<string, string> = {};
|
||
if (filters.keyword?.trim()) nextFilters.keyword = filters.keyword.trim();
|
||
if (filters.protocol?.trim()) nextFilters.protocol = filters.protocol.trim();
|
||
return nextFilters;
|
||
}, [filters.keyword, filters.protocol]);
|
||
|
||
const load = (values: Record<string, string> = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => {
|
||
setLoading(true);
|
||
const params = new URLSearchParams({ limit: String(pageSize), offset: String((page - 1) * pageSize) });
|
||
if (values?.keyword) params.set('keyword', values.keyword);
|
||
if (values?.protocol) params.set('protocol', values.protocol);
|
||
if (values?.coverage) params.set('coverage', values.coverage);
|
||
if (values?.missingProtocol) params.set('missingProtocol', values.missingProtocol);
|
||
if (values?.online) params.set('online', values.online);
|
||
if (values?.bindingStatus) params.set('bindingStatus', values.bindingStatus);
|
||
if (values?.archiveStatus) params.set('archiveStatus', values.archiveStatus);
|
||
if (values?.archiveMissing) params.set('archiveMissing', values.archiveMissing);
|
||
if (values?.serviceStatus) params.set('serviceStatus', values.serviceStatus);
|
||
const summaryParams = new URLSearchParams(params);
|
||
summaryParams.delete('limit');
|
||
summaryParams.delete('offset');
|
||
Promise.all([api.vehicleCoverage(params), api.vehicleCoverageSummary(summaryParams)])
|
||
.then(([nextPage, nextSummary]) => {
|
||
setRows(nextPage.items);
|
||
setSummary(nextSummary);
|
||
setPagination({ currentPage: page, pageSize, total: nextPage.total });
|
||
})
|
||
.catch((error: Error) => Toast.error(error.message))
|
||
.finally(() => setLoading(false));
|
||
};
|
||
|
||
const applyFilters = (nextFilters: Record<string, string>) => {
|
||
setFilters(nextFilters);
|
||
onFiltersChange?.(nextFilters);
|
||
load(nextFilters, 1, pagination.pageSize);
|
||
};
|
||
const exportVehicles = () => {
|
||
if (rows.length === 0) {
|
||
Toast.warning('当前没有可导出的车辆结果');
|
||
return;
|
||
}
|
||
downloadCsv(exportFileName(filters), buildCsv(vehicleExportColumns, rows));
|
||
Toast.success(`已导出 ${rows.length} 条车辆结果`);
|
||
};
|
||
const copyGovernanceSummary = () => {
|
||
copyText(vehicleGovernanceSummaryText({ filters, summary, actionQueue }), '治理摘要');
|
||
};
|
||
const copyDispatchList = () => {
|
||
if (rows.length === 0) {
|
||
Toast.warning('当前没有可复制的车辆处置清单');
|
||
return;
|
||
}
|
||
copyText(vehicleDispatchListText({ filters, rows, total: pagination.total }), '车辆处置清单');
|
||
};
|
||
const copyIdentityMaintenanceList = () => {
|
||
const maintenanceRows = rows.filter((row) => row.bindingStatus !== 'bound' || vehicleArchiveMissingLabels(row).length > 0);
|
||
if (maintenanceRows.length === 0) {
|
||
Toast.warning('当前页没有需要维护身份或档案的车辆');
|
||
return;
|
||
}
|
||
copyText(vehicleIdentityMaintenanceText({ filters, rows, total: pagination.total }), '车辆身份维护清单');
|
||
};
|
||
const serviceDeskActions = useMemo(() => [
|
||
{
|
||
label: '实时监控',
|
||
value: `${(summary?.onlineVehicles ?? 0).toLocaleString()} 在线`,
|
||
detail: '查看车辆在线、速度、最后上报和来源状态。',
|
||
color: 'green' as const,
|
||
disabled: !onOpenRealtime,
|
||
onClick: () => onOpenRealtime?.({ ...serviceScopeFilters, online: 'online' })
|
||
},
|
||
{
|
||
label: '车辆地图',
|
||
value: '地图态势',
|
||
detail: '把当前筛选车辆带入实时地图,优先看位置分布。',
|
||
color: 'blue' as const,
|
||
disabled: !onOpenMap,
|
||
onClick: () => onOpenMap?.({ ...serviceScopeFilters, online: 'online' })
|
||
},
|
||
{
|
||
label: '轨迹回放',
|
||
value: filters.keyword?.trim() ? '单车轨迹' : '选择车辆',
|
||
detail: filters.keyword?.trim() ? '按当前车辆进入历史轨迹复盘。' : '先选择车辆后进入精确轨迹回放。',
|
||
color: filters.keyword?.trim() ? 'blue' as const : 'grey' as const,
|
||
disabled: !onOpenHistory || !filters.keyword?.trim(),
|
||
onClick: () => onOpenHistory?.({ ...serviceScopeFilters, tab: 'location' })
|
||
},
|
||
{
|
||
label: '里程统计',
|
||
value: filters.keyword?.trim() ? '区间核对' : '车辆口径',
|
||
detail: filters.keyword?.trim() ? '按当前车辆核对区间里程和日里程。' : '输入车辆后查看里程闭合统计。',
|
||
color: filters.keyword?.trim() ? 'green' as const : 'grey' as const,
|
||
disabled: !onOpenMileage || !filters.keyword?.trim(),
|
||
onClick: () => onOpenMileage?.(serviceScopeFilters)
|
||
},
|
||
{
|
||
label: '数据导出',
|
||
value: `${rows.length.toLocaleString()} 当前页`,
|
||
detail: '导出当前页车辆清单,后续可继续导出轨迹和原始记录。',
|
||
color: rows.length > 0 ? 'blue' as const : 'grey' as const,
|
||
disabled: rows.length === 0,
|
||
onClick: exportVehicles
|
||
}
|
||
], [exportVehicles, filters.keyword, onOpenHistory, onOpenMap, onOpenMileage, onOpenRealtime, rows.length, serviceScopeFilters, summary?.onlineVehicles]);
|
||
|
||
useEffect(() => {
|
||
setFilters(initialFilters);
|
||
load(initialFilters, 1, pagination.pageSize);
|
||
}, [JSON.stringify(initialFilters)]);
|
||
|
||
const columns = useMemo(
|
||
() => [
|
||
{ title: 'VIN', dataIndex: 'vin', width: 190 },
|
||
{ title: '车牌', dataIndex: 'plate', width: 120 },
|
||
{ title: '手机号', dataIndex: 'phone', width: 130 },
|
||
{ title: 'OEM', dataIndex: 'oem', width: 120 },
|
||
{
|
||
title: '档案完整度',
|
||
width: 190,
|
||
render: (_: unknown, row: VehicleCoverageRow) => {
|
||
const archive = vehicleArchiveCompleteness(row);
|
||
const missingLabels = vehicleArchiveMissingLabels(row);
|
||
return (
|
||
<Space spacing={4} wrap>
|
||
<Tag color={archive.completed === archive.total ? 'green' : 'orange'}>{archive.label}</Tag>
|
||
{missingLabels.map((label) => (
|
||
<Tag key={label} color="orange">{label}</Tag>
|
||
))}
|
||
</Space>
|
||
);
|
||
}
|
||
},
|
||
{
|
||
title: '车辆服务状态',
|
||
width: 130,
|
||
render: (_: unknown, row: VehicleCoverageRow) => {
|
||
const status = vehicleServiceStatus(row);
|
||
return <Tag color={status.color}>{status.label}</Tag>;
|
||
}
|
||
},
|
||
{
|
||
title: '来源证据',
|
||
width: 300,
|
||
render: (_: unknown, row: VehicleCoverageRow) => (
|
||
<SourceStatusTags sourceStatus={row.sourceStatus} protocols={row.protocols} lastSeen={row.lastSeen} />
|
||
)
|
||
},
|
||
{
|
||
title: '缺失来源',
|
||
width: 220,
|
||
render: (_: unknown, row: VehicleCoverageRow) => (
|
||
<Space spacing={4} wrap>
|
||
{(row.missingProtocols ?? []).length > 0
|
||
? row.missingProtocols.map((protocol) => (
|
||
<Button
|
||
key={protocol}
|
||
size="small"
|
||
theme="light"
|
||
type="warning"
|
||
onClick={() => applyFilters({ ...filters, missingProtocol: protocol })}
|
||
>
|
||
缺 {protocol}
|
||
</Button>
|
||
))
|
||
: <Tag color="green">完整</Tag>}
|
||
</Space>
|
||
)
|
||
},
|
||
{
|
||
title: '来源一致性',
|
||
width: 140,
|
||
render: (_: unknown, row: VehicleCoverageRow) => sourceConsistencyAction(row, (nextFilters) => applyFilters({ ...filters, ...nextFilters }))
|
||
},
|
||
{ title: '诊断摘要', width: 240, render: (_: unknown, row: VehicleCoverageRow) => sourceDiagnosisText(row) },
|
||
{
|
||
title: '建议动作',
|
||
width: 180,
|
||
render: (_: unknown, row: VehicleCoverageRow) => {
|
||
const action = vehicleActionRecommendation(row);
|
||
if (action.filters) {
|
||
return (
|
||
<Button size="small" theme="light" type={action.color === 'red' ? 'danger' : 'warning'} onClick={() => applyFilters({ ...filters, ...action.filters })}>
|
||
{action.label}
|
||
</Button>
|
||
);
|
||
}
|
||
return <Tag color={action.color}>{action.label}</Tag>;
|
||
}
|
||
},
|
||
{ title: '证据覆盖', width: 120, render: (_: unknown, row: VehicleCoverageRow) => sourceEvidenceText(row) },
|
||
{ title: '在线', width: 90, render: (_: unknown, row: VehicleCoverageRow) => <StatusTag status={row.online ? 'ok' : 'offline'} /> },
|
||
{ title: '最后在线', dataIndex: 'lastSeen', width: 170 },
|
||
{ title: '绑定', width: 90, render: (_: unknown, row: VehicleCoverageRow) => <Tag color={row.bindingStatus === 'bound' ? 'green' : 'orange'}>{row.bindingStatus === 'bound' ? '已绑定' : '未绑定'}</Tag> },
|
||
{
|
||
title: '操作',
|
||
width: 300,
|
||
render: (_: unknown, row: VehicleCoverageRow) => (
|
||
<Space wrap>
|
||
<Button disabled={!row.vin || !onOpenRealtime} onClick={() => onOpenRealtime?.(rowWorkflowFilters(row, filters.protocol))}>实时</Button>
|
||
<Button disabled={!row.vin || !onOpenHistory} onClick={() => onOpenHistory?.(rowWorkflowFilters(row, filters.protocol))}>轨迹</Button>
|
||
<Button onClick={() => onOpenVehicle(row.vin, primaryRowProtocol(row, filters.protocol))}>服务</Button>
|
||
<Button disabled={!row.vin || !onOpenQuality} onClick={() => onOpenQuality?.(rowWorkflowFilters(row, filters.protocol))}>告警</Button>
|
||
</Space>
|
||
)
|
||
}
|
||
],
|
||
[filters, onOpenHistory, onOpenQuality, onOpenRealtime, onOpenVehicle]
|
||
);
|
||
|
||
return (
|
||
<div className="vp-page">
|
||
<PageHeader title="车辆服务" description="围绕车辆完成实时监控、地图查看、轨迹回放、里程统计和数据导出,协议来源只作为证据过滤条件" />
|
||
<Card bordered title="车辆服务台">
|
||
<div className="vp-vehicle-service-desk">
|
||
<div className="vp-vehicle-service-summary">
|
||
<Space wrap>
|
||
<Tag color="blue">客户视角</Tag>
|
||
<Tag color={filters.keyword?.trim() ? 'green' : 'grey'}>{filters.keyword?.trim() ? '单车服务' : '车辆池'}</Tag>
|
||
</Space>
|
||
<strong>{filters.keyword?.trim() || '全部车辆'}</strong>
|
||
<span>
|
||
当前筛选覆盖 {(summary?.totalVehicles ?? pagination.total).toLocaleString()} 辆车,在线 {(summary?.onlineVehicles ?? 0).toLocaleString()} 辆。
|
||
客户先看车辆能不能服务,再进入地图、轨迹、里程和导出。
|
||
</span>
|
||
<Space wrap>
|
||
<Button size="small" theme="solid" type="primary" disabled={!onOpenRealtime} onClick={() => onOpenRealtime?.({ ...serviceScopeFilters, online: 'online' })}>查看在线车辆</Button>
|
||
<Button size="small" disabled={!onOpenMap} onClick={() => onOpenMap?.({ ...serviceScopeFilters, online: 'online' })}>打开地图</Button>
|
||
<Button size="small" onClick={copyDispatchList}>复制服务清单</Button>
|
||
</Space>
|
||
</div>
|
||
<div className="vp-vehicle-service-grid">
|
||
{serviceDeskActions.map((item) => (
|
||
<button
|
||
key={item.label}
|
||
className="vp-vehicle-service-action"
|
||
type="button"
|
||
disabled={item.disabled}
|
||
onClick={item.onClick}
|
||
>
|
||
<Tag color={item.color}>{item.label}</Tag>
|
||
<strong>{item.value}</strong>
|
||
<span>{item.detail}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
<Card bordered>
|
||
<Form key={JSON.stringify(filters)} initValues={filters} layout="horizontal" onSubmit={(values) => {
|
||
const nextFilters = values as Record<string, string>;
|
||
applyFilters(nextFilters);
|
||
}}>
|
||
<Form.Input field="keyword" label="关键词" placeholder="VIN / 车牌 / 手机号 / OEM" style={{ width: 260 }} />
|
||
<Form.Select field="protocol" label="数据来源" placeholder="全部来源" style={{ width: 180 }}>
|
||
<Select.Option value="GB32960">GB32960</Select.Option>
|
||
<Select.Option value="JT808">JT808</Select.Option>
|
||
<Select.Option value="YUTONG_MQTT">YUTONG_MQTT</Select.Option>
|
||
</Form.Select>
|
||
<Form.Select field="coverage" label="车辆覆盖" placeholder="全部" style={{ width: 130 }}>
|
||
<Select.Option value="single">单源</Select.Option>
|
||
<Select.Option value="multi">多源</Select.Option>
|
||
</Form.Select>
|
||
<Form.Select field="missingProtocol" label="缺失来源" placeholder="全部" style={{ width: 170 }} data-testid="missing-protocol-filter">
|
||
<Select.Option value="GB32960">缺 GB32960</Select.Option>
|
||
<Select.Option value="JT808">缺 JT808</Select.Option>
|
||
<Select.Option value="YUTONG_MQTT">缺 YUTONG_MQTT</Select.Option>
|
||
</Form.Select>
|
||
<Form.Select field="serviceStatus" label="服务状态" placeholder="全部" style={{ width: 150 }} data-testid="service-status-filter">
|
||
<Select.Option value="healthy">服务正常</Select.Option>
|
||
<Select.Option value="degraded">来源不完整</Select.Option>
|
||
<Select.Option value="offline">车辆离线</Select.Option>
|
||
<Select.Option value="no_data">暂无数据来源</Select.Option>
|
||
<Select.Option value="identity_required">身份未绑定</Select.Option>
|
||
</Form.Select>
|
||
<Form.Select field="online" label="在线" placeholder="全部" style={{ width: 130 }}>
|
||
<Select.Option value="online">在线</Select.Option>
|
||
<Select.Option value="offline">离线</Select.Option>
|
||
</Form.Select>
|
||
<Form.Select field="bindingStatus" label="绑定" placeholder="全部" style={{ width: 130 }}>
|
||
<Select.Option value="bound">已绑定</Select.Option>
|
||
<Select.Option value="unbound">未绑定</Select.Option>
|
||
</Form.Select>
|
||
<Form.Select field="archiveStatus" label="档案" placeholder="全部" style={{ width: 130 }}>
|
||
<Select.Option value="complete">完整</Select.Option>
|
||
<Select.Option value="incomplete">不完整</Select.Option>
|
||
</Form.Select>
|
||
<Form.Select field="archiveMissing" label="档案缺项" placeholder="全部" style={{ width: 140 }}>
|
||
<Select.Option value="plate">缺车牌</Select.Option>
|
||
<Select.Option value="phone">缺手机号</Select.Option>
|
||
<Select.Option value="oem">缺OEM</Select.Option>
|
||
</Form.Select>
|
||
<Space>
|
||
<Button htmlType="submit" theme="solid" type="primary">查询</Button>
|
||
<Button icon={<IconCopy />} onClick={copyVehicleShareURL}>复制筛选链接</Button>
|
||
<Button onClick={() => {
|
||
applyFilters({});
|
||
}}>重置</Button>
|
||
</Space>
|
||
</Form>
|
||
</Card>
|
||
{filterSummary.length > 0 ? (
|
||
<Card bordered title="当前车辆筛选" style={{ marginTop: 16 }}>
|
||
<Space wrap>
|
||
{filterSummary.map((item) => (
|
||
<Tag key={item} color="blue">{item}</Tag>
|
||
))}
|
||
<Button size="small" onClick={() => applyFilters({})}>清空筛选</Button>
|
||
</Space>
|
||
</Card>
|
||
) : null}
|
||
<Card
|
||
bordered
|
||
title={(
|
||
<Space>
|
||
<span>当前车辆结果</span>
|
||
<Button size="small" theme="light" onClick={copyGovernanceSummary}>复制治理摘要</Button>
|
||
<Button size="small" theme="light" onClick={copyDispatchList}>复制当前页处置清单</Button>
|
||
<Button size="small" theme="light" onClick={copyIdentityMaintenanceList}>复制身份维护清单</Button>
|
||
</Space>
|
||
)}
|
||
style={{ marginTop: 16 }}
|
||
>
|
||
{rows.length > 0 ? (
|
||
<div className="vp-current-service-board">
|
||
<div>
|
||
<div className="vp-current-service-title">当前页服务判读</div>
|
||
<div className="vp-current-service-grid">
|
||
{pageVerdictStats.map((item) => (
|
||
<div key={item.status} className="vp-current-service-item">
|
||
<Space spacing={6} wrap>
|
||
<Tag color={item.color}>{item.status}</Tag>
|
||
<Tag color="grey">{item.count.toLocaleString()} 辆</Tag>
|
||
</Space>
|
||
<div className="vp-current-service-risk">{item.risk}</div>
|
||
<div className="vp-current-service-next">{item.nextStep}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="vp-current-service-title">建议下一步</div>
|
||
<div className="vp-current-action-list">
|
||
{pageActionStats.map((item) => (
|
||
item.filters ? (
|
||
<Button
|
||
key={item.label}
|
||
size="small"
|
||
theme="light"
|
||
type={item.color === 'red' ? 'danger' : 'warning'}
|
||
onClick={() => applyFilters({ ...filters, ...item.filters })}
|
||
>
|
||
{item.label} {item.count.toLocaleString()}
|
||
</Button>
|
||
) : (
|
||
<Tag key={item.label} color={item.color}>{item.label} {item.count.toLocaleString()}</Tag>
|
||
)
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
<div className="vp-result-summary-grid">
|
||
{resultSummary.map((item) => (
|
||
<button
|
||
key={item.label}
|
||
className="vp-result-summary-item vp-result-summary-button"
|
||
type="button"
|
||
aria-label={`${item.label} ${item.value}`}
|
||
onClick={() => applyFilters({ ...filters, ...item.filters })}
|
||
>
|
||
<div className="vp-result-summary-value">{item.value}</div>
|
||
<div className="vp-result-summary-label">{item.label}</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
{actionQueue.length > 0 ? (
|
||
<Card bordered title="处置队列" style={{ marginTop: 16 }}>
|
||
<div className="vp-action-grid">
|
||
{actionQueue.map((item) => (
|
||
<div key={`${item.label}-${item.count}`} className="vp-action-item">
|
||
<div>
|
||
<Space spacing={6} wrap>
|
||
<Tag color={item.priority === 'P0' ? 'red' : 'orange'}>{item.priority}</Tag>
|
||
<Tag color={item.color}>{item.label} {item.count.toLocaleString()}</Tag>
|
||
</Space>
|
||
<div style={{ marginTop: 8 }}>{item.detail}</div>
|
||
</div>
|
||
<Button size="small" theme="light" type={item.color === 'red' ? 'danger' : 'warning'} onClick={() => applyFilters({ ...filters, ...item.filters })}>
|
||
{item.label} {item.count.toLocaleString()}
|
||
</Button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
) : null}
|
||
<Card bordered style={{ marginTop: 16 }}>
|
||
{rows.length === 0 && !loading ? (
|
||
<DataEmpty />
|
||
) : (
|
||
<>
|
||
<div className="vp-table-toolbar">
|
||
<Space wrap>
|
||
<Tag color="blue">当前页 {rows.length.toLocaleString()} 辆</Tag>
|
||
<Tag color="grey">总计 {pagination.total.toLocaleString()} 辆</Tag>
|
||
<Button size="small" onClick={exportVehicles}>导出车辆当前页 CSV</Button>
|
||
</Space>
|
||
</div>
|
||
<Table
|
||
loading={loading}
|
||
rowKey="vin"
|
||
dataSource={rows}
|
||
columns={columns}
|
||
pagination={{
|
||
currentPage: pagination.currentPage,
|
||
pageSize: pagination.pageSize,
|
||
total: pagination.total,
|
||
showSizeChanger: true,
|
||
onPageChange: (page) => load(filters, page, pagination.pageSize),
|
||
onPageSizeChange: (pageSize) => load(filters, 1, pageSize)
|
||
}}
|
||
/>
|
||
</>
|
||
)}
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|