feat(platform): enhance mileage statistics workspace
This commit is contained in:
@@ -27,6 +27,10 @@ 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 canOpenVehicle(vin?: string) {
|
||||
const value = vin?.trim();
|
||||
return Boolean(value && value !== 'unknown');
|
||||
@@ -55,6 +59,33 @@ function mergeInitialFilters(initialVin: string, initialProtocol?: string, initi
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export function Mileage({
|
||||
initialVin,
|
||||
initialProtocol,
|
||||
@@ -76,6 +107,13 @@ export function Mileage({
|
||||
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, 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 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}` : '',
|
||||
@@ -181,6 +219,68 @@ export function Mileage({
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<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: `${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: summary.recordCount > 0 ? formatPercent((rows.length / summary.recordCount) * 100) : '0%', color: 'grey' as const, detail: '当前分页记录数 / 全量统计记录数。' }
|
||||
].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-stat-definition-grid">
|
||||
<div>
|
||||
<Tag color="blue">日里程</Tag>
|
||||
<p>按车辆、来源、日期聚合,以总里程差值作为日统计结果,避免中间断链导致分段累计不闭合。</p>
|
||||
</div>
|
||||
<div>
|
||||
<Tag color="green">区间里程</Tag>
|
||||
<p>按当前筛选范围汇总日里程,区间总值应等于各日里程之和,用于 BI 和运营报表口径。</p>
|
||||
</div>
|
||||
<div>
|
||||
<Tag color="orange">异常复核</Tag>
|
||||
<p>异常优先检查补传、来源切换、总里程回退和跨协议差异,必要时回到车辆服务查看 RAW 证据。</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card bordered style={{ marginTop: 16 }}>
|
||||
<Table
|
||||
loading={loading}
|
||||
|
||||
Reference in New Issue
Block a user