From 6cddc0a43d7fbf6ad1635bf260e97033ecd554dd Mon Sep 17 00:00:00 2001 From: lingniu Date: Tue, 14 Jul 2026 13:18:52 +0800 Subject: [PATCH] feat: add vehicle mileage statistics --- .../apps/api/internal/platform/handler.go | 6 ++ .../api/internal/platform/handler_test.go | 1 + .../apps/api/internal/platform/mock_store.go | 44 +++++++++ .../apps/api/internal/platform/model.go | 30 +++++++ .../api/internal/platform/mysql_queries.go | 77 ++++++++++++++++ .../api/internal/platform/production_store.go | 61 +++++++++++++ .../internal/platform/query_builders_test.go | 47 ++++++++++ .../apps/api/internal/platform/service.go | 37 ++++++++ .../apps/web/src/api/client.ts | 2 + .../apps/web/src/api/types.ts | 8 ++ .../apps/web/src/v2/AppV2.tsx | 2 + .../apps/web/src/v2/layout/AppShell.tsx | 2 + .../web/src/v2/pages/StatisticsPage.test.tsx | 30 +++++++ .../apps/web/src/v2/pages/StatisticsPage.tsx | 90 +++++++++++++++++++ .../apps/web/src/v2/styles/v2.css | 86 +++++++++++++++++- 15 files changed, 522 insertions(+), 1 deletion(-) create mode 100644 vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.test.tsx create mode 100644 vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx diff --git a/vehicle-data-platform/apps/api/internal/platform/handler.go b/vehicle-data-platform/apps/api/internal/platform/handler.go index 51100eb9..5c3359ee 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler.go @@ -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) diff --git a/vehicle-data-platform/apps/api/internal/platform/handler_test.go b/vehicle-data-platform/apps/api/internal/platform/handler_test.go index 44a9e4f9..a4b26558 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -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"}, diff --git a/vehicle-data-platform/apps/api/internal/platform/mock_store.go b/vehicle-data-platform/apps/api/internal/platform/mock_store.go index 32e5734b..85250f5b 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -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"}, diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index 1df5b440..edb2d884 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -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"` diff --git a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go index fe3481b3..4e8f832b 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go +++ b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go @@ -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) } diff --git a/vehicle-data-platform/apps/api/internal/platform/production_store.go b/vehicle-data-platform/apps/api/internal/platform/production_store.go index e0fba8cb..cd6e974a 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -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{} diff --git a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go index 52262112..70d61c4d 100644 --- a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go @@ -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", diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index 395f75f8..2b39fe63 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -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 { diff --git a/vehicle-data-platform/apps/web/src/api/client.ts b/vehicle-data-platform/apps/web/src/api/client.ts index 72df25bf..fd7192c9 100644 --- a/vehicle-data-platform/apps/web/src/api/client.ts +++ b/vehicle-data-platform/apps/web/src/api/client.ts @@ -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(`/api/mileage/summary?${params.toString()}`), dailyMileage: (params = new URLSearchParams()) => request>(`/api/mileage/daily?${params.toString()}`), + mileageStatistics: (params = new URLSearchParams()) => request(`/api/v2/statistics/mileage?${params.toString()}`), onlineStatisticsSummary: (params = new URLSearchParams()) => request(`/api/statistics/online-summary?${params.toString()}`), onlineVehicleStatuses: (params = new URLSearchParams()) => request>(`/api/statistics/online-vehicles?${params.toString()}`), qualitySummary: (params = new URLSearchParams()) => request(`/api/quality/summary?${params.toString()}`), diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index e953bca4..f8766c5b 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -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; diff --git a/vehicle-data-platform/apps/web/src/v2/AppV2.tsx b/vehicle-data-platform/apps/web/src/v2/AppV2.tsx index fc7edc9e..c24c3c43 100644 --- a/vehicle-data-platform/apps/web/src/v2/AppV2.tsx +++ b/vehicle-data-platform/apps/web/src/v2/AppV2.tsx @@ -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() { }>} /> }>} /> }>} /> + }>} /> }>} /> }>} /> }>} /> diff --git a/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx b/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx index 1303453a..97dbd4a5 100644 --- a/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx +++ b/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx @@ -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 = { vehicles: '车辆查询', tracks: '轨迹回放', history: '历史数据', + statistics: '车辆统计', alerts: '告警中心', access: '接入管理', operations: '运维质量' diff --git a/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.test.tsx new file mode 100644 index 00000000..f94493c4 --- /dev/null +++ b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.test.tsx @@ -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(); + + 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}$/); +}); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx new file mode 100644 index 00000000..c519dc95 --- /dev/null +++ b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx @@ -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
当前筛选范围没有日里程数据
; + 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
+ {[0, .5, 1].map((ratio) => )} + {formatKm(max, true)}{formatKm(max / 2, true)}0{labelIndexes.map((index) => {points[index].date.slice(5)})} + + + {points.map((point, index) => {point.date}:{formatKm(point.mileageKm)} km,{point.vehicles} 辆)} +
; +} + +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
{items.map(([label, value, note], index) =>
{label}{value}{note}
)}
; +} + +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
+

车辆里程统计

日里程按车辆和自然日归并;最新总里程来自每辆车最后一次有效上报。

+
+ + + + + +
+
+ {query.isError ? query.refetch()} /> : null} + +
+
每日行驶里程{data?.dateFrom || criteria.dateFrom} 至 {data?.dateTo || criteria.dateTo}
{data?.trend.length ?? 0} 个有效自然日
{[...(data?.trend ?? [])].reverse().slice(0, 10).map((point) =>
{formatKm(point.mileageKm)} km{point.vehicles} 辆
)}
+
车辆里程排名按统计期累计里程排序,最多 20 辆
{data?.ranking.map((item, index) =>
{index + 1}
{item.plate || item.vin}{formatKm(item.mileageKm)} km
{item.plate ? item.vin : '未绑定车牌'}{item.activeDays} 个有效日 · 最新 {formatKm(item.latestMileageKm)} km
)}{!query.isLoading && !data?.ranking.length ?
当前范围没有车辆里程记录
: null}
+
+
数据更新时间:{data?.asOf || '—'}{data?.evidence || '正在读取生产统计证据'}页面每 5 分钟自动更新
+
; +} + +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; +} diff --git a/vehicle-data-platform/apps/web/src/v2/styles/v2.css b/vehicle-data-platform/apps/web/src/v2/styles/v2.css index aedfa158..0b1fd3c4 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/v2.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/v2.css @@ -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 {