3087 lines
141 KiB
TypeScript
3087 lines
141 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
|
||
};
|
||
|
||
function numberOrZero(value: unknown) {
|
||
const numeric = Number(value);
|
||
return Number.isFinite(numeric) ? numeric : 0;
|
||
}
|
||
|
||
function normalizeMileageSummary(summary?: Partial<MileageSummary> | null): MileageSummary {
|
||
return {
|
||
...emptySummary,
|
||
...(summary ?? {}),
|
||
vehicleCount: numberOrZero(summary?.vehicleCount),
|
||
recordCount: numberOrZero(summary?.recordCount),
|
||
sourceCount: numberOrZero(summary?.sourceCount),
|
||
totalMileageKm: numberOrZero(summary?.totalMileageKm),
|
||
averageMileagePerVin: numberOrZero(summary?.averageMileagePerVin)
|
||
};
|
||
}
|
||
|
||
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 mileageDeliveryPackageText({
|
||
filters,
|
||
summary,
|
||
pageMileageTotal,
|
||
pageCoverageRate,
|
||
anomalyCount,
|
||
closureStatus,
|
||
closureDeltaKm,
|
||
confidenceStatus,
|
||
publishAuditConclusion,
|
||
onlineSummary,
|
||
peakMileage,
|
||
statisticsURL,
|
||
vehicleURL
|
||
}: {
|
||
filters: Record<string, string>;
|
||
summary: MileageSummary;
|
||
pageMileageTotal: number;
|
||
pageCoverageRate: number;
|
||
anomalyCount: number;
|
||
closureStatus: string;
|
||
closureDeltaKm?: number;
|
||
confidenceStatus: string;
|
||
publishAuditConclusion: string;
|
||
onlineSummary: OnlineStatisticsSummary;
|
||
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`
|
||
: '-';
|
||
const deliveryState = publishAuditConclusion === '可发布' && confidenceStatus === '可用于 BI'
|
||
? '可交付'
|
||
: publishAuditConclusion === '阻断发布' ? '暂缓交付' : '复核后交付';
|
||
return [
|
||
'【客户里程复核交付包】',
|
||
`交付状态:${deliveryState}`,
|
||
`车辆范围:${keyword}`,
|
||
`数据通道:${protocol}`,
|
||
`日期范围:${dateRange}`,
|
||
`累计里程:${formatKm(summary.totalMileageKm)} km`,
|
||
`当前页明细合计:${formatKm(pageMileageTotal)} km`,
|
||
`闭合状态:${closureStatus}${closureDeltaKm == null ? '' : `,差值 ${formatKm(Math.abs(closureDeltaKm))} km`}`,
|
||
`记录覆盖率:${formatPercent(pageCoverageRate)}`,
|
||
`异常记录:${anomalyCount.toLocaleString()}`,
|
||
`最大单日:${peak}`,
|
||
`在线影响:${onlineSummary.onlineVehicleCount.toLocaleString()} 在线 / ${onlineSummary.offlineVehicleCount.toLocaleString()} 离线 / 在线率 ${formatPercent(onlineSummary.onlineRatePercent)}`,
|
||
`发布判断:${publishAuditConclusion}`,
|
||
`可信度:${confidenceStatus}`,
|
||
'里程口径:日里程按首末总里程差值计算,区间总值应等于每日里程之和',
|
||
`里程统计:${statisticsURL}`,
|
||
`轨迹复核:${appURL(buildAppHash({ page: 'history', keyword: filters.keyword?.trim() || '', protocol: filters.protocol?.trim() || '', filters: { ...(dateFrom ? { dateFrom } : {}), ...(dateTo ? { dateTo } : {}) } }))}`,
|
||
`历史明细:${appURL(buildAppHash({ page: 'history-query', keyword: filters.keyword?.trim() || '', protocol: filters.protocol?.trim() || '', filters: { ...(dateFrom ? { dateFrom } : {}), ...(dateTo ? { dateTo } : {}), tab: 'raw', includeFields: 'true' } }))}`,
|
||
...(vehicleURL ? [`车辆服务:${vehicleURL}`] : [])
|
||
].join('\n');
|
||
}
|
||
|
||
function mileageCustomerDecisionText({
|
||
filters,
|
||
summary,
|
||
pageMileageTotal,
|
||
pageCoverageRate,
|
||
anomalyCount,
|
||
closureStatus,
|
||
closureDeltaKm,
|
||
confidenceStatus,
|
||
publishAuditConclusion,
|
||
onlineSummary,
|
||
peakMileage,
|
||
statisticsURL,
|
||
vehicleURL
|
||
}: {
|
||
filters: Record<string, string>;
|
||
summary: MileageSummary;
|
||
pageMileageTotal: number;
|
||
pageCoverageRate: number;
|
||
anomalyCount: number;
|
||
closureStatus: string;
|
||
closureDeltaKm?: number;
|
||
confidenceStatus: string;
|
||
publishAuditConclusion: string;
|
||
onlineSummary: OnlineStatisticsSummary;
|
||
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 deliveryState = publishAuditConclusion === '可发布' && confidenceStatus === '可用于 BI'
|
||
? '可交付'
|
||
: publishAuditConclusion === '阻断发布' ? '暂缓交付' : '复核后交付';
|
||
const peak = peakMileage
|
||
? `${peakMileage.plate || peakMileage.vin} / ${peakMileage.date} / ${formatKm(peakMileage.dailyMileageKm)} km`
|
||
: '-';
|
||
return [
|
||
'【客户里程决策说明】',
|
||
`决策结论:${deliveryState}`,
|
||
`车辆范围:${keyword}`,
|
||
`数据通道:${protocol}`,
|
||
`时间范围:${dateRange}`,
|
||
`区间总里程:${formatKm(summary.totalMileageKm)} km`,
|
||
`当前明细合计:${formatKm(pageMileageTotal)} km`,
|
||
`区间闭合:${closureStatus}${closureDeltaKm == null ? ',当前为分页抽样' : `,差值 ${formatKm(Math.abs(closureDeltaKm))} km`}`,
|
||
`记录覆盖:${formatPercent(pageCoverageRate)}`,
|
||
`异常记录:${anomalyCount.toLocaleString()} 条`,
|
||
`最高单日:${peak}`,
|
||
`在线影响:${onlineSummary.onlineVehicleCount.toLocaleString()} 在线 / ${onlineSummary.offlineVehicleCount.toLocaleString()} 离线 / 在线率 ${formatPercent(onlineSummary.onlineRatePercent)}`,
|
||
`BI发布判断:${publishAuditConclusion}`,
|
||
'',
|
||
'客户判断路径:',
|
||
'1. 先看区间闭合:区间总值必须等于每日里程之和。',
|
||
'2. 再看记录覆盖:发布前需要覆盖全量明细或导出全量。',
|
||
'3. 再看异常记录:异常日必须回看轨迹和历史明细。',
|
||
'4. 最后看在线影响:离线车辆会影响当天里程解释。',
|
||
`里程统计:${statisticsURL}`,
|
||
`轨迹复核:${appURL(buildAppHash({ page: 'history', keyword: filters.keyword?.trim() || '', protocol: filters.protocol?.trim() || '', filters: { ...(dateFrom ? { dateFrom } : {}), ...(dateTo ? { dateTo } : {}) } }))}`,
|
||
`历史明细:${appURL(buildAppHash({ page: 'history-query', keyword: filters.keyword?.trim() || '', protocol: filters.protocol?.trim() || '', filters: { ...(dateFrom ? { dateFrom } : {}), ...(dateTo ? { dateTo } : {}), tab: 'raw', includeFields: 'true' } }))}`,
|
||
...(vehicleURL ? [`车辆服务:${vehicleURL}`] : [])
|
||
].join('\n');
|
||
}
|
||
|
||
function mileageReconciliationControlText({
|
||
filters,
|
||
summary,
|
||
pageMileageTotal,
|
||
closureStatus,
|
||
closureDeltaKm,
|
||
anomalyCount,
|
||
pageCoverageRate,
|
||
onlineSummary,
|
||
peakMileage,
|
||
statisticsURL,
|
||
vehicleURL
|
||
}: {
|
||
filters: Record<string, string>;
|
||
summary: MileageSummary;
|
||
pageMileageTotal: number;
|
||
closureStatus: string;
|
||
closureDeltaKm?: number;
|
||
anomalyCount: number;
|
||
pageCoverageRate: number;
|
||
onlineSummary: OnlineStatisticsSummary;
|
||
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}`,
|
||
`区间里程:${formatKm(summary.totalMileageKm)} km`,
|
||
`明细合计:${formatKm(pageMileageTotal)} km`,
|
||
`闭合状态:${closureStatus}${closureDeltaKm == null ? '' : `,差值 ${formatKm(Math.abs(closureDeltaKm))} km`}`,
|
||
`异常记录:${anomalyCount.toLocaleString()} 条`,
|
||
`覆盖率:${formatPercent(pageCoverageRate)}`,
|
||
`在线影响:${onlineSummary.onlineVehicleCount.toLocaleString()} 在线 / ${onlineSummary.offlineVehicleCount.toLocaleString()} 离线 / 在线率 ${formatPercent(onlineSummary.onlineRatePercent)}`,
|
||
`轨迹证明:${peak}`,
|
||
`里程统计:${statisticsURL}`,
|
||
`报表导出:${statisticsURL}`,
|
||
`轨迹回放:${appURL(buildAppHash({ page: 'history', keyword: filters.keyword?.trim() || '', protocol: filters.protocol?.trim() || '', filters: { ...(dateFrom ? { dateFrom } : {}), ...(dateTo ? { dateTo } : {}) } }))}`,
|
||
`历史明细:${appURL(buildAppHash({ page: 'history-query', keyword: filters.keyword?.trim() || '', protocol: filters.protocol?.trim() || '', filters: { ...(dateFrom ? { dateFrom } : {}), ...(dateTo ? { dateTo } : {}), tab: 'raw', includeFields: 'true' } }))}`,
|
||
...(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 ? '当前筛选范围闭合且无异常记录' : '存在分页未全量、异常记录或闭合差值,需要先复核轨迹和历史明细'}`,
|
||
`里程统计:${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口径' : '当前统计范围需要先复核轨迹、历史明细和离线车辆影响'}`,
|
||
`里程统计:${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 }))}`,
|
||
`历史明细:${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. 在线率下降时先检查实时在线状态、数据接入和最后上报时间',
|
||
'2. 离线车辆优先打开车辆服务,核对实时、轨迹、历史明细和告警记录',
|
||
'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 ? '优先打开异常行的轨迹和历史明细复核。' : '继续检查通道一致性和在线状态。'
|
||
},
|
||
{
|
||
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 mileageDeliveryState = publishAuditConclusion === '可发布' && confidenceStatus === '可用于 BI'
|
||
? '可交付'
|
||
: publishAuditConclusion === '阻断发布' ? '暂缓交付' : '复核后交付';
|
||
const mileageDeliveryColor = mileageDeliveryState === '可交付' ? 'green' as const : mileageDeliveryState === '暂缓交付' ? 'red' as const : 'orange' as const;
|
||
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 mileageDecisionItems = [
|
||
{
|
||
label: '交付结论',
|
||
value: mileageDeliveryState,
|
||
detail: mileageDeliveryState === '可交付' ? '可进入客户查询、BI发布和报表交付。' : mileageDeliveryState === '暂缓交付' ? '存在阻断项,先完成证据复核。' : '需补齐闭合、覆盖、异常或在线证据后交付。',
|
||
color: mileageDeliveryColor,
|
||
action: '复制决策',
|
||
onClick: () => copyMileageDecision()
|
||
},
|
||
{
|
||
label: '区间闭合',
|
||
value: closureStatus,
|
||
detail: closureDeltaKm == null ? '当前页是分页抽样,发布前请拉齐全量明细。' : `闭合差值 ${formatKm(Math.abs(closureDeltaKm))} km。`,
|
||
color: closureStatusColor,
|
||
action: '统计摘要',
|
||
onClick: () => copyStatisticsSummary()
|
||
},
|
||
{
|
||
label: '覆盖率',
|
||
value: formatPercent(pageCoverageRate),
|
||
detail: `${rows.length.toLocaleString()} / ${summary.recordCount.toLocaleString()} 条记录,交付前建议覆盖全量。`,
|
||
color: pageCoverageRate >= 100 ? 'green' as const : pageCoverageRate >= 60 ? 'orange' as const : 'red' as const,
|
||
action: '导出明细',
|
||
onClick: () => exportMileage()
|
||
},
|
||
{
|
||
label: '异常复核',
|
||
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,
|
||
action: '异常明细',
|
||
onClick: () => anomalyRows[0] ? copyMileageEvidence(anomalyRows[0]) : copyMileageDecision()
|
||
},
|
||
{
|
||
label: '在线影响',
|
||
value: formatPercent(onlineRatePercent),
|
||
detail: `${onlineVehicleCount.toLocaleString()} 在线 / ${offlineVehicleCount.toLocaleString()} 离线。`,
|
||
color: onlineRatePercent >= 90 ? 'green' as const : onlineRatePercent >= 60 ? 'orange' as const : 'red' as const,
|
||
action: '在线态势',
|
||
onClick: () => copyOnlineOperationsReport()
|
||
}
|
||
];
|
||
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 mileageCustomerWorkbenchItems = [
|
||
{
|
||
label: '区间里程',
|
||
value: `${formatKm(summary.totalMileageKm)} km`,
|
||
detail: `${summary.vehicleCount.toLocaleString()} 辆车,${mileageRangeText}。`,
|
||
color: 'blue' as const,
|
||
action: '复制摘要',
|
||
disabled: false,
|
||
onClick: () => copyStatisticsSummary()
|
||
},
|
||
{
|
||
label: '每日闭合',
|
||
value: closureStatus,
|
||
detail: closureDeltaKm == null ? `当前页覆盖 ${formatPercent(pageCoverageRate)},发布前需要拉齐全量。` : `闭合差值 ${formatKm(Math.abs(closureDeltaKm))} km。`,
|
||
color: closureStatusColor,
|
||
action: '发布审计',
|
||
disabled: false,
|
||
onClick: () => copyPublishAudit()
|
||
},
|
||
{
|
||
label: '轨迹回放',
|
||
value: peakMileage?.date || mileageRangeText,
|
||
detail: peakMileage ? `${peakMileage.plate || peakMileage.vin} 最大单日 ${formatKm(peakMileage.dailyMileageKm)} km。` : '选择车辆后用同一时间窗回放轨迹。',
|
||
color: currentVehicleKeyword ? 'blue' as const : 'grey' as const,
|
||
action: '回放轨迹',
|
||
disabled: !currentVehicleKeyword || !onOpenHistory,
|
||
onClick: () => {
|
||
if (peakMileage) {
|
||
openMileageEvidence(peakMileage);
|
||
return;
|
||
}
|
||
onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' });
|
||
}
|
||
},
|
||
{
|
||
label: '历史导出',
|
||
value: `${rows.length.toLocaleString()} 条`,
|
||
detail: `当前页覆盖 ${formatPercent(pageCoverageRate)},用于客户对账和 BI 复核。`,
|
||
color: rows.length > 0 ? 'blue' as const : 'grey' as const,
|
||
action: '导出明细',
|
||
disabled: rows.length === 0,
|
||
onClick: () => exportMileage()
|
||
},
|
||
{
|
||
label: '告警通知',
|
||
value: `${offlineVehicleCount.toLocaleString()} 离线`,
|
||
detail: offlineVehicleCount > 0 ? '离线会影响定位、里程解释和客户通知。' : '当前在线状态未形成主要统计风险。',
|
||
color: offlineVehicleCount > 0 ? 'orange' as const : 'green' as const,
|
||
action: '在线态势',
|
||
disabled: false,
|
||
onClick: () => copyOnlineOperationsReport()
|
||
}
|
||
];
|
||
const mileageConclusionItems = [
|
||
{
|
||
label: '区间累计',
|
||
value: `${formatKm(summary.totalMileageKm)} km`,
|
||
detail: `${summary.vehicleCount.toLocaleString()} 辆车,${mileageRangeText}`,
|
||
color: 'blue' as const,
|
||
onClick: () => copyStatisticsSummary()
|
||
},
|
||
{
|
||
label: '明细合计',
|
||
displayLabel: '当前明细',
|
||
value: `${formatKm(pageMileageTotal)} km`,
|
||
detail: `${rows.length.toLocaleString()} 条当前页明细,覆盖 ${formatPercent(pageCoverageRate)}`,
|
||
color: pageCoverageRate >= 100 ? 'green' as const : 'orange' as const,
|
||
onClick: () => exportMileage()
|
||
},
|
||
{
|
||
label: '异常记录',
|
||
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,
|
||
onClick: () => anomalyRows[0] ? copyMileageEvidence(anomalyRows[0]) : copyMileageDecision()
|
||
},
|
||
{
|
||
label: '在线影响',
|
||
value: `${offlineVehicleCount.toLocaleString()} 离线`,
|
||
detail: `${onlineVehicleCount.toLocaleString()} 在线,在线率 ${formatPercent(onlineRatePercent)}`,
|
||
color: offlineVehicleCount > 0 ? 'orange' as const : 'green' as const,
|
||
onClick: () => copyOnlineOperationsReport()
|
||
}
|
||
];
|
||
const mileageConclusionActions = [
|
||
{
|
||
label: '复制结论',
|
||
action: '复制摘要',
|
||
color: 'blue' as const,
|
||
disabled: false,
|
||
onClick: () => copyMileageDeliveryPackage()
|
||
},
|
||
{
|
||
label: '轨迹复核',
|
||
action: '轨迹回放',
|
||
color: currentVehicleKeyword ? 'blue' as const : 'grey' as const,
|
||
disabled: !currentVehicleKeyword || !onOpenHistory,
|
||
onClick: () => onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' })
|
||
},
|
||
{
|
||
label: '导出明细',
|
||
action: '导出CSV',
|
||
color: rows.length > 0 ? 'blue' as const : 'grey' as const,
|
||
disabled: rows.length === 0,
|
||
onClick: () => exportMileage()
|
||
},
|
||
{
|
||
label: '告警通知',
|
||
action: '在线态势',
|
||
color: offlineVehicleCount > 0 ? 'orange' as const : 'green' as const,
|
||
disabled: false,
|
||
onClick: () => copyOnlineOperationsReport()
|
||
}
|
||
];
|
||
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 mileageCustomerQuerySteps = [
|
||
{
|
||
step: '01',
|
||
title: '选择范围',
|
||
value: currentVehicleKeyword || '全部车辆',
|
||
detail: `时间范围 ${mileageRangeText},数据通道 ${currentProtocol || '全部数据通道'}。`,
|
||
action: '调整筛选',
|
||
color: currentVehicleKeyword ? 'green' as const : 'blue' as const,
|
||
disabled: false,
|
||
onClick: () => {
|
||
const input = document.querySelector<HTMLInputElement>('input[name="keyword"]');
|
||
input?.focus();
|
||
}
|
||
},
|
||
{
|
||
step: '02',
|
||
title: '区间里程',
|
||
value: `${formatKm(summary.totalMileageKm)} km`,
|
||
detail: `${summary.vehicleCount.toLocaleString()} 辆车,${summary.recordCount.toLocaleString()} 条日里程记录。`,
|
||
action: '复制摘要',
|
||
color: 'blue' as const,
|
||
disabled: false,
|
||
onClick: () => copyStatisticsSummary()
|
||
},
|
||
{
|
||
step: '03',
|
||
title: '发布判断',
|
||
value: publishAuditConclusion,
|
||
detail: `闭合 ${closureStatus},覆盖 ${formatPercent(pageCoverageRate)}。`,
|
||
action: '发布审计',
|
||
color: publishAuditColor,
|
||
disabled: false,
|
||
onClick: () => copyPublishAudit()
|
||
},
|
||
{
|
||
step: '04',
|
||
title: '复核证据',
|
||
value: anomalyRows.length > 0 ? `${anomalyRows.length.toLocaleString()} 异常` : '轨迹/原始',
|
||
detail: anomalyRows.length > 0 ? '异常日优先回看轨迹和历史明细。' : '可从同一时间窗进入轨迹和历史明细。',
|
||
action: '轨迹复核',
|
||
color: anomalyRows.length > 0 ? 'orange' as const : 'green' as const,
|
||
disabled: !currentVehicleKeyword || !onOpenHistory,
|
||
onClick: () => onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' })
|
||
},
|
||
{
|
||
step: '05',
|
||
title: '明细导出',
|
||
value: `${rows.length.toLocaleString()} 当前页`,
|
||
detail: '导出明细并附带里程口径,支撑 BI 核对、客户复盘和报表交付。',
|
||
action: '导出明细',
|
||
color: rows.length > 0 ? 'blue' as const : 'grey' as const,
|
||
disabled: rows.length === 0,
|
||
onClick: () => exportMileage()
|
||
}
|
||
];
|
||
const mileageReconciliationConclusionItems = [
|
||
{
|
||
label: '区间总里程',
|
||
value: `${formatKm(summary.totalMileageKm)} km`,
|
||
detail: `${summary.vehicleCount.toLocaleString()} 辆车,${mileageRangeText}。`,
|
||
action: '复制摘要',
|
||
color: 'blue' as const,
|
||
disabled: false,
|
||
onClick: () => copyStatisticsSummary()
|
||
},
|
||
{
|
||
label: '闭合状态',
|
||
value: closureStatus,
|
||
detail: closureActionText,
|
||
action: '发布审计',
|
||
color: closureStatusColor,
|
||
disabled: false,
|
||
onClick: () => copyPublishAudit()
|
||
},
|
||
{
|
||
label: '异常记录',
|
||
value: `${anomalyRows.length.toLocaleString()} 条`,
|
||
detail: anomalyRows[0]
|
||
? `${anomalyRows[0].plate || anomalyRows[0].vin} / ${anomalyRows[0].date}`
|
||
: '当前明细没有异常标记。',
|
||
action: '复制决策',
|
||
color: anomalyRows.length > 0 ? 'orange' as const : 'green' as const,
|
||
disabled: false,
|
||
onClick: () => copyMileageDecision()
|
||
},
|
||
{
|
||
label: '下一步',
|
||
value: mileageDeliveryState,
|
||
detail: mileageDeliveryState === '可交付' ? '可以进入客户交付、BI复核或导出。' : '先补齐闭合、覆盖、异常或在线影响证据。',
|
||
action: '查看证据',
|
||
color: mileageDeliveryColor,
|
||
disabled: !currentVehicleKeyword || (!onOpenHistory && !onOpenRaw),
|
||
onClick: () => {
|
||
if (currentVehicleKeyword && onOpenHistory) {
|
||
onOpenHistory({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' });
|
||
return;
|
||
}
|
||
if (currentVehicleKeyword && onOpenRaw) {
|
||
onOpenRaw({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '', includeFields: 'true' });
|
||
return;
|
||
}
|
||
copyMileageDeliveryPackage();
|
||
}
|
||
}
|
||
];
|
||
const mileageDeliveryNavigation = [
|
||
{
|
||
title: '确认里程口径',
|
||
value: `${formatKm(summary.totalMileageKm)} km`,
|
||
detail: `范围 ${mileageRangeText},闭合状态 ${closureStatus},发布判断 ${publishAuditConclusion}。`,
|
||
action: '复制统计摘要',
|
||
color: publishAuditColor,
|
||
disabled: false,
|
||
onClick: () => copyStatisticsSummary()
|
||
},
|
||
{
|
||
title: '回放里程轨迹',
|
||
value: currentVehicleKeyword || '请选择车辆',
|
||
detail: '用同一车辆和时间窗回看位置、速度、停驶和里程断点。',
|
||
action: '轨迹复核',
|
||
color: currentVehicleKeyword ? 'blue' as const : 'grey' as const,
|
||
disabled: !currentVehicleKeyword || !onOpenHistory,
|
||
onClick: () => onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' })
|
||
},
|
||
{
|
||
title: '导出明细证据',
|
||
value: `${rows.length.toLocaleString()} 条当前页`,
|
||
detail: `覆盖 ${formatPercent(pageCoverageRate)},用于客户复盘、BI 核对和报表附件。`,
|
||
action: '导出CSV',
|
||
color: rows.length > 0 ? 'blue' as const : 'grey' as const,
|
||
disabled: rows.length === 0,
|
||
onClick: () => exportMileage()
|
||
},
|
||
{
|
||
title: '发送交付结论',
|
||
value: mileageDeliveryState,
|
||
detail: mileageDeliveryState === '可交付' ? '复制交付包后可直接发给客户或BI使用。' : '交付前需要补齐闭合、异常、覆盖或在线影响说明。',
|
||
action: '复制交付包',
|
||
color: mileageDeliveryColor,
|
||
disabled: false,
|
||
onClick: () => copyMileageDeliveryPackage()
|
||
}
|
||
];
|
||
const mileageAcceptanceItems = [
|
||
{
|
||
label: '范围确认',
|
||
value: currentVehicleKeyword || '全部车辆',
|
||
detail: `${mileageRangeText} / ${currentProtocol || '全部数据通道'}`,
|
||
color: currentVehicleKeyword ? 'blue' as const : 'orange' as const,
|
||
disabled: false,
|
||
onClick: () => copyStatisticsSummary()
|
||
},
|
||
{
|
||
label: '区间闭合',
|
||
value: closureStatus,
|
||
detail: closureDeltaKm == null ? '当前为分页抽样,交付前需要拉齐全量明细。' : `闭合差值 ${formatKm(Math.abs(closureDeltaKm))} km。`,
|
||
color: closureStatusColor,
|
||
disabled: false,
|
||
onClick: () => copyPublishAudit()
|
||
},
|
||
{
|
||
label: '明细覆盖',
|
||
value: formatPercent(pageCoverageRate),
|
||
detail: `${rows.length.toLocaleString()} / ${summary.recordCount.toLocaleString()} 条记录,覆盖全量后再交付更稳。`,
|
||
color: pageCoverageRate >= 100 ? 'green' as const : 'orange' as const,
|
||
disabled: rows.length === 0,
|
||
onClick: () => exportMileage()
|
||
},
|
||
{
|
||
label: '异常复核',
|
||
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,
|
||
disabled: false,
|
||
onClick: () => anomalyRows[0] ? openMileageEvidence(anomalyRows[0]) : copyMileageDecision()
|
||
},
|
||
{
|
||
label: '在线影响',
|
||
value: `${offlineVehicleCount.toLocaleString()} 离线`,
|
||
detail: offlineVehicleCount > 0 ? '离线车辆会影响当日解释和客户通知。' : '在线状态未形成主要统计风险。',
|
||
color: offlineVehicleCount > 0 ? 'orange' as const : 'green' as const,
|
||
disabled: false,
|
||
onClick: () => copyOnlineOperationsReport()
|
||
}
|
||
];
|
||
const mileageAcceptanceActions = [
|
||
{
|
||
label: '复制验收',
|
||
action: '交付说明',
|
||
color: mileageDeliveryColor,
|
||
disabled: false,
|
||
onClick: () => copyMileageDeliveryPackage()
|
||
},
|
||
{
|
||
label: '复核轨迹',
|
||
action: '轨迹',
|
||
color: currentVehicleKeyword ? 'blue' as const : 'grey' as const,
|
||
disabled: !currentVehicleKeyword || !onOpenHistory,
|
||
onClick: () => onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' })
|
||
},
|
||
{
|
||
label: '导出明细',
|
||
action: 'CSV',
|
||
color: rows.length > 0 ? 'blue' as const : 'grey' as const,
|
||
disabled: rows.length === 0,
|
||
onClick: () => exportMileage()
|
||
}
|
||
];
|
||
|
||
const loadSummary = (values: Record<string, string> = filters) => {
|
||
setSummaryLoading(true);
|
||
api.mileageSummary(mileageParams(values))
|
||
.then((nextSummary) => setSummary(normalizeMileageSummary(nextSummary)))
|
||
.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 copyMileageDeliveryPackage = () => {
|
||
const vehicleURL = currentVehicleKeyword
|
||
? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol }))
|
||
: undefined;
|
||
copyText(
|
||
mileageDeliveryPackageText({
|
||
filters,
|
||
summary,
|
||
pageMileageTotal,
|
||
pageCoverageRate,
|
||
anomalyCount: anomalyRows.length,
|
||
closureStatus,
|
||
closureDeltaKm,
|
||
confidenceStatus,
|
||
publishAuditConclusion,
|
||
onlineSummary,
|
||
peakMileage,
|
||
statisticsURL: appURL(window.location.hash || buildAppHash({ page: 'mileage', keyword: currentVehicleKeyword, protocol: currentProtocol })),
|
||
vehicleURL
|
||
}),
|
||
'客户里程复核交付包'
|
||
);
|
||
};
|
||
const copyMileageDecision = () => {
|
||
const vehicleURL = currentVehicleKeyword
|
||
? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol }))
|
||
: undefined;
|
||
copyText(
|
||
mileageCustomerDecisionText({
|
||
filters,
|
||
summary,
|
||
pageMileageTotal,
|
||
pageCoverageRate,
|
||
anomalyCount: anomalyRows.length,
|
||
closureStatus,
|
||
closureDeltaKm,
|
||
confidenceStatus,
|
||
publishAuditConclusion,
|
||
onlineSummary,
|
||
peakMileage,
|
||
statisticsURL: appURL(window.location.hash || buildAppHash({ page: 'mileage', keyword: currentVehicleKeyword, protocol: currentProtocol })),
|
||
vehicleURL
|
||
}),
|
||
'客户里程决策说明'
|
||
);
|
||
};
|
||
const copyMileageReconciliationControl = () => {
|
||
const vehicleURL = currentVehicleKeyword
|
||
? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol }))
|
||
: undefined;
|
||
copyText(
|
||
mileageReconciliationControlText({
|
||
filters,
|
||
summary,
|
||
pageMileageTotal,
|
||
closureStatus,
|
||
closureDeltaKm,
|
||
anomalyCount: anomalyRows.length,
|
||
pageCoverageRate,
|
||
onlineSummary,
|
||
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 copyMileageReportJourneyPackage = () => {
|
||
const vehicleURL = currentVehicleKeyword
|
||
? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol }))
|
||
: undefined;
|
||
const reportPeriod = mileageRangeText;
|
||
const trajectoryProof = peakMileage
|
||
? `${peakMileage.plate || peakMileage.vin} / ${peakMileage.date} / ${formatKm(peakMileage.dailyMileageKm)} km`
|
||
: '暂无轨迹证明样本';
|
||
copyText([
|
||
'【里程报表交付旅程包】',
|
||
`报表周期:${reportPeriod}`,
|
||
`车辆范围:${currentVehicleKeyword || '全部车辆'}`,
|
||
`数据通道:${currentProtocol || '全部数据通道'}`,
|
||
`区间里程:${formatKm(summary.totalMileageKm)} km`,
|
||
`区间闭合:${closureStatus}`,
|
||
`记录覆盖:${formatPercent(pageCoverageRate)}`,
|
||
`异常记录:${anomalyRows.length.toLocaleString()} 条`,
|
||
`轨迹证明:${trajectoryProof}`,
|
||
`导出发布:${mileageDeliveryState} / ${publishAuditConclusion}`,
|
||
`在线影响:${onlineVehicleCount.toLocaleString()} 在线 / ${offlineVehicleCount.toLocaleString()} 离线 / 在线率 ${formatPercent(onlineRatePercent)}`,
|
||
`里程统计:${appURL(window.location.hash || buildAppHash({ page: 'mileage', keyword: currentVehicleKeyword, protocol: currentProtocol }))}`,
|
||
`轨迹回放:${appURL(buildAppHash({ page: 'history', keyword: currentVehicleKeyword, protocol: currentProtocol, filters: { ...(filters.dateFrom ? { dateFrom: filters.dateFrom } : {}), ...(filters.dateTo ? { dateTo: filters.dateTo } : {}) } }))}`,
|
||
`历史明细:${appURL(buildAppHash({ page: 'history-query', keyword: currentVehicleKeyword, protocol: currentProtocol, filters: { ...(filters.dateFrom ? { dateFrom: filters.dateFrom } : {}), ...(filters.dateTo ? { dateTo: filters.dateTo } : {}), tab: 'raw', includeFields: 'true' } }))}`,
|
||
...(vehicleURL ? [`车辆服务:${vehicleURL}`] : [])
|
||
].join('\n'), '里程报表交付旅程包');
|
||
};
|
||
const mileageReportJourneyItems = [
|
||
{
|
||
step: '1',
|
||
title: '报表周期',
|
||
value: mileageRangeText,
|
||
detail: `${currentVehicleKeyword || '全部车辆'} / ${currentProtocol || '全部数据通道'},先确认客户要看的车辆和时间段。`,
|
||
color: currentVehicleKeyword ? 'blue' as const : 'orange' as const,
|
||
disabled: false,
|
||
onClick: copyStatisticsSummary
|
||
},
|
||
{
|
||
step: '2',
|
||
title: '区间闭合',
|
||
value: closureStatus,
|
||
detail: closureDeltaKm == null ? `当前明细覆盖 ${formatPercent(pageCoverageRate)},发布前需要拉齐全量。` : `闭合差值 ${formatKm(Math.abs(closureDeltaKm))} km。`,
|
||
color: closureStatusColor,
|
||
disabled: false,
|
||
onClick: copyPublishAudit
|
||
},
|
||
{
|
||
step: '3',
|
||
title: '轨迹证明',
|
||
value: peakMileage?.plate || peakMileage?.vin || '待选车辆',
|
||
detail: peakMileage ? `${peakMileage.date} 最大单日 ${formatKm(peakMileage.dailyMileageKm)} km,建议回放轨迹证明。` : '选择车辆后可进入同一时间窗轨迹回放。',
|
||
color: currentVehicleKeyword ? 'blue' as const : 'grey' as const,
|
||
disabled: !currentVehicleKeyword || !onOpenHistory,
|
||
onClick: () => {
|
||
if (peakMileage) {
|
||
openMileageEvidence(peakMileage);
|
||
return;
|
||
}
|
||
onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' });
|
||
}
|
||
},
|
||
{
|
||
step: '4',
|
||
title: '导出发布',
|
||
value: mileageDeliveryState,
|
||
detail: `${publishAuditConclusion},导出明细和交付包给客户或 BI 复核。`,
|
||
color: mileageDeliveryColor,
|
||
disabled: false,
|
||
onClick: copyMileageReportJourneyPackage
|
||
}
|
||
];
|
||
const mileageDeliveryConsoleItems = [
|
||
{
|
||
label: '交付状态',
|
||
value: mileageDeliveryState,
|
||
detail: mileageDeliveryState === '可交付'
|
||
? '可进入客户查询、BI发布和报表交付。'
|
||
: mileageDeliveryState === '暂缓交付'
|
||
? '存在阻断项,先完成闭合和异常复核。'
|
||
: '复核闭合、覆盖、异常或在线影响后再交付。',
|
||
color: mileageDeliveryColor,
|
||
action: '复制交付',
|
||
onClick: copyMileageDeliveryPackage
|
||
},
|
||
{
|
||
label: '区间里程',
|
||
value: `${formatKm(summary.totalMileageKm)} km`,
|
||
detail: `范围 ${mileageRangeText},统计车辆 ${summary.vehicleCount.toLocaleString()} 辆。`,
|
||
color: 'blue' as const,
|
||
action: '复制摘要',
|
||
onClick: copyStatisticsSummary
|
||
},
|
||
{
|
||
label: '闭合校验',
|
||
value: closureStatus,
|
||
detail: closureDeltaKm == null ? '当前页未覆盖全量记录,发布前需要导出或缩小范围。' : `闭合差值 ${formatKm(Math.abs(closureDeltaKm))} km。`,
|
||
color: closureStatusColor,
|
||
action: '发布审计',
|
||
onClick: copyPublishAudit
|
||
},
|
||
{
|
||
label: '异常复核',
|
||
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,
|
||
action: '轨迹复核',
|
||
onClick: () => {
|
||
if (anomalyRows[0]) {
|
||
openMileageEvidence(anomalyRows[0]);
|
||
return;
|
||
}
|
||
onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' });
|
||
}
|
||
},
|
||
{
|
||
label: '数据导出',
|
||
value: `${rows.length.toLocaleString()} 条明细`,
|
||
detail: `当前页覆盖 ${formatPercent(pageCoverageRate)},用于客户复盘和BI核对。`,
|
||
color: rows.length > 0 ? 'blue' as const : 'grey' as const,
|
||
action: '导出明细',
|
||
onClick: exportMileage
|
||
}
|
||
];
|
||
const mileageReconciliationControlItems = [
|
||
{
|
||
title: '区间里程',
|
||
value: `${formatKm(summary.totalMileageKm)} km`,
|
||
detail: `${summary.vehicleCount.toLocaleString()} 辆车,${mileageRangeText}。`,
|
||
action: '复制摘要',
|
||
color: 'blue' as const,
|
||
disabled: false,
|
||
onClick: copyStatisticsSummary
|
||
},
|
||
{
|
||
title: '每日闭合',
|
||
value: closureStatus,
|
||
detail: closureDeltaKm == null ? `当前页覆盖 ${formatPercent(pageCoverageRate)},交付前需要拉齐全量明细。` : `闭合差值 ${formatKm(Math.abs(closureDeltaKm))} km。`,
|
||
action: '闭合审计',
|
||
color: closureStatusColor,
|
||
disabled: false,
|
||
onClick: copyPublishAudit
|
||
},
|
||
{
|
||
title: '轨迹证明',
|
||
value: peakMileage?.date || mileageRangeText,
|
||
detail: peakMileage ? `${peakMileage.plate || peakMileage.vin} 最大单日 ${formatKm(peakMileage.dailyMileageKm)} km。` : '选择车辆后按同一时间窗回放轨迹。',
|
||
action: '轨迹复核',
|
||
color: currentVehicleKeyword ? 'blue' as const : 'grey' as const,
|
||
disabled: !currentVehicleKeyword || !onOpenHistory,
|
||
onClick: () => {
|
||
if (peakMileage) {
|
||
openMileageEvidence(peakMileage);
|
||
return;
|
||
}
|
||
onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' });
|
||
}
|
||
},
|
||
{
|
||
title: '报表导出',
|
||
value: `${rows.length.toLocaleString()} 条`,
|
||
detail: '导出当前里程明细和口径说明,作为客户对账附件。',
|
||
action: '导出报表',
|
||
color: rows.length > 0 ? 'blue' as const : 'grey' as const,
|
||
disabled: rows.length === 0,
|
||
onClick: exportMileage
|
||
}
|
||
];
|
||
const mileageReconciliationItems = [
|
||
{
|
||
label: '区间里程',
|
||
value: `${formatKm(summary.totalMileageKm)} km`,
|
||
detail: `车辆 ${summary.vehicleCount.toLocaleString()} 辆,时间窗 ${mileageRangeText}。`,
|
||
color: 'blue' as const,
|
||
action: '复制摘要',
|
||
onClick: copyStatisticsSummary
|
||
},
|
||
{
|
||
label: '日报闭合',
|
||
value: closureStatus,
|
||
detail: closureDeltaKm == null ? `当前页覆盖 ${formatPercent(pageCoverageRate)},交付前需要拉齐明细。` : `闭合差值 ${formatKm(Math.abs(closureDeltaKm))} km。`,
|
||
color: closureStatusColor,
|
||
action: '闭合审计',
|
||
onClick: copyPublishAudit
|
||
},
|
||
{
|
||
label: '轨迹证据',
|
||
value: peakMileage?.date || mileageRangeText,
|
||
detail: peakMileage ? `${peakMileage.plate || peakMileage.vin} 最大单日 ${formatKm(peakMileage.dailyMileageKm)} km。` : '选择车辆后可按同一时间窗回放轨迹。',
|
||
color: currentVehicleKeyword ? 'blue' as const : 'grey' as const,
|
||
action: '轨迹回放',
|
||
disabled: !currentVehicleKeyword || !onOpenHistory,
|
||
onClick: () => {
|
||
if (peakMileage) {
|
||
openMileageEvidence(peakMileage);
|
||
return;
|
||
}
|
||
onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' });
|
||
}
|
||
},
|
||
{
|
||
label: '告警通知',
|
||
value: `${offlineVehicleCount.toLocaleString()} 离线`,
|
||
detail: offlineVehicleCount > 0 ? '离线车辆会影响当日里程、定位和客户通知。' : '当前在线状态未形成主要统计风险。',
|
||
color: offlineVehicleCount > 0 ? 'orange' as const : 'green' as const,
|
||
action: '在线态势',
|
||
onClick: copyOnlineOperationsReport
|
||
},
|
||
{
|
||
label: '报表导出',
|
||
value: `${rows.length.toLocaleString()} 条`,
|
||
detail: '导出当前明细,作为客户对账、BI 核对和报表附件。',
|
||
color: rows.length > 0 ? 'blue' as const : 'grey' as const,
|
||
action: '导出CSV',
|
||
disabled: rows.length === 0,
|
||
onClick: exportMileage
|
||
}
|
||
];
|
||
const mileageDefinitionMetrics = [
|
||
{
|
||
label: '区间总值',
|
||
value: `${formatKm(summary.totalMileageKm)} km`,
|
||
detail: `来自汇总口径,覆盖 ${summary.vehicleCount.toLocaleString()} 辆车。`,
|
||
color: 'blue' as const,
|
||
onClick: copyStatisticsSummary
|
||
},
|
||
{
|
||
label: '明细合计',
|
||
value: `${formatKm(pageMileageTotal)} km`,
|
||
detail: `${rows.length.toLocaleString()} / ${summary.recordCount.toLocaleString()} 条明细,覆盖 ${formatPercent(pageCoverageRate)}。`,
|
||
color: hasFullMileagePage ? 'green' as const : 'orange' as const,
|
||
onClick: copyPublishAudit
|
||
},
|
||
{
|
||
label: '闭合差值',
|
||
value: closureDeltaKm == null ? '需全量' : `${formatKm(Math.abs(closureDeltaKm))} km`,
|
||
detail: closureDeltaKm == null ? '当前为分页抽样,交付前需要拉齐全量明细。' : closureActionText,
|
||
color: closureStatusColor,
|
||
onClick: copyPublishAudit
|
||
},
|
||
{
|
||
label: '异常记录',
|
||
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,
|
||
onClick: () => anomalyRows[0] ? copyMileageEvidence(anomalyRows[0]) : copyMileageDecision()
|
||
}
|
||
];
|
||
const mileageDefinitionActions = [
|
||
{
|
||
label: '复制口径',
|
||
action: '决策说明',
|
||
color: 'blue' as const,
|
||
disabled: false,
|
||
onClick: copyMileageDecision
|
||
},
|
||
{
|
||
label: '复制交付',
|
||
action: '交付包',
|
||
color: mileageDeliveryColor,
|
||
disabled: false,
|
||
onClick: copyMileageDeliveryPackage
|
||
},
|
||
{
|
||
label: '轨迹复核',
|
||
action: '轨迹',
|
||
color: currentVehicleKeyword ? 'blue' as const : 'grey' as const,
|
||
disabled: !currentVehicleKeyword || !onOpenHistory,
|
||
onClick: () => onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' })
|
||
},
|
||
{
|
||
label: '导出明细',
|
||
action: 'CSV',
|
||
color: rows.length > 0 ? 'blue' as const : 'grey' as const,
|
||
disabled: rows.length === 0,
|
||
onClick: exportMileage
|
||
}
|
||
];
|
||
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: '历史明细',
|
||
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
|
||
}
|
||
];
|
||
const mileageNextActions = [
|
||
{
|
||
title: publishAuditConclusion === '阻断发布' ? '先解除发布阻断' : publishAuditConclusion === '复核后发布' ? '先完成发布复核' : '可交付里程报告',
|
||
value: publishAuditConclusion,
|
||
detail: publishAuditConclusion === '可发布'
|
||
? '闭合、覆盖和异常检查通过后,可复制交付包或导出明细给客户。'
|
||
: '先处理闭合、覆盖、异常和在线影响,再进入客户交付。',
|
||
color: publishAuditColor,
|
||
action: publishAuditConclusion === '可发布' ? '复制交付包' : '发布审计',
|
||
disabled: false,
|
||
onClick: publishAuditConclusion === '可发布' ? copyMileageDeliveryPackage : copyPublishAudit
|
||
},
|
||
{
|
||
title: anomalyRows.length > 0 ? '优先复核异常日' : '按时间窗核对轨迹',
|
||
value: anomalyRows.length > 0 ? `${anomalyRows.length.toLocaleString()} 条异常` : mileageRangeText,
|
||
detail: anomalyRows[0]
|
||
? `${anomalyRows[0].plate || anomalyRows[0].vin} / ${anomalyRows[0].date} 建议先回放轨迹。`
|
||
: '用同一个车辆和时间窗进入轨迹回放,核对位置、速度和里程变化。',
|
||
color: anomalyRows.length > 0 ? 'orange' as const : 'blue' as const,
|
||
action: '轨迹回放',
|
||
disabled: !currentVehicleKeyword || !onOpenHistory,
|
||
onClick: () => {
|
||
if (anomalyRows[0]) {
|
||
openMileageEvidence(anomalyRows[0]);
|
||
return;
|
||
}
|
||
onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' });
|
||
}
|
||
},
|
||
{
|
||
title: pageCoverageRate >= 100 ? '明细覆盖完整' : '拉齐全量明细',
|
||
value: formatPercent(pageCoverageRate),
|
||
detail: pageCoverageRate >= 100
|
||
? '当前页已经覆盖筛选范围内全部明细,可直接导出复核。'
|
||
: `${rows.length.toLocaleString()} / ${summary.recordCount.toLocaleString()} 条记录在当前页,导出前建议拉齐。`,
|
||
color: pageCoverageRate >= 100 ? 'green' as const : 'orange' as const,
|
||
action: '导出明细',
|
||
disabled: rows.length === 0,
|
||
onClick: exportMileage
|
||
},
|
||
{
|
||
title: offlineVehicleCount > 0 ? '关注离线影响' : '在线状态正常',
|
||
value: `${offlineVehicleCount.toLocaleString()} 离线`,
|
||
detail: offlineVehicleCount > 0
|
||
? '离线车辆会影响实时位置、当日里程和告警触达,建议同步运营处理。'
|
||
: '当前筛选范围内在线状态未形成主要统计风险。',
|
||
color: offlineVehicleCount > 0 ? 'orange' as const : 'green' as const,
|
||
action: '在线态势',
|
||
disabled: false,
|
||
onClick: copyOnlineOperationsReport
|
||
}
|
||
];
|
||
const mileageCustomerQuestions = [
|
||
{
|
||
question: '这段时间总共跑了多少?',
|
||
answer: `${formatKm(summary.totalMileageKm)} km / ${summary.vehicleCount.toLocaleString()} 辆车`,
|
||
action: '复制摘要',
|
||
color: 'blue' as const,
|
||
disabled: false,
|
||
onClick: copyStatisticsSummary
|
||
},
|
||
{
|
||
question: '这份里程能给客户或 BI 吗?',
|
||
answer: `${mileageDeliveryState} / ${publishAuditConclusion}`,
|
||
action: mileageDeliveryState === '可交付' ? '复制交付包' : '发布审计',
|
||
color: mileageDeliveryColor,
|
||
disabled: false,
|
||
onClick: mileageDeliveryState === '可交付' ? copyMileageDeliveryPackage : copyPublishAudit
|
||
},
|
||
{
|
||
question: '哪天或哪辆车有异常?',
|
||
answer: anomalyRows[0] ? `${anomalyRows[0].plate || anomalyRows[0].vin} / ${anomalyRows[0].date}` : '当前明细无异常',
|
||
action: anomalyRows[0] ? '异常证据' : '复制决策',
|
||
color: anomalyRows.length > 0 ? 'orange' as const : 'green' as const,
|
||
disabled: false,
|
||
onClick: () => anomalyRows[0] ? copyMileageEvidence(anomalyRows[0]) : copyMileageDecision()
|
||
},
|
||
{
|
||
question: '这段路能回放轨迹吗?',
|
||
answer: currentVehicleKeyword ? mileageRangeText : '请先选择车辆',
|
||
action: '轨迹复核',
|
||
color: currentVehicleKeyword ? 'blue' as const : 'grey' as const,
|
||
disabled: !currentVehicleKeyword || !onOpenHistory,
|
||
onClick: () => onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' })
|
||
},
|
||
{
|
||
question: '明细数据能导出吗?',
|
||
answer: `${rows.length.toLocaleString()} 条当前页 / 覆盖 ${formatPercent(pageCoverageRate)}`,
|
||
action: '导出明细',
|
||
color: rows.length > 0 ? 'blue' as const : 'grey' as const,
|
||
disabled: rows.length === 0,
|
||
onClick: exportMileage
|
||
}
|
||
];
|
||
|
||
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>
|
||
)}
|
||
/>
|
||
<section className="vp-mileage-conclusion-strip" aria-label="客户里程对账总控">
|
||
<div className="vp-mileage-conclusion-copy">
|
||
<Space wrap>
|
||
<Tag color="blue">客户里程对账总控</Tag>
|
||
<Tag color={mileageDeliveryColor}>{mileageDeliveryState}</Tag>
|
||
<Tag color={closureStatusColor}>{closureStatus}</Tag>
|
||
</Space>
|
||
<Typography.Title heading={5} style={{ margin: 0 }}>
|
||
把区间里程、每日闭合、轨迹证明、异常通知和报表导出收敛成客户可验收的对账链路。
|
||
</Typography.Title>
|
||
<Typography.Text type="secondary">
|
||
客户只需要知道这段时间跑了多少、是否闭合、哪里需要复核,以及能拿到哪份导出报表。
|
||
</Typography.Text>
|
||
<Space wrap>
|
||
<Button size="small" theme="solid" type="primary" onClick={copyMileageReconciliationControl}>复制对账总控包</Button>
|
||
<Button size="small" disabled={!currentVehicleKeyword || !onOpenHistory} onClick={() => onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' })}>轨迹复核</Button>
|
||
<Button size="small" disabled={rows.length === 0} onClick={exportMileage}>导出报表</Button>
|
||
</Space>
|
||
</div>
|
||
<div className="vp-mileage-conclusion-metrics">
|
||
{mileageReconciliationControlItems.map((item) => (
|
||
<button
|
||
key={item.title}
|
||
type="button"
|
||
className="vp-mileage-conclusion-metric"
|
||
disabled={item.disabled}
|
||
onClick={item.onClick}
|
||
aria-label={`客户里程对账总控 ${item.title} ${item.value} ${item.action}`}
|
||
>
|
||
<Tag color={item.color}>{item.title}</Tag>
|
||
<strong>{item.value}</strong>
|
||
<span>{item.detail}</span>
|
||
<em>{item.action}</em>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
<section className="vp-mileage-report-journey" aria-label="里程报表交付旅程">
|
||
<div className="vp-mileage-report-journey-copy">
|
||
<Space wrap>
|
||
<Tag color="blue">里程报表交付旅程</Tag>
|
||
<Tag color={mileageDeliveryColor}>{mileageDeliveryState}</Tag>
|
||
<Tag color={publishAuditColor}>{publishAuditConclusion}</Tag>
|
||
</Space>
|
||
<Typography.Title heading={5} style={{ margin: 0 }}>
|
||
按客户报表习惯组织:先确认报表周期和车辆范围,再检查区间闭合、轨迹证明,最后导出或暂缓发布。
|
||
</Typography.Title>
|
||
<Typography.Text type="secondary">
|
||
报表交付不再从协议解释开始,而是围绕车辆、时间、里程闭合和可导出证据完成客户复核。
|
||
</Typography.Text>
|
||
<Button size="small" theme="solid" type="primary" onClick={copyMileageReportJourneyPackage}>
|
||
复制报表旅程包
|
||
</Button>
|
||
</div>
|
||
<div className="vp-mileage-report-journey-steps">
|
||
{mileageReportJourneyItems.map((item) => (
|
||
<button
|
||
key={item.step}
|
||
type="button"
|
||
className="vp-mileage-report-journey-step"
|
||
disabled={item.disabled}
|
||
onClick={item.onClick}
|
||
aria-label={`里程报表交付旅程 ${item.step} ${item.title} ${item.value}`}
|
||
>
|
||
<Tag color={item.color}>{item.step}</Tag>
|
||
<strong>{item.title}</strong>
|
||
<span>{item.value}</span>
|
||
<small>{item.detail}</small>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
<section className="vp-mileage-conclusion-strip" aria-label="客户里程结论条">
|
||
<div className="vp-mileage-conclusion-copy">
|
||
<Space wrap>
|
||
<Tag color="blue">客户里程结论条</Tag>
|
||
<Tag color={mileageDeliveryColor}>{mileageDeliveryState}</Tag>
|
||
<Tag color={closureStatusColor}>{closureStatus}</Tag>
|
||
</Space>
|
||
<Typography.Title heading={5} style={{ margin: 0 }}>
|
||
先给客户一个可交付结论,再进入轨迹复核、明细导出和告警通知。
|
||
</Typography.Title>
|
||
<Typography.Text type="secondary">
|
||
当前范围:{currentVehicleKeyword || '全部车辆'} / {mileageRangeText},数据源只作为证据,不作为客户操作入口。
|
||
</Typography.Text>
|
||
</div>
|
||
<div className="vp-mileage-conclusion-metrics">
|
||
{mileageConclusionItems.map((item) => (
|
||
<button
|
||
key={item.label}
|
||
type="button"
|
||
className="vp-mileage-conclusion-metric"
|
||
onClick={item.onClick}
|
||
aria-label={`客户里程结论 ${item.label} ${item.value}`}
|
||
>
|
||
<Tag color={item.color}>{item.displayLabel ?? item.label}</Tag>
|
||
<strong>{item.value}</strong>
|
||
<span>{item.detail}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="vp-mileage-conclusion-actions">
|
||
{mileageConclusionActions.map((item) => (
|
||
<button
|
||
key={item.label}
|
||
type="button"
|
||
className="vp-mileage-conclusion-action"
|
||
disabled={item.disabled}
|
||
onClick={item.onClick}
|
||
aria-label={`客户里程动作 ${item.label} ${item.action}`}
|
||
>
|
||
<Tag color={item.color}>{item.label}</Tag>
|
||
<span>{item.action}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
<section className="vp-mileage-definition-strip" aria-label="客户里程口径交付条">
|
||
<div className="vp-mileage-definition-copy">
|
||
<Space wrap>
|
||
<Tag color="blue">客户里程口径交付条</Tag>
|
||
<Tag color={closureStatusColor}>{closureStatus}</Tag>
|
||
<Tag color={publishAuditColor}>{publishAuditConclusion}</Tag>
|
||
</Space>
|
||
<Typography.Title heading={5} style={{ margin: 0 }}>
|
||
日里程按当天首末总里程差值计算;区间总值必须等于每日里程之和,异常日进入轨迹和历史明细复核。
|
||
</Typography.Title>
|
||
<Typography.Text type="secondary">
|
||
这是客户、BI 和运营三方共用口径:同一车辆、同一时间窗、同一统计证据,导出前先看闭合差值和异常记录。
|
||
</Typography.Text>
|
||
</div>
|
||
<div className="vp-mileage-definition-metrics">
|
||
{mileageDefinitionMetrics.map((item) => (
|
||
<button
|
||
key={item.label}
|
||
type="button"
|
||
className="vp-mileage-definition-metric"
|
||
onClick={item.onClick}
|
||
aria-label={`客户里程口径交付 ${item.label} ${item.value}`}
|
||
>
|
||
<Tag color={item.color}>{item.label}</Tag>
|
||
<strong>{item.value}</strong>
|
||
<span>{item.detail}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="vp-mileage-definition-actions">
|
||
{mileageDefinitionActions.map((item) => (
|
||
<button
|
||
key={item.label}
|
||
type="button"
|
||
className="vp-mileage-definition-action"
|
||
disabled={item.disabled}
|
||
onClick={item.onClick}
|
||
aria-label={`客户里程口径动作 ${item.label} ${item.action}`}
|
||
>
|
||
<Tag color={item.color}>{item.label}</Tag>
|
||
<span>{item.action}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
<section className="vp-mileage-customer-workbench" aria-label="客户里程统计工作台">
|
||
<div className="vp-mileage-customer-workbench-main">
|
||
<Space wrap>
|
||
<Tag color="blue">客户里程统计工作台</Tag>
|
||
<Tag color={mileageDeliveryColor}>{mileageDeliveryState}</Tag>
|
||
<Tag color={publishAuditColor}>{publishAuditConclusion}</Tag>
|
||
</Space>
|
||
<Typography.Title heading={5} style={{ margin: 0 }}>
|
||
统计页先回答客户这段时间跑了多少、每日是否闭合、异常在哪里、能否导出。
|
||
</Typography.Title>
|
||
<div className="vp-mileage-customer-workbench-meta">
|
||
<span>车辆范围:{currentVehicleKeyword || '全部车辆'}</span>
|
||
<span>时间范围:{mileageRangeText}</span>
|
||
<span>来源证据只用于统计可信度:{currentProtocol || '全部数据通道'}</span>
|
||
</div>
|
||
</div>
|
||
<div className="vp-mileage-customer-workbench-grid">
|
||
{mileageCustomerWorkbenchItems.map((item) => (
|
||
<button
|
||
key={item.label}
|
||
type="button"
|
||
className="vp-mileage-customer-workbench-item"
|
||
disabled={item.disabled}
|
||
onClick={item.onClick}
|
||
aria-label={`客户里程统计工作台 ${item.label} ${item.value} ${item.action}`}
|
||
>
|
||
<Tag color={item.color}>{item.label}</Tag>
|
||
<strong>{item.value}</strong>
|
||
<span>{item.detail}</span>
|
||
<em>{item.action}</em>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
<section className="vp-mileage-reconciliation-conclusion" aria-label="里程对账结论">
|
||
<div className="vp-mileage-reconciliation-conclusion-summary">
|
||
<Space wrap>
|
||
<Tag color="blue">里程对账结论</Tag>
|
||
<Tag color={mileageDeliveryColor}>{mileageDeliveryState}</Tag>
|
||
</Space>
|
||
<Typography.Title heading={5} style={{ margin: 0 }}>
|
||
先回答客户这段时间跑了多少、闭合是否通过、异常是否需要复核,再进入轨迹、历史和导出。
|
||
</Typography.Title>
|
||
</div>
|
||
<div className="vp-mileage-reconciliation-conclusion-grid">
|
||
{mileageReconciliationConclusionItems.map((item) => (
|
||
<button
|
||
key={item.label}
|
||
type="button"
|
||
className="vp-mileage-reconciliation-conclusion-item"
|
||
disabled={item.disabled}
|
||
onClick={item.onClick}
|
||
aria-label={`里程对账结论 ${item.label} ${item.value} ${item.action}`}
|
||
>
|
||
<Tag color={item.color}>{item.label}</Tag>
|
||
<strong>{item.value}</strong>
|
||
<span>{item.detail}</span>
|
||
<em>{item.action}</em>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
<section className="vp-mileage-reconciliation-desk" aria-label="客户里程对账台">
|
||
<div className="vp-mileage-reconciliation-summary">
|
||
<Space wrap>
|
||
<Tag color="blue">客户里程对账台</Tag>
|
||
<Tag color={mileageDeliveryColor}>{mileageDeliveryState}</Tag>
|
||
<Tag color={publishAuditColor}>{publishAuditConclusion}</Tag>
|
||
</Space>
|
||
<Typography.Title heading={5} style={{ margin: 0 }}>用同一辆车和同一时间窗完成区间里程、日报闭合、轨迹证据、告警通知和报表导出。</Typography.Title>
|
||
<Typography.Text type="secondary">
|
||
面向客户的统计页只围绕车辆服务展开:先看里程结论,再用轨迹和在线态势解释风险,最后交付 CSV 或复核说明。
|
||
</Typography.Text>
|
||
</div>
|
||
<div className="vp-mileage-reconciliation-grid">
|
||
{mileageReconciliationItems.map((item) => (
|
||
<button
|
||
key={item.label}
|
||
type="button"
|
||
className="vp-mileage-reconciliation-item"
|
||
disabled={item.disabled}
|
||
onClick={item.onClick}
|
||
aria-label={`客户里程对账台 ${item.label} ${item.value} ${item.action}`}
|
||
>
|
||
<Tag color={item.color}>{item.label}</Tag>
|
||
<strong>{item.value}</strong>
|
||
<span>{item.detail}</span>
|
||
<em>{item.action}</em>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
<section className="vp-mileage-delivery-console" aria-label="客户里程交付台">
|
||
<div className="vp-mileage-delivery-console-summary">
|
||
<Space wrap>
|
||
<Tag color="blue">客户里程交付台</Tag>
|
||
<Tag color={mileageDeliveryColor}>{mileageDeliveryState}</Tag>
|
||
<Tag color={closureStatusColor}>{closureStatus}</Tag>
|
||
</Space>
|
||
<strong>按自定义时间范围交付里程、闭合校验、异常复核和导出证据。</strong>
|
||
<span>客户看统计页时先拿到可交付结论,再按同一车辆和时间窗进入轨迹、历史明细、发布审计和 CSV 导出。</span>
|
||
</div>
|
||
<div className="vp-mileage-delivery-console-grid">
|
||
{mileageDeliveryConsoleItems.map((item) => (
|
||
<button
|
||
key={item.label}
|
||
type="button"
|
||
className="vp-mileage-delivery-console-item"
|
||
onClick={item.onClick}
|
||
aria-label={`客户里程交付台 ${item.label} ${item.value} ${item.action}`}
|
||
>
|
||
<Tag color={item.color}>{item.label}</Tag>
|
||
<strong>{item.value}</strong>
|
||
<span>{item.detail}</span>
|
||
<em>{item.action}</em>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
<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 className="vp-mileage-customer-query-board" bodyStyle={{ padding: 0 }}>
|
||
<div className="vp-mileage-customer-query-summary">
|
||
<Space wrap>
|
||
<Tag color="blue">客户里程统计</Tag>
|
||
<Tag color={mileageDeliveryColor}>{mileageDeliveryState}</Tag>
|
||
<Tag color={closureStatusColor}>{closureStatus}</Tag>
|
||
</Space>
|
||
<Typography.Title heading={5} style={{ margin: 0 }}>先定车辆和时间范围,再判断里程能否交付</Typography.Title>
|
||
<Typography.Text type="secondary">
|
||
里程统计从车辆服务出发:选择车辆或车队、设定时间窗、判断区间里程和发布状态,再进入轨迹、历史明细和导出明细。
|
||
</Typography.Text>
|
||
<Space wrap>
|
||
<Button size="small" theme="solid" type="primary" onClick={copyStatisticsSummary}>复制统计摘要</Button>
|
||
<Button size="small" theme="light" type="primary" onClick={copyMileageDecision}>复制决策说明</Button>
|
||
<Button size="small" disabled={!currentVehicleKeyword || !onOpenHistory} onClick={() => onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' })}>轨迹复核</Button>
|
||
<Button size="small" disabled={rows.length === 0} onClick={exportMileage}>导出明细</Button>
|
||
</Space>
|
||
</div>
|
||
<div className="vp-mileage-customer-query-steps">
|
||
{mileageCustomerQuerySteps.map((item) => (
|
||
<button
|
||
key={item.step}
|
||
type="button"
|
||
className="vp-mileage-customer-query-step"
|
||
disabled={item.disabled}
|
||
onClick={item.onClick}
|
||
aria-label={`客户里程统计 ${item.title} ${item.action}`}
|
||
>
|
||
<span>{item.step}</span>
|
||
<Tag color={item.color}>{item.title}</Tag>
|
||
<strong>{item.value}</strong>
|
||
<small>{item.detail}</small>
|
||
<em>{item.action}</em>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
<Card bordered title="客户里程交付导航" style={{ marginTop: 16 }}>
|
||
<div className="vp-mileage-delivery-nav">
|
||
<div className="vp-mileage-delivery-nav-summary">
|
||
<Space wrap>
|
||
<Tag color={mileageDeliveryColor}>{mileageDeliveryState}</Tag>
|
||
<Tag color={publishAuditColor}>{publishAuditConclusion}</Tag>
|
||
<Tag color={closureStatusColor}>{closureStatus}</Tag>
|
||
</Space>
|
||
<Typography.Title heading={5} style={{ margin: 0 }}>把统计结果变成客户可交付的里程说明</Typography.Title>
|
||
<Typography.Text type="secondary">
|
||
把里程统计变成客户能看懂的交付路径:先确认口径,再回放轨迹,最后导出明细和结论。
|
||
</Typography.Text>
|
||
</div>
|
||
<div className="vp-mileage-delivery-nav-grid">
|
||
{mileageDeliveryNavigation.map((item) => (
|
||
<button
|
||
key={item.title}
|
||
type="button"
|
||
className="vp-mileage-delivery-nav-item"
|
||
disabled={item.disabled}
|
||
onClick={item.onClick}
|
||
aria-label={`客户里程交付导航 ${item.title} ${item.action}`}
|
||
>
|
||
<Tag color={item.color}>{item.title}</Tag>
|
||
<strong>{item.value}</strong>
|
||
<span>{item.detail}</span>
|
||
<em>{item.action}</em>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
<section className="vp-mileage-acceptance-strip" aria-label="里程客户验收清单">
|
||
<div className="vp-mileage-acceptance-copy">
|
||
<Space wrap>
|
||
<Tag color="blue">里程客户验收清单</Tag>
|
||
<Tag color={mileageDeliveryColor}>{mileageDeliveryState}</Tag>
|
||
<Tag color={publishAuditColor}>{publishAuditConclusion}</Tag>
|
||
</Space>
|
||
<Typography.Title heading={5} style={{ margin: 0 }}>
|
||
客户交付前只看五个门禁:范围、闭合、覆盖、异常、在线影响;任何一项不通过都能直接进入复核动作。
|
||
</Typography.Title>
|
||
<Typography.Text type="secondary">
|
||
这份清单把里程统计从技术查询变成客户验收口径,确保区间总值、每日明细、异常解释和导出证据能讲清楚。
|
||
</Typography.Text>
|
||
</div>
|
||
<div className="vp-mileage-acceptance-grid">
|
||
{mileageAcceptanceItems.map((item) => (
|
||
<button
|
||
key={item.label}
|
||
type="button"
|
||
className="vp-mileage-acceptance-item"
|
||
disabled={item.disabled}
|
||
onClick={item.onClick}
|
||
aria-label={`里程客户验收 ${item.label} ${item.value}`}
|
||
>
|
||
<Tag color={item.color}>{item.label}</Tag>
|
||
<strong>{item.value}</strong>
|
||
<span>{item.detail}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="vp-mileage-acceptance-actions">
|
||
{mileageAcceptanceActions.map((item) => (
|
||
<button
|
||
key={item.label}
|
||
type="button"
|
||
className="vp-mileage-acceptance-action"
|
||
disabled={item.disabled}
|
||
onClick={item.onClick}
|
||
aria-label={`里程验收动作 ${item.label} ${item.action}`}
|
||
>
|
||
<Tag color={item.color}>{item.label}</Tag>
|
||
<span>{item.action}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
<Card bordered title="客户里程问题" style={{ marginTop: 16 }}>
|
||
<div className="vp-mileage-question-grid">
|
||
{mileageCustomerQuestions.map((item) => (
|
||
<button
|
||
key={item.question}
|
||
type="button"
|
||
className="vp-mileage-question-item"
|
||
disabled={item.disabled}
|
||
onClick={item.onClick}
|
||
aria-label={`客户里程问题 ${item.question} ${item.action}`}
|
||
>
|
||
<strong>{item.question}</strong>
|
||
<span>{item.answer}</span>
|
||
<Tag color={item.color}>{item.action}</Tag>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
<Card bordered title="里程报告建议" style={{ marginTop: 16 }}>
|
||
<div className="vp-mileage-next-actions">
|
||
<div className="vp-mileage-next-summary">
|
||
<Space wrap>
|
||
<Tag color={publishAuditColor}>{publishAuditConclusion}</Tag>
|
||
<Tag color={mileageDeliveryColor}>{mileageDeliveryState}</Tag>
|
||
<Tag color={confidenceColor}>{confidenceStatus}</Tag>
|
||
</Space>
|
||
<Typography.Title heading={5} style={{ margin: 0 }}>从车辆服务视角安排下一步</Typography.Title>
|
||
<Typography.Text type="secondary">
|
||
里程页不再让客户先理解 GB32960、JT808、MQTT 的差异,而是直接给出交付、轨迹回放、历史查询、导出和在线影响的下一步动作。
|
||
</Typography.Text>
|
||
</div>
|
||
<div className="vp-mileage-next-grid">
|
||
{mileageNextActions.map((item) => (
|
||
<button
|
||
key={item.title}
|
||
type="button"
|
||
className="vp-mileage-next-action"
|
||
disabled={item.disabled}
|
||
onClick={item.onClick}
|
||
aria-label={`里程报告建议 ${item.title} ${item.action}`}
|
||
>
|
||
<Tag color={item.color}>{item.title}</Tag>
|
||
<strong>{item.value}</strong>
|
||
<span>{item.detail}</span>
|
||
<em>{item.action}</em>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
<Card bordered title="车辆统计服务总览" style={{ marginTop: 16 }}>
|
||
<div className="vp-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={copyMileageDecision}>复制决策说明</Button></Space>}
|
||
style={{ marginTop: 16 }}
|
||
>
|
||
<div className="vp-mileage-decision-board">
|
||
<div className="vp-mileage-decision-summary">
|
||
<Space wrap>
|
||
<Tag color={mileageDeliveryColor}>{mileageDeliveryState}</Tag>
|
||
<Tag color={publishAuditColor}>{publishAuditConclusion}</Tag>
|
||
<Tag color={confidenceColor}>{confidenceStatus}</Tag>
|
||
</Space>
|
||
<Typography.Title heading={5} style={{ margin: 0 }}>先判断能不能交付,再进入轨迹、历史明细和导出复核</Typography.Title>
|
||
<Typography.Text type="secondary">
|
||
客户关心的是区间里程是否可信、异常是否解释清楚、离线是否影响统计。数据通道只作为可追溯依据,不作为页面主入口。
|
||
</Typography.Text>
|
||
<Space wrap>
|
||
<Button size="small" theme="solid" type="primary" onClick={copyMileageDecision}>复制决策说明</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' })}>历史明细</Button>
|
||
<Button size="small" disabled={rows.length === 0} onClick={exportMileage}>导出明细</Button>
|
||
</Space>
|
||
</div>
|
||
<div className="vp-mileage-decision-grid">
|
||
{mileageDecisionItems.map((item) => (
|
||
<button
|
||
key={item.label}
|
||
type="button"
|
||
className="vp-mileage-decision-item"
|
||
aria-label={`客户里程决策 ${item.label} ${item.action}`}
|
||
onClick={item.onClick}
|
||
>
|
||
<Tag color={item.color}>{item.label}</Tag>
|
||
<strong>{item.value}</strong>
|
||
<span>{item.detail}</span>
|
||
<em>{item.action}</em>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
<Card bordered title="客户里程复核交付包" style={{ marginTop: 16 }}>
|
||
<div className="vp-mileage-delivery-board">
|
||
<div className="vp-mileage-delivery-summary">
|
||
<Space wrap>
|
||
<Tag color={mileageDeliveryColor}>{mileageDeliveryState}</Tag>
|
||
<Tag color={closureStatusColor}>{closureStatus}</Tag>
|
||
</Space>
|
||
<div className="vp-monitor-metric-value">{formatKm(summary.totalMileageKm)} km</div>
|
||
<Typography.Text type="secondary">
|
||
面向客户交付里程时,先确认区间里程、闭合状态、异常记录和在线影响,再导出明细或进入轨迹与历史明细复核。
|
||
</Typography.Text>
|
||
<Space wrap>
|
||
<Button size="small" theme="solid" type="primary" onClick={copyMileageDeliveryPackage}>复制交付包</Button>
|
||
<Button size="small" disabled={rows.length === 0} onClick={exportMileage}>导出明细</Button>
|
||
<Button
|
||
size="small"
|
||
disabled={!currentVehicleKeyword || !onOpenHistory}
|
||
onClick={() => onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' })}
|
||
>
|
||
轨迹复核
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
<div className="vp-mileage-delivery-grid">
|
||
{[
|
||
{
|
||
label: '区间里程',
|
||
value: `${formatKm(summary.totalMileageKm)} km`,
|
||
detail: `范围 ${mileageRangeText},当前页明细 ${formatKm(pageMileageTotal)} km。`,
|
||
color: 'blue' as const,
|
||
action: '复制交付',
|
||
onClick: copyMileageDeliveryPackage
|
||
},
|
||
{
|
||
label: '闭合校验',
|
||
value: closureStatus,
|
||
detail: closureDeltaKm == null ? '当前页未覆盖全量记录,交付前需导出或缩小范围复核。' : `闭合差值 ${formatKm(Math.abs(closureDeltaKm))} km。`,
|
||
color: closureStatusColor,
|
||
action: '闭合摘要',
|
||
onClick: copyStatisticsSummary
|
||
},
|
||
{
|
||
label: '异常记录',
|
||
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,
|
||
action: '异常明细',
|
||
onClick: () => anomalyRows[0] ? copyMileageEvidence(anomalyRows[0]) : copyMileageDeliveryPackage
|
||
},
|
||
{
|
||
label: '在线影响',
|
||
value: formatPercent(onlineRatePercent),
|
||
detail: `${onlineVehicleCount.toLocaleString()} 在线 / ${offlineVehicleCount.toLocaleString()} 离线,影响当日里程完整性判断。`,
|
||
color: onlineRatePercent >= 90 ? 'green' as const : onlineRatePercent >= 60 ? 'orange' as const : 'red' as const,
|
||
action: '在线态势',
|
||
onClick: copyOnlineOperationsReport
|
||
}
|
||
].map((item) => (
|
||
<button
|
||
key={item.label}
|
||
type="button"
|
||
className="vp-mileage-delivery-item"
|
||
aria-label={`里程交付 ${item.label} ${item.action}`}
|
||
onClick={item.onClick}
|
||
>
|
||
<span>
|
||
<Tag color={item.color}>{item.label}</Tag>
|
||
<strong>{item.value}</strong>
|
||
</span>
|
||
<Typography.Text type="secondary">{item.detail}</Typography.Text>
|
||
<em>{item.action}</em>
|
||
</button>
|
||
))}
|
||
</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 前需要结合异常记录、轨迹回放和历史明细完成复核。'}
|
||
</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' })}
|
||
>
|
||
闭合复核原始
|
||
</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">
|
||
发布前统一审计闭合、覆盖、异常、数据通道和在线状态;不满足时先回到轨迹和历史明细复核。
|
||
</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>异常优先检查补传、通道切换、总里程回退和跨通道差异,必要时回到车辆服务查看历史明细。</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)}>核对原始</Button>
|
||
<Button disabled={!canOpenVehicle(row.vin)} onClick={() => onOpenVehicle(row.vin, row.source)}>车辆服务</Button>
|
||
</Space>
|
||
)
|
||
}
|
||
]}
|
||
/>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|