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

@@ -42,6 +42,7 @@ func (h *Handler) routes() {
h.mux.HandleFunc("POST /api/history/raw-frames/query", h.handleRawFramesPost)
h.mux.HandleFunc("GET /api/mileage/summary", h.handleMileageSummary)
h.mux.HandleFunc("GET /api/mileage/daily", h.handleDailyMileage)
h.mux.HandleFunc("GET /api/v2/statistics/mileage", h.handleMileageStatistics)
h.mux.HandleFunc("GET /api/statistics/online-summary", h.handleOnlineStatisticsSummary)
h.mux.HandleFunc("GET /api/statistics/online-vehicles", h.handleOnlineVehicleStatuses)
h.mux.HandleFunc("GET /api/quality/summary", h.handleQualitySummary)
@@ -428,6 +429,11 @@ func (h *Handler) handleMileageSummary(w http.ResponseWriter, r *http.Request) {
h.write(w, r, data, err)
}
func (h *Handler) handleMileageStatistics(w http.ResponseWriter, r *http.Request) {
data, err := h.service.MileageStatistics(r.Context(), r.URL.Query())
h.write(w, r, data, err)
}
func (h *Handler) handleOnlineStatisticsSummary(w http.ResponseWriter, r *http.Request) {
data, err := h.service.OnlineStatisticsSummary(r.Context(), r.URL.Query())
h.write(w, r, data, err)

View File

@@ -880,6 +880,7 @@ func TestHandlerHistoryMileageQualityOps(t *testing.T) {
{"/api/history/raw-frames?protocol=GB32960&vin=LB9A32A24R0LS1426&limit=1&includeFields=true", "plate"},
{"/api/mileage/summary?limit=10", "totalMileageKm"},
{"/api/mileage/daily?limit=10", "dailyMileageKm"},
{"/api/v2/statistics/mileage?dateFrom=2026-07-01&dateTo=2026-07-31", "periodMileageKm"},
{"/api/statistics/online-summary?limit=10", "onlineRatePercent"},
{"/api/statistics/online-vehicles?limit=10", "offlineDurationMinutes"},
{"/api/quality/summary?limit=10", "issueVehicleCount"},

View File

@@ -843,6 +843,50 @@ func (m *MockStore) MileageSummary(_ context.Context, query url.Values) (Mileage
return summary, nil
}
func (m *MockStore) MileageStatistics(_ context.Context, query url.Values) (MileageStatistics, error) {
rows := m.dailyMileageRows(query)
byDate := map[string]*MileageTrendPoint{}
result := MileageStatistics{DateFrom: query.Get("dateFrom"), DateTo: query.Get("dateTo"), Trend: []MileageTrendPoint{}, Ranking: []MileageVehicleRank{}, AsOf: "2026-07-03 20:12:06", Evidence: "mock mileage statistics"}
vehicles, sources := map[string]*MileageVehicleRank{}, map[string]struct{}{}
for _, row := range rows {
result.PeriodMileageKm += row.DailyMileageKm
result.RecordCount++
sources[row.Source] = struct{}{}
point := byDate[row.Date]
if point == nil {
point = &MileageTrendPoint{Date: row.Date}
byDate[row.Date] = point
}
point.MileageKm += row.DailyMileageKm
point.Vehicles++
rank := vehicles[row.VIN]
if rank == nil {
rank = &MileageVehicleRank{VIN: row.VIN, Plate: row.Plate}
vehicles[row.VIN] = rank
}
rank.MileageKm += row.DailyMileageKm
rank.LatestMileageKm = row.EndMileageKm
rank.ActiveDays++
}
result.VehicleCount, result.SourceCount = len(vehicles), len(sources)
for _, point := range byDate {
result.Trend = append(result.Trend, *point)
}
sort.Slice(result.Trend, func(i, j int) bool { return result.Trend[i].Date < result.Trend[j].Date })
for _, rank := range vehicles {
result.Ranking = append(result.Ranking, *rank)
result.FleetLatestMileageKm += rank.LatestMileageKm
}
sort.Slice(result.Ranking, func(i, j int) bool { return result.Ranking[i].MileageKm > result.Ranking[j].MileageKm })
if result.VehicleCount > 0 {
result.AverageMileagePerVIN = result.PeriodMileageKm / float64(result.VehicleCount)
}
if result.RecordCount > 0 {
result.AverageDailyMileageKm = result.PeriodMileageKm / float64(result.RecordCount)
}
return result, nil
}
func (m *MockStore) dailyMileageRows(query url.Values) []DailyMileageRow {
rows := []DailyMileageRow{
{VIN: "LB9A32A24R0LS1426", Plate: "粤AG18312", Date: "2026-07-03", StartMileageKm: 48710.2, EndMileageKm: 48798.9, DailyMileageKm: 88.7, Source: "JT808"},

View File

@@ -1064,6 +1064,36 @@ type MileageSummary struct {
AverageMileagePerVIN float64 `json:"averageMileagePerVin"`
}
type MileageTrendPoint struct {
Date string `json:"date"`
MileageKm float64 `json:"mileageKm"`
Vehicles int `json:"vehicles"`
}
type MileageVehicleRank struct {
VIN string `json:"vin"`
Plate string `json:"plate"`
MileageKm float64 `json:"mileageKm"`
LatestMileageKm float64 `json:"latestMileageKm"`
ActiveDays int `json:"activeDays"`
}
type MileageStatistics struct {
DateFrom string `json:"dateFrom"`
DateTo string `json:"dateTo"`
VehicleCount int `json:"vehicleCount"`
RecordCount int `json:"recordCount"`
SourceCount int `json:"sourceCount"`
PeriodMileageKm float64 `json:"periodMileageKm"`
FleetLatestMileageKm float64 `json:"fleetLatestMileageKm"`
AverageMileagePerVIN float64 `json:"averageMileagePerVin"`
AverageDailyMileageKm float64 `json:"averageDailyMileageKm"`
Trend []MileageTrendPoint `json:"trend"`
Ranking []MileageVehicleRank `json:"ranking"`
AsOf string `json:"asOf"`
Evidence string `json:"evidence"`
}
type OnlineStatisticsSummary struct {
VehicleCount int `json:"vehicleCount"`
OnlineVehicleCount int `json:"onlineVehicleCount"`

View File

@@ -474,6 +474,83 @@ func buildMileageSummarySQL(query url.Values) SQLQuery {
}
}
func buildMileageStatisticsWhere(query url.Values) (string, []any) {
where := []string{"1 = 1"}
args := []any{}
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
where = append(where, "(m.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
like := "%" + vin + "%"
args = append(args, like, like, like, like)
}
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
where = append(where, "m.protocol = ?")
args = append(args, protocol)
}
if dateFrom := strings.TrimSpace(query.Get("dateFrom")); dateFrom != "" {
where = append(where, "m.stat_date >= ?")
args = append(args, dateFrom)
}
if dateTo := strings.TrimSpace(query.Get("dateTo")); dateTo != "" {
where = append(where, "m.stat_date <= ?")
args = append(args, dateTo)
}
return strings.Join(where, " AND "), args
}
func buildMileageStatisticsBaseSQL(query url.Values) (string, []any) {
where, args := buildMileageStatisticsWhere(query)
return `SELECT m.vin, COALESCE(MAX(NULLIF(b.plate, '')), '') AS plate, m.stat_date, ` +
`MAX(COALESCE(m.daily_mileage_km, 0)) AS daily_mileage_km, ` +
`MAX(COALESCE(m.latest_total_mileage_km, 0)) AS latest_mileage_km ` +
`FROM vehicle_daily_mileage m LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin ` +
`WHERE ` + where + ` GROUP BY m.vin, m.stat_date`, args
}
func buildMileageStatisticsSummarySQL(query url.Values) SQLQuery {
base, args := buildMileageStatisticsBaseSQL(query)
return SQLQuery{Text: `SELECT COUNT(DISTINCT d.vin), COUNT(*), COALESCE(SUM(d.daily_mileage_km), 0), ` +
`COALESCE(AVG(d.daily_mileage_km), 0) FROM (` + base + `) d`, Args: args}
}
func buildMileageStatisticsTrendSQL(query url.Values) SQLQuery {
base, args := buildMileageStatisticsBaseSQL(query)
return SQLQuery{Text: `SELECT DATE_FORMAT(d.stat_date, '%Y-%m-%d'), COALESCE(SUM(d.daily_mileage_km), 0), ` +
`COUNT(DISTINCT d.vin) FROM (` + base + `) d GROUP BY d.stat_date ORDER BY d.stat_date ASC`, Args: args}
}
func buildMileageStatisticsRankingSQL(query url.Values) SQLQuery {
base, args := buildMileageStatisticsBaseSQL(query)
return SQLQuery{Text: `SELECT d.vin, COALESCE(MAX(d.plate), ''), COALESCE(SUM(d.daily_mileage_km), 0), ` +
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(d.latest_mileage_km AS CHAR) ORDER BY d.stat_date DESC), ',', 1) AS DECIMAL(18,3)), 0), ` +
`COUNT(DISTINCT d.stat_date) FROM (` + base + `) d ` +
`GROUP BY d.vin ORDER BY SUM(d.daily_mileage_km) DESC, d.vin ASC LIMIT 20`, Args: args}
}
func buildMileageStatisticsSourceCountSQL(query url.Values) SQLQuery {
where, args := buildMileageStatisticsWhere(query)
return SQLQuery{Text: `SELECT COUNT(DISTINCT m.protocol) FROM vehicle_daily_mileage m ` +
`LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin WHERE ` + where, Args: args}
}
func buildFleetLatestMileageSQL(query url.Values) SQLQuery {
where := []string{"l.total_mileage_km IS NOT NULL", "l.total_mileage_km >= 0"}
args := []any{}
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
where = append(where, "(l.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
like := "%" + vin + "%"
args = append(args, like, like, like, like)
}
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
where = append(where, "l.protocol = ?")
args = append(args, protocol)
}
inner := `SELECT l.vin, CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.total_mileage_km AS CHAR) ` +
`ORDER BY l.updated_at DESC, l.protocol ASC), ',', 1) AS DECIMAL(18,3)) AS latest_mileage_km ` +
`FROM vehicle_realtime_location l LEFT JOIN vehicle_identity_binding b ON b.vin = l.vin WHERE ` +
strings.Join(where, " AND ") + ` GROUP BY l.vin`
return SQLQuery{Text: `SELECT COALESCE(SUM(x.latest_mileage_km), 0) FROM (` + inner + `) x`, Args: args}
}
func buildLimitOffset(query url.Values) (int, int) {
return parsePositive(query.Get("limit"), 20), parsePositive(query.Get("offset"), 0)
}

View File

@@ -1012,6 +1012,67 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P
return Page[DailyMileageRow]{Items: items, Total: total, Limit: limit, Offset: offset}, nil
}
func (s *ProductionStore) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
result := MileageStatistics{
DateFrom: query.Get("dateFrom"), DateTo: query.Get("dateTo"),
Trend: []MileageTrendPoint{}, Ranking: []MileageVehicleRank{},
Evidence: "vehicle_daily_mileage按车辆和日期去重/ vehicle_realtime_location最新里程表",
}
summary := buildMileageStatisticsSummarySQL(query)
if err := s.db.QueryRowContext(ctx, summary.Text, summary.Args...).Scan(&result.VehicleCount, &result.RecordCount, &result.PeriodMileageKm, &result.AverageDailyMileageKm); err != nil {
return MileageStatistics{}, err
}
if result.VehicleCount > 0 {
result.AverageMileagePerVIN = result.PeriodMileageKm / float64(result.VehicleCount)
}
sources := buildMileageStatisticsSourceCountSQL(query)
if err := s.db.QueryRowContext(ctx, sources.Text, sources.Args...).Scan(&result.SourceCount); err != nil {
return MileageStatistics{}, err
}
fleet := buildFleetLatestMileageSQL(query)
if err := s.db.QueryRowContext(ctx, fleet.Text, fleet.Args...).Scan(&result.FleetLatestMileageKm); err != nil {
return MileageStatistics{}, err
}
trend := buildMileageStatisticsTrendSQL(query)
rows, err := s.db.QueryContext(ctx, trend.Text, trend.Args...)
if err != nil {
return MileageStatistics{}, err
}
for rows.Next() {
var point MileageTrendPoint
if err := rows.Scan(&point.Date, &point.MileageKm, &point.Vehicles); err != nil {
rows.Close()
return MileageStatistics{}, err
}
result.Trend = append(result.Trend, point)
}
if err := rows.Err(); err != nil {
rows.Close()
return MileageStatistics{}, err
}
if err := rows.Close(); err != nil {
return MileageStatistics{}, err
}
ranking := buildMileageStatisticsRankingSQL(query)
rows, err = s.db.QueryContext(ctx, ranking.Text, ranking.Args...)
if err != nil {
return MileageStatistics{}, err
}
defer rows.Close()
for rows.Next() {
var item MileageVehicleRank
if err := rows.Scan(&item.VIN, &item.Plate, &item.MileageKm, &item.LatestMileageKm, &item.ActiveDays); err != nil {
return MileageStatistics{}, err
}
result.Ranking = append(result.Ranking, item)
}
if err := rows.Err(); err != nil {
return MileageStatistics{}, err
}
result.AsOf = time.Now().Format("2006-01-02 15:04:05")
return result, nil
}
func buildQualityIssueWhere(query url.Values) (string, []any) {
where := []string{"1 = 1"}
args := []any{}

View File

@@ -280,6 +280,53 @@ func TestBuildMileageSummarySQL(t *testing.T) {
}
}
func TestBuildMileageStatisticsSQLDeduplicatesVehicleDays(t *testing.T) {
query := url.Values{"vin": {"粤A"}, "protocol": {"GB32960"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-31"}}
summary := buildMileageStatisticsSummarySQL(query)
for _, want := range []string{"GROUP BY m.vin, m.stat_date", "MAX(COALESCE(m.daily_mileage_km", "COUNT(DISTINCT d.vin)", "SUM(d.daily_mileage_km)"} {
if !strings.Contains(summary.Text, want) {
t.Fatalf("statistics summary SQL missing %q: %s", want, summary.Text)
}
}
if len(summary.Args) != 7 || summary.Args[0] != "%粤A%" || summary.Args[4] != "GB32960" {
t.Fatalf("statistics args = %#v", summary.Args)
}
trend := buildMileageStatisticsTrendSQL(query)
if !strings.Contains(trend.Text, "GROUP BY d.stat_date ORDER BY d.stat_date ASC") {
t.Fatalf("statistics trend SQL should be chronologically stable: %s", trend.Text)
}
ranking := buildMileageStatisticsRankingSQL(query)
if !strings.Contains(ranking.Text, "ORDER BY SUM(d.daily_mileage_km) DESC") || !strings.Contains(ranking.Text, "ORDER BY d.stat_date DESC") || !strings.Contains(ranking.Text, "LIMIT 20") {
t.Fatalf("statistics ranking SQL should be bounded and stable: %s", ranking.Text)
}
}
func TestNormalizeMileageStatisticsWindow(t *testing.T) {
now := time.Date(2026, 7, 14, 12, 0, 0, 0, time.Local)
defaults, err := normalizeMileageStatisticsWindow(url.Values{}, now)
if err != nil || defaults.Get("dateFrom") != "2026-06-15" || defaults.Get("dateTo") != "2026-07-14" {
t.Fatalf("default statistics window = %v, err=%v", defaults, err)
}
if _, err := normalizeMileageStatisticsWindow(url.Values{"dateFrom": {"2025-01-01"}, "dateTo": {"2026-07-14"}}, now); err == nil {
t.Fatal("statistics window over 366 days must be rejected")
}
if _, err := normalizeMileageStatisticsWindow(url.Values{"dateFrom": {"2026-07-15"}, "dateTo": {"2026-07-14"}}, now); err == nil {
t.Fatal("reversed statistics window must be rejected")
}
}
func TestBuildFleetLatestMileageSQLUsesOneLatestValuePerVehicle(t *testing.T) {
built := buildFleetLatestMileageSQL(url.Values{"vin": {"VIN001"}, "protocol": {"JT808"}})
for _, want := range []string{"GROUP_CONCAT", "ORDER BY l.updated_at DESC", "GROUP BY l.vin", "SUM(x.latest_mileage_km)"} {
if !strings.Contains(built.Text, want) {
t.Fatalf("fleet latest mileage SQL missing %q: %s", want, built.Text)
}
}
if len(built.Args) != 5 || built.Args[0] != "%VIN001%" || built.Args[4] != "JT808" {
t.Fatalf("fleet latest mileage args = %#v", built.Args)
}
}
func TestBuildRawFrameSQL(t *testing.T) {
built := buildRawFrameSQL("lingniu_vehicle_ts", RawFrameQuery{
Protocol: "GB32960",

View File

@@ -33,6 +33,7 @@ type Store interface {
RawFrames(context.Context, RawFrameQuery) (Page[RawFrameRow], error)
MileageSummary(context.Context, url.Values) (MileageSummary, error)
DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error)
MileageStatistics(context.Context, url.Values) (MileageStatistics, error)
QualitySummary(context.Context, url.Values) (QualitySummary, error)
QualityIssues(context.Context, url.Values) (Page[QualityIssueRow], error)
OpsHealth(context.Context) (OpsHealth, error)
@@ -3105,6 +3106,42 @@ func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[Dail
return s.store.DailyMileage(ctx, resolvedQuery)
}
func (s *Service) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
if err != nil {
return MileageStatistics{}, err
}
resolvedQuery, err = normalizeMileageStatisticsWindow(resolvedQuery, time.Now())
if err != nil {
return MileageStatistics{}, err
}
return s.store.MileageStatistics(ctx, resolvedQuery)
}
func normalizeMileageStatisticsWindow(query url.Values, now time.Time) (url.Values, error) {
next := cloneValues(query)
dateTo := strings.TrimSpace(next.Get("dateTo"))
if dateTo == "" {
dateTo = now.Format("2006-01-02")
next.Set("dateTo", dateTo)
}
dateFrom := strings.TrimSpace(next.Get("dateFrom"))
if dateFrom == "" {
parsedTo, _ := time.ParseInLocation("2006-01-02", dateTo, now.Location())
dateFrom = parsedTo.AddDate(0, 0, -29).Format("2006-01-02")
next.Set("dateFrom", dateFrom)
}
from, fromErr := time.ParseInLocation("2006-01-02", dateFrom, now.Location())
to, toErr := time.ParseInLocation("2006-01-02", dateTo, now.Location())
if fromErr != nil || toErr != nil || from.After(to) {
return nil, clientError{Code: "INVALID_DATE_RANGE", Message: "统计日期范围无效"}
}
if to.Sub(from) > 365*24*time.Hour {
return nil, clientError{Code: "DATE_RANGE_TOO_LARGE", Message: "车辆统计最多查询 366 个自然日"}
}
return next, nil
}
func (s *Service) OnlineStatisticsSummary(ctx context.Context, query url.Values) (OnlineStatisticsSummary, error) {
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
if err != nil {

View File

@@ -25,6 +25,7 @@ import type {
MetricCatalog,
LatestTelemetryResponse,
MileageSummary,
MileageStatistics,
MapReverseGeocode,
MonitorMapResponse,
MonitorSummary,
@@ -201,6 +202,7 @@ export const api = {
}),
mileageSummary: (params = new URLSearchParams()) => request<MileageSummary>(`/api/mileage/summary?${params.toString()}`),
dailyMileage: (params = new URLSearchParams()) => request<Page<DailyMileageRow>>(`/api/mileage/daily?${params.toString()}`),
mileageStatistics: (params = new URLSearchParams()) => request<MileageStatistics>(`/api/v2/statistics/mileage?${params.toString()}`),
onlineStatisticsSummary: (params = new URLSearchParams()) => request<OnlineStatisticsSummary>(`/api/statistics/online-summary?${params.toString()}`),
onlineVehicleStatuses: (params = new URLSearchParams()) => request<Page<OnlineVehicleStatusRow>>(`/api/statistics/online-vehicles?${params.toString()}`),
qualitySummary: (params = new URLSearchParams()) => request<QualitySummary>(`/api/quality/summary?${params.toString()}`),

View File

@@ -585,6 +585,14 @@ export interface MileageSummary {
averageMileagePerVin: number;
}
export interface MileageTrendPoint { date: string; mileageKm: number; vehicles: number; }
export interface MileageVehicleRank { vin: string; plate: string; mileageKm: number; latestMileageKm: number; activeDays: number; }
export interface MileageStatistics {
dateFrom: string; dateTo: string; vehicleCount: number; recordCount: number; sourceCount: number;
periodMileageKm: number; fleetLatestMileageKm: number; averageMileagePerVin: number; averageDailyMileageKm: number;
trend: MileageTrendPoint[]; ranking: MileageVehicleRank[]; asOf: string; evidence: string;
}
export interface OnlineStatisticsSummary {
vehicleCount: number;
onlineVehicleCount: number;

View File

@@ -9,6 +9,7 @@ const MonitorPage = lazy(() => import('./pages/MonitorPage'));
const VehiclePage = lazy(() => import('./pages/VehiclePage'));
const TrackPage = lazy(() => import('./pages/TrackPage'));
const HistoryPage = lazy(() => import('./pages/HistoryPage'));
const StatisticsPage = lazy(() => import('./pages/StatisticsPage'));
const AccessPage = lazy(() => import('./pages/AccessPage'));
const AlertsPage = lazy(() => import('./pages/AlertsPage'));
const OperationsPage = lazy(() => import('./pages/OperationsPage'));
@@ -35,6 +36,7 @@ export function AppV2() {
<Route path="/vehicles/:vin?" element={<Suspense fallback={<PageLoading />}><VehiclePage /></Suspense>} />
<Route path="/tracks" element={<Suspense fallback={<PageLoading />}><TrackPage /></Suspense>} />
<Route path="/history" element={<Suspense fallback={<PageLoading />}><HistoryPage /></Suspense>} />
<Route path="/statistics" element={<Suspense fallback={<PageLoading />}><StatisticsPage /></Suspense>} />
<Route path="/access" element={<Suspense fallback={<PageLoading />}><AccessPage /></Suspense>} />
<Route path="/alerts/*" element={<Suspense fallback={<PageLoading />}><AlertsPage /></Suspense>} />
<Route path="/operations" element={<Suspense fallback={<PageLoading />}><OperationsPage /></Suspense>} />

View File

@@ -20,6 +20,7 @@ const navigation = [
{ to: '/vehicles', label: '车辆查询', icon: IconSearch },
{ to: '/tracks', label: '轨迹回放', icon: IconMapPin },
{ to: '/history', label: '历史数据', icon: IconBarChartHStroked },
{ to: '/statistics', label: '车辆统计', icon: IconBarChartHStroked },
{ to: '/alerts', label: '告警中心', icon: IconAlarm },
{ to: '/access', label: '接入管理', icon: IconBox }
];
@@ -29,6 +30,7 @@ const pageNames: Record<string, string> = {
vehicles: '车辆查询',
tracks: '轨迹回放',
history: '历史数据',
statistics: '车辆统计',
alerts: '告警中心',
access: '接入管理',
operations: '运维质量'

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

View File

@@ -811,6 +811,67 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-ops-kpis { grid-template-columns: repeat(3,1fr); }.v2-ops-grid { grid-template-columns: 1fr; }.v2-ops-sources > div { grid-template-columns: 1fr; }.v2-ops-sources article + article { border-top: 1px solid var(--v2-border); border-left: 0; }
}
.v2-stat-page { display: flex; min-height: 100%; flex-direction: column; gap: 12px; padding: 16px 18px 20px; }
.v2-stat-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.v2-stat-heading h2 { margin: 0; font-size: 20px; letter-spacing: -.03em; }
.v2-stat-heading p { margin: 5px 0 0; color: var(--v2-muted); font-size: 11px; }
.v2-stat-heading > button { display: flex; height: 34px; align-items: center; gap: 6px; border: 1px solid #dce4ef; border-radius: 7px; background: #fff; padding: 0 12px; color: #59677c; cursor: pointer; font-size: 11px; font-weight: 700; }
.v2-stat-filter { display: grid; grid-template-columns: minmax(250px, 1.3fr) minmax(130px, .65fr) minmax(130px, .65fr) minmax(130px, .65fr) auto; align-items: end; gap: 10px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; padding: 12px; box-shadow: var(--v2-shadow); }
.v2-stat-filter label { display: flex; min-width: 0; flex-direction: column; gap: 6px; color: #68768a; font-size: 10px; font-weight: 600; }
.v2-stat-filter input, .v2-stat-filter select { width: 100%; height: 36px; border: 1px solid #dce4ef; border-radius: 7px; background: #fff; padding: 0 10px; color: var(--v2-text); outline: 0; font-size: 12px; }
.v2-stat-search > div { display: flex; height: 36px; align-items: center; gap: 7px; border: 1px solid #dce4ef; border-radius: 7px; padding: 0 10px; color: #8a98aa; }
.v2-stat-search > div:focus-within, .v2-stat-filter input:focus, .v2-stat-filter select:focus { border-color: #8bb6fb; box-shadow: 0 0 0 3px rgba(18,104,243,.08); }
.v2-stat-search input { height: auto; border: 0; padding: 0; box-shadow: none !important; }
.v2-stat-ranges { display: flex; grid-column: 1 / -1; gap: 6px; }
.v2-stat-ranges button { height: 26px; border: 1px solid #dce4ef; border-radius: 13px; background: #fff; padding: 0 11px; color: #64748b; cursor: pointer; font-size: 9px; }
.v2-stat-ranges button:hover { border-color: #a9c8f8; color: var(--v2-blue); }
.v2-stat-kpis { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
.v2-stat-kpis article { position: relative; min-width: 0; padding: 14px 16px; }
.v2-stat-kpis article + article::before { position: absolute; inset: 14px auto 14px 0; width: 1px; background: var(--v2-border); content: ""; }
.v2-stat-kpis small, .v2-stat-kpis span { display: block; overflow: hidden; color: var(--v2-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.v2-stat-kpis strong { display: block; margin: 7px 0 5px; overflow: hidden; font-size: clamp(17px, 1.55vw, 24px); font-variant-numeric: tabular-nums; letter-spacing: -.03em; text-overflow: ellipsis; white-space: nowrap; }
.v2-stat-kpis .is-primary strong { color: var(--v2-blue); }
.v2-stat-grid-layout { display: grid; min-height: 490px; flex: 1; grid-template-columns: minmax(0, 1.65fr) minmax(320px, .85fr); gap: 12px; }
.v2-stat-card { min-width: 0; overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
.v2-stat-card > header { display: flex; min-height: 48px; align-items: center; justify-content: space-between; gap: 10px; border-bottom: 1px solid var(--v2-border); padding: 8px 14px; }
.v2-stat-card > header div { display: flex; flex-direction: column; gap: 3px; }
.v2-stat-card > header strong { font-size: 12px; }
.v2-stat-card > header span, .v2-stat-card > header em { color: var(--v2-muted); font-size: 9px; font-style: normal; }
.v2-stat-chart-wrap { padding: 14px 12px 2px; }
.v2-stat-chart-wrap svg { display: block; width: 100%; max-height: 260px; overflow: visible; }
.v2-stat-grid line { stroke: #e9eef5; stroke-width: 1; vector-effect: non-scaling-stroke; }
.v2-stat-axis text { fill: #8290a3; font-size: 10px; }
.v2-stat-area { fill: url(#none); fill: rgba(18,104,243,.07); stroke: none; }
.v2-stat-line { fill: none; stroke: var(--v2-blue); stroke-linecap: round; stroke-linejoin: round; stroke-width: 2.2; vector-effect: non-scaling-stroke; }
.v2-stat-point { fill: #fff; stroke: var(--v2-blue); stroke-width: 2; vector-effect: non-scaling-stroke; }
.v2-stat-daily-list { display: grid; grid-template-columns: repeat(5, minmax(0,1fr)); margin: 0 14px 14px; border: 1px solid #edf1f6; border-radius: 8px; overflow: hidden; }
.v2-stat-daily-list > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; border-right: 1px solid #edf1f6; border-bottom: 1px solid #edf1f6; padding: 8px 9px; }
.v2-stat-daily-list time, .v2-stat-daily-list span { color: var(--v2-muted); font-size: 8px; }
.v2-stat-daily-list strong { overflow: hidden; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.v2-stat-ranking > div { max-height: 440px; overflow: auto; }
.v2-stat-ranking article { display: grid; grid-template-columns: 24px minmax(0, 1fr); gap: 8px; border-bottom: 1px solid #eef2f7; padding: 10px 13px; }
.v2-stat-ranking article > b { display: grid; width: 22px; height: 22px; place-items: center; border-radius: 6px; background: #f2f5f9; color: #728096; font-size: 9px; }
.v2-stat-ranking article:nth-child(-n+3) > b { background: var(--v2-blue-soft); color: var(--v2-blue); }
.v2-stat-ranking article header, .v2-stat-ranking article footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.v2-stat-ranking article header a { overflow: hidden; color: var(--v2-text); font-size: 11px; font-weight: 700; text-decoration: none; text-overflow: ellipsis; white-space: nowrap; }
.v2-stat-ranking article header strong { flex: 0 0 auto; color: var(--v2-blue); font-size: 10px; }
.v2-stat-ranking article > div > span { display: block; height: 5px; margin: 7px 0; overflow: hidden; border-radius: 3px; background: #eef2f7; }
.v2-stat-ranking article > div > span i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #4f93fa, #1268f3); }
.v2-stat-ranking article footer small, .v2-stat-ranking article footer em { overflow: hidden; color: var(--v2-muted); font-size: 8px; font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
.v2-stat-ranking article footer small { max-width: 46%; }
.v2-stat-empty { display: grid; min-height: 180px; place-items: center; padding: 20px; color: var(--v2-muted); text-align: center; font-size: 11px; }
.v2-stat-evidence { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6px 16px; color: var(--v2-muted); font-size: 9px; }
@media (max-width: 1050px) {
.v2-stat-filter { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.v2-stat-search { grid-column: 1 / -1; }
.v2-stat-filter > .v2-primary-button { width: 100%; }
.v2-stat-kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.v2-stat-kpis article:nth-child(4)::before { display: none; }
.v2-stat-grid-layout { grid-template-columns: 1fr; }
.v2-stat-ranking > div { max-height: 400px; }
}
@media (max-width: 680px) {
html, body, #root { min-width: 320px; min-height: 100%; }
body { overscroll-behavior-y: none; }
@@ -820,7 +881,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-auth-card button { height: 46px; }
.v2-sidebar { inset: auto 0 0 0; width: auto; height: calc(64px + env(safe-area-inset-bottom)); flex-direction: row; border: 0; border-top: 1px solid var(--v2-border); padding-bottom: env(safe-area-inset-bottom); box-shadow: 0 -8px 24px rgba(21,32,51,.07); }
.v2-brand, .v2-collapse, .v2-nav-operations { display: none; }
.v2-navigation { display: grid; width: 100%; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 0; padding: 4px 3px; }
.v2-navigation { display: grid; width: 100%; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 0; padding: 4px 3px; }
.v2-nav-item { height: 56px; justify-content: center; flex-direction: column; gap: 2px; padding: 3px 0 2px; border-radius: 8px; font-weight: 600; }
.v2-nav-item > svg { flex: 0 0 auto; font-size: 18px; }
.v2-nav-label, .v2-sidebar.is-collapsed .v2-nav-label { display: block; max-width: 100%; overflow: hidden; font-size: 9px; line-height: 1.2; text-overflow: ellipsis; }
@@ -962,6 +1023,29 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-alert-rule-editor > footer { align-items: stretch; flex-direction: column; }
.v2-alert-notifications > footer { flex-direction: column; gap: 5px; }
.v2-ops-page { padding: 8px; }.v2-ops-heading { align-items: flex-start; flex-direction: column; gap: 8px; }.v2-ops-kpis { grid-template-columns: 1fr 1fr; }.v2-ops-kpis article + article::before { display: none; }.v2-ops-kpis article { border-bottom: 1px solid var(--v2-border); }
.v2-stat-page { gap: 9px; padding: 10px 8px 16px; }
.v2-stat-heading { align-items: flex-start; flex-direction: column; gap: 8px; }
.v2-stat-heading h2 { font-size: 18px; }
.v2-stat-heading p { line-height: 1.6; }
.v2-stat-heading > button { width: 100%; height: 40px; justify-content: center; }
.v2-stat-filter { grid-template-columns: 1fr; gap: 9px; padding: 10px; }
.v2-stat-search, .v2-stat-ranges { grid-column: auto; }
.v2-stat-filter input, .v2-stat-filter select, .v2-stat-search > div { height: 44px; font-size: 16px; }
.v2-stat-filter > .v2-primary-button { height: 44px; }
.v2-stat-ranges { display: grid; grid-template-columns: repeat(3, 1fr); }
.v2-stat-ranges button { height: 34px; border-radius: 7px; }
.v2-stat-kpis { display: flex; overflow-x: auto; scroll-snap-type: x proximity; scrollbar-width: none; }
.v2-stat-kpis article { width: 150px; min-width: 150px; flex: 0 0 150px; padding: 12px 14px; scroll-snap-align: start; }
.v2-stat-kpis article + article::before { display: block; }
.v2-stat-kpis strong { font-size: 19px; }
.v2-stat-grid-layout { min-height: 0; grid-template-columns: 1fr; }
.v2-stat-card > header { align-items: flex-start; flex-direction: column; }
.v2-stat-chart-wrap { overflow-x: auto; padding: 10px 4px 0; }
.v2-stat-chart-wrap svg { width: 680px; max-width: none; }
.v2-stat-daily-list { grid-template-columns: repeat(2, minmax(0,1fr)); margin: 0 9px 10px; }
.v2-stat-ranking > div { max-height: none; }
.v2-stat-ranking article { padding: 11px 10px; }
.v2-stat-evidence { flex-direction: column; line-height: 1.5; }
}
@keyframes v2-mobile-sheet-enter {