feat: add vehicle mileage statistics
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user