Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/pages/Mileage.tsx
2026-07-05 02:17:41 +08:00

1520 lines
71 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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>
);
}