feat: add vehicle mileage statistics

This commit is contained in:
lingniu
2026-07-14 13:18:52 +08:00
parent bb59303a4b
commit 6cddc0a43d
15 changed files with 522 additions and 1 deletions

View File

@@ -0,0 +1,30 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import StatisticsPage from './StatisticsPage';
const mileageStatistics = vi.hoisted(() => vi.fn());
vi.mock('../../api/client', () => ({ api: { mileageStatistics } }));
afterEach(() => { cleanup(); mileageStatistics.mockReset(); });
test('shows distinct period and latest fleet mileage metrics with exact daily evidence', async () => {
mileageStatistics.mockResolvedValue({
dateFrom: '2026-07-01', dateTo: '2026-07-14', vehicleCount: 2, recordCount: 2, sourceCount: 2,
periodMileageKm: 193.3, fleetLatestMileageKm: 168723.9, averageMileagePerVin: 96.65, averageDailyMileageKm: 96.65,
trend: [{ date: '2026-07-14', mileageKm: 193.3, vehicles: 2 }],
ranking: [{ vin: 'LTEST000000000001', plate: '粤A12345', mileageKm: 104.6, latestMileageKm: 119925, activeDays: 1 }],
asOf: '2026-07-14 13:20:00', evidence: 'production mileage evidence'
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter><StatisticsPage /></MemoryRouter></QueryClientProvider>);
expect((await screen.findAllByText('193.3 km')).length).toBeGreaterThan(0);
expect(screen.getByText('168,723.9 km')).toBeInTheDocument();
expect(screen.getByText('按车辆与自然日去重')).toBeInTheDocument();
expect(screen.getByText('粤A12345')).toHaveAttribute('href', '/vehicles/LTEST000000000001');
expect(screen.getByRole('img', { name: '每日行驶里程趋势图' })).toBeInTheDocument();
await waitFor(() => expect(mileageStatistics).toHaveBeenCalledTimes(1));
expect(mileageStatistics.mock.calls[0][0].get('dateFrom')).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});

View File

@@ -0,0 +1,90 @@
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { useQuery } from '@tanstack/react-query';
import { FormEvent, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { MileageStatistics, MileageTrendPoint } from '../../api/types';
import { InlineError } from '../shared/AsyncState';
const DAY = 86_400_000;
function localDate(value = new Date()) {
const offset = value.getTimezoneOffset() * 60_000;
return new Date(value.getTime() - offset).toISOString().slice(0, 10);
}
function defaultWindow(days = 30) {
const end = new Date();
return { dateFrom: localDate(new Date(end.getTime() - (days - 1) * DAY)), dateTo: localDate(end) };
}
function formatKm(value?: number, compact = false) {
if (value == null || !Number.isFinite(value)) return '—';
return new Intl.NumberFormat('zh-CN', compact ? { notation: 'compact', maximumFractionDigits: 1 } : { maximumFractionDigits: 1 }).format(value);
}
function MileageChart({ points }: { points: MileageTrendPoint[] }) {
if (!points.length) return <div className="v2-stat-empty"></div>;
const width = 860; const height = 238; const left = 58; const right = 18; const top = 16; const bottom = 34;
const max = Math.max(...points.map((point) => point.mileageKm), 1);
const x = (index: number) => left + (width - left - right) * (points.length === 1 ? .5 : index / (points.length - 1));
const y = (value: number) => top + (height - top - bottom) * (1 - value / max);
const path = points.map((point, index) => `${index ? 'L' : 'M'}${x(index).toFixed(1)},${y(point.mileageKm).toFixed(1)}`).join(' ');
const labelIndexes = Array.from(new Set([0, Math.floor((points.length - 1) / 2), points.length - 1]));
return <div className="v2-stat-chart-wrap"><svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label="每日行驶里程趋势图">
<g className="v2-stat-grid">{[0, .5, 1].map((ratio) => <line key={ratio} x1={left} x2={width - right} y1={y(max * ratio)} y2={y(max * ratio)} />)}</g>
<g className="v2-stat-axis"><text x={left - 8} y={y(max) + 4} textAnchor="end">{formatKm(max, true)}</text><text x={left - 8} y={y(max / 2) + 4} textAnchor="end">{formatKm(max / 2, true)}</text><text x={left - 8} y={y(0) + 4} textAnchor="end">0</text>{labelIndexes.map((index) => <text key={index} x={x(index)} y={height - 8} textAnchor={index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'middle'}>{points[index].date.slice(5)}</text>)}</g>
<path className="v2-stat-area" d={`${path} L${x(points.length - 1)},${y(0)} L${x(0)},${y(0)} Z`} />
<path className="v2-stat-line" d={path} />
{points.map((point, index) => <circle key={point.date} className="v2-stat-point" cx={x(index)} cy={y(point.mileageKm)} r="3"><title>{point.date}{formatKm(point.mileageKm)} km{point.vehicles} </title></circle>)}
</svg></div>;
}
function Kpis({ data }: { data?: MileageStatistics }) {
const items = [
['统计期行驶里程', `${formatKm(data?.periodMileageKm)} km`, '按车辆与自然日去重'],
['最新里程表总和', `${formatKm(data?.fleetLatestMileageKm)} km`, '每车取最新一次上报'],
['有里程车辆', formatKm(data?.vehicleCount), `${data?.sourceCount ?? 0} 个数据来源`],
['车均行驶里程', `${formatKm(data?.averageMileagePerVin)} km`, '统计期累计 / 车辆'],
['车日均里程', `${formatKm(data?.averageDailyMileageKm)} km`, '有效车辆日平均']
];
return <section className="v2-stat-kpis">{items.map(([label, value, note], index) => <article key={label} className={index < 2 ? 'is-primary' : ''}><small>{label}</small><strong>{value}</strong><span>{note}</span></article>)}</section>;
}
export default function StatisticsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const defaults = useMemo(() => defaultWindow(30), []);
const initial = { vin: searchParams.get('vin') ?? '', protocol: searchParams.get('protocol') ?? '', dateFrom: searchParams.get('dateFrom') ?? defaults.dateFrom, dateTo: searchParams.get('dateTo') ?? defaults.dateTo };
const [draft, setDraft] = useState(initial); const [criteria, setCriteria] = useState(initial);
const params = useMemo(() => { const next = new URLSearchParams({ dateFrom: criteria.dateFrom, dateTo: criteria.dateTo }); if (criteria.vin) next.set('vin', criteria.vin); if (criteria.protocol) next.set('protocol', criteria.protocol); return next; }, [criteria]);
const query = useQuery({ queryKey: ['mileage-statistics', params.toString()], queryFn: () => api.mileageStatistics(params), staleTime: 60_000, refetchInterval: 5 * 60_000, placeholderData: (previous) => previous });
const submit = (event: FormEvent) => { event.preventDefault(); setCriteria(draft); setSearchParams(paramsFrom(draft), { replace: true }); };
const setDays = (days: number) => { const range = defaultWindow(days); const next = { ...draft, ...range }; setDraft(next); setCriteria(next); setSearchParams(paramsFrom(next), { replace: true }); };
const data = query.data; const maximumRank = Math.max(...(data?.ranking.map((item) => item.mileageKm) ?? [1]), 1);
return <div className="v2-stat-page">
<header className="v2-stat-heading"><div><h2></h2><p></p></div><button type="button" onClick={() => query.refetch()} disabled={query.isFetching}><IconRefresh />{query.isFetching ? '更新中' : '刷新数据'}</button></header>
<form className="v2-stat-filter" onSubmit={submit}>
<label className="v2-stat-search"><span></span><div><IconSearch /><input value={draft.vin} onChange={(event) => setDraft((current) => ({ ...current, vin: event.target.value }))} placeholder="车牌 / VIN留空统计全车队" /></div></label>
<label><span></span><input type="date" value={draft.dateFrom} max={draft.dateTo} onChange={(event) => setDraft((current) => ({ ...current, dateFrom: event.target.value }))} /></label>
<label><span></span><input type="date" value={draft.dateTo} min={draft.dateFrom} onChange={(event) => setDraft((current) => ({ ...current, dateTo: event.target.value }))} /></label>
<label><span></span><select value={draft.protocol} onChange={(event) => setDraft((current) => ({ ...current, protocol: event.target.value }))}><option value=""></option><option value="GB32960">GB32960</option><option value="JT808">JT808</option><option value="YUTONG_MQTT">YUTONG_MQTT</option></select></label>
<button className="v2-primary-button" type="submit"></button>
<div className="v2-stat-ranges"><button type="button" onClick={() => setDays(7)}> 7 </button><button type="button" onClick={() => setDays(30)}> 30 </button><button type="button" onClick={() => setDays(90)}> 90 </button></div>
</form>
{query.isError ? <InlineError message={query.error instanceof Error ? query.error.message : '统计数据加载失败'} onRetry={() => query.refetch()} /> : null}
<Kpis data={data} />
<div className="v2-stat-grid-layout">
<section className="v2-stat-card v2-stat-trend"><header><div><strong></strong><span>{data?.dateFrom || criteria.dateFrom} {data?.dateTo || criteria.dateTo}</span></div><em>{data?.trend.length ?? 0} </em></header><MileageChart points={data?.trend ?? []} /><div className="v2-stat-daily-list" aria-label="每日里程精确数据">{[...(data?.trend ?? [])].reverse().slice(0, 10).map((point) => <div key={point.date}><time>{point.date}</time><strong>{formatKm(point.mileageKm)} km</strong><span>{point.vehicles} </span></div>)}</div></section>
<section className="v2-stat-card v2-stat-ranking"><header><div><strong></strong><span> 20 </span></div></header><div>{data?.ranking.map((item, index) => <article key={item.vin}><b>{index + 1}</b><div><header><Link to={`/vehicles/${encodeURIComponent(item.vin)}`}>{item.plate || item.vin}</Link><strong>{formatKm(item.mileageKm)} km</strong></header><span><i style={{ width: `${Math.max(2, item.mileageKm / maximumRank * 100)}%` }} /></span><footer><small>{item.plate ? item.vin : '未绑定车牌'}</small><em>{item.activeDays} · {formatKm(item.latestMileageKm)} km</em></footer></div></article>)}{!query.isLoading && !data?.ranking.length ? <div className="v2-stat-empty"></div> : null}</div></section>
</div>
<footer className="v2-stat-evidence"><span>{data?.asOf || '—'}</span><span>{data?.evidence || '正在读取生产统计证据'}</span><span> 5 </span></footer>
</div>;
}
function paramsFrom(criteria: { vin: string; protocol: string; dateFrom: string; dateTo: string }) {
const next = new URLSearchParams({ dateFrom: criteria.dateFrom, dateTo: criteria.dateTo });
if (criteria.vin.trim()) next.set('vin', criteria.vin.trim());
if (criteria.protocol) next.set('protocol', criteria.protocol);
return next;
}