feat: add vehicle mileage statistics
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()}`),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>} />
|
||||
|
||||
@@ -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: '运维质量'
|
||||
|
||||
@@ -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}$/);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user