feat(platform): enhance mileage statistics workspace

This commit is contained in:
lingniu
2026-07-04 10:56:10 +08:00
parent adb9d146d9
commit 312382c95d
3 changed files with 254 additions and 0 deletions

View File

@@ -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}

View File

@@ -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;

View File

@@ -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) => {