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}
|
||||
|
||||
@@ -430,6 +430,85 @@ body {
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.vp-stat-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.vp-stat-chart {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vp-stat-bar-row {
|
||||
min-height: 32px;
|
||||
display: grid;
|
||||
grid-template-columns: 110px minmax(0, 1fr) 96px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vp-stat-bar-label,
|
||||
.vp-stat-bar-value {
|
||||
color: var(--vp-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vp-stat-bar-value {
|
||||
color: var(--vp-text);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.vp-stat-bar-track {
|
||||
height: 10px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #eef4ff;
|
||||
}
|
||||
|
||||
.vp-stat-bar-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--vp-primary);
|
||||
}
|
||||
|
||||
.vp-stat-bar-fill-green {
|
||||
background: var(--vp-success);
|
||||
}
|
||||
|
||||
.vp-stat-insight-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.vp-stat-definition-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vp-stat-definition-grid > div {
|
||||
min-height: 104px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--vp-border);
|
||||
border-radius: var(--vp-radius);
|
||||
background: #fbfcff;
|
||||
}
|
||||
|
||||
.vp-stat-definition-grid p {
|
||||
margin: 10px 0 0;
|
||||
color: var(--vp-text-muted);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.vp-playback-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 260px;
|
||||
@@ -603,6 +682,9 @@ body {
|
||||
.vp-monitor-layout,
|
||||
.vp-alert-flow,
|
||||
.vp-alert-ops-grid,
|
||||
.vp-stat-workspace,
|
||||
.vp-stat-insight-grid,
|
||||
.vp-stat-definition-grid,
|
||||
.vp-playback-layout,
|
||||
.vp-playback-timeline {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -3440,6 +3440,78 @@ test('shows mileage anomaly action guidance', async () => {
|
||||
expect(screen.getByText('日里程异常,优先核对当天首末总里程、来源断链和补传数据。')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows mileage statistics workspace with trend source and definition', async () => {
|
||||
window.history.replaceState(null, '', '/#/mileage?keyword=VIN-MILEAGE-STATS');
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||
const path = String(input);
|
||||
if (path.includes('/api/ops/health')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: { linkHealth: [], kafkaLag: 0, redisOnlineKeys: 0, tdengineWritable: true, mysqlWritable: true, runtime: { requestTimeoutMs: 5000 } },
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
}
|
||||
if (path.includes('/api/mileage/summary')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: { vehicleCount: 2, recordCount: 4, sourceCount: 2, totalMileageKm: 140, averageMileagePerVin: 70 },
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
}
|
||||
if (path.includes('/api/mileage/daily')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
items: [
|
||||
{ vin: 'VIN-MILEAGE-STATS', plate: '粤A统计1', source: 'JT808', date: '2026-07-01', startMileageKm: 100, endMileageKm: 140, dailyMileageKm: 40, anomalySeverity: '' },
|
||||
{ vin: 'VIN-MILEAGE-STATS', plate: '粤A统计1', source: 'GB32960', date: '2026-07-01', startMileageKm: 200, endMileageKm: 230, dailyMileageKm: 30, anomalySeverity: '' },
|
||||
{ vin: 'VIN-MILEAGE-STATS-2', plate: '粤A统计2', source: 'JT808', date: '2026-07-02', startMileageKm: 300, endMileageKm: 360, dailyMileageKm: 60, anomalySeverity: 'warning' },
|
||||
{ vin: 'VIN-MILEAGE-STATS-2', plate: '粤A统计2', source: 'GB32960', date: '2026-07-02', startMileageKm: 400, endMileageKm: 410, dailyMileageKm: 10, anomalySeverity: '' }
|
||||
],
|
||||
total: 4,
|
||||
limit: 20,
|
||||
offset: 0
|
||||
},
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: { items: [], total: 0, limit: 20, offset: 0 },
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText('区间趋势')).toBeInTheDocument();
|
||||
expect(screen.getByText('来源贡献')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('2026-07-01').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('2026-07-02').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('JT808').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('GB32960').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('当前页里程')).toBeInTheDocument();
|
||||
expect(screen.getByText('140 km')).toBeInTheDocument();
|
||||
expect(screen.getByText('异常记录')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('1').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('最大单日')).toBeInTheDocument();
|
||||
expect(screen.getByText('60 km')).toBeInTheDocument();
|
||||
expect(screen.getByText('统计口径')).toBeInTheDocument();
|
||||
expect(screen.getByText((content) => content.includes('区间总值应等于各日里程之和'))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('opens vehicle service from history results with row source evidence', async () => {
|
||||
window.history.replaceState(null, '', '/#/history?keyword=%E7%B2%A4AG18312&protocol=JT808');
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
||||
|
||||
Reference in New Issue
Block a user