1520 lines
71 KiB
TypeScript
1520 lines
71 KiB
TypeScript
import { Button, Card, Form, Select, Space, Table, Tag, Toast, Typography } from '@douyinfe/semi-ui';
|
||
import { useEffect, useState } from 'react';
|
||
import { api } from '../api/client';
|
||
import type { DailyMileageRow, MileageSummary, OnlineStatisticsSummary, OnlineVehicleStatusRow } from '../api/types';
|
||
import { PageHeader } from '../components/PageHeader';
|
||
import { buildAppHash } from '../domain/appRoute';
|
||
import { buildCsv, downloadCsv, type CsvColumn } from '../domain/csvExport';
|
||
|
||
const emptySummary: MileageSummary = {
|
||
vehicleCount: 0,
|
||
recordCount: 0,
|
||
sourceCount: 0,
|
||
totalMileageKm: 0,
|
||
averageMileagePerVin: 0
|
||
};
|
||
|
||
const emptyOnlineSummary: OnlineStatisticsSummary = {
|
||
vehicleCount: 0,
|
||
onlineVehicleCount: 0,
|
||
offlineVehicleCount: 0,
|
||
onlineRatePercent: 0,
|
||
redisOnlineKeys: null,
|
||
protocolStats: [],
|
||
evidence: ''
|
||
};
|
||
|
||
function mileageParams(values: Record<string, string>, pageSize?: number, offset?: number) {
|
||
const params = new URLSearchParams();
|
||
if (pageSize != null) params.set('limit', String(pageSize));
|
||
if (offset != null) params.set('offset', String(offset));
|
||
if (values?.keyword) params.set('keyword', values.keyword);
|
||
if (values?.protocol) params.set('protocol', values.protocol);
|
||
if (values?.dateFrom) params.set('dateFrom', values.dateFrom);
|
||
if (values?.dateTo) params.set('dateTo', values.dateTo);
|
||
return params;
|
||
}
|
||
|
||
function formatKm(value: number) {
|
||
return Number.isFinite(value) ? value.toLocaleString(undefined, { maximumFractionDigits: 1 }) : '0';
|
||
}
|
||
|
||
function formatPercent(value: number) {
|
||
return Number.isFinite(value) ? `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })}%` : '0%';
|
||
}
|
||
|
||
function formatOfflineDuration(minutes?: number | null) {
|
||
if (minutes == null || !Number.isFinite(Number(minutes))) return '-';
|
||
const value = Math.max(0, Number(minutes));
|
||
if (value < 60) return `${value.toLocaleString()} 分钟`;
|
||
const hours = Math.floor(value / 60);
|
||
const rest = Math.round(value % 60);
|
||
return rest > 0 ? `${hours.toLocaleString()} 小时 ${rest} 分钟` : `${hours.toLocaleString()} 小时`;
|
||
}
|
||
|
||
function canOpenVehicle(vin?: string) {
|
||
const value = vin?.trim();
|
||
return Boolean(value && value !== 'unknown');
|
||
}
|
||
|
||
function nextDate(value: string) {
|
||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim());
|
||
if (!match) return '';
|
||
const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]) + 1));
|
||
return date.toISOString().slice(0, 10);
|
||
}
|
||
|
||
function mileageActionRecommendation(row: DailyMileageRow) {
|
||
if (row.anomalySeverity) {
|
||
return {
|
||
label: '核对里程来源',
|
||
color: 'orange' as const,
|
||
detail: '日里程异常,优先核对当天首末总里程、来源断链和补传数据。'
|
||
};
|
||
}
|
||
return {
|
||
label: '可用于日报统计',
|
||
color: 'green' as const,
|
||
detail: '该日里程未发现异常,可进入车辆服务查看来源证据。'
|
||
};
|
||
}
|
||
|
||
function mergeInitialFilters(initialVin: string, initialProtocol?: string, initialFilters: Record<string, string> = {}) {
|
||
return {
|
||
keyword: initialVin,
|
||
protocol: initialProtocol ?? '',
|
||
...initialFilters
|
||
};
|
||
}
|
||
|
||
function groupMileageByDate(rows: DailyMileageRow[]) {
|
||
const bucket = new Map<string, number>();
|
||
rows.forEach((row) => {
|
||
bucket.set(row.date || '-', (bucket.get(row.date || '-') ?? 0) + row.dailyMileageKm);
|
||
});
|
||
return [...bucket.entries()]
|
||
.map(([date, value]) => ({ date, value }))
|
||
.sort((a, b) => a.date.localeCompare(b.date));
|
||
}
|
||
|
||
function groupMileageBySource(rows: DailyMileageRow[]) {
|
||
const bucket = new Map<string, number>();
|
||
rows.forEach((row) => {
|
||
bucket.set(row.source || 'UNKNOWN', (bucket.get(row.source || 'UNKNOWN') ?? 0) + row.dailyMileageKm);
|
||
});
|
||
return [...bucket.entries()]
|
||
.map(([source, value]) => ({ source, value }))
|
||
.sort((a, b) => b.value - a.value);
|
||
}
|
||
|
||
function maxMileageRow(rows: DailyMileageRow[]) {
|
||
return rows.reduce<DailyMileageRow | undefined>((max, row) => {
|
||
if (!max || row.dailyMileageKm > max.dailyMileageKm) return row;
|
||
return max;
|
||
}, undefined);
|
||
}
|
||
|
||
const mileageExportColumns: CsvColumn<DailyMileageRow>[] = [
|
||
{ title: '日期', value: (row) => row.date },
|
||
{ title: 'VIN', value: (row) => row.vin },
|
||
{ title: '车牌', value: (row) => row.plate },
|
||
{ title: '来源', value: (row) => row.source },
|
||
{ title: '起始里程km', value: (row) => row.startMileageKm },
|
||
{ title: '结束里程km', value: (row) => row.endMileageKm },
|
||
{ title: '日里程km', value: (row) => row.dailyMileageKm },
|
||
{ title: '异常', value: (row) => row.anomalySeverity || '正常' }
|
||
];
|
||
|
||
function exportFileName(filters: Record<string, string>) {
|
||
const keyword = filters.keyword?.trim() || 'all';
|
||
const protocol = filters.protocol?.trim() || 'all-source';
|
||
return `daily-mileage-${keyword}-${protocol}.csv`;
|
||
}
|
||
|
||
function appURL(hash: string) {
|
||
return `${window.location.origin}${window.location.pathname}${hash}`;
|
||
}
|
||
|
||
function mileageSummaryText({
|
||
filters,
|
||
summary,
|
||
pageMileageTotal,
|
||
anomalyCount,
|
||
peakMileage,
|
||
statisticsURL,
|
||
vehicleURL
|
||
}: {
|
||
filters: Record<string, string>;
|
||
summary: MileageSummary;
|
||
pageMileageTotal: number;
|
||
anomalyCount: number;
|
||
peakMileage?: DailyMileageRow;
|
||
statisticsURL: string;
|
||
vehicleURL?: string;
|
||
}) {
|
||
const keyword = filters.keyword?.trim() || '全部车辆';
|
||
const protocol = filters.protocol?.trim() || '全部来源';
|
||
const dateFrom = filters.dateFrom?.trim();
|
||
const dateTo = filters.dateTo?.trim();
|
||
const dateRange = dateFrom || dateTo ? `${dateFrom || '-'} 至 ${dateTo || '-'}` : '全部日期';
|
||
const peak = peakMileage
|
||
? `${peakMileage.plate || peakMileage.vin} / ${peakMileage.date} / ${formatKm(peakMileage.dailyMileageKm)} km`
|
||
: '-';
|
||
return [
|
||
'【里程统计摘要】',
|
||
`车辆范围:${keyword}`,
|
||
`数据来源:${protocol}`,
|
||
`日期范围:${dateRange}`,
|
||
`统计车辆:${summary.vehicleCount.toLocaleString()},记录:${summary.recordCount.toLocaleString()},来源:${summary.sourceCount.toLocaleString()}`,
|
||
`累计里程:${formatKm(summary.totalMileageKm)} km`,
|
||
`单车平均:${formatKm(summary.averageMileagePerVin)} km`,
|
||
`当前页里程:${formatKm(pageMileageTotal)} km`,
|
||
`异常记录:${anomalyCount.toLocaleString()}`,
|
||
`最大单日:${peak}`,
|
||
'统计口径:区间总值等于各日里程之和',
|
||
`统计查询:${statisticsURL}`,
|
||
...(vehicleURL ? [`车辆服务:${vehicleURL}`] : [])
|
||
].join('\n');
|
||
}
|
||
|
||
function mileageBIPublishText({
|
||
filters,
|
||
summary,
|
||
pageMileageTotal,
|
||
pageCoverageRate,
|
||
anomalyCount,
|
||
closureStatus,
|
||
closureDeltaKm,
|
||
confidenceStatus,
|
||
sourceConsistencyText,
|
||
statisticsURL,
|
||
vehicleURL
|
||
}: {
|
||
filters: Record<string, string>;
|
||
summary: MileageSummary;
|
||
pageMileageTotal: number;
|
||
pageCoverageRate: number;
|
||
anomalyCount: number;
|
||
closureStatus: string;
|
||
closureDeltaKm?: number;
|
||
confidenceStatus: string;
|
||
sourceConsistencyText: string;
|
||
statisticsURL: string;
|
||
vehicleURL?: string;
|
||
}) {
|
||
const keyword = filters.keyword?.trim() || '全部车辆';
|
||
const protocol = filters.protocol?.trim() || '全部来源';
|
||
const dateFrom = filters.dateFrom?.trim();
|
||
const dateTo = filters.dateTo?.trim();
|
||
const dateRange = dateFrom || dateTo ? `${dateFrom || '-'} 至 ${dateTo || '-'}` : '全部日期';
|
||
const publishable = confidenceStatus === '可用于 BI' && closureStatus === '闭合通过';
|
||
return [
|
||
'【BI里程发布口径】',
|
||
`发布结论:${publishable ? '可发布' : '需复核后发布'}`,
|
||
`车辆范围:${keyword}`,
|
||
`数据来源:${protocol}`,
|
||
`日期范围:${dateRange}`,
|
||
`统计口径:日里程按首末总里程差值计算,区间总值等于每日里程之和`,
|
||
`累计里程:${formatKm(summary.totalMileageKm)} km`,
|
||
`明细合计:${formatKm(pageMileageTotal)} km`,
|
||
`闭合状态:${closureStatus}${closureDeltaKm == null ? '' : `,差值 ${formatKm(Math.abs(closureDeltaKm))} km`}`,
|
||
`记录覆盖率:${formatPercent(pageCoverageRate)}`,
|
||
`异常记录:${anomalyCount.toLocaleString()}`,
|
||
`来源一致性:${sourceConsistencyText}`,
|
||
`统计车辆:${summary.vehicleCount.toLocaleString()},记录:${summary.recordCount.toLocaleString()},来源:${summary.sourceCount.toLocaleString()}`,
|
||
`发布风险:${publishable ? '当前筛选范围闭合且无异常记录' : '存在分页未全量、异常记录或闭合差值,需要先复核轨迹和 RAW 证据'}`,
|
||
`统计查询:${statisticsURL}`,
|
||
...(vehicleURL ? [`车辆服务:${vehicleURL}`] : [])
|
||
].join('\n');
|
||
}
|
||
|
||
function statisticsImpactText({
|
||
filters,
|
||
summary,
|
||
onlineSummary,
|
||
closureStatus,
|
||
confidenceStatus,
|
||
anomalyCount,
|
||
pageCoverageRate,
|
||
publishAuditConclusion,
|
||
statisticsURL,
|
||
vehicleURL
|
||
}: {
|
||
filters: Record<string, string>;
|
||
summary: MileageSummary;
|
||
onlineSummary: OnlineStatisticsSummary;
|
||
closureStatus: string;
|
||
confidenceStatus: string;
|
||
anomalyCount: number;
|
||
pageCoverageRate: number;
|
||
publishAuditConclusion: string;
|
||
statisticsURL: string;
|
||
vehicleURL?: string;
|
||
}) {
|
||
const keyword = filters.keyword?.trim() || '全部车辆';
|
||
const protocol = filters.protocol?.trim() || '全部来源';
|
||
const dateFrom = filters.dateFrom?.trim();
|
||
const dateTo = filters.dateTo?.trim();
|
||
const dateRange = dateFrom || dateTo ? `${dateFrom || '-'} 至 ${dateTo || '-'}` : '全部日期';
|
||
return [
|
||
'【统计影响范围】',
|
||
`车辆范围:${keyword}`,
|
||
`数据来源:${protocol}`,
|
||
`日期范围:${dateRange}`,
|
||
`影响车辆:${summary.vehicleCount.toLocaleString()} 辆`,
|
||
`统计记录:${summary.recordCount.toLocaleString()} 条`,
|
||
`累计里程:${formatKm(summary.totalMileageKm)} km`,
|
||
`在线状态:${onlineSummary.onlineVehicleCount.toLocaleString()} 在线 / ${onlineSummary.offlineVehicleCount.toLocaleString()} 离线 / 在线率 ${formatPercent(onlineSummary.onlineRatePercent)}`,
|
||
`闭合状态:${closureStatus}`,
|
||
`发布判断:${publishAuditConclusion}`,
|
||
`可信度:${confidenceStatus}`,
|
||
`异常记录:${anomalyCount.toLocaleString()}`,
|
||
`当前页覆盖:${formatPercent(pageCoverageRate)}`,
|
||
`业务影响:${publishAuditConclusion === '可发布' ? '当前统计范围可进入运营和BI口径' : '当前统计范围需要先复核轨迹、RAW和离线车辆影响'}`,
|
||
`统计查询:${statisticsURL}`,
|
||
...(vehicleURL ? [`车辆服务:${vehicleURL}`] : [])
|
||
].join('\n');
|
||
}
|
||
|
||
type MileagePublishAuditItem = {
|
||
key: string;
|
||
label: string;
|
||
status: 'pass' | 'review' | 'blocked';
|
||
value: string;
|
||
detail: string;
|
||
action: string;
|
||
};
|
||
|
||
function auditStatusColor(status: MileagePublishAuditItem['status']): 'green' | 'orange' | 'red' {
|
||
if (status === 'pass') return 'green';
|
||
if (status === 'blocked') return 'red';
|
||
return 'orange';
|
||
}
|
||
|
||
function auditStatusLabel(status: MileagePublishAuditItem['status']) {
|
||
if (status === 'pass') return '通过';
|
||
if (status === 'blocked') return '阻断';
|
||
return '复核';
|
||
}
|
||
|
||
function mileagePublishAuditReport({
|
||
filters,
|
||
items,
|
||
summary,
|
||
statisticsURL,
|
||
vehicleURL
|
||
}: {
|
||
filters: Record<string, string>;
|
||
items: MileagePublishAuditItem[];
|
||
summary: MileageSummary;
|
||
statisticsURL: string;
|
||
vehicleURL?: string;
|
||
}) {
|
||
const blocked = items.filter((item) => item.status === 'blocked').length;
|
||
const review = items.filter((item) => item.status === 'review').length;
|
||
const pass = items.filter((item) => item.status === 'pass').length;
|
||
const keyword = filters.keyword?.trim() || '全部车辆';
|
||
const protocol = filters.protocol?.trim() || '全部来源';
|
||
return [
|
||
'【统计发布审计】',
|
||
`发布结论:${blocked > 0 ? '阻断发布' : review > 0 ? '复核后发布' : '可发布'}`,
|
||
`车辆范围:${keyword}`,
|
||
`数据来源:${protocol}`,
|
||
`统计规模:${summary.vehicleCount.toLocaleString()} 车 / ${summary.recordCount.toLocaleString()} 条 / ${summary.sourceCount.toLocaleString()} 来源`,
|
||
`审计结果:通过 ${pass.toLocaleString()} / 复核 ${review.toLocaleString()} / 阻断 ${blocked.toLocaleString()}`,
|
||
'',
|
||
...items.map((item, index) => [
|
||
`${index + 1}. [${auditStatusLabel(item.status)}] ${item.label}:${item.value}`,
|
||
` 说明:${item.detail}`,
|
||
` 动作:${item.action}`
|
||
].join('\n')),
|
||
'',
|
||
`统计查询:${statisticsURL}`,
|
||
...(vehicleURL ? [`车辆服务:${vehicleURL}`] : [])
|
||
].join('\n');
|
||
}
|
||
|
||
function mileageEvidencePackageText(row: DailyMileageRow) {
|
||
const dateFrom = row.date?.trim() ?? '';
|
||
const dateTo = nextDate(dateFrom);
|
||
const evidenceFilters = {
|
||
...(dateFrom ? { dateFrom } : {}),
|
||
...(dateTo ? { dateTo } : {})
|
||
};
|
||
const vehicle = row.plate?.trim() ? `${row.plate.trim()} / ${row.vin}` : row.vin;
|
||
return [
|
||
'【里程异常证据包】',
|
||
`车辆:${vehicle}`,
|
||
`数据来源:${row.source || '未知来源'}`,
|
||
`统计日期:${row.date || '-'}`,
|
||
`起始里程:${formatKm(row.startMileageKm)} km`,
|
||
`结束里程:${formatKm(row.endMileageKm)} km`,
|
||
`日里程:${formatKm(row.dailyMileageKm)} km`,
|
||
`异常等级:${row.anomalySeverity || '正常'}`,
|
||
`处置建议:${mileageActionRecommendation(row).detail}`,
|
||
`车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: row.vin, protocol: row.source }))}`,
|
||
`轨迹证据:${appURL(buildAppHash({ page: 'history', keyword: row.vin, protocol: row.source, filters: evidenceFilters }))}`,
|
||
`RAW证据:${appURL(buildAppHash({ page: 'history-query', keyword: row.vin, protocol: row.source, filters: { ...evidenceFilters, tab: 'raw', includeFields: 'true' } }))}`,
|
||
`统计查询:${appURL(buildAppHash({ page: 'mileage', keyword: row.vin, protocol: row.source, filters: evidenceFilters }))}`
|
||
].join('\n');
|
||
}
|
||
|
||
function onlineStatusHandoffText({
|
||
filters,
|
||
summary,
|
||
rows,
|
||
total
|
||
}: {
|
||
filters: Record<string, string>;
|
||
summary: OnlineStatisticsSummary;
|
||
rows: OnlineVehicleStatusRow[];
|
||
total: number;
|
||
}) {
|
||
const keyword = filters.keyword?.trim() || '全部车辆';
|
||
const protocol = filters.protocol?.trim() || '全部来源';
|
||
const handoffRows = Array.isArray(rows) ? rows : [];
|
||
const offlineRows = handoffRows.filter((row) => !row.online);
|
||
const protocolStats = Array.isArray(summary.protocolStats) ? summary.protocolStats : [];
|
||
const protocolLines = protocolStats.length > 0
|
||
? protocolStats.map((item) => `${item.protocol} ${item.online.toLocaleString()}/${item.total.toLocaleString()}`).join(';')
|
||
: '-';
|
||
return [
|
||
'【车辆在线状态交接】',
|
||
`车辆范围:${keyword}`,
|
||
`数据来源:${protocol}`,
|
||
`在线概览:${summary.onlineVehicleCount.toLocaleString()} 在线 / ${summary.offlineVehicleCount.toLocaleString()} 离线 / 在线率 ${formatPercent(summary.onlineRatePercent)}`,
|
||
`Redis 在线 Key:${summary.redisOnlineKeys == null ? '-' : summary.redisOnlineKeys.toLocaleString()}`,
|
||
`协议在线:${protocolLines}`,
|
||
`统计证据:${summary.evidence || '-'}`,
|
||
`当前页离线:${offlineRows.length.toLocaleString()} 辆 / 当前筛选 ${total.toLocaleString()} 辆`,
|
||
'',
|
||
'离线车辆:',
|
||
...(offlineRows.length > 0 ? offlineRows.map((row, index) => [
|
||
`${index + 1}. ${row.plate || '-'} / ${row.vin || '-'} / ${row.oem || '-'}`,
|
||
` 离线时长:${formatOfflineDuration(row.offlineDurationMinutes)};最后上报:${row.lastSeen || '-'}`,
|
||
` 来源覆盖:${row.onlineSourceCount}/${row.sourceCount};协议:${(row.protocols ?? []).join('、') || '-'}`,
|
||
` 车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: row.vin, protocol: filters.protocol?.trim() || row.protocols?.[0] || '' }))}`,
|
||
` 实时监控:${appURL(buildAppHash({ page: 'realtime', keyword: row.vin, protocol: filters.protocol?.trim() || row.protocols?.[0] || '' }))}`
|
||
].join('\n')) : ['当前页暂无离线车辆'])
|
||
].join('\n');
|
||
}
|
||
|
||
function onlineOperationsReportText({
|
||
filters,
|
||
summary,
|
||
onlineRows,
|
||
total,
|
||
statisticsURL,
|
||
vehicleURL
|
||
}: {
|
||
filters: Record<string, string>;
|
||
summary: OnlineStatisticsSummary;
|
||
onlineRows: OnlineVehicleStatusRow[];
|
||
total: number;
|
||
statisticsURL: string;
|
||
vehicleURL?: string;
|
||
}) {
|
||
const keyword = filters.keyword?.trim() || '全部车辆';
|
||
const protocol = filters.protocol?.trim() || '全部来源';
|
||
const protocolStats = Array.isArray(summary.protocolStats) ? summary.protocolStats : [];
|
||
const offlineRows = onlineRows.filter((row) => !row.online);
|
||
const longestOffline = offlineRows.reduce<OnlineVehicleStatusRow | undefined>((current, row) => {
|
||
if (!current) return row;
|
||
return Number(row.offlineDurationMinutes ?? 0) > Number(current.offlineDurationMinutes ?? 0) ? row : current;
|
||
}, undefined);
|
||
return [
|
||
'【在线统计运营态势】',
|
||
`车辆范围:${keyword}`,
|
||
`数据来源:${protocol}`,
|
||
`在线概览:${summary.onlineVehicleCount.toLocaleString()} 在线 / ${summary.offlineVehicleCount.toLocaleString()} 离线 / 在线率 ${formatPercent(summary.onlineRatePercent)}`,
|
||
`Redis 在线 Key:${summary.redisOnlineKeys == null ? '-' : summary.redisOnlineKeys.toLocaleString()}`,
|
||
`统计证据:${summary.evidence || '-'}`,
|
||
`当前页车辆:${onlineRows.length.toLocaleString()} / 当前筛选 ${total.toLocaleString()}`,
|
||
`当前页离线:${offlineRows.length.toLocaleString()}`,
|
||
`最长离线:${longestOffline ? `${longestOffline.plate || longestOffline.vin} / ${formatOfflineDuration(longestOffline.offlineDurationMinutes)} / ${longestOffline.lastSeen || '-'}` : '-'}`,
|
||
'',
|
||
'协议在线:',
|
||
...(protocolStats.length > 0 ? protocolStats.map((item, index) => {
|
||
const rate = item.total > 0 ? (item.online / item.total) * 100 : 0;
|
||
return `${index + 1}. ${item.protocol}:${item.online.toLocaleString()}/${item.total.toLocaleString()},在线率 ${formatPercent(rate)}`;
|
||
}) : ['暂无协议在线统计']),
|
||
'',
|
||
'处置建议:',
|
||
'1. 在线率下降时先检查 Redis online key、协议接入和最后上报时间',
|
||
'2. 离线车辆优先打开车辆服务,核对实时、轨迹、RAW 和告警证据',
|
||
'3. 统计发布前确认离线车辆是否影响当日里程和区间闭合',
|
||
`统计查询:${statisticsURL}`,
|
||
...(vehicleURL ? [`车辆服务:${vehicleURL}`] : [])
|
||
].join('\n');
|
||
}
|
||
|
||
async function copyText(value: string, label: string) {
|
||
const text = value.trim();
|
||
if (!text) {
|
||
Toast.warning(`${label}为空`);
|
||
return;
|
||
}
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
Toast.success(`已复制${label}`);
|
||
} catch {
|
||
Toast.error(`复制${label}失败`);
|
||
}
|
||
}
|
||
|
||
export function Mileage({
|
||
initialVin,
|
||
initialProtocol,
|
||
initialFilters = {},
|
||
onFiltersChange,
|
||
onOpenVehicle,
|
||
onOpenHistory,
|
||
onOpenRaw
|
||
}: {
|
||
initialVin: string;
|
||
initialProtocol?: string;
|
||
initialFilters?: Record<string, string>;
|
||
onFiltersChange?: (filters: Record<string, string>) => void;
|
||
onOpenVehicle: (vin: string, protocol?: string) => void;
|
||
onOpenHistory?: (filters: Record<string, string>) => void;
|
||
onOpenRaw?: (filters: Record<string, string>) => void;
|
||
}) {
|
||
const [rows, setRows] = useState<DailyMileageRow[]>([]);
|
||
const [onlineRows, setOnlineRows] = useState<OnlineVehicleStatusRow[]>([]);
|
||
const [summary, setSummary] = useState<MileageSummary>(emptySummary);
|
||
const [onlineSummary, setOnlineSummary] = useState<OnlineStatisticsSummary>(emptyOnlineSummary);
|
||
const [loading, setLoading] = useState(true);
|
||
const [onlineLoading, setOnlineLoading] = useState(true);
|
||
const [summaryLoading, setSummaryLoading] = useState(true);
|
||
const [filters, setFilters] = useState<Record<string, string>>(mergeInitialFilters(initialVin, initialProtocol, initialFilters));
|
||
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 });
|
||
const [onlinePagination, setOnlinePagination] = useState({ currentPage: 1, pageSize: 10, total: 0 });
|
||
const currentVehicleKeyword = filters.keyword?.trim() ?? '';
|
||
const currentProtocol = filters.protocol?.trim() ?? '';
|
||
const dateSeries = groupMileageByDate(rows);
|
||
const sourceSeries = groupMileageBySource(rows);
|
||
const anomalyRows = rows.filter((row) => row.anomalySeverity);
|
||
const peakMileage = maxMileageRow(rows);
|
||
const pageMileageTotal = rows.reduce((total, row) => total + row.dailyMileageKm, 0);
|
||
const anomalyRate = rows.length > 0 ? (anomalyRows.length / rows.length) * 100 : 0;
|
||
const peakMileageShare = pageMileageTotal > 0 && peakMileage ? (peakMileage.dailyMileageKm / pageMileageTotal) * 100 : 0;
|
||
const pageCoverageRate = summary.recordCount > 0 ? (rows.length / summary.recordCount) * 100 : 0;
|
||
const hasFullMileagePage = summary.recordCount > 0 && rows.length === summary.recordCount;
|
||
const closureDeltaKm = hasFullMileagePage ? summary.totalMileageKm - pageMileageTotal : undefined;
|
||
const closurePassed = closureDeltaKm != null && Math.abs(closureDeltaKm) < 0.01;
|
||
const closureStatus = closureDeltaKm == null ? '分页抽样' : closurePassed ? '闭合通过' : '闭合异常';
|
||
const closureStatusColor = closurePassed ? 'green' as const : closureDeltaKm == null ? 'grey' as const : 'orange' as const;
|
||
const closureActionText = closureDeltaKm == null
|
||
? '当前页未覆盖全量记录,请翻页、导出或缩小筛选范围后核验。'
|
||
: closurePassed
|
||
? '当前筛选范围汇总总里程与明细合计一致,可作为统计证据。'
|
||
: '汇总总里程与明细合计不一致,请核对分页、来源和异常记录。';
|
||
const confidenceStatus = anomalyRows.length > 0 || pageCoverageRate < 100 ? '需复核' : '可用于 BI';
|
||
const confidenceColor = confidenceStatus === '可用于 BI' ? 'green' as const : 'orange' as const;
|
||
const currentEvidenceText = `${summary.vehicleCount.toLocaleString()} 车 / ${summary.sourceCount.toLocaleString()} 来源 / ${rows.length.toLocaleString()} 条明细`;
|
||
const sourceConsistencyText = summary.sourceCount > 1 ? '可做跨来源核对' : summary.sourceCount === 1 ? '单来源车辆需关注' : '等待来源证据';
|
||
const onlineVehicleCount = Number(onlineSummary.onlineVehicleCount ?? 0);
|
||
const offlineVehicleCount = Number(onlineSummary.offlineVehicleCount ?? 0);
|
||
const onlineRatePercent = Number(onlineSummary.onlineRatePercent ?? 0);
|
||
const redisOnlineKeyText = onlineSummary.redisOnlineKeys == null ? '' : `Redis 在线 ${onlineSummary.redisOnlineKeys.toLocaleString()} key`;
|
||
const onlineStatsDetail = [
|
||
`在线 ${onlineVehicleCount.toLocaleString()} 车`,
|
||
`离线 ${offlineVehicleCount.toLocaleString()} 车`,
|
||
redisOnlineKeyText,
|
||
onlineSummary.evidence
|
||
].filter(Boolean).join(',');
|
||
const onlineProtocolStats = Array.isArray(onlineSummary.protocolStats) ? onlineSummary.protocolStats : [];
|
||
const topOfflineRow = onlineRows.filter((row) => !row.online).reduce<OnlineVehicleStatusRow | undefined>((current, row) => {
|
||
if (!current) return row;
|
||
return Number(row.offlineDurationMinutes ?? 0) > Number(current.offlineDurationMinutes ?? 0) ? row : current;
|
||
}, undefined);
|
||
const publishAuditItems: MileagePublishAuditItem[] = [
|
||
{
|
||
key: 'closure',
|
||
label: '区间闭合',
|
||
status: closureDeltaKm == null ? 'review' : closurePassed ? 'pass' : 'blocked',
|
||
value: closureStatus,
|
||
detail: closureDeltaKm == null ? '当前分页未覆盖全量记录,无法直接证明区间总值等于每日之和。' : `闭合差值 ${formatKm(Math.abs(closureDeltaKm))} km。`,
|
||
action: closurePassed ? '保留当前统计证据,可继续审计异常和来源。' : '缩小筛选范围、翻页或导出后重新核验闭合。'
|
||
},
|
||
{
|
||
key: 'coverage',
|
||
label: '记录覆盖',
|
||
status: pageCoverageRate >= 100 ? 'pass' : summary.recordCount > rows.length ? 'review' : 'pass',
|
||
value: formatPercent(pageCoverageRate),
|
||
detail: `${rows.length.toLocaleString()} / ${summary.recordCount.toLocaleString()} 条明细在当前页。`,
|
||
action: pageCoverageRate >= 100 ? '当前明细已覆盖统计范围。' : '发布前需要导出或拉齐全量明细。'
|
||
},
|
||
{
|
||
key: 'anomaly',
|
||
label: '异常记录',
|
||
status: anomalyRows.length > 0 ? 'review' : 'pass',
|
||
value: anomalyRows.length.toLocaleString(),
|
||
detail: anomalyRows.length > 0 ? '存在日里程异常标记,需核对首末总里程、补传和断链。' : '当前筛选范围未发现异常里程记录。',
|
||
action: anomalyRows.length > 0 ? '优先打开异常行的轨迹和 RAW 证据复核。' : '继续检查来源一致性和在线证据。'
|
||
},
|
||
{
|
||
key: 'source',
|
||
label: '来源一致性',
|
||
status: summary.sourceCount > 1 ? 'pass' : summary.sourceCount === 1 ? 'review' : 'blocked',
|
||
value: sourceConsistencyText,
|
||
detail: summary.sourceCount > 1 ? '可使用多协议来源交叉验证车辆统计。' : summary.sourceCount === 1 ? '只有单一来源,统计可用但可信度低于多来源。' : '当前统计缺少来源证据。',
|
||
action: summary.sourceCount > 1 ? '保留来源贡献作为审计附件。' : '核对 32960、808、MQTT 是否有缺失来源。'
|
||
},
|
||
{
|
||
key: 'online',
|
||
label: '在线证据',
|
||
status: onlineVehicleCount > 0 ? 'pass' : offlineVehicleCount > 0 ? 'review' : 'review',
|
||
value: `${onlineVehicleCount.toLocaleString()} 在线 / ${offlineVehicleCount.toLocaleString()} 离线`,
|
||
detail: onlineStatsDetail || '等待在线统计证据。',
|
||
action: onlineVehicleCount > 0 ? '实时在线证据可辅助统计发布。' : '统计发布前建议检查离线时长和平台转发状态。'
|
||
}
|
||
];
|
||
const publishBlockedCount = publishAuditItems.filter((item) => item.status === 'blocked').length;
|
||
const publishReviewCount = publishAuditItems.filter((item) => item.status === 'review').length;
|
||
const publishAuditConclusion = publishBlockedCount > 0 ? '阻断发布' : publishReviewCount > 0 ? '复核后发布' : '可发布';
|
||
const publishAuditColor = publishBlockedCount > 0 ? 'red' as const : publishReviewCount > 0 ? 'orange' as const : 'green' as const;
|
||
const statisticsImpactColor = publishAuditColor;
|
||
const statisticsImpactLevel = publishAuditConclusion === '可发布' ? '可运营发布' : publishAuditConclusion === '阻断发布' ? '发布阻断' : '影响待复核';
|
||
const statisticsImpactItems = [
|
||
{
|
||
label: '影响车辆',
|
||
value: `${summary.vehicleCount.toLocaleString()} 辆`,
|
||
detail: `${onlineVehicleCount.toLocaleString()} 在线 / ${offlineVehicleCount.toLocaleString()} 离线`,
|
||
color: offlineVehicleCount > 0 ? 'orange' as const : 'green' as const
|
||
},
|
||
{
|
||
label: '统计规模',
|
||
value: `${summary.recordCount.toLocaleString()} 条`,
|
||
detail: `${summary.sourceCount.toLocaleString()} 个来源,累计 ${formatKm(summary.totalMileageKm)} km`,
|
||
color: summary.recordCount > 0 ? 'blue' as const : 'grey' as const
|
||
},
|
||
{
|
||
label: '闭合影响',
|
||
value: closureStatus,
|
||
detail: closureActionText,
|
||
color: closureStatusColor
|
||
},
|
||
{
|
||
label: '异常影响',
|
||
value: `${anomalyRows.length.toLocaleString()} 条`,
|
||
detail: anomalyRows.length > 0 ? '异常会影响BI与运营日报,需要回到证据复核。' : '当前明细未命中异常标记。',
|
||
color: anomalyRows.length > 0 ? 'orange' as const : 'green' as const
|
||
},
|
||
{
|
||
label: '发布判断',
|
||
value: publishAuditConclusion,
|
||
detail: publishAuditConclusion === '可发布' ? '统计范围闭合且审计项通过。' : '发布前需要补齐审计项证据。',
|
||
color: publishAuditColor
|
||
}
|
||
];
|
||
const maxDateMileage = Math.max(...dateSeries.map((item) => item.value), 0);
|
||
const maxSourceMileage = Math.max(...sourceSeries.map((item) => item.value), 0);
|
||
const filterSummary = [
|
||
currentVehicleKeyword ? `车辆:${currentVehicleKeyword}` : '',
|
||
currentProtocol ? `数据来源:${currentProtocol}` : '',
|
||
filters.dateFrom?.trim() ? `开始日期:${filters.dateFrom.trim()}` : '',
|
||
filters.dateTo?.trim() ? `结束日期:${filters.dateTo.trim()}` : ''
|
||
].filter(Boolean);
|
||
const mileageRangeText = filters.dateFrom?.trim() || filters.dateTo?.trim()
|
||
? `${filters.dateFrom?.trim() || '-'} 至 ${filters.dateTo?.trim() || '-'}`
|
||
: '全部日期';
|
||
const mileageCustomerKpis = [
|
||
{
|
||
label: '区间总里程',
|
||
value: `${formatKm(summary.totalMileageKm)} km`,
|
||
color: 'blue' as const,
|
||
detail: `范围:${mileageRangeText}`
|
||
},
|
||
{
|
||
label: '统计车辆',
|
||
value: `${summary.vehicleCount.toLocaleString()} 辆`,
|
||
color: summary.vehicleCount > 0 ? 'green' as const : 'grey' as const,
|
||
detail: `${summary.recordCount.toLocaleString()} 条日里程记录`
|
||
},
|
||
{
|
||
label: '异常记录',
|
||
value: anomalyRows.length.toLocaleString(),
|
||
color: anomalyRows.length > 0 ? 'orange' as const : 'green' as const,
|
||
detail: anomalyRows.length > 0 ? '建议先核对轨迹和原始记录' : '当前页未发现异常'
|
||
},
|
||
{
|
||
label: '在线影响',
|
||
value: formatPercent(onlineRatePercent),
|
||
color: onlineRatePercent >= 90 ? 'green' as const : onlineRatePercent >= 60 ? 'orange' as const : 'red' as const,
|
||
detail: `${onlineVehicleCount.toLocaleString()} 在线 / ${offlineVehicleCount.toLocaleString()} 离线`
|
||
}
|
||
];
|
||
const mileageCustomerActions = [
|
||
{
|
||
label: '导出当前明细',
|
||
disabled: rows.length === 0,
|
||
action: () => exportMileage()
|
||
},
|
||
{
|
||
label: '查看轨迹',
|
||
disabled: !currentVehicleKeyword || !onOpenHistory,
|
||
action: () => onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' })
|
||
},
|
||
{
|
||
label: '查看原始记录',
|
||
disabled: !currentVehicleKeyword || !onOpenRaw,
|
||
action: () => onOpenRaw?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '', includeFields: 'true' })
|
||
},
|
||
{
|
||
label: '车辆档案',
|
||
disabled: !currentVehicleKeyword,
|
||
action: () => onOpenVehicle(currentVehicleKeyword, currentProtocol)
|
||
}
|
||
];
|
||
|
||
const loadSummary = (values: Record<string, string> = filters) => {
|
||
setSummaryLoading(true);
|
||
api.mileageSummary(mileageParams(values))
|
||
.then((nextSummary) => setSummary(nextSummary ?? emptySummary))
|
||
.catch((error: Error) => Toast.error(error.message))
|
||
.finally(() => setSummaryLoading(false));
|
||
api.onlineStatisticsSummary(mileageParams(values))
|
||
.then((nextSummary) => setOnlineSummary({
|
||
...emptyOnlineSummary,
|
||
...(nextSummary ?? {}),
|
||
protocolStats: Array.isArray(nextSummary?.protocolStats) ? nextSummary.protocolStats : []
|
||
}))
|
||
.catch((error: Error) => Toast.error(error.message));
|
||
};
|
||
|
||
const load = (values: Record<string, string> = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => {
|
||
setLoading(true);
|
||
api.dailyMileage(mileageParams(values, pageSize, (page - 1) * pageSize))
|
||
.then((nextPage) => {
|
||
setRows(Array.isArray(nextPage.items) ? nextPage.items : []);
|
||
setPagination({ currentPage: page, pageSize, total: nextPage.total });
|
||
})
|
||
.catch((error: Error) => Toast.error(error.message))
|
||
.finally(() => setLoading(false));
|
||
};
|
||
|
||
const loadOnlineRows = (values: Record<string, string> = filters, page = onlinePagination.currentPage, pageSize = onlinePagination.pageSize) => {
|
||
setOnlineLoading(true);
|
||
api.onlineVehicleStatuses(mileageParams(values, pageSize, (page - 1) * pageSize))
|
||
.then((nextPage) => {
|
||
setOnlineRows(Array.isArray(nextPage.items) ? nextPage.items : []);
|
||
setOnlinePagination({ currentPage: page, pageSize, total: nextPage.total });
|
||
})
|
||
.catch((error: Error) => Toast.error(error.message))
|
||
.finally(() => setOnlineLoading(false));
|
||
};
|
||
|
||
const applyFilters = (nextFilters: Record<string, string>) => {
|
||
setFilters(nextFilters);
|
||
onFiltersChange?.(nextFilters);
|
||
loadSummary(nextFilters);
|
||
load(nextFilters, 1, pagination.pageSize);
|
||
loadOnlineRows(nextFilters, 1, onlinePagination.pageSize);
|
||
};
|
||
|
||
const clearRangeFilters = () => {
|
||
applyFilters(currentVehicleKeyword ? { keyword: currentVehicleKeyword } : {});
|
||
};
|
||
|
||
const openMileageEvidence = (row: DailyMileageRow) => {
|
||
const dateFrom = row.date?.trim() ?? '';
|
||
const dateTo = nextDate(dateFrom);
|
||
onOpenHistory?.({
|
||
keyword: row.vin,
|
||
protocol: row.source,
|
||
...(dateFrom ? { dateFrom } : {}),
|
||
...(dateTo ? { dateTo } : {})
|
||
});
|
||
};
|
||
const openMileageRawEvidence = (row: DailyMileageRow) => {
|
||
const dateFrom = row.date?.trim() ?? '';
|
||
const dateTo = nextDate(dateFrom);
|
||
onOpenRaw?.({
|
||
keyword: row.vin,
|
||
protocol: row.source,
|
||
...(dateFrom ? { dateFrom } : {}),
|
||
...(dateTo ? { dateTo } : {})
|
||
});
|
||
};
|
||
const copyMileageEvidence = (row: DailyMileageRow) => {
|
||
copyText(mileageEvidencePackageText(row), '里程证据包');
|
||
};
|
||
const exportMileage = () => {
|
||
if (rows.length === 0) {
|
||
Toast.warning('当前没有可导出的里程明细');
|
||
return;
|
||
}
|
||
downloadCsv(exportFileName(filters), buildCsv(mileageExportColumns, rows));
|
||
Toast.success(`已导出 ${rows.length} 条里程明细`);
|
||
};
|
||
const copyStatisticsSummary = () => {
|
||
const vehicleURL = currentVehicleKeyword
|
||
? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol }))
|
||
: undefined;
|
||
copyText(
|
||
mileageSummaryText({
|
||
filters,
|
||
summary,
|
||
pageMileageTotal,
|
||
anomalyCount: anomalyRows.length,
|
||
peakMileage,
|
||
statisticsURL: appURL(window.location.hash || buildAppHash({ page: 'mileage', keyword: currentVehicleKeyword, protocol: currentProtocol })),
|
||
vehicleURL
|
||
}),
|
||
'统计摘要'
|
||
);
|
||
};
|
||
const copyBIPublishText = () => {
|
||
const vehicleURL = currentVehicleKeyword
|
||
? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol }))
|
||
: undefined;
|
||
copyText(
|
||
mileageBIPublishText({
|
||
filters,
|
||
summary,
|
||
pageMileageTotal,
|
||
pageCoverageRate,
|
||
anomalyCount: anomalyRows.length,
|
||
closureStatus,
|
||
closureDeltaKm,
|
||
confidenceStatus,
|
||
sourceConsistencyText,
|
||
statisticsURL: appURL(window.location.hash || buildAppHash({ page: 'mileage', keyword: currentVehicleKeyword, protocol: currentProtocol })),
|
||
vehicleURL
|
||
}),
|
||
'BI里程发布口径'
|
||
);
|
||
};
|
||
const copyPublishAudit = () => {
|
||
const vehicleURL = currentVehicleKeyword
|
||
? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol }))
|
||
: undefined;
|
||
copyText(
|
||
mileagePublishAuditReport({
|
||
filters,
|
||
items: publishAuditItems,
|
||
summary,
|
||
statisticsURL: appURL(window.location.hash || buildAppHash({ page: 'mileage', keyword: currentVehicleKeyword, protocol: currentProtocol })),
|
||
vehicleURL
|
||
}),
|
||
'统计发布审计'
|
||
);
|
||
};
|
||
const copyStatisticsImpact = () => {
|
||
const vehicleURL = currentVehicleKeyword
|
||
? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol }))
|
||
: undefined;
|
||
copyText(
|
||
statisticsImpactText({
|
||
filters,
|
||
summary,
|
||
onlineSummary,
|
||
closureStatus,
|
||
confidenceStatus,
|
||
anomalyCount: anomalyRows.length,
|
||
pageCoverageRate,
|
||
publishAuditConclusion,
|
||
statisticsURL: appURL(window.location.hash || buildAppHash({ page: 'mileage', keyword: currentVehicleKeyword, protocol: currentProtocol })),
|
||
vehicleURL
|
||
}),
|
||
'统计影响范围'
|
||
);
|
||
};
|
||
const copyOnlineStatusHandoff = () => {
|
||
copyText(
|
||
onlineStatusHandoffText({
|
||
filters,
|
||
summary: onlineSummary,
|
||
rows: onlineRows,
|
||
total: onlinePagination.total
|
||
}),
|
||
'在线状态交接'
|
||
);
|
||
};
|
||
const copyOnlineOperationsReport = () => {
|
||
const vehicleURL = currentVehicleKeyword
|
||
? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol }))
|
||
: undefined;
|
||
copyText(
|
||
onlineOperationsReportText({
|
||
filters,
|
||
summary: onlineSummary,
|
||
onlineRows,
|
||
total: onlinePagination.total,
|
||
statisticsURL: appURL(window.location.hash || buildAppHash({ page: 'mileage', keyword: currentVehicleKeyword, protocol: currentProtocol })),
|
||
vehicleURL
|
||
}),
|
||
'在线统计运营态势'
|
||
);
|
||
};
|
||
const mileageServiceTasks = [
|
||
{
|
||
title: '发布判断',
|
||
value: publishAuditConclusion,
|
||
detail: publishAuditConclusion === '可发布' ? '当前统计范围可进入运营日报或 BI 口径。' : '发布前需要补齐闭合、覆盖、异常或在线证据。',
|
||
color: publishAuditColor,
|
||
primaryAction: 'BI口径',
|
||
secondaryAction: '发布审计',
|
||
disabled: false,
|
||
secondaryDisabled: false,
|
||
onPrimary: copyBIPublishText,
|
||
onSecondary: copyPublishAudit
|
||
},
|
||
{
|
||
title: '区间复核',
|
||
value: closureStatus,
|
||
detail: `范围 ${mileageRangeText},${closureActionText}`,
|
||
color: closureStatusColor,
|
||
primaryAction: '轨迹证据',
|
||
secondaryAction: 'RAW证据',
|
||
disabled: !currentVehicleKeyword || !onOpenHistory,
|
||
secondaryDisabled: !currentVehicleKeyword || !onOpenRaw,
|
||
onPrimary: () => onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' }),
|
||
onSecondary: () => onOpenRaw?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '', includeFields: 'true' })
|
||
},
|
||
{
|
||
title: '异常处理',
|
||
value: `${anomalyRows.length.toLocaleString()} 条异常`,
|
||
detail: anomalyRows[0] ? `${anomalyRows[0].plate || anomalyRows[0].vin} / ${anomalyRows[0].date} 需要复核。` : '当前页未发现异常里程记录。',
|
||
color: anomalyRows.length > 0 ? 'orange' as const : 'green' as const,
|
||
primaryAction: '复制证据',
|
||
secondaryAction: '车辆档案',
|
||
disabled: !anomalyRows[0],
|
||
secondaryDisabled: !currentVehicleKeyword,
|
||
onPrimary: () => anomalyRows[0] && copyMileageEvidence(anomalyRows[0]),
|
||
onSecondary: () => currentVehicleKeyword && onOpenVehicle(currentVehicleKeyword, currentProtocol)
|
||
},
|
||
{
|
||
title: '数据交付',
|
||
value: `${rows.length.toLocaleString()} 条当前页`,
|
||
detail: `累计 ${formatKm(summary.totalMileageKm)} km,当前页 ${formatKm(pageMileageTotal)} km。`,
|
||
color: 'blue' as const,
|
||
primaryAction: '导出明细',
|
||
secondaryAction: '统计摘要',
|
||
disabled: rows.length === 0,
|
||
secondaryDisabled: false,
|
||
onPrimary: exportMileage,
|
||
onSecondary: copyStatisticsSummary
|
||
}
|
||
];
|
||
|
||
useEffect(() => {
|
||
const nextFilters = mergeInitialFilters(initialVin, initialProtocol, initialFilters);
|
||
setFilters(nextFilters);
|
||
loadSummary(nextFilters);
|
||
load(nextFilters, 1, pagination.pageSize);
|
||
loadOnlineRows(nextFilters, 1, onlinePagination.pageSize);
|
||
}, [initialVin, initialProtocol, JSON.stringify(initialFilters)]);
|
||
|
||
return (
|
||
<div className="vp-page">
|
||
<PageHeader
|
||
title="里程统计"
|
||
description="按车辆汇总每日里程、区间里程和异常差值分析"
|
||
actions={(
|
||
<Button disabled={!currentVehicleKeyword} onClick={() => onOpenVehicle(currentVehicleKeyword, currentProtocol)}>
|
||
当前车辆服务
|
||
</Button>
|
||
)}
|
||
/>
|
||
<div className="vp-scope-bar">
|
||
<span className="vp-scope-label">当前车辆:{currentVehicleKeyword || '-'}</span>
|
||
<Tag color={currentProtocol ? 'blue' : 'green'}>当前来源:{currentProtocol || '全部来源'}</Tag>
|
||
<Typography.Text type="tertiary">里程统计按当前车辆与来源范围汇总。</Typography.Text>
|
||
</div>
|
||
<Card bordered title="车辆统计服务总览" style={{ marginTop: 16 }}>
|
||
<div className="vp-table-toolbar">
|
||
<Space wrap>
|
||
<Tag color="blue">当前统计证据</Tag>
|
||
<Typography.Text strong>{currentEvidenceText}</Typography.Text>
|
||
<Typography.Text type="secondary">三类数据源最终汇总为一个车辆服务统计视图</Typography.Text>
|
||
</Space>
|
||
</div>
|
||
<div className="vp-stat-insight-grid">
|
||
{[
|
||
{
|
||
title: '里程统计',
|
||
value: `${formatKm(summary.totalMileageKm)} km`,
|
||
color: 'blue' as const,
|
||
detail: '每日里程、区间里程、异常差值和可审计证据。'
|
||
},
|
||
{
|
||
title: '在线率与离线时长',
|
||
value: formatPercent(onlineRatePercent),
|
||
color: 'green' as const,
|
||
detail: onlineStatsDetail || '按 VIN 统计在线率、离线时间和无更新时长。'
|
||
},
|
||
{
|
||
title: '数据完整性',
|
||
value: confidenceStatus,
|
||
color: confidenceColor,
|
||
detail: '围绕位置、里程、SOC、速度和来源新鲜度判断统计可信度。'
|
||
},
|
||
{
|
||
title: '来源一致性',
|
||
value: sourceConsistencyText,
|
||
color: summary.sourceCount > 1 ? 'green' as const : 'orange' as const,
|
||
detail: 'GB32960、JT808、Yutong MQTT 只作为同一车辆服务的来源证据。'
|
||
}
|
||
].map((item) => (
|
||
<div key={item.title} className="vp-monitor-metric">
|
||
<Tag color={item.color}>{item.title}</Tag>
|
||
<div className="vp-monitor-metric-value">{item.value}</div>
|
||
<Typography.Text type="secondary">{item.detail}</Typography.Text>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
<Card
|
||
bordered
|
||
title={<Space><span>统计影响范围</span><Button size="small" onClick={copyStatisticsImpact}>复制影响范围</Button></Space>}
|
||
style={{ marginTop: 16 }}
|
||
>
|
||
<div className="vp-stat-impact-board">
|
||
<div className="vp-stat-impact-summary">
|
||
<Tag color={statisticsImpactColor}>{statisticsImpactLevel}</Tag>
|
||
<div className="vp-monitor-metric-value">{summary.vehicleCount.toLocaleString()} 辆车</div>
|
||
<Typography.Text type="secondary">
|
||
当前筛选会影响 {summary.recordCount.toLocaleString()} 条里程记录、{formatKm(summary.totalMileageKm)} km 统计口径和 {offlineVehicleCount.toLocaleString()} 辆离线车辆判断。
|
||
</Typography.Text>
|
||
<Space wrap>
|
||
<Tag color={confidenceColor}>{confidenceStatus}</Tag>
|
||
<Tag color={closureStatusColor}>{closureStatus}</Tag>
|
||
<Tag color={publishAuditColor}>{publishAuditConclusion}</Tag>
|
||
</Space>
|
||
</div>
|
||
<div className="vp-stat-impact-grid">
|
||
{statisticsImpactItems.map((item) => (
|
||
<div key={item.label} className="vp-stat-impact-item">
|
||
<Tag color={item.color}>{item.label}</Tag>
|
||
<strong>{item.value}</strong>
|
||
<Typography.Text type="secondary">{item.detail}</Typography.Text>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
<Card bordered title="里程服务任务板" style={{ marginTop: 16 }}>
|
||
<div className="vp-mileage-task-grid">
|
||
{mileageServiceTasks.map((item) => (
|
||
<div key={item.title} className="vp-mileage-task-item">
|
||
<div className="vp-mileage-task-head">
|
||
<Tag color={item.color}>{item.title}</Tag>
|
||
<strong>{item.value}</strong>
|
||
</div>
|
||
<Typography.Text type="secondary">{item.detail}</Typography.Text>
|
||
<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.secondaryDisabled}
|
||
aria-label={`里程服务任务 ${item.title} ${item.secondaryAction}`}
|
||
onClick={item.onSecondary}
|
||
>
|
||
{item.secondaryAction}
|
||
</Button>
|
||
</Space>
|
||
</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.Input field="dateFrom" label="开始日期" placeholder="2026-07-01" style={{ width: 160 }} />
|
||
<Form.Input field="dateTo" label="结束日期" placeholder="2026-07-03" style={{ width: 160 }} />
|
||
<Space>
|
||
<Button htmlType="submit" theme="solid" type="primary">查询</Button>
|
||
<Button onClick={() => {
|
||
const nextFilters = mergeInitialFilters(initialVin, initialProtocol, {});
|
||
applyFilters(nextFilters);
|
||
}}>重置</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={clearRangeFilters}>清空筛选</Button>
|
||
</Space>
|
||
</Card>
|
||
) : null}
|
||
<Card bordered loading={summaryLoading || loading} title="区间里程核对台" style={{ marginTop: 16 }}>
|
||
<div className="vp-mileage-console">
|
||
<div className="vp-mileage-console-main">
|
||
<div className="vp-mileage-console-head">
|
||
<div>
|
||
<Typography.Title heading={5} style={{ margin: 0 }}>车辆里程核对</Typography.Title>
|
||
<Typography.Text type="secondary">
|
||
面向车辆运营查看区间里程、异常记录、在线影响和复核动作。
|
||
</Typography.Text>
|
||
</div>
|
||
<Space wrap>
|
||
<Tag color={closureStatusColor}>{closureStatus}</Tag>
|
||
<Tag color={publishAuditColor}>{publishAuditConclusion}</Tag>
|
||
</Space>
|
||
</div>
|
||
<div className="vp-mileage-kpi-grid">
|
||
{mileageCustomerKpis.map((item) => (
|
||
<button
|
||
key={item.label}
|
||
type="button"
|
||
className="vp-mileage-kpi-item"
|
||
onClick={() => {
|
||
if (item.label === '异常记录' && anomalyRows[0]) copyMileageEvidence(anomalyRows[0]);
|
||
}}
|
||
>
|
||
<Tag color={item.color}>{item.label}</Tag>
|
||
<strong>{item.value}</strong>
|
||
<span>{item.detail}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="vp-mileage-mini-charts">
|
||
<div>
|
||
<div className="vp-mileage-mini-title">日里程趋势</div>
|
||
<div className="vp-mileage-mini-chart">
|
||
{dateSeries.length > 0 ? dateSeries.slice(0, 10).map((item) => (
|
||
<div key={item.date} className="vp-mileage-mini-row">
|
||
<span>{item.date}</span>
|
||
<div><i style={{ width: `${maxDateMileage > 0 ? Math.max(4, (item.value / maxDateMileage) * 100) : 0}%` }} /></div>
|
||
<strong>{formatKm(item.value)} km</strong>
|
||
</div>
|
||
)) : <Typography.Text type="tertiary">暂无日里程趋势。</Typography.Text>}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="vp-mileage-mini-title">来源贡献</div>
|
||
<div className="vp-mileage-mini-chart">
|
||
{sourceSeries.length > 0 ? sourceSeries.slice(0, 5).map((item) => (
|
||
<div key={item.source} className="vp-mileage-mini-row">
|
||
<span>{item.source}</span>
|
||
<div><i className="vp-mileage-mini-row-green" style={{ width: `${maxSourceMileage > 0 ? Math.max(4, (item.value / maxSourceMileage) * 100) : 0}%` }} /></div>
|
||
<strong>{formatKm(item.value)} km</strong>
|
||
</div>
|
||
)) : <Typography.Text type="tertiary">暂无来源贡献。</Typography.Text>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<aside className="vp-mileage-console-side">
|
||
<div className="vp-mileage-side-card">
|
||
<div className="vp-mileage-side-title">核对动作</div>
|
||
{mileageCustomerActions.map((item) => (
|
||
<Button key={item.label} disabled={item.disabled} onClick={item.action}>{item.label}</Button>
|
||
))}
|
||
</div>
|
||
<div className="vp-mileage-side-card">
|
||
<div className="vp-mileage-side-title">最高单日</div>
|
||
{peakMileage ? (
|
||
<>
|
||
<Typography.Text strong>{peakMileage.plate || peakMileage.vin}</Typography.Text>
|
||
<Typography.Text type="secondary">{peakMileage.date} / {peakMileage.source}</Typography.Text>
|
||
<div className="vp-monitor-metric-value">{formatKm(peakMileage.dailyMileageKm)} km</div>
|
||
<Space wrap>
|
||
<Button size="small" onClick={() => openMileageEvidence(peakMileage)}>轨迹复核</Button>
|
||
<Button size="small" onClick={() => copyMileageEvidence(peakMileage)}>复制证据</Button>
|
||
</Space>
|
||
</>
|
||
) : (
|
||
<Typography.Text type="tertiary">当前筛选下暂无最高单日记录。</Typography.Text>
|
||
)}
|
||
</div>
|
||
<div className="vp-mileage-side-card">
|
||
<div className="vp-mileage-side-title">统计口径</div>
|
||
<Typography.Text type="secondary">
|
||
区间里程以每日首末总里程差值汇总,目标是区间总值等于每日里程之和。
|
||
</Typography.Text>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</Card>
|
||
<div className="vp-kpi-grid" style={{ marginTop: 16 }}>
|
||
{[
|
||
{ label: '车辆数', value: summary.vehicleCount.toLocaleString() },
|
||
{ label: '累计里程 km', value: formatKm(summary.totalMileageKm) },
|
||
{ label: '单车平均 km', value: formatKm(summary.averageMileagePerVin) },
|
||
{ label: '来源 / 记录', value: `${summary.sourceCount}/${summary.recordCount.toLocaleString()}` }
|
||
].map((item) => (
|
||
<Card key={item.label} bordered loading={summaryLoading}>
|
||
<div className="vp-kpi-value">{item.value}</div>
|
||
<div className="vp-kpi-label">{item.label}</div>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
<Card
|
||
bordered
|
||
loading={onlineLoading}
|
||
title={<Space><span>在线统计运营态势</span><Button size="small" onClick={copyOnlineOperationsReport}>复制在线态势</Button></Space>}
|
||
style={{ marginTop: 16 }}
|
||
>
|
||
<div className="vp-online-ops-board">
|
||
<div className="vp-online-ops-summary">
|
||
<Space wrap>
|
||
<Tag color={onlineRatePercent >= 90 ? 'green' : onlineRatePercent >= 60 ? 'orange' : 'red'}>
|
||
在线率 {formatPercent(onlineRatePercent)}
|
||
</Tag>
|
||
<Tag color={onlineSummary.redisOnlineKeys == null ? 'grey' : 'blue'}>
|
||
Redis {onlineSummary.redisOnlineKeys == null ? '-' : onlineSummary.redisOnlineKeys.toLocaleString()}
|
||
</Tag>
|
||
</Space>
|
||
<div className="vp-monitor-metric-value">{onlineVehicleCount.toLocaleString()} 在线</div>
|
||
<Typography.Text type="secondary">
|
||
在线状态用于解释统计缺口、离线车辆影响和当日里程是否需要延迟发布。
|
||
</Typography.Text>
|
||
</div>
|
||
<div className="vp-online-ops-grid">
|
||
{[
|
||
{
|
||
label: '离线车辆',
|
||
value: offlineVehicleCount.toLocaleString(),
|
||
detail: '离线车辆会影响实时统计、位置查询和当日里程完整性。',
|
||
color: offlineVehicleCount > 0 ? 'orange' as const : 'green' as const
|
||
},
|
||
{
|
||
label: '协议覆盖',
|
||
value: onlineProtocolStats.length.toLocaleString(),
|
||
detail: onlineProtocolStats.length > 0 ? onlineProtocolStats.map((item) => `${item.protocol} ${item.online}/${item.total}`).join(';') : '暂无协议在线统计。',
|
||
color: onlineProtocolStats.length > 0 ? 'blue' as const : 'grey' as const
|
||
},
|
||
{
|
||
label: '当前页离线',
|
||
value: onlineRows.filter((row) => !row.online).length.toLocaleString(),
|
||
detail: `${onlineRows.length.toLocaleString()} 条在线状态明细参与当前页复核。`,
|
||
color: onlineRows.some((row) => !row.online) ? 'orange' as const : 'green' as const
|
||
},
|
||
{
|
||
label: '最长离线',
|
||
value: topOfflineRow ? formatOfflineDuration(topOfflineRow.offlineDurationMinutes) : '-',
|
||
detail: topOfflineRow ? `${topOfflineRow.plate || topOfflineRow.vin} / ${topOfflineRow.lastSeen || '-'}` : '当前页暂无离线车辆。',
|
||
color: topOfflineRow ? 'orange' as const : 'green' as const
|
||
},
|
||
{
|
||
label: '统计证据',
|
||
value: onlineSummary.evidence || '-',
|
||
detail: '后端在线统计证据,可与 Redis key 和车辆快照互相校验。',
|
||
color: onlineSummary.evidence ? 'blue' as const : 'grey' as const
|
||
}
|
||
].map((item) => (
|
||
<div key={item.label} className="vp-online-ops-item">
|
||
<Tag color={item.color}>{item.label}</Tag>
|
||
<strong>{item.value}</strong>
|
||
<Typography.Text type="secondary">{item.detail}</Typography.Text>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
<Card bordered title="统计闭合工作台" loading={summaryLoading || loading} style={{ marginTop: 16 }}>
|
||
<div className="vp-stat-closure-board">
|
||
{[
|
||
{ label: '汇总总里程', value: `${formatKm(summary.totalMileageKm)} km`, color: 'blue' as const, detail: '后端按当前筛选范围返回的区间里程总值。' },
|
||
{ label: '明细合计', value: `${formatKm(pageMileageTotal)} km`, color: 'green' as const, detail: '当前页日里程明细求和,用于快速复核。' },
|
||
{ label: '闭合差值', value: closureDeltaKm == null ? '需全量' : `${formatKm(Math.abs(closureDeltaKm))} km`, color: closureStatusColor, detail: closureActionText },
|
||
{ label: '记录覆盖率', value: formatPercent(pageCoverageRate), color: pageCoverageRate >= 100 ? 'green' as const : 'grey' as const, detail: `${rows.length.toLocaleString()} / ${summary.recordCount.toLocaleString()} 条明细在当前页。` }
|
||
].map((item) => (
|
||
<div key={item.label} className="vp-stat-closure-item">
|
||
<Tag color={item.color}>{item.label}</Tag>
|
||
<div className="vp-monitor-metric-value">{item.value}</div>
|
||
<Typography.Text type="secondary">{item.detail}</Typography.Text>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="vp-stat-closure-action">
|
||
<Tag color={closureStatusColor}>{closureStatus}</Tag>
|
||
<Typography.Text type="secondary">
|
||
{closurePassed && anomalyRows.length === 0 ? '当前统计口径可直接用于 BI 里程口径;若存在跨来源差异,再进入车辆服务查看来源证据。' : '统计进入 BI 前需要结合异常记录、轨迹回放和 RAW 解析字段完成复核。'}
|
||
</Typography.Text>
|
||
<Space wrap>
|
||
<Button size="small" onClick={copyStatisticsSummary}>复制闭合摘要</Button>
|
||
<Button
|
||
size="small"
|
||
disabled={!currentVehicleKeyword || !onOpenHistory}
|
||
onClick={() => onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' })}
|
||
>
|
||
闭合复核轨迹
|
||
</Button>
|
||
<Button
|
||
size="small"
|
||
disabled={!currentVehicleKeyword || !onOpenRaw}
|
||
onClick={() => onOpenRaw?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '', includeFields: 'true' })}
|
||
>
|
||
闭合复核 RAW
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
</Card>
|
||
<Card bordered title="BI发布口径" style={{ marginTop: 16 }}>
|
||
<div className="vp-bi-publish-board">
|
||
<div className="vp-bi-publish-main">
|
||
<Tag color={confidenceColor}>{confidenceStatus}</Tag>
|
||
<div className="vp-monitor-metric-value">
|
||
{confidenceStatus === '可用于 BI' && closureStatus === '闭合通过' ? '可发布' : '需复核后发布'}
|
||
</div>
|
||
<Typography.Text type="secondary">
|
||
日里程按首末总里程差值计算,区间总值必须等于每日里程之和;发布前同步检查异常记录、分页覆盖和来源一致性。
|
||
</Typography.Text>
|
||
</div>
|
||
<div className="vp-bi-publish-grid">
|
||
<div><span>闭合状态</span><strong>{closureStatus}</strong></div>
|
||
<div><span>记录覆盖</span><strong>{formatPercent(pageCoverageRate)}</strong></div>
|
||
<div><span>异常记录</span><strong>{anomalyRows.length.toLocaleString()}</strong></div>
|
||
<div><span>来源一致性</span><strong>{sourceConsistencyText}</strong></div>
|
||
</div>
|
||
<Space wrap>
|
||
<Button size="small" onClick={copyBIPublishText}>复制BI发布口径</Button>
|
||
<Button size="small" onClick={copyStatisticsSummary}>复制统计摘要</Button>
|
||
</Space>
|
||
</div>
|
||
</Card>
|
||
<Card bordered title="统计发布审计" style={{ marginTop: 16 }}>
|
||
<div className="vp-stat-audit-board">
|
||
<div className="vp-stat-audit-summary">
|
||
<Tag color={publishAuditColor}>{publishAuditConclusion}</Tag>
|
||
<div className="vp-monitor-metric-value">
|
||
通过 {publishAuditItems.length - publishReviewCount - publishBlockedCount} / 复核 {publishReviewCount} / 阻断 {publishBlockedCount}
|
||
</div>
|
||
<Typography.Text type="secondary">
|
||
发布前统一审计闭合、覆盖、异常、来源和在线证据;不满足时先回到轨迹和 RAW 证据复核。
|
||
</Typography.Text>
|
||
<Space wrap>
|
||
<Button size="small" onClick={copyPublishAudit}>复制统计发布审计</Button>
|
||
<Button size="small" disabled={!currentVehicleKeyword} onClick={() => onOpenVehicle(currentVehicleKeyword, currentProtocol)}>车辆服务</Button>
|
||
</Space>
|
||
</div>
|
||
<div className="vp-stat-audit-grid">
|
||
{publishAuditItems.map((item) => (
|
||
<div key={item.key} className="vp-stat-audit-item">
|
||
<Space wrap>
|
||
<Tag color={auditStatusColor(item.status)}>{auditStatusLabel(item.status)}</Tag>
|
||
<Tag color="blue">{item.label}</Tag>
|
||
</Space>
|
||
<strong>{item.value}</strong>
|
||
<Typography.Text type="secondary">{item.detail}</Typography.Text>
|
||
<div className="vp-stat-audit-action">{item.action}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
<div className="vp-stat-workspace">
|
||
<Card bordered title="区间趋势">
|
||
<div className="vp-stat-chart">
|
||
{dateSeries.length > 0 ? dateSeries.map((item) => (
|
||
<div key={item.date} className="vp-stat-bar-row">
|
||
<span className="vp-stat-bar-label">{item.date}</span>
|
||
<div className="vp-stat-bar-track">
|
||
<span className="vp-stat-bar-fill" style={{ width: `${maxDateMileage > 0 ? Math.max(4, (item.value / maxDateMileage) * 100) : 0}%` }} />
|
||
</div>
|
||
<span className="vp-stat-bar-value">{formatKm(item.value)} km</span>
|
||
</div>
|
||
)) : (
|
||
<Typography.Text type="secondary">当前筛选范围暂无可展示趋势。</Typography.Text>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
<Card bordered title="来源贡献">
|
||
<div className="vp-stat-chart">
|
||
{sourceSeries.length > 0 ? sourceSeries.map((item) => (
|
||
<div key={item.source} className="vp-stat-bar-row">
|
||
<span className="vp-stat-bar-label">{item.source}</span>
|
||
<div className="vp-stat-bar-track">
|
||
<span className="vp-stat-bar-fill vp-stat-bar-fill-green" style={{ width: `${maxSourceMileage > 0 ? Math.max(4, (item.value / maxSourceMileage) * 100) : 0}%` }} />
|
||
</div>
|
||
<span className="vp-stat-bar-value">{formatKm(item.value)} km</span>
|
||
</div>
|
||
)) : (
|
||
<Typography.Text type="secondary">当前筛选范围暂无来源贡献。</Typography.Text>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
<div className="vp-stat-insight-grid">
|
||
{[
|
||
{ label: '统计可信度', value: confidenceStatus, color: confidenceColor, detail: anomalyRows.length > 0 ? '存在异常里程记录,建议先核对来源证据。' : '当前分页未发现异常记录。' },
|
||
{ label: '异常率', value: formatPercent(anomalyRate), color: anomalyRows.length > 0 ? 'orange' as const : 'green' as const, detail: `${anomalyRows.length.toLocaleString()} / ${rows.length.toLocaleString()} 条明细命中异常标记。` },
|
||
{ label: '最大单日占比', value: formatPercent(peakMileageShare), color: peakMileageShare > 50 ? 'orange' as const : 'blue' as const, detail: peakMileage ? `${peakMileage.plate || peakMileage.vin} 贡献最高单日里程。` : '暂无明细。' },
|
||
{ label: '当前页里程', value: `${formatKm(pageMileageTotal)} km`, color: 'blue' as const, detail: '用于快速核对当前分页明细合计。' },
|
||
{ label: '异常记录', value: anomalyRows.length.toLocaleString(), color: anomalyRows.length > 0 ? 'orange' as const : 'green' as const, detail: '来自异常标记,优先核对首末总里程和断链。' },
|
||
{ label: '最大单日', value: peakMileage ? `${formatKm(peakMileage.dailyMileageKm)} km` : '-', color: 'blue' as const, detail: peakMileage ? `${peakMileage.plate || peakMileage.vin} / ${peakMileage.date}` : '暂无明细。' },
|
||
{ label: '分页覆盖率', value: formatPercent(pageCoverageRate), color: pageCoverageRate >= 100 ? 'green' as const : 'grey' as const, detail: '当前分页记录数 / 全量统计记录数。' },
|
||
{
|
||
label: '闭合校验',
|
||
value: closureDeltaKm == null ? '需全量核验' : `${formatKm(Math.abs(closureDeltaKm))} km`,
|
||
color: closurePassed ? 'green' as const : closureDeltaKm == null ? 'grey' as const : 'orange' as const,
|
||
detail: closureDeltaKm == null ? '当前分页未覆盖全量记录,需翻页或导出全量后核验。' : '汇总总里程与当前明细合计的差值。'
|
||
}
|
||
].map((item) => (
|
||
<Card key={item.label} bordered>
|
||
<Tag color={item.color}>{item.label}</Tag>
|
||
<div className="vp-monitor-metric-value">{item.value}</div>
|
||
<Typography.Text type="secondary">{item.detail}</Typography.Text>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
<Card bordered title="在线状态明细" style={{ marginTop: 16 }}>
|
||
<div className="vp-table-toolbar">
|
||
<Space wrap>
|
||
<Tag color="green">在线 {onlineVehicleCount.toLocaleString()} 车</Tag>
|
||
<Tag color="orange">离线 {offlineVehicleCount.toLocaleString()} 车</Tag>
|
||
<Typography.Text type="secondary">按车辆服务归并 VIN,展示最近上报时间、离线时长和来源覆盖。</Typography.Text>
|
||
<Button size="small" onClick={copyOnlineStatusHandoff}>复制在线状态清单</Button>
|
||
</Space>
|
||
</div>
|
||
<Table
|
||
loading={onlineLoading}
|
||
rowKey={(row?: OnlineVehicleStatusRow) => row?.vin ?? ''}
|
||
dataSource={onlineRows}
|
||
pagination={{
|
||
currentPage: onlinePagination.currentPage,
|
||
pageSize: onlinePagination.pageSize,
|
||
total: onlinePagination.total,
|
||
showSizeChanger: true,
|
||
onPageChange: (page) => loadOnlineRows(filters, page, onlinePagination.pageSize),
|
||
onPageSizeChange: (pageSize) => loadOnlineRows(filters, 1, pageSize)
|
||
}}
|
||
columns={[
|
||
{ title: 'VIN', dataIndex: 'vin', width: 190 },
|
||
{ title: '车牌', dataIndex: 'plate', width: 120 },
|
||
{ title: 'OEM', dataIndex: 'oem', width: 120 },
|
||
{
|
||
title: '在线',
|
||
width: 90,
|
||
render: (_: unknown, row: OnlineVehicleStatusRow) => <Tag color={row.online ? 'green' : 'orange'}>{row.online ? '在线' : '离线'}</Tag>
|
||
},
|
||
{ title: '最近上报', dataIndex: 'lastSeen', width: 180 },
|
||
{
|
||
title: '离线时长',
|
||
width: 130,
|
||
render: (_: unknown, row: OnlineVehicleStatusRow) => formatOfflineDuration(row.offlineDurationMinutes)
|
||
},
|
||
{
|
||
title: '来源覆盖',
|
||
width: 150,
|
||
render: (_: unknown, row: OnlineVehicleStatusRow) => `${row.onlineSourceCount}/${row.sourceCount}`
|
||
},
|
||
{
|
||
title: '协议',
|
||
width: 240,
|
||
render: (_: unknown, row: OnlineVehicleStatusRow) => (
|
||
<Space spacing={4} wrap>
|
||
{(row.protocols ?? []).map((protocol) => <Tag key={protocol} color="blue">{protocol}</Tag>)}
|
||
</Space>
|
||
)
|
||
},
|
||
{ title: '绑定状态', dataIndex: 'bindingStatus', width: 130 },
|
||
{
|
||
title: '操作',
|
||
width: 120,
|
||
render: (_: unknown, row: OnlineVehicleStatusRow) => (
|
||
<Button disabled={!canOpenVehicle(row.vin)} onClick={() => onOpenVehicle(row.vin, currentProtocol)}>车辆服务</Button>
|
||
)
|
||
}
|
||
]}
|
||
/>
|
||
</Card>
|
||
<Card bordered title="统计口径" style={{ marginTop: 16 }}>
|
||
<div className="vp-table-toolbar">
|
||
<Space wrap>
|
||
<Tag color="blue">可复制日报口径</Tag>
|
||
<Button size="small" onClick={copyStatisticsSummary}>复制统计摘要</Button>
|
||
</Space>
|
||
</div>
|
||
<div className="vp-stat-definition-grid">
|
||
<div>
|
||
<Tag color="blue">日里程</Tag>
|
||
<p>按车辆、来源、日期聚合,以总里程差值作为日统计结果,避免中间断链导致分段累计不闭合。</p>
|
||
</div>
|
||
<div>
|
||
<Tag color="green">区间里程</Tag>
|
||
<p>按当前筛选范围汇总日里程,区间总值应等于各日里程之和,用于 BI 和运营报表口径。</p>
|
||
</div>
|
||
<div>
|
||
<Tag color="orange">异常复核</Tag>
|
||
<p>异常优先检查补传、来源切换、总里程回退和跨协议差异,必要时回到车辆服务查看 RAW 证据。</p>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
<Card bordered style={{ marginTop: 16 }}>
|
||
<div className="vp-table-toolbar">
|
||
<Space wrap>
|
||
<Tag color="blue">当前页 {rows.length.toLocaleString()} 条</Tag>
|
||
<Button size="small" onClick={exportMileage}>导出里程当前页 CSV</Button>
|
||
</Space>
|
||
</div>
|
||
<Table
|
||
loading={loading}
|
||
rowKey={(row?: DailyMileageRow) => `${row?.date ?? ''}-${row?.vin ?? ''}-${row?.source ?? ''}`}
|
||
dataSource={rows}
|
||
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)
|
||
}}
|
||
columns={[
|
||
{ title: '日期', dataIndex: 'date' },
|
||
{ title: 'VIN', dataIndex: 'vin' },
|
||
{ title: '车牌', dataIndex: 'plate' },
|
||
{ title: '起始里程', dataIndex: 'startMileageKm' },
|
||
{ title: '结束里程', dataIndex: 'endMileageKm' },
|
||
{ title: '日里程', dataIndex: 'dailyMileageKm' },
|
||
{ title: '来源', dataIndex: 'source' },
|
||
{ title: '异常', render: (_: unknown, row: DailyMileageRow) => row.anomalySeverity ? <Tag color="orange">{row.anomalySeverity}</Tag> : <Tag color="green">正常</Tag> },
|
||
{
|
||
title: '处置建议',
|
||
width: 260,
|
||
render: (_: unknown, row: DailyMileageRow) => {
|
||
const action = mileageActionRecommendation(row);
|
||
return (
|
||
<div>
|
||
<Tag color={action.color}>{action.label}</Tag>
|
||
<div style={{ marginTop: 6 }}>{action.detail}</div>
|
||
</div>
|
||
);
|
||
}
|
||
},
|
||
{
|
||
title: '操作',
|
||
width: 360,
|
||
render: (_: unknown, row: DailyMileageRow) => (
|
||
<Space spacing={4} wrap>
|
||
<Button disabled={!canOpenVehicle(row.vin)} onClick={() => copyMileageEvidence(row)}>复制证据</Button>
|
||
<Button disabled={!canOpenVehicle(row.vin) || !onOpenHistory} onClick={() => openMileageEvidence(row)}>核对轨迹</Button>
|
||
<Button disabled={!canOpenVehicle(row.vin) || !onOpenRaw} onClick={() => openMileageRawEvidence(row)}>核对 RAW</Button>
|
||
<Button disabled={!canOpenVehicle(row.vin)} onClick={() => onOpenVehicle(row.vin, row.source)}>车辆服务</Button>
|
||
</Space>
|
||
)
|
||
}
|
||
]}
|
||
/>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|