feat(platform): add customer mileage review console
This commit is contained in:
@@ -616,15 +616,70 @@ export function Mileage({
|
||||
filters.dateFrom?.trim() ? `开始日期:${filters.dateFrom.trim()}` : '',
|
||||
filters.dateTo?.trim() ? `结束日期:${filters.dateTo.trim()}` : ''
|
||||
].filter(Boolean);
|
||||
const mileageRangeText = filters.dateFrom?.trim() || filters.dateTo?.trim()
|
||||
? `${filters.dateFrom?.trim() || '-'} 至 ${filters.dateTo?.trim() || '-'}`
|
||||
: '全部日期';
|
||||
const mileageCustomerKpis = [
|
||||
{
|
||||
label: '区间总里程',
|
||||
value: `${formatKm(summary.totalMileageKm)} km`,
|
||||
color: 'blue' as const,
|
||||
detail: `范围:${mileageRangeText}`
|
||||
},
|
||||
{
|
||||
label: '统计车辆',
|
||||
value: `${summary.vehicleCount.toLocaleString()} 辆`,
|
||||
color: summary.vehicleCount > 0 ? 'green' as const : 'grey' as const,
|
||||
detail: `${summary.recordCount.toLocaleString()} 条日里程记录`
|
||||
},
|
||||
{
|
||||
label: '异常记录',
|
||||
value: anomalyRows.length.toLocaleString(),
|
||||
color: anomalyRows.length > 0 ? 'orange' as const : 'green' as const,
|
||||
detail: anomalyRows.length > 0 ? '建议先核对轨迹和原始记录' : '当前页未发现异常'
|
||||
},
|
||||
{
|
||||
label: '在线影响',
|
||||
value: formatPercent(onlineRatePercent),
|
||||
color: onlineRatePercent >= 90 ? 'green' as const : onlineRatePercent >= 60 ? 'orange' as const : 'red' as const,
|
||||
detail: `${onlineVehicleCount.toLocaleString()} 在线 / ${offlineVehicleCount.toLocaleString()} 离线`
|
||||
}
|
||||
];
|
||||
const mileageCustomerActions = [
|
||||
{
|
||||
label: '导出当前明细',
|
||||
disabled: rows.length === 0,
|
||||
action: () => exportMileage()
|
||||
},
|
||||
{
|
||||
label: '查看轨迹',
|
||||
disabled: !currentVehicleKeyword || !onOpenHistory,
|
||||
action: () => onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' })
|
||||
},
|
||||
{
|
||||
label: '查看原始记录',
|
||||
disabled: !currentVehicleKeyword || !onOpenRaw,
|
||||
action: () => onOpenRaw?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '', includeFields: 'true' })
|
||||
},
|
||||
{
|
||||
label: '车辆档案',
|
||||
disabled: !currentVehicleKeyword,
|
||||
action: () => onOpenVehicle(currentVehicleKeyword, currentProtocol)
|
||||
}
|
||||
];
|
||||
|
||||
const loadSummary = (values: Record<string, string> = filters) => {
|
||||
setSummaryLoading(true);
|
||||
api.mileageSummary(mileageParams(values))
|
||||
.then(setSummary)
|
||||
.then((nextSummary) => setSummary(nextSummary ?? emptySummary))
|
||||
.catch((error: Error) => Toast.error(error.message))
|
||||
.finally(() => setSummaryLoading(false));
|
||||
api.onlineStatisticsSummary(mileageParams(values))
|
||||
.then(setOnlineSummary)
|
||||
.then((nextSummary) => setOnlineSummary({
|
||||
...emptyOnlineSummary,
|
||||
...(nextSummary ?? {}),
|
||||
protocolStats: Array.isArray(nextSummary?.protocolStats) ? nextSummary.protocolStats : []
|
||||
}))
|
||||
.catch((error: Error) => Toast.error(error.message));
|
||||
};
|
||||
|
||||
@@ -922,6 +977,96 @@ export function Mileage({
|
||||
</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() },
|
||||
|
||||
Reference in New Issue
Block a user