From 3fabcf181aac79f67134493e47e85c91257ed24e Mon Sep 17 00:00:00 2001 From: lingniu Date: Wed, 15 Jul 2026 23:26:29 +0800 Subject: [PATCH] feat(platform): consolidate production vehicle data workflows --- .../apps/api/cmd/oneos-scope-sync/main.go | 139 + .../api/cmd/oneos-scope-sync/main_test.go | 38 + .../apps/api/internal/app/server.go | 4 +- .../apps/api/internal/app/server_test.go | 18 + .../apps/api/internal/businessscope/model.go | 205 + .../api/internal/businessscope/model_test.go | 93 + .../api/internal/businessscope/publish.go | 105 + .../internal/businessscope/publish_test.go | 123 + .../apps/api/internal/businessscope/source.go | 200 + .../api/internal/businessscope/source_test.go | 60 + .../apps/api/internal/platform/access.go | 206 +- .../api/internal/platform/access_store.go | 28 +- .../apps/api/internal/platform/access_test.go | 53 +- .../api/internal/platform/handler_test.go | 21 + .../apps/api/internal/platform/mock_store.go | 19 +- .../apps/api/internal/platform/model.go | 114 +- .../api/internal/platform/mysql_queries.go | 141 +- .../internal/platform/query_builders_test.go | 118 + .../apps/api/internal/platform/service.go | 80 +- vehicle-data-platform/apps/web/package.json | 3 + vehicle-data-platform/apps/web/pnpm-lock.yaml | 881 ++++- .../apps/web/src/api/types.ts | 12 +- .../apps/web/src/integrations/amap.ts | 1 + .../apps/web/src/pages/Mileage.tsx | 3300 ++--------------- .../apps/web/src/styles/global.css | 271 ++ .../apps/web/src/test/Mileage.test.tsx | 46 + .../apps/web/src/v2/domain/access.test.ts | 5 +- .../apps/web/src/v2/domain/access.ts | 11 +- .../web/src/v2/domain/mileageExport.test.ts | 38 + .../apps/web/src/v2/domain/mileageExport.ts | 161 + .../web/src/v2/hooks/useMonitorData.test.ts | 10 +- .../apps/web/src/v2/hooks/useMonitorData.ts | 28 +- .../apps/web/src/v2/layout/AppShell.tsx | 4 +- .../apps/web/src/v2/map/TrackMap.tsx | 105 +- .../apps/web/src/v2/pages/AccessPage.tsx | 175 +- .../web/src/v2/pages/MonitorPage.test.tsx | 60 +- .../apps/web/src/v2/pages/MonitorPage.tsx | 126 +- .../web/src/v2/pages/StatisticsPage.test.tsx | 132 +- .../apps/web/src/v2/pages/StatisticsPage.tsx | 358 +- .../apps/web/src/v2/pages/TrackPage.test.tsx | 60 + .../apps/web/src/v2/pages/TrackPage.tsx | 288 +- .../apps/web/src/v2/styles/v2.css | 534 ++- .../012_business_scope_projection.sql | 66 + .../lingniu-vehicle-oneos-scope-sync.service | 23 + .../lingniu-vehicle-oneos-scope-sync.timer | 13 + vehicle-data-platform/design-qa.md | 77 + vehicle-data-platform/docs/api-contract.md | 2 + .../customer-scope-api-enforcement-matrix.md | 194 + vehicle-data-platform/docs/deployment.md | 24 +- .../design/mileage-query-modern-concept.png | Bin 0 -> 1062576 bytes .../docs/oneos-business-impact-matrix.md | 97 + .../docs/oneos-change-approval-proposal.md | 206 + .../oneos-customer-vehicle-scope-contract.md | 180 + .../docs/oneos-integration-analysis.md | 301 ++ ...eos-ln-asset-management-system-analysis.md | 398 ++ .../docs/oneos-production-audit-report.md | 135 + .../docs/oneos-readonly-audit.sql | 252 ++ .../docs/oneos-scope-reader-provision.sql | 37 + vehicle-data-platform/package.json | 2 +- 59 files changed, 6849 insertions(+), 3532 deletions(-) create mode 100644 vehicle-data-platform/apps/api/cmd/oneos-scope-sync/main.go create mode 100644 vehicle-data-platform/apps/api/cmd/oneos-scope-sync/main_test.go create mode 100644 vehicle-data-platform/apps/api/internal/businessscope/model.go create mode 100644 vehicle-data-platform/apps/api/internal/businessscope/model_test.go create mode 100644 vehicle-data-platform/apps/api/internal/businessscope/publish.go create mode 100644 vehicle-data-platform/apps/api/internal/businessscope/publish_test.go create mode 100644 vehicle-data-platform/apps/api/internal/businessscope/source.go create mode 100644 vehicle-data-platform/apps/api/internal/businessscope/source_test.go create mode 100644 vehicle-data-platform/apps/web/src/test/Mileage.test.tsx create mode 100644 vehicle-data-platform/apps/web/src/v2/domain/mileageExport.test.ts create mode 100644 vehicle-data-platform/apps/web/src/v2/domain/mileageExport.ts create mode 100644 vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx create mode 100644 vehicle-data-platform/deploy/migrations/012_business_scope_projection.sql create mode 100644 vehicle-data-platform/deploy/systemd/lingniu-vehicle-oneos-scope-sync.service create mode 100644 vehicle-data-platform/deploy/systemd/lingniu-vehicle-oneos-scope-sync.timer create mode 100644 vehicle-data-platform/design-qa.md create mode 100644 vehicle-data-platform/docs/customer-scope-api-enforcement-matrix.md create mode 100644 vehicle-data-platform/docs/design/mileage-query-modern-concept.png create mode 100644 vehicle-data-platform/docs/oneos-business-impact-matrix.md create mode 100644 vehicle-data-platform/docs/oneos-change-approval-proposal.md create mode 100644 vehicle-data-platform/docs/oneos-customer-vehicle-scope-contract.md create mode 100644 vehicle-data-platform/docs/oneos-integration-analysis.md create mode 100644 vehicle-data-platform/docs/oneos-ln-asset-management-system-analysis.md create mode 100644 vehicle-data-platform/docs/oneos-production-audit-report.md create mode 100644 vehicle-data-platform/docs/oneos-readonly-audit.sql create mode 100644 vehicle-data-platform/docs/oneos-scope-reader-provision.sql diff --git a/vehicle-data-platform/apps/api/cmd/oneos-scope-sync/main.go b/vehicle-data-platform/apps/api/cmd/oneos-scope-sync/main.go new file mode 100644 index 00000000..337aa619 --- /dev/null +++ b/vehicle-data-platform/apps/api/cmd/oneos-scope-sync/main.go @@ -0,0 +1,139 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "log" + "os" + "strconv" + "strings" + "time" + + "github.com/go-sql-driver/mysql" + + "lingniu/vehicle-data-platform/apps/api/internal/businessscope" +) + +func main() { + if err := run(); err != nil { + log.Printf("OneOS scope sync failed: %v", err) + os.Exit(1) + } +} + +func run() error { + sourceDSN, err := normalizedDSN("ONEOS_MYSQL_DSN", os.Getenv("ONEOS_MYSQL_DSN"), true) + if err != nil { + return err + } + targetDSN, err := normalizedDSN("MYSQL_DSN", os.Getenv("MYSQL_DSN"), false) + if err != nil { + return err + } + timeout := time.Duration(envInt("ONEOS_SCOPE_SYNC_TIMEOUT_SEC", 60)) * time.Second + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + source, err := openDB(ctx, sourceDSN, 2) + if err != nil { + return fmt.Errorf("open OneOS read-only database: %w", err) + } + defer source.Close() + target, err := openDB(ctx, targetDSN, 4) + if err != nil { + return fmt.Errorf("open vehicle platform database: %w", err) + } + defer target.Close() + candidates, err := businessscope.ReadCandidates(ctx, source) + if err != nil { + return err + } + snapshot, err := businessscope.BuildSnapshot(candidates, time.Now()) + if err != nil { + return err + } + maxRejected := envInt("ONEOS_SCOPE_MAX_REJECTED", 100) + maxRejectedRatio, err := envFloat("ONEOS_SCOPE_MAX_REJECT_RATIO", 0.10) + if err != nil { + return err + } + if err := businessscope.ValidateSnapshot(snapshot, maxRejected, maxRejectedRatio); err != nil { + return err + } + result, err := businessscope.Publish(ctx, target, snapshot) + if err != nil { + return err + } + log.Printf("OneOS scope sync complete changed=%t candidates=%d accepted=%d rejected=%d version=%s", + result.Changed, snapshot.Candidates, len(snapshot.Items), len(snapshot.Rejections), snapshot.SourceVersion) + return nil +} + +func normalizedDSN(name, raw string, requireOneOSDatabase bool) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", fmt.Errorf("%s is required", name) + } + config, err := mysql.ParseDSN(raw) + if err != nil { + return "", fmt.Errorf("parse %s: %w", name, err) + } + if requireOneOSDatabase && config.DBName != "ln_asset_management" { + return "", fmt.Errorf("%s must select ln_asset_management", name) + } + location, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + return "", fmt.Errorf("load Asia/Shanghai timezone: %w", err) + } + config.ParseTime = true + config.Loc = location + if config.Timeout <= 0 { + config.Timeout = 10 * time.Second + } + if config.ReadTimeout <= 0 { + config.ReadTimeout = 30 * time.Second + } + if config.WriteTimeout <= 0 { + config.WriteTimeout = 10 * time.Second + } + return config.FormatDSN(), nil +} + +func openDB(ctx context.Context, dsn string, maxConnections int) (*sql.DB, error) { + db, err := sql.Open("mysql", dsn) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(maxConnections) + db.SetMaxIdleConns(1) + db.SetConnMaxLifetime(5 * time.Minute) + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + return nil, err + } + return db, nil +} + +func envInt(key string, fallback int) int { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + parsed, err := strconv.Atoi(value) + if err != nil { + return fallback + } + return parsed +} + +func envFloat(key string, fallback float64) (float64, error) { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback, nil + } + parsed, err := strconv.ParseFloat(value, 64) + if err != nil || parsed < 0 || parsed > 1 { + return 0, fmt.Errorf("%s must be a number between 0 and 1", key) + } + return parsed, nil +} diff --git a/vehicle-data-platform/apps/api/cmd/oneos-scope-sync/main_test.go b/vehicle-data-platform/apps/api/cmd/oneos-scope-sync/main_test.go new file mode 100644 index 00000000..b9aef36c --- /dev/null +++ b/vehicle-data-platform/apps/api/cmd/oneos-scope-sync/main_test.go @@ -0,0 +1,38 @@ +package main + +import ( + "testing" + "time" + + "github.com/go-sql-driver/mysql" +) + +func TestNormalizedOneOSDSNForcesTimeParsingAndTimeouts(t *testing.T) { + dsn, err := normalizedDSN("ONEOS_MYSQL_DSN", "reader:secret@tcp(mysql:3306)/ln_asset_management", true) + if err != nil { + t.Fatal(err) + } + config, err := mysql.ParseDSN(dsn) + if err != nil { + t.Fatal(err) + } + if !config.ParseTime || config.Loc == nil || config.Loc.String() != "Asia/Shanghai" { + t.Fatalf("time settings not normalized: %#v", config) + } + if config.Timeout != 10*time.Second || config.ReadTimeout != 30*time.Second || config.WriteTimeout != 10*time.Second { + t.Fatalf("timeouts not normalized: %#v", config) + } +} + +func TestNormalizedOneOSDSNRejectsWrongDatabase(t *testing.T) { + if _, err := normalizedDSN("ONEOS_MYSQL_DSN", "reader:secret@tcp(mysql:3306)/other", true); err == nil { + t.Fatal("wrong OneOS database should be rejected") + } +} + +func TestEnvFloatValidation(t *testing.T) { + t.Setenv("RATIO_TEST", "1.1") + if _, err := envFloat("RATIO_TEST", 0.1); err == nil { + t.Fatal("ratio above one should be rejected") + } +} diff --git a/vehicle-data-platform/apps/api/internal/app/server.go b/vehicle-data-platform/apps/api/internal/app/server.go index 22910a39..ef9a4f92 100644 --- a/vehicle-data-platform/apps/api/internal/app/server.go +++ b/vehicle-data-platform/apps/api/internal/app/server.go @@ -88,8 +88,10 @@ func NewServer(cfg config.Config) http.Handler { httpx.WriteError(w, http.StatusServiceUnavailable, "DATA_STORE_UNAVAILABLE", "生产数据源不可用", storeErr.Error(), requestTraceID(r)) }) } + // Reverse geocoding consumes the server-side AMap credential, so it must + // stay behind the same authentication boundary as the platform API. + api = withAMapReverseGeocodeAPI(api, cfg, "https://restapi.amap.com", http.DefaultClient) handler := static.Handler(cfg.StaticDir, withAPIAuth(api, cfg)) - handler = withAMapReverseGeocodeAPI(handler, cfg, "https://restapi.amap.com", http.DefaultClient) handler = withAppConfig(handler, cfg) handler = withAMapSecurityProxy(handler, cfg, defaultAMapProxyUpstreams(), http.DefaultClient) return withRequestTimeout(handler, cfg.RequestTimeout) diff --git a/vehicle-data-platform/apps/api/internal/app/server_test.go b/vehicle-data-platform/apps/api/internal/app/server_test.go index aab202d9..c5d14dd9 100644 --- a/vehicle-data-platform/apps/api/internal/app/server_test.go +++ b/vehicle-data-platform/apps/api/internal/app/server_test.go @@ -256,3 +256,21 @@ func TestAMapReverseGeocodeAPIRejectsBadCoordinate(t *testing.T) { t.Fatalf("body should mention bad coordinate: %s", rec.Body.String()) } } + +func TestServerRequiresAuthenticationForAMapReverseGeocode(t *testing.T) { + handler := NewServer(config.Config{ + AuthMode: "enforce", + AuthTokensJSON: `[{"token":"0123456789abcdef","name":"test-viewer","role":"viewer"}]`, + DataMode: "mock", + AMapAPIKey: "server-api-key", + RequestTimeout: time.Second, + }) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/api/map/reverse-geocode?longitude=121.47&latitude=31.23", nil) + + handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("anonymous reverse geocode status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} diff --git a/vehicle-data-platform/apps/api/internal/businessscope/model.go b/vehicle-data-platform/apps/api/internal/businessscope/model.go new file mode 100644 index 00000000..9d7023c9 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/businessscope/model.go @@ -0,0 +1,205 @@ +package businessscope + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + "strings" + "time" +) + +const SourceSystem = "oneos" + +const ( + ReasonVehicleMissing = "VEHICLE_MISSING" + ReasonVINMissing = "VIN_MISSING" + ReasonCustomerMissing = "CUSTOMER_MISSING" + ReasonCustomerProfileMissing = "CUSTOMER_PROFILE_MISSING" + ReasonContractMissing = "CONTRACT_MISSING" + ReasonContractProfileMissing = "CONTRACT_PROFILE_MISSING" + ReasonContractCustomerMissing = "CONTRACT_CUSTOMER_MISSING" + ReasonCustomerMismatch = "CUSTOMER_MISMATCH" + ReasonDuplicateVehicle = "DUPLICATE_VEHICLE_SCOPE" + ReasonDuplicateVIN = "DUPLICATE_VIN_SCOPE" +) + +type Candidate struct { + RowNumber int + VehicleID int64 + VehiclePresent bool + VIN string + PlateNumber string + CustomerID int64 + CustomerPresent bool + CustomerProfileExists bool + ContractID int64 + ContractPresent bool + ContractProfileExists bool + EffectiveCustomerID int64 + EffectiveCustomerSet bool + ContractCode string + ProjectName string + OperationStatus string + ScopeStartAt time.Time + SourceUpdatedAt *time.Time +} + +type ScopeItem struct { + VehicleID int64 + VIN string + PlateNumber string + CustomerID int64 + ContractID int64 + ContractCode string + ProjectName string + OperationStatus string + ScopeStartAt time.Time + SourceUpdatedAt *time.Time +} + +type Rejection struct { + RowNumber int + VehicleID *int64 + VIN string + CustomerID *int64 + ContractID *int64 + ReasonCode string +} + +type Snapshot struct { + RunID string + SourceVersion string + Checksum string + GeneratedAt time.Time + Candidates int + Items []ScopeItem + Rejections []Rejection +} + +func BuildSnapshot(candidates []Candidate, generatedAt time.Time) (Snapshot, error) { + generatedAt = generatedAt.UTC() + reasons := make([]string, len(candidates)) + vehicleRows := map[int64][]int{} + vinRows := map[string][]int{} + for i, candidate := range candidates { + candidate.VIN = normalizeVIN(candidate.VIN) + candidates[i] = candidate + reasons[i] = validateCandidate(candidate) + if reasons[i] == "" { + vehicleRows[candidate.VehicleID] = append(vehicleRows[candidate.VehicleID], i) + vinRows[candidate.VIN] = append(vinRows[candidate.VIN], i) + } + } + for _, indexes := range vehicleRows { + if len(indexes) > 1 { + for _, index := range indexes { + reasons[index] = ReasonDuplicateVehicle + } + } + } + for _, indexes := range vinRows { + if len(indexes) > 1 { + for _, index := range indexes { + if reasons[index] == "" { + reasons[index] = ReasonDuplicateVIN + } + } + } + } + + items := make([]ScopeItem, 0, len(candidates)) + rejections := make([]Rejection, 0) + for index, candidate := range candidates { + if reasons[index] != "" { + rejections = append(rejections, rejectionFor(candidate, reasons[index])) + continue + } + items = append(items, ScopeItem{ + VehicleID: candidate.VehicleID, VIN: candidate.VIN, PlateNumber: strings.TrimSpace(candidate.PlateNumber), + CustomerID: candidate.CustomerID, ContractID: candidate.ContractID, + ContractCode: strings.TrimSpace(candidate.ContractCode), ProjectName: strings.TrimSpace(candidate.ProjectName), + OperationStatus: strings.TrimSpace(candidate.OperationStatus), ScopeStartAt: candidate.ScopeStartAt, + SourceUpdatedAt: candidate.SourceUpdatedAt, + }) + } + sort.Slice(items, func(i, j int) bool { + if items[i].CustomerID != items[j].CustomerID { + return items[i].CustomerID < items[j].CustomerID + } + return items[i].VIN < items[j].VIN + }) + sort.Slice(rejections, func(i, j int) bool { return rejections[i].RowNumber < rejections[j].RowNumber }) + canonical, err := json.Marshal(items) + if err != nil { + return Snapshot{}, fmt.Errorf("marshal canonical scope: %w", err) + } + sum := sha256.Sum256(canonical) + checksum := hex.EncodeToString(sum[:]) + runID, err := randomHex(16) + if err != nil { + return Snapshot{}, err + } + return Snapshot{ + RunID: runID, SourceVersion: "oneos-v1:" + checksum, Checksum: checksum, + GeneratedAt: generatedAt, Candidates: len(candidates), Items: items, Rejections: rejections, + }, nil +} + +func validateCandidate(candidate Candidate) string { + if !candidate.VehiclePresent || candidate.VehicleID <= 0 { + return ReasonVehicleMissing + } + if normalizeVIN(candidate.VIN) == "" { + return ReasonVINMissing + } + if !candidate.CustomerPresent || candidate.CustomerID <= 0 { + return ReasonCustomerMissing + } + if !candidate.CustomerProfileExists { + return ReasonCustomerProfileMissing + } + if !candidate.ContractPresent || candidate.ContractID <= 0 { + return ReasonContractMissing + } + if !candidate.ContractProfileExists { + return ReasonContractProfileMissing + } + if !candidate.EffectiveCustomerSet || candidate.EffectiveCustomerID <= 0 { + return ReasonContractCustomerMissing + } + if candidate.CustomerID != candidate.EffectiveCustomerID { + return ReasonCustomerMismatch + } + if candidate.ScopeStartAt.IsZero() { + return ReasonVehicleMissing + } + return "" +} + +func rejectionFor(candidate Candidate, reason string) Rejection { + rejection := Rejection{RowNumber: candidate.RowNumber, VIN: normalizeVIN(candidate.VIN), ReasonCode: reason} + if candidate.VehicleID > 0 { + rejection.VehicleID = int64Pointer(candidate.VehicleID) + } + if candidate.CustomerPresent && candidate.CustomerID > 0 { + rejection.CustomerID = int64Pointer(candidate.CustomerID) + } + if candidate.ContractPresent && candidate.ContractID > 0 { + rejection.ContractID = int64Pointer(candidate.ContractID) + } + return rejection +} + +func normalizeVIN(value string) string { return strings.ToUpper(strings.TrimSpace(value)) } +func int64Pointer(value int64) *int64 { return &value } + +func randomHex(bytes int) (string, error) { + buffer := make([]byte, bytes) + if _, err := rand.Read(buffer); err != nil { + return "", fmt.Errorf("generate run id: %w", err) + } + return hex.EncodeToString(buffer), nil +} diff --git a/vehicle-data-platform/apps/api/internal/businessscope/model_test.go b/vehicle-data-platform/apps/api/internal/businessscope/model_test.go new file mode 100644 index 00000000..538ffac7 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/businessscope/model_test.go @@ -0,0 +1,93 @@ +package businessscope + +import ( + "testing" + "time" +) + +func validCandidate(row int, vehicleID, customerID int64, vin string) Candidate { + return Candidate{ + RowNumber: row, VehicleID: vehicleID, VehiclePresent: true, VIN: vin, PlateNumber: "沪A00001", + CustomerID: customerID, CustomerPresent: true, CustomerProfileExists: true, + ContractID: vehicleID + 1000, ContractPresent: true, ContractProfileExists: true, + EffectiveCustomerID: customerID, EffectiveCustomerSet: true, + ContractCode: "HT-1", ProjectName: "项目", OperationStatus: "1", + ScopeStartAt: time.Date(2026, 7, 1, 8, 0, 0, 0, time.FixedZone("CST", 8*3600)), + } +} + +func TestBuildSnapshotClassifiesAndNormalizes(t *testing.T) { + candidates := []Candidate{ + validCandidate(1, 10, 100, " lvin0001 "), + func() Candidate { + value := validCandidate(2, 11, 101, "LVIN0002") + value.CustomerProfileExists = false + return value + }(), + func() Candidate { + value := validCandidate(3, 12, 102, "LVIN0003") + value.EffectiveCustomerID = 999 + return value + }(), + } + snapshot, err := BuildSnapshot(candidates, time.Now()) + if err != nil { + t.Fatal(err) + } + if snapshot.Candidates != 3 || len(snapshot.Items) != 1 || len(snapshot.Rejections) != 2 { + t.Fatalf("unexpected accounting: %#v", snapshot) + } + if snapshot.Items[0].VIN != "LVIN0001" { + t.Fatalf("VIN not normalized: %q", snapshot.Items[0].VIN) + } + if snapshot.Rejections[0].ReasonCode != ReasonCustomerProfileMissing || snapshot.Rejections[1].ReasonCode != ReasonCustomerMismatch { + t.Fatalf("unexpected reasons: %#v", snapshot.Rejections) + } +} + +func TestBuildSnapshotRejectsEveryDuplicateVehicle(t *testing.T) { + snapshot, err := BuildSnapshot([]Candidate{ + validCandidate(1, 10, 100, "LVIN0001"), + validCandidate(2, 10, 100, "LVIN0001"), + }, time.Now()) + if err != nil { + t.Fatal(err) + } + if len(snapshot.Items) != 0 || len(snapshot.Rejections) != 2 { + t.Fatalf("duplicates must all be quarantined: %#v", snapshot) + } + for _, rejection := range snapshot.Rejections { + if rejection.ReasonCode != ReasonDuplicateVehicle { + t.Fatalf("unexpected duplicate reason: %#v", rejection) + } + } +} + +func TestSnapshotVersionIsContentAddressed(t *testing.T) { + first := []Candidate{validCandidate(1, 10, 100, "LVIN0001"), validCandidate(2, 11, 101, "LVIN0002")} + second := []Candidate{first[1], first[0]} + a, err := BuildSnapshot(first, time.Now()) + if err != nil { + t.Fatal(err) + } + b, err := BuildSnapshot(second, time.Now().Add(time.Hour)) + if err != nil { + t.Fatal(err) + } + if a.SourceVersion != b.SourceVersion || a.Checksum != b.Checksum { + t.Fatalf("same content produced different versions: %s %s", a.SourceVersion, b.SourceVersion) + } +} + +func TestValidateSnapshotUsesCountAndRatioGates(t *testing.T) { + snapshot := Snapshot{Candidates: 639, Items: make([]ScopeItem, 613), Rejections: make([]Rejection, 26)} + if err := ValidateSnapshot(snapshot, 100, 0.10); err != nil { + t.Fatalf("production baseline should pass: %v", err) + } + if err := ValidateSnapshot(snapshot, 20, 0.10); err == nil { + t.Fatal("rejection count limit should fail") + } + if err := ValidateSnapshot(snapshot, 100, 0.01); err == nil { + t.Fatal("rejection ratio limit should fail") + } +} diff --git a/vehicle-data-platform/apps/api/internal/businessscope/publish.go b/vehicle-data-platform/apps/api/internal/businessscope/publish.go new file mode 100644 index 00000000..7e9059c6 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/businessscope/publish.go @@ -0,0 +1,105 @@ +package businessscope + +import ( + "context" + "database/sql" + "fmt" + "time" +) + +type PublishResult struct { + Changed bool + ActiveVersion string +} + +func ValidateSnapshot(snapshot Snapshot, maxRejected int, maxRejectRatio float64) error { + if snapshot.Candidates <= 0 { + return fmt.Errorf("OneOS scope snapshot contains no candidate rows") + } + if len(snapshot.Items) <= 0 { + return fmt.Errorf("OneOS scope snapshot contains no publishable rows") + } + if snapshot.Candidates != len(snapshot.Items)+len(snapshot.Rejections) { + return fmt.Errorf("scope accounting mismatch: candidates=%d accepted=%d rejected=%d", snapshot.Candidates, len(snapshot.Items), len(snapshot.Rejections)) + } + if maxRejected >= 0 && len(snapshot.Rejections) > maxRejected { + return fmt.Errorf("scope rejected rows exceed limit: rejected=%d limit=%d", len(snapshot.Rejections), maxRejected) + } + ratio := float64(len(snapshot.Rejections)) / float64(snapshot.Candidates) + if maxRejectRatio >= 0 && ratio > maxRejectRatio { + return fmt.Errorf("scope rejected ratio exceeds limit: ratio=%.6f limit=%.6f", ratio, maxRejectRatio) + } + return nil +} + +func Publish(ctx context.Context, db *sql.DB, snapshot Snapshot) (PublishResult, error) { + tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + return PublishResult{}, fmt.Errorf("begin scope publish: %w", err) + } + defer tx.Rollback() + var activeVersion sql.NullString + if err := tx.QueryRowContext(ctx, `SELECT active_version FROM business_scope_state WHERE id=1 FOR UPDATE`).Scan(&activeVersion); err != nil { + return PublishResult{}, fmt.Errorf("lock business scope state; apply migration 012_business_scope_projection.sql: %w", err) + } + status := "published" + changed := !activeVersion.Valid || activeVersion.String != snapshot.SourceVersion + if !changed { + status = "unchanged" + } + now := time.Now().UTC() + if _, err := tx.ExecContext(ctx, `INSERT INTO business_scope_sync_run( +run_id,source_system,source_version,status,candidate_count,accepted_count,rejected_count,source_checksum,started_at,finished_at +) VALUES(?,?,?,?,?,?,?,?,?,?)`, snapshot.RunID, SourceSystem, snapshot.SourceVersion, status, snapshot.Candidates, len(snapshot.Items), len(snapshot.Rejections), snapshot.Checksum, snapshot.GeneratedAt, now); err != nil { + return PublishResult{}, fmt.Errorf("insert business scope sync run: %w", err) + } + if changed { + // A source version can become active again after the source data reverts. + // Rebuild that inactive version while holding the state lock so publishing + // remains atomic and cannot fail on its existing primary keys. + if _, err := tx.ExecContext(ctx, `DELETE FROM business_customer_vehicle_scope WHERE source_version=?`, snapshot.SourceVersion); err != nil { + return PublishResult{}, fmt.Errorf("clear inactive business scope version: %w", err) + } + statement, err := tx.PrepareContext(ctx, `INSERT INTO business_customer_vehicle_scope( +source_version,customer_id,vin,vehicle_id,contract_id,contract_code,plate_number,project_name,operation_status,scope_start_at,source_updated_at,published_at +) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`) + if err != nil { + return PublishResult{}, fmt.Errorf("prepare business scope insert: %w", err) + } + defer statement.Close() + for _, item := range snapshot.Items { + var contractID any + if item.ContractID > 0 { + contractID = item.ContractID + } + if _, err := statement.ExecContext(ctx, snapshot.SourceVersion, item.CustomerID, item.VIN, item.VehicleID, contractID, + item.ContractCode, item.PlateNumber, item.ProjectName, item.OperationStatus, item.ScopeStartAt, item.SourceUpdatedAt, now); err != nil { + return PublishResult{}, fmt.Errorf("insert business scope item: %w", err) + } + } + } + if len(snapshot.Rejections) > 0 { + statement, err := tx.PrepareContext(ctx, `INSERT INTO business_scope_rejection( +run_id,row_number,vehicle_id,vin,customer_id,contract_id,reason_code,created_at +) VALUES(?,?,?,?,?,?,?,?)`) + if err != nil { + return PublishResult{}, fmt.Errorf("prepare scope rejection insert: %w", err) + } + defer statement.Close() + for _, rejected := range snapshot.Rejections { + if _, err := statement.ExecContext(ctx, snapshot.RunID, rejected.RowNumber, rejected.VehicleID, rejected.VIN, + rejected.CustomerID, rejected.ContractID, rejected.ReasonCode, now); err != nil { + return PublishResult{}, fmt.Errorf("insert business scope rejection: %w", err) + } + } + } + if _, err := tx.ExecContext(ctx, `UPDATE business_scope_state SET +active_version=?,source_checksum=?,candidate_count=?,accepted_count=?,rejected_count=?,generated_at=?,published_at=?,last_success_at=? +WHERE id=1`, snapshot.SourceVersion, snapshot.Checksum, snapshot.Candidates, len(snapshot.Items), len(snapshot.Rejections), snapshot.GeneratedAt, now, now); err != nil { + return PublishResult{}, fmt.Errorf("activate business scope snapshot: %w", err) + } + if err := tx.Commit(); err != nil { + return PublishResult{}, fmt.Errorf("commit business scope snapshot: %w", err) + } + return PublishResult{Changed: changed, ActiveVersion: snapshot.SourceVersion}, nil +} diff --git a/vehicle-data-platform/apps/api/internal/businessscope/publish_test.go b/vehicle-data-platform/apps/api/internal/businessscope/publish_test.go new file mode 100644 index 00000000..dd0e4cc0 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/businessscope/publish_test.go @@ -0,0 +1,123 @@ +package businessscope + +import ( + "context" + "errors" + "regexp" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" +) + +const scopeInsertSQL = `INSERT INTO business_customer_vehicle_scope( +source_version,customer_id,vin,vehicle_id,contract_id,contract_code,plate_number,project_name,operation_status,scope_start_at,source_updated_at,published_at +) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)` + +func publishSnapshot(version string) Snapshot { + generatedAt := time.Date(2026, 7, 14, 8, 0, 0, 0, time.UTC) + return Snapshot{ + RunID: "0123456789abcdef0123456789abcdef", SourceVersion: version, + Checksum: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + GeneratedAt: generatedAt, Candidates: 1, + Items: []ScopeItem{{ + VehicleID: 10, VIN: "LVIN0001", PlateNumber: "沪A00001", CustomerID: 100, + ContractID: 1010, ContractCode: "HT-1", ProjectName: "项目", OperationStatus: "1", + ScopeStartAt: generatedAt.Add(-time.Hour), + }}, + } +} + +func TestPublishRebuildsPreviouslyStoredVersionBeforeActivation(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + snapshot := publishSnapshot("oneos-v1:new") + + mock.ExpectBegin() + mock.ExpectQuery(regexp.QuoteMeta(`SELECT active_version FROM business_scope_state WHERE id=1 FOR UPDATE`)). + WillReturnRows(sqlmock.NewRows([]string{"active_version"}).AddRow("oneos-v1:current")) + mock.ExpectExec(`INSERT INTO business_scope_sync_run`). + WithArgs(snapshot.RunID, SourceSystem, snapshot.SourceVersion, "published", 1, 1, 0, snapshot.Checksum, snapshot.GeneratedAt, sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM business_customer_vehicle_scope WHERE source_version=?`)). + WithArgs(snapshot.SourceVersion).WillReturnResult(sqlmock.NewResult(0, 1)) + prepared := mock.ExpectPrepare(regexp.QuoteMeta(scopeInsertSQL)) + item := snapshot.Items[0] + prepared.ExpectExec().WithArgs( + snapshot.SourceVersion, item.CustomerID, item.VIN, item.VehicleID, item.ContractID, + item.ContractCode, item.PlateNumber, item.ProjectName, item.OperationStatus, item.ScopeStartAt, nil, sqlmock.AnyArg(), + ).WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec(`UPDATE business_scope_state SET`). + WithArgs(snapshot.SourceVersion, snapshot.Checksum, 1, 1, 0, snapshot.GeneratedAt, sqlmock.AnyArg(), sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + result, err := Publish(context.Background(), db, snapshot) + if err != nil { + t.Fatal(err) + } + if !result.Changed || result.ActiveVersion != snapshot.SourceVersion { + t.Fatalf("unexpected publish result: %#v", result) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestPublishUnchangedDoesNotRewriteScopeRows(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + snapshot := publishSnapshot("oneos-v1:same") + + mock.ExpectBegin() + mock.ExpectQuery(regexp.QuoteMeta(`SELECT active_version FROM business_scope_state WHERE id=1 FOR UPDATE`)). + WillReturnRows(sqlmock.NewRows([]string{"active_version"}).AddRow(snapshot.SourceVersion)) + mock.ExpectExec(`INSERT INTO business_scope_sync_run`). + WithArgs(snapshot.RunID, SourceSystem, snapshot.SourceVersion, "unchanged", 1, 1, 0, snapshot.Checksum, snapshot.GeneratedAt, sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec(`UPDATE business_scope_state SET`). + WithArgs(snapshot.SourceVersion, snapshot.Checksum, 1, 1, 0, snapshot.GeneratedAt, sqlmock.AnyArg(), sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + result, err := Publish(context.Background(), db, snapshot) + if err != nil { + t.Fatal(err) + } + if result.Changed { + t.Fatalf("unchanged snapshot reported as changed: %#v", result) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestPublishRollsBackWhenVersionCannotBeCleared(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + snapshot := publishSnapshot("oneos-v1:new") + + mock.ExpectBegin() + mock.ExpectQuery(regexp.QuoteMeta(`SELECT active_version FROM business_scope_state WHERE id=1 FOR UPDATE`)). + WillReturnRows(sqlmock.NewRows([]string{"active_version"}).AddRow("oneos-v1:current")) + mock.ExpectExec(`INSERT INTO business_scope_sync_run`).WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM business_customer_vehicle_scope WHERE source_version=?`)). + WithArgs(snapshot.SourceVersion).WillReturnError(errors.New("delete failed")) + mock.ExpectRollback() + + if _, err := Publish(context.Background(), db, snapshot); err == nil { + t.Fatal("publish should fail when stale version cannot be rebuilt") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/vehicle-data-platform/apps/api/internal/businessscope/source.go b/vehicle-data-platform/apps/api/internal/businessscope/source.go new file mode 100644 index 00000000..b5fb3577 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/businessscope/source.go @@ -0,0 +1,200 @@ +package businessscope + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" +) + +// candidateQuery deliberately derives the active lease lifecycle from delivery and +// return task facts. vehicle_lease_order_record remains an independent customer +// ownership check, but its last_return_time is not authoritative: saving a return +// draft currently updates that aggregate field before the return is completed. +const candidateQuery = `SELECT + dv.vehicle_id, + v.id, + COALESCE(v.vin, ''), + COALESCE(v.plate_number, dv.plate_number, r.plate_number, ''), + r.customer_id, + ci.id, + dv.contract_id, + co.id, + COALESCE(co.other_customer_id, co.customer_id), + COALESCE(co.contract_code, r.contract_code, ''), + COALESCE(co.project_name, r.project_name, ''), + COALESCE(vs.operation_status, ''), + dv.delivery_time, + dv.update_time +FROM delivery_vehicle dv +LEFT JOIN vehicle_info v ON v.id = dv.vehicle_id AND v.del_flag = '0' +LEFT JOIN vehicle_lease_order_record r ON r.vehicle_id = dv.vehicle_id AND r.del_flag = '0' +LEFT JOIN customer_info ci ON ci.id = r.customer_id AND ci.del_flag = '0' +LEFT JOIN vehicle_lease_contract_info co ON co.id = dv.contract_id AND co.del_flag = '0' +LEFT JOIN vehicle_status vs ON vs.vehicle_id = dv.vehicle_id AND vs.del_flag = '0' +WHERE dv.del_flag = '0' + AND dv.delivery_status IN (2, 3) + AND dv.vehicle_id IS NOT NULL + AND dv.delivery_time IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM delivery_vehicle newer + WHERE newer.del_flag = '0' + AND newer.vehicle_id = dv.vehicle_id + AND newer.vehicle_id IS NOT NULL + AND ( + COALESCE(newer.delivery_time, '1000-01-01') > COALESCE(dv.delivery_time, '1000-01-01') + OR ( + COALESCE(newer.delivery_time, '1000-01-01') = COALESCE(dv.delivery_time, '1000-01-01') + AND newer.id > dv.id + ) + ) + ) + AND NOT EXISTS ( + SELECT 1 + FROM return_vehicle_task rt + WHERE rt.delivery_vehicle_id = dv.id + AND rt.del_flag = '0' + AND rt.status IN (2, 3, 5) + ) +ORDER BY dv.vehicle_id, dv.id, r.id` + +func ReadCandidates(ctx context.Context, db *sql.DB) ([]Candidate, error) { + connection, err := db.Conn(ctx) + if err != nil { + return nil, fmt.Errorf("acquire OneOS connection: %w", err) + } + defer connection.Close() + if err := VerifyReadOnlyGrants(ctx, connection); err != nil { + return nil, err + } + if _, err := connection.ExecContext(ctx, `SET SESSION TRANSACTION READ ONLY`); err != nil { + return nil, fmt.Errorf("force OneOS session read only: %w", err) + } + if _, err := connection.ExecContext(ctx, `SET SESSION MAX_EXECUTION_TIME = 10000`); err != nil { + return nil, fmt.Errorf("set OneOS query timeout: %w", err) + } + tx, err := connection.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true}) + if err != nil { + return nil, fmt.Errorf("begin OneOS read-only snapshot: %w", err) + } + defer tx.Rollback() + rows, err := tx.QueryContext(ctx, candidateQuery) + if err != nil { + return nil, fmt.Errorf("query OneOS customer vehicle candidates: %w", err) + } + defer rows.Close() + candidates := make([]Candidate, 0, 1024) + for rows.Next() { + var vehicleID, vehicleProfileID, customerID, customerProfileID sql.NullInt64 + var contractID, contractProfileID, effectiveCustomerID sql.NullInt64 + var vin, plate, contractCode, projectName, operationStatus string + var scopeStart time.Time + var updatedAt sql.NullTime + if err := rows.Scan( + &vehicleID, &vehicleProfileID, &vin, &plate, &customerID, &customerProfileID, + &contractID, &contractProfileID, &effectiveCustomerID, &contractCode, &projectName, + &operationStatus, &scopeStart, &updatedAt, + ); err != nil { + return nil, fmt.Errorf("scan OneOS scope candidate: %w", err) + } + candidate := Candidate{ + RowNumber: len(candidates) + 1, + VehicleID: vehicleID.Int64, VehiclePresent: vehicleID.Valid && vehicleProfileID.Valid, + VIN: vin, PlateNumber: plate, + CustomerID: customerID.Int64, CustomerPresent: customerID.Valid, + CustomerProfileExists: customerProfileID.Valid, + ContractID: contractID.Int64, ContractPresent: contractID.Valid, + ContractProfileExists: contractProfileID.Valid, + EffectiveCustomerID: effectiveCustomerID.Int64, EffectiveCustomerSet: effectiveCustomerID.Valid, + ContractCode: contractCode, ProjectName: projectName, OperationStatus: operationStatus, + ScopeStartAt: scopeStart, + } + if updatedAt.Valid { + value := updatedAt.Time + candidate.SourceUpdatedAt = &value + } + candidates = append(candidates, candidate) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate OneOS scope candidates: %w", err) + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("finish OneOS read-only snapshot: %w", err) + } + return candidates, nil +} + +func VerifyReadOnlyGrants(ctx context.Context, connection *sql.Conn) error { + rows, err := connection.QueryContext(ctx, `SHOW GRANTS FOR CURRENT_USER`) + if err != nil { + return fmt.Errorf("inspect OneOS database grants: %w", err) + } + defer rows.Close() + grants := make([]string, 0) + for rows.Next() { + var grant string + if err := rows.Scan(&grant); err != nil { + return fmt.Errorf("scan OneOS database grant: %w", err) + } + grants = append(grants, grant) + } + if err := rows.Err(); err != nil { + return fmt.Errorf("iterate OneOS database grants: %w", err) + } + if err := ValidateReadOnlyGrants(grants); err != nil { + return fmt.Errorf("OneOS database account is not read-only: %w", err) + } + return nil +} + +func ValidateReadOnlyGrants(grants []string) error { + if len(grants) == 0 { + return fmt.Errorf("SHOW GRANTS returned no rows") + } + allowedSelectScopes := map[string]bool{ + "LN_ASSET_MANAGEMENT.VEHICLE_LEASE_ORDER_RECORD": true, + "LN_ASSET_MANAGEMENT.DELIVERY_VEHICLE": true, + "LN_ASSET_MANAGEMENT.RETURN_VEHICLE_TASK": true, + "LN_ASSET_MANAGEMENT.VEHICLE_INFO": true, + "LN_ASSET_MANAGEMENT.CUSTOMER_INFO": true, + "LN_ASSET_MANAGEMENT.VEHICLE_LEASE_CONTRACT_INFO": true, + "LN_ASSET_MANAGEMENT.VEHICLE_STATUS": true, + } + for _, raw := range grants { + grant := strings.ToUpper(strings.TrimSpace(raw)) + if strings.HasPrefix(grant, "SET DEFAULT ROLE ") { + return fmt.Errorf("role-based grants are not accepted; grant SELECT directly to the sync account") + } + if !strings.HasPrefix(grant, "GRANT ") { + return fmt.Errorf("unsupported grant statement") + } + onIndex := strings.Index(grant, " ON ") + if onIndex < 0 { + return fmt.Errorf("unsupported role or dynamic grant") + } + privileges := strings.TrimSpace(strings.TrimPrefix(grant[:onIndex], "GRANT ")) + toIndex := strings.Index(grant[onIndex+4:], " TO ") + if toIndex < 0 { + return fmt.Errorf("unsupported grant scope") + } + scope := strings.ReplaceAll(strings.TrimSpace(grant[onIndex+4:onIndex+4+toIndex]), "`", "") + for _, privilege := range strings.Split(privileges, ",") { + privilege = strings.TrimSpace(privilege) + switch privilege { + case "USAGE": + if scope != "*.*" { + return fmt.Errorf("USAGE has unsupported scope %q", scope) + } + case "SELECT": + if !allowedSelectScopes[scope] { + return fmt.Errorf("SELECT scope %q is not required by the sync query", scope) + } + default: + return fmt.Errorf("disallowed privilege %q", privilege) + } + } + } + return nil +} diff --git a/vehicle-data-platform/apps/api/internal/businessscope/source_test.go b/vehicle-data-platform/apps/api/internal/businessscope/source_test.go new file mode 100644 index 00000000..77bf135b --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/businessscope/source_test.go @@ -0,0 +1,60 @@ +package businessscope + +import ( + "strings" + "testing" +) + +func TestCandidateQueryUsesCompletedLifecycleFactsInsteadOfAggregateReturnTime(t *testing.T) { + assertions := []string{ + "FROM delivery_vehicle dv", + "dv.delivery_status IN (2, 3)", + "FROM return_vehicle_task rt", + "rt.status IN (2, 3, 5)", + "FROM delivery_vehicle newer", + "LEFT JOIN vehicle_lease_order_record r", + } + for _, expected := range assertions { + if !strings.Contains(candidateQuery, expected) { + t.Fatalf("candidate query missing %q", expected) + } + } + if strings.Contains(candidateQuery, "r.last_return_time") { + t.Fatal("candidate query must not trust aggregate last_return_time written by return drafts") + } +} + +func TestValidateReadOnlyGrantsAllowsOnlyRequiredTableSelects(t *testing.T) { + grants := []string{ + "GRANT USAGE ON *.* TO `reader`@`%`", + "GRANT SELECT ON `ln_asset_management`.`vehicle_lease_order_record` TO `reader`@`%`", + "GRANT SELECT ON `ln_asset_management`.`delivery_vehicle` TO `reader`@`%`", + "GRANT SELECT ON `ln_asset_management`.`return_vehicle_task` TO `reader`@`%`", + "GRANT SELECT ON `ln_asset_management`.`vehicle_info` TO `reader`@`%`", + "GRANT SELECT ON `ln_asset_management`.`customer_info` TO `reader`@`%`", + "GRANT SELECT ON `ln_asset_management`.`vehicle_lease_contract_info` TO `reader`@`%`", + "GRANT SELECT ON `ln_asset_management`.`vehicle_status` TO `reader`@`%`", + } + if err := ValidateReadOnlyGrants(grants); err != nil { + t.Fatalf("read-only grants rejected: %v", err) + } +} + +func TestValidateReadOnlyGrantsRejectsBroadOrWritePrivileges(t *testing.T) { + cases := [][]string{ + {"GRANT ALL PRIVILEGES ON `ln_asset_management`.* TO `reader`@`%`"}, + {"GRANT SELECT, UPDATE ON `ln_asset_management`.* TO `reader`@`%`"}, + {"GRANT SELECT ON *.* TO `reader`@`%`"}, + {"GRANT SELECT ON `ln_asset_management`.* TO `reader`@`%`"}, + {"GRANT SELECT ON `ry-cloud`.`sys_user` TO `reader`@`%`"}, + {"GRANT SHOW VIEW ON `ln_asset_management`.`vehicle_info` TO `reader`@`%`"}, + {"GRANT PROCESS, REPLICATION CLIENT ON *.* TO `reader`@`%`"}, + {"GRANT `scope_reader_role`@`%` TO `reader`@`%`"}, + {"SET DEFAULT ROLE `scope_reader_role`@`%` TO `reader`@`%`"}, + } + for _, grants := range cases { + if err := ValidateReadOnlyGrants(grants); err == nil { + t.Fatalf("unsafe grants accepted: %#v", grants) + } + } +} diff --git a/vehicle-data-platform/apps/api/internal/platform/access.go b/vehicle-data-platform/apps/api/internal/platform/access.go index 924d0915..a1d2f4c0 100644 --- a/vehicle-data-platform/apps/api/internal/platform/access.go +++ b/vehicle-data-platform/apps/api/internal/platform/access.go @@ -124,6 +124,14 @@ func (s *Service) AccessSummary(ctx context.Context, query AccessQuery) (AccessS protocols := map[string]*AccessDistribution{} oems := map[string]*AccessDistribution{} for _, row := range rows { + switch row.ConnectionState { + case "healthy": + result.HealthyVehicles++ + case "incomplete": + result.IncompleteVehicles++ + case "degraded": + result.DegradedVehicles++ + } switch row.OnlineState { case "online": result.OnlineVehicles++ @@ -143,7 +151,11 @@ func (s *Service) AccessSummary(ctx context.Context, query AccessQuery) (AccessS if reportedOnDay(row.LatestReceivedAt, now) { result.ReportedToday++ } - addAccessDistribution(protocols, firstNonEmpty(row.Protocol, "未识别"), row.OnlineState == "online") + for _, status := range row.ProtocolStatuses { + if status.Connected { + addAccessDistribution(protocols, status.Protocol, status.OnlineState == "online") + } + } addAccessDistribution(oems, firstNonEmpty(row.OEM, "未维护"), row.OnlineState == "online") } if result.TotalVehicles > 0 { @@ -171,9 +183,18 @@ func (s *Service) accessRows(ctx context.Context, query AccessQuery) ([]AccessVe return nil, AccessThresholdConfig{}, err } now := time.Now() - rows := make([]AccessVehicleRow, 0, len(evidence)) + byVIN := make(map[string][]AccessEvidenceRow, len(evidence)) + order := make([]string, 0, len(evidence)) for _, item := range evidence { - row := buildAccessVehicleRow(item, config, now) + vin := strings.TrimSpace(item.VIN) + if _, exists := byVIN[vin]; !exists { + order = append(order, vin) + } + byVIN[vin] = append(byVIN[vin], item) + } + rows := make([]AccessVehicleRow, 0, len(byVIN)) + for _, vin := range order { + row := buildAccessVehicleGroup(byVIN[vin], config, now) if keepAccessRow(row, query) { rows = append(rows, row) } @@ -191,6 +212,159 @@ func (s *Service) accessRows(ctx context.Context, query AccessQuery) ([]AccessVe return rows, config, nil } +func buildAccessVehicleGroup(items []AccessEvidenceRow, config AccessThresholdConfig, now time.Time) AccessVehicleRow { + if len(items) == 0 { + return AccessVehicleRow{} + } + base := items[0] + row := AccessVehicleRow{ + VIN: strings.TrimSpace(base.VIN), + Plate: strings.TrimSpace(base.Plate), + OEM: strings.TrimSpace(base.OEM), + Model: strings.TrimSpace(base.Model), + Company: strings.TrimSpace(base.Company), + ExpectedProtocols: append([]string(nil), canonicalVehicleProtocols...), + ActualProtocols: []string{}, + MissingProtocols: []string{}, + ProtocolStatuses: []AccessProtocolStatus{}, + ExpectationEvidence: "平台标准接入基线:GB32960 / JT808 / YUTONG_MQTT", + ConnectionState: "not_connected", + OnlineState: "never_reported", + FirstSeenEvidence: "尚未形成任何协议接入快照", + ReportIntervalProof: "需要连续上报样本后才能计算", + } + byProtocol := make(map[string]AccessEvidenceRow, len(items)) + for _, item := range items { + protocol := strings.ToUpper(strings.TrimSpace(item.Protocol)) + if protocol != "" { + byProtocol[protocol] = item + } + if row.Plate == "" { + row.Plate = strings.TrimSpace(item.Plate) + } + if row.OEM == "" { + row.OEM = strings.TrimSpace(item.OEM) + } + if row.Model == "" { + row.Model = strings.TrimSpace(item.Model) + } + if row.Company == "" { + row.Company = strings.TrimSpace(item.Company) + } + } + onlineCount := 0 + unknownCount := 0 + connectedCount := 0 + for _, protocol := range canonicalVehicleProtocols { + item, connected := byProtocol[protocol] + if !connected { + threshold := accessProtocolThreshold(config, protocol) + row.MissingProtocols = append(row.MissingProtocols, protocol) + row.ProtocolStatuses = append(row.ProtocolStatuses, AccessProtocolStatus{ + Protocol: protocol, Expected: true, Connected: false, OnlineState: "never_reported", ThresholdSec: threshold, + FirstSeenEvidence: "应接协议尚未形成实时快照", ReportIntervalEvidence: "尚无接收样本", + }) + continue + } + source := buildAccessVehicleRow(item, config, now) + connectedCount++ + row.ActualProtocols = append(row.ActualProtocols, protocol) + if source.OnlineState == "online" { + onlineCount++ + } + if source.DelayAbnormal { + row.DelayAbnormal = true + } + row.ProtocolStatuses = append(row.ProtocolStatuses, AccessProtocolStatus{ + Protocol: protocol, Expected: true, Connected: true, Provider: source.Provider, + FirstSeenAt: source.FirstSeenAt, LatestEventAt: source.LatestEventAt, LatestReceivedAt: source.LatestReceivedAt, + ReportIntervalSec: source.ReportIntervalSec, DataDelaySec: source.DataDelaySec, FreshnessSec: source.FreshnessSec, + OnlineState: source.OnlineState, ThresholdSec: source.ThresholdSec, DelayAbnormal: source.DelayAbnormal, + FirstSeenEvidence: source.FirstSeenEvidence, ReportIntervalEvidence: source.ReportIntervalProof, + Longitude: item.Longitude, Latitude: item.Latitude, SpeedKmh: item.SpeedKmh, SOCPercent: item.SOCPercent, TotalMileageKm: item.TotalMileageKm, DailyMileageKm: item.DailyMileageKm, + }) + if row.FirstSeenAt == "" || source.FirstSeenAt != "" && source.FirstSeenAt < row.FirstSeenAt { + row.FirstSeenAt = source.FirstSeenAt + row.FirstSeenEvidence = source.FirstSeenEvidence + row.FirstSeenSource = source.FirstSeenSource + } + if row.LatestReceivedAt == "" || source.LatestReceivedAt > row.LatestReceivedAt { + copyAccessPrimaryFields(&row, source) + } + } + for protocol, item := range byProtocol { + if containsString(canonicalVehicleProtocols, protocol) { + continue + } + source := buildAccessVehicleRow(item, config, now) + row.ActualProtocols = append(row.ActualProtocols, protocol) + if source.OnlineState == "online" { + onlineCount++ + } + if source.OnlineState == "unknown" { + unknownCount++ + } + row.ProtocolStatuses = append(row.ProtocolStatuses, AccessProtocolStatus{ + Protocol: protocol, Expected: false, Connected: true, Provider: source.Provider, + FirstSeenAt: source.FirstSeenAt, LatestEventAt: source.LatestEventAt, LatestReceivedAt: source.LatestReceivedAt, + ReportIntervalSec: source.ReportIntervalSec, DataDelaySec: source.DataDelaySec, FreshnessSec: source.FreshnessSec, + OnlineState: source.OnlineState, ThresholdSec: source.ThresholdSec, DelayAbnormal: source.DelayAbnormal, + FirstSeenEvidence: source.FirstSeenEvidence, ReportIntervalEvidence: source.ReportIntervalProof, + Longitude: item.Longitude, Latitude: item.Latitude, SpeedKmh: item.SpeedKmh, SOCPercent: item.SOCPercent, TotalMileageKm: item.TotalMileageKm, DailyMileageKm: item.DailyMileageKm, + }) + if row.LatestReceivedAt == "" || source.LatestReceivedAt > row.LatestReceivedAt { + copyAccessPrimaryFields(&row, source) + } + } + switch { + case connectedCount == 0 && len(row.ActualProtocols) == 0: + row.ConnectionState = "not_connected" + row.OnlineState = "never_reported" + case connectedCount == len(canonicalVehicleProtocols) && onlineCount == connectedCount: + row.ConnectionState = "healthy" + row.OnlineState = "online" + case onlineCount == 0 && unknownCount > 0: + row.ConnectionState = "incomplete" + row.OnlineState = "unknown" + case onlineCount == 0: + row.ConnectionState = "offline" + row.OnlineState = "offline" + case connectedCount < len(canonicalVehicleProtocols): + row.ConnectionState = "incomplete" + row.OnlineState = "online" + default: + row.ConnectionState = "degraded" + row.OnlineState = "online" + } + return row +} + +func accessProtocolThreshold(config AccessThresholdConfig, protocol string) int { + for _, override := range config.Protocols { + if strings.EqualFold(override.Protocol, protocol) { + return override.ThresholdSec + } + } + return config.DefaultThresholdSec +} + +func copyAccessPrimaryFields(target *AccessVehicleRow, source AccessVehicleRow) { + target.Protocol = source.Protocol + target.Provider = source.Provider + target.Source = source.Source + target.LatestEventAt = source.LatestEventAt + target.LatestReceivedAt = source.LatestReceivedAt + target.ReportIntervalSec = source.ReportIntervalSec + target.DataDelaySec = source.DataDelaySec + target.FreshnessSec = source.FreshnessSec + target.ThresholdSec = source.ThresholdSec + target.LatestMessageType = source.LatestMessageType + target.LatestEventID = source.LatestEventID + target.LatestError = source.LatestError + target.ReportIntervalProof = source.ReportIntervalProof + target.ReportSampleCount = source.ReportSampleCount +} + func buildAccessVehicleRow(item AccessEvidenceRow, config AccessThresholdConfig, now time.Time) AccessVehicleRow { threshold := config.DefaultThresholdSec for _, override := range config.Protocols { @@ -266,10 +440,10 @@ func buildAccessVehicleRow(item AccessEvidenceRow, config AccessThresholdConfig, func keepAccessRow(row AccessVehicleRow, query AccessQuery) bool { keyword := strings.ToLower(strings.TrimSpace(query.Keyword)) - if keyword != "" && !strings.Contains(strings.ToLower(row.VIN), keyword) && !strings.Contains(strings.ToLower(row.Plate), keyword) { + if keyword != "" && !strings.Contains(strings.ToLower(row.VIN), keyword) && !strings.Contains(strings.ToLower(row.Plate), keyword) && !strings.Contains(strings.ToLower(row.OEM), keyword) && !strings.Contains(strings.ToLower(row.Model), keyword) && !strings.Contains(strings.ToLower(row.Company), keyword) { return false } - if value := strings.TrimSpace(query.Protocol); value != "" && !strings.EqualFold(value, row.Protocol) { + if value := strings.TrimSpace(query.Protocol); value != "" && !containsString(row.ActualProtocols, value) { return false } if value := strings.TrimSpace(query.OEM); value != "" && !strings.EqualFold(value, row.OEM) { @@ -278,8 +452,17 @@ func keepAccessRow(row AccessVehicleRow, query AccessQuery) bool { if value := strings.ToLower(strings.TrimSpace(query.Model)); value != "" && !strings.Contains(strings.ToLower(row.Model), value) { return false } - if value := strings.ToLower(strings.TrimSpace(query.Provider)); value != "" && !strings.Contains(strings.ToLower(row.Provider), value) { - return false + if value := strings.ToLower(strings.TrimSpace(query.Provider)); value != "" { + matched := false + for _, status := range row.ProtocolStatuses { + if strings.Contains(strings.ToLower(status.Provider), value) { + matched = true + break + } + } + if !matched { + return false + } } if !accessTimeMatches(row.FirstSeenAt, query.FirstSeenFrom, query.FirstSeenTo) || !accessTimeMatches(row.LatestReceivedAt, query.LatestSeenFrom, query.LatestSeenTo) { return false @@ -287,6 +470,15 @@ func keepAccessRow(row AccessVehicleRow, query AccessQuery) bool { if value := strings.TrimSpace(query.OnlineState); value != "" && value != "all" && value != row.OnlineState { return false } + if value := strings.TrimSpace(query.ConnectionState); value != "" && value != "all" { + if value == "attention" { + if row.ConnectionState == "healthy" { + return false + } + } else if value != row.ConnectionState { + return false + } + } switch strings.TrimSpace(query.DelayState) { case "abnormal": return row.DelayAbnormal diff --git a/vehicle-data-platform/apps/api/internal/platform/access_store.go b/vehicle-data-platform/apps/api/internal/platform/access_store.go index a9bd7a23..48b4bbd3 100644 --- a/vehicle-data-platform/apps/api/internal/platform/access_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/access_store.go @@ -77,21 +77,27 @@ COALESCE(DATE_FORMAT(s.access_latest_received_at, '%Y-%m-%d %H:%i:%s.%f'), '') A COALESCE(DATE_FORMAT(s.access_latest_received_at, '%Y-%m-%d %H:%i:%s.%f'), '') AS updated_at, COALESCE(s.access_report_interval_ms, -1) AS report_interval_ms, COALESCE(s.access_sample_count, 0) AS report_sample_count, -COALESCE(s.event_id, '') AS event_id +COALESCE(s.event_id, '') AS event_id, +COALESCE(l.longitude, 0) AS longitude, +COALESCE(l.latitude, 0) AS latitude, +COALESCE(l.speed_kmh, 0) AS speed_kmh, +COALESCE(l.soc_percent, 0) AS soc_percent, +COALESCE(l.total_mileage_km, 0) AS total_mileage_km, +COALESCE(m.daily_mileage_km, 0) AS daily_mileage_km FROM ( - SELECT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin <> '' - UNION - SELECT vin FROM vehicle_realtime_snapshot WHERE vin IS NOT NULL AND vin <> '' + SELECT DISTINCT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin <> '' ) v LEFT JOIN vehicle_identity_binding b ON b.vin = v.vin LEFT JOIN vehicle_profile p ON p.vin = v.vin LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin - AND NOT EXISTS ( - SELECT 1 FROM vehicle_realtime_snapshot newer - WHERE newer.vin = s.vin - AND (newer.access_latest_received_at > s.access_latest_received_at OR (newer.access_latest_received_at = s.access_latest_received_at AND newer.protocol < s.protocol)) - ) -ORDER BY s.access_latest_received_at DESC, v.vin ASC +LEFT JOIN vehicle_realtime_location l ON l.vin = s.vin AND l.protocol = s.protocol +LEFT JOIN ( + SELECT vin, protocol, MAX(daily_mileage_km) AS daily_mileage_km + FROM vehicle_daily_mileage + WHERE stat_date = CURDATE() + GROUP BY vin, protocol +) m ON m.vin = s.vin AND m.protocol = s.protocol +ORDER BY v.vin ASC, s.protocol ASC LIMIT ?`, accessEvidenceLimit+1) if err != nil { return nil, err @@ -101,7 +107,7 @@ LIMIT ?`, accessEvidenceLimit+1) for rows.Next() { var row AccessEvidenceRow var reportIntervalMS int64 - if err := rows.Scan(&row.VIN, &row.Plate, &row.OEM, &row.Model, &row.Company, &row.Protocol, &row.Provider, &row.FirstSeenAt, &row.FirstSeenSource, &row.LatestEventAt, &row.LatestReceivedAt, &row.LatestUpdatedAt, &reportIntervalMS, &row.ReportSampleCount, &row.LatestEventID); err != nil { + if err := rows.Scan(&row.VIN, &row.Plate, &row.OEM, &row.Model, &row.Company, &row.Protocol, &row.Provider, &row.FirstSeenAt, &row.FirstSeenSource, &row.LatestEventAt, &row.LatestReceivedAt, &row.LatestUpdatedAt, &reportIntervalMS, &row.ReportSampleCount, &row.LatestEventID, &row.Longitude, &row.Latitude, &row.SpeedKmh, &row.SOCPercent, &row.TotalMileageKm, &row.DailyMileageKm); err != nil { return nil, err } if reportIntervalMS >= 0 { diff --git a/vehicle-data-platform/apps/api/internal/platform/access_test.go b/vehicle-data-platform/apps/api/internal/platform/access_test.go index 2e3abcc4..49ba3959 100644 --- a/vehicle-data-platform/apps/api/internal/platform/access_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/access_test.go @@ -23,6 +23,9 @@ func TestAccessSummaryUsesDynamicFreshnessAndDelay(t *testing.T) { if summary.DelayAbnormal != 1 || summary.ThresholdVersion != 1 { t.Fatalf("summary must expose dynamic delay and threshold version: %+v", summary) } + if summary.HealthyVehicles != 0 || summary.IncompleteVehicles != 3 || summary.DegradedVehicles != 0 { + t.Fatalf("connection-state counters must be mutually exclusive: %+v", summary) + } } func TestAccessVehiclesFiltersAndKeepsEvidenceGapsExplicit(t *testing.T) { @@ -42,6 +45,11 @@ func TestAccessVehiclesFiltersAndKeepsEvidenceGapsExplicit(t *testing.T) { if err != nil || delayed.Total != 1 || delayed.Items[0].DataDelaySec == nil || *delayed.Items[0].DataDelaySec != 45 { t.Fatalf("delay filter should use event/receive difference: page=%+v err=%v", delayed, err) } + + attention, err := service.AccessVehicles(context.Background(), AccessQuery{ConnectionState: "attention", Limit: 20}) + if err != nil || attention.Total != 6 { + t.Fatalf("attention filter should return every vehicle with an access difference: page=%+v err=%v", attention, err) + } } func TestAccessVehiclesSupportsModelProviderAndTimeFilters(t *testing.T) { @@ -96,6 +104,42 @@ func TestAccessVehicleCarriesDurableProjectionEvidenceAndMasterData(t *testing.T } } +func TestAccessVehiclesKeywordMatchesFleetAndModel(t *testing.T) { + service := NewService(NewMockStore()) + fleet, err := service.AccessVehicles(t.Context(), AccessQuery{Keyword: "岭牛示范车队", Limit: 20}) + if err != nil || fleet.Total != 1 || fleet.Items[0].VIN != "LB9A32A24R0LS1426" { + t.Fatalf("fleet keyword should find its vehicles: page=%+v err=%v", fleet, err) + } + model, err := service.AccessVehicles(t.Context(), AccessQuery{Keyword: "氢燃料车型", Limit: 20}) + if err != nil || model.Total != 1 || model.Items[0].VIN != "LNXNEGRR7SR318212" { + t.Fatalf("model keyword should find its vehicles: page=%+v err=%v", model, err) + } +} + +func TestAccessVehicleGroupsCanonicalProtocolsByMasterVehicle(t *testing.T) { + store := NewMockStore() + service := NewService(store) + page, err := service.AccessVehicles(t.Context(), AccessQuery{Keyword: "LB9A32A24R0LS1426", Limit: 10}) + if err != nil || len(page.Items) != 1 { + t.Fatalf("access vehicle query failed: page=%+v err=%v", page, err) + } + row := page.Items[0] + if len(row.ExpectedProtocols) != 3 || len(row.ProtocolStatuses) != 3 { + t.Fatalf("vehicle must expose three canonical protocol slots: %+v", row) + } + if len(row.ActualProtocols) != 1 || row.ActualProtocols[0] != "JT808" || len(row.MissingProtocols) != 2 { + t.Fatalf("actual and missing protocols are incorrect: %+v", row) + } + if row.ConnectionState != "incomplete" || row.OnlineState != "online" { + t.Fatalf("single online source should be an online but incomplete vehicle: %+v", row) + } + for _, status := range row.ProtocolStatuses { + if status.Protocol == "JT808" && (!status.Connected || status.LatestReceivedAt == "") { + t.Fatalf("connected protocol lost evidence: %+v", status) + } + } +} + func TestProductionAccessEvidenceReadsDurableWriterProjection(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { @@ -103,12 +147,12 @@ func TestProductionAccessEvidenceReadsDurableWriterProjection(t *testing.T) { } defer db.Close() store := NewProductionStore(db, nil, "") - mock.ExpectQuery("(?s)SELECT.*access_first_seen_at.*access_first_seen_source.*access_report_interval_ms.*vehicle_profile.*access_latest_received_at"). + mock.ExpectQuery("(?s)SELECT.*access_first_seen_at.*access_latest_received_at.*FROM.*SELECT DISTINCT vin FROM vehicle_identity_binding.*vehicle_profile.*vehicle_realtime_snapshot"). WithArgs(accessEvidenceLimit + 1). WillReturnRows(sqlmock.NewRows([]string{ "vin", "plate", "oem", "model_name", "company_name", "protocol", "provider", "first_seen_at", "first_seen_source", - "event_time", "received_at", "updated_at", "report_interval_ms", "report_sample_count", "event_id", - }).AddRow("VIN001", "粤A1", "示范厂家", "纯电客车", "示范公交", "GB32960", "车厂平台", "2026-07-14 05:00:00.000", "snapshot_backfill", "2026-07-14 05:01:00.000", "2026-07-14 05:01:30.500", "2026-07-14 05:01:30.500", int64(30500), int64(3), "evt-3")) + "event_time", "received_at", "updated_at", "report_interval_ms", "report_sample_count", "event_id", "longitude", "latitude", "speed_kmh", "soc_percent", "total_mileage_km", "daily_mileage_km", + }).AddRow("VIN001", "粤A1", "示范厂家", "纯电客车", "示范公交", "GB32960", "车厂平台", "2026-07-14 05:00:00.000", "snapshot_backfill", "2026-07-14 05:01:00.000", "2026-07-14 05:01:30.500", "2026-07-14 05:01:30.500", int64(30500), int64(3), "evt-3", 113.1, 23.1, 32.5, 76.0, 12000.5, 38.6)) rows, err := store.AccessEvidence(context.Background()) if err != nil || len(rows) != 1 { t.Fatalf("AccessEvidence rows=%+v err=%v", rows, err) @@ -117,6 +161,9 @@ func TestProductionAccessEvidenceReadsDurableWriterProjection(t *testing.T) { if row.ReportIntervalSec == nil || *row.ReportIntervalSec != 31 || row.ReportSampleCount != 3 || row.Model != "纯电客车" || row.Company != "示范公交" { t.Fatalf("unexpected durable projection row: %+v", row) } + if row.SpeedKmh != 32.5 || row.TotalMileageKm != 12000.5 || row.DailyMileageKm != 38.6 { + t.Fatalf("realtime source metrics missing: %+v", row) + } if row.FirstSeenEvidence != "上线基线:由实时快照回填(非历史首次接入)" || row.ReportIntervalProof == "" { t.Fatalf("projection evidence boundary missing: %+v", row) } 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 a4b26558..72c2018f 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -871,6 +871,27 @@ func TestHandlerVehicleRealtimeAcceptsKeyword(t *testing.T) { } } +func TestHandlerVehicleRealtimeAcceptsBatchKeywords(t *testing.T) { + handler := NewHandler(NewService(NewMockStore())) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/realtime/vehicles?keywords=%E7%B2%A4AG18312%2C%E5%B7%9DAHTWO1%2C%E7%B2%A4AG18312&limit=10", nil) + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Data struct { + Items []VehicleRealtimeRow `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("response JSON should decode: %v body=%s", err, rec.Body.String()) + } + if len(body.Data.Items) != 2 { + t.Fatalf("batch search should return two deduplicated vehicles, got %+v body=%s", body.Data.Items, rec.Body.String()) + } +} + func TestHandlerHistoryMileageQualityOps(t *testing.T) { cases := []struct { path string 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 85250f5b..7065c158 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -485,10 +485,16 @@ func buildArchiveMissingFieldStats(missingPlate, missingPhone, missingOEM int) [ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[VehicleRealtimeRow], error) { rows := m.locations - if vin := strings.TrimSpace(query.Get("vin")); vin != "" { - keyword := strings.ToLower(vin) + if keywords := vehicleSearchKeywords(query); len(keywords) > 0 { rows = keep(rows, func(row RealtimeLocationRow) bool { - return strings.Contains(strings.ToLower(row.VIN+row.Plate), keyword) + vehicle := m.vehicleByVIN(row.VIN) + haystack := strings.ToLower(row.VIN + row.Plate + vehicle.Plate + vehicle.Phone + vehicle.OEM) + for _, keyword := range keywords { + if strings.Contains(haystack, strings.ToLower(keyword)) { + return true + } + } + return false }) } if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" { @@ -898,6 +904,13 @@ func (m *MockStore) dailyMileageRows(query url.Values) []DailyMileageRow { return strings.Contains(strings.ToLower(row.VIN+row.Plate), keyword) }) } + if raw := strings.TrimSpace(query.Get("vins")); raw != "" { + selected := map[string]bool{} + for _, vin := range strings.Split(raw, ",") { + selected[strings.TrimSpace(vin)] = true + } + rows = keep(rows, func(row DailyMileageRow) bool { return selected[row.VIN] }) + } if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" { rows = keep(rows, func(row DailyMileageRow) bool { return row.Source == protocol }) } diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index edb2d884..379a1277 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -353,19 +353,20 @@ type HistoryExportCursor struct { } type AccessQuery struct { - Keyword string `json:"keyword"` - Protocol string `json:"protocol"` - OEM string `json:"oem"` - Model string `json:"model"` - Provider string `json:"provider"` - FirstSeenFrom string `json:"firstSeenFrom"` - FirstSeenTo string `json:"firstSeenTo"` - LatestSeenFrom string `json:"latestSeenFrom"` - LatestSeenTo string `json:"latestSeenTo"` - OnlineState string `json:"onlineState"` - DelayState string `json:"delayState"` - Limit int `json:"limit"` - Offset int `json:"offset"` + Keyword string `json:"keyword"` + Protocol string `json:"protocol"` + OEM string `json:"oem"` + Model string `json:"model"` + Provider string `json:"provider"` + FirstSeenFrom string `json:"firstSeenFrom"` + FirstSeenTo string `json:"firstSeenTo"` + LatestSeenFrom string `json:"latestSeenFrom"` + LatestSeenTo string `json:"latestSeenTo"` + OnlineState string `json:"onlineState"` + DelayState string `json:"delayState"` + ConnectionState string `json:"connectionState"` + Limit int `json:"limit"` + Offset int `json:"offset"` } type AccessEvidenceRow struct { @@ -389,33 +390,69 @@ type AccessEvidenceRow struct { FirstSeenSource string ReportIntervalProof string ReportSampleCount int64 + Longitude float64 + Latitude float64 + SpeedKmh float64 + SOCPercent float64 + TotalMileageKm float64 + DailyMileageKm float64 } type AccessVehicleRow struct { - VIN string `json:"vin"` - Plate string `json:"plate"` - OEM string `json:"oem"` - Model string `json:"model"` - Company string `json:"company"` - Protocol string `json:"protocol"` - Provider string `json:"provider"` - Source string `json:"source"` - FirstSeenAt string `json:"firstSeenAt"` - LatestEventAt string `json:"latestEventAt"` - LatestReceivedAt string `json:"latestReceivedAt"` - ReportIntervalSec *int `json:"reportIntervalSec"` - DataDelaySec *int `json:"dataDelaySec"` - FreshnessSec *int `json:"freshnessSec"` - OnlineState string `json:"onlineState"` - ThresholdSec int `json:"thresholdSec"` - LatestMessageType string `json:"latestMessageType"` - LatestEventID string `json:"latestEventId"` - LatestError string `json:"latestError"` - DelayAbnormal bool `json:"delayAbnormal"` - FirstSeenEvidence string `json:"firstSeenEvidence"` - FirstSeenSource string `json:"firstSeenSource"` - ReportIntervalProof string `json:"reportIntervalEvidence"` - ReportSampleCount int64 `json:"reportSampleCount"` + VIN string `json:"vin"` + Plate string `json:"plate"` + OEM string `json:"oem"` + Model string `json:"model"` + Company string `json:"company"` + Protocol string `json:"protocol"` + Provider string `json:"provider"` + Source string `json:"source"` + FirstSeenAt string `json:"firstSeenAt"` + LatestEventAt string `json:"latestEventAt"` + LatestReceivedAt string `json:"latestReceivedAt"` + ReportIntervalSec *int `json:"reportIntervalSec"` + DataDelaySec *int `json:"dataDelaySec"` + FreshnessSec *int `json:"freshnessSec"` + OnlineState string `json:"onlineState"` + ThresholdSec int `json:"thresholdSec"` + LatestMessageType string `json:"latestMessageType"` + LatestEventID string `json:"latestEventId"` + LatestError string `json:"latestError"` + DelayAbnormal bool `json:"delayAbnormal"` + FirstSeenEvidence string `json:"firstSeenEvidence"` + FirstSeenSource string `json:"firstSeenSource"` + ReportIntervalProof string `json:"reportIntervalEvidence"` + ReportSampleCount int64 `json:"reportSampleCount"` + ExpectedProtocols []string `json:"expectedProtocols"` + ActualProtocols []string `json:"actualProtocols"` + MissingProtocols []string `json:"missingProtocols"` + ProtocolStatuses []AccessProtocolStatus `json:"protocolStatuses"` + ConnectionState string `json:"connectionState"` + ExpectationEvidence string `json:"expectationEvidence"` +} + +type AccessProtocolStatus struct { + Protocol string `json:"protocol"` + Expected bool `json:"expected"` + Connected bool `json:"connected"` + Provider string `json:"provider"` + FirstSeenAt string `json:"firstSeenAt"` + LatestEventAt string `json:"latestEventAt"` + LatestReceivedAt string `json:"latestReceivedAt"` + ReportIntervalSec *int `json:"reportIntervalSec"` + DataDelaySec *int `json:"dataDelaySec"` + FreshnessSec *int `json:"freshnessSec"` + OnlineState string `json:"onlineState"` + ThresholdSec int `json:"thresholdSec"` + DelayAbnormal bool `json:"delayAbnormal"` + FirstSeenEvidence string `json:"firstSeenEvidence"` + ReportIntervalEvidence string `json:"reportIntervalEvidence"` + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` + SpeedKmh float64 `json:"speedKmh"` + SOCPercent float64 `json:"socPercent"` + TotalMileageKm float64 `json:"totalMileageKm"` + DailyMileageKm float64 `json:"dailyMileageKm"` } type AccessUnresolvedIdentityQuery struct { @@ -457,6 +494,9 @@ type AccessSummary struct { UnknownVehicles int `json:"unknownVehicles"` DelayAbnormal int `json:"delayAbnormal"` ReportedToday int `json:"reportedToday"` + HealthyVehicles int `json:"healthyVehicles"` + IncompleteVehicles int `json:"incompleteVehicles"` + DegradedVehicles int `json:"degradedVehicles"` OnlineRate float64 `json:"onlineRate"` Protocols []AccessDistribution `json:"protocols"` OEMs []AccessDistribution `json:"oems"` 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 4e8f832b..862ffc34 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go +++ b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go @@ -326,10 +326,14 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery { where = append(where, "l.protocol = ?") args = append(args, protocol) } - if keyword := strings.TrimSpace(query.Get("vin")); keyword != "" { - where = append(where, "(l.vin LIKE ? OR l.plate LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)") - like := "%" + keyword + "%" - args = append(args, like, like, like, like, like) + if keywords := vehicleSearchKeywords(query); len(keywords) > 0 { + matches := make([]string, 0, len(keywords)) + for _, keyword := range keywords { + matches = append(matches, "(l.vin LIKE ? OR l.plate LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)") + like := "%" + keyword + "%" + args = append(args, like, like, like, like, like) + } + where = append(where, "("+strings.Join(matches, " OR ")+")") } switch strings.TrimSpace(query.Get("online")) { case "online": @@ -415,12 +419,19 @@ func buildDailyMileageSQL(query url.Values) SQLQuery { offset := parsePositive(query.Get("offset"), 0) args := []any{} where := []string{"1 = 1"} + if strings.EqualFold(strings.TrimSpace(query.Get("vehicleScope")), "bound") { + where = append(where, "b.vin IS NOT NULL", "b.vin <> ''") + } 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, args = appendVINListFilter(where, args, "m.vin", query.Get("vins")) + protocols := parseMileageProtocols(query.Get("protocols")) + if len(protocols) > 0 { + where, args = appendMileageProtocolFilter(where, args, "m.protocol", protocols) + } else if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" { where = append(where, "m.protocol = ?") args = append(args, protocol) } @@ -435,6 +446,26 @@ func buildDailyMileageSQL(query url.Values) SQLQuery { countArgs := append([]any(nil), args...) args = append(args, limit, offset) fromSQL := `FROM vehicle_daily_mileage m LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin WHERE ` + strings.Join(where, " AND ") + if query.Get("deduplicate") == "1" || strings.EqualFold(query.Get("deduplicate"), "true") { + selectionOrder := `m.daily_mileage_km DESC, m.protocol ASC` + dailyMileageExpression := `MAX(COALESCE(m.daily_mileage_km, 0))` + if len(protocols) > 0 { + selectionOrder = mileageProtocolOrder("m.protocol", protocols) + dailyMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.daily_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)` + } + groupSQL := fromSQL + ` GROUP BY m.vin, m.stat_date` + return SQLQuery{ + Text: `SELECT m.vin, COALESCE(MAX(NULLIF(b.plate, '')), '') AS plate, DATE_FORMAT(m.stat_date, '%Y-%m-%d') AS stat_date, ` + + `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(m.latest_total_mileage_km - m.daily_mileage_km AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0) AS start_mileage_km, ` + + `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(m.latest_total_mileage_km AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0) AS end_mileage_km, ` + + dailyMileageExpression + ` AS daily_mileage_km, ` + + `COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(m.protocol ORDER BY ` + selectionOrder + `), ',', 1), '') AS protocol ` + + groupSQL + ` ORDER BY m.stat_date DESC, m.vin ASC LIMIT ? OFFSET ?`, + Args: args, + CountText: `SELECT COUNT(*) FROM (SELECT m.vin ` + groupSQL + `) vehicle_daily_mileage_count`, + CountArgs: countArgs, + } + } return SQLQuery{ Text: `SELECT m.vin, COALESCE(b.plate, '') AS plate, DATE_FORMAT(m.stat_date, '%Y-%m-%d') AS stat_date, ` + `COALESCE(m.latest_total_mileage_km - m.daily_mileage_km, 0) AS start_mileage_km, ` + @@ -449,12 +480,18 @@ func buildDailyMileageSQL(query url.Values) SQLQuery { func buildMileageSummarySQL(query url.Values) SQLQuery { args := []any{} where := []string{"1 = 1"} + if strings.EqualFold(strings.TrimSpace(query.Get("vehicleScope")), "bound") { + where = append(where, "b.vin IS NOT NULL", "b.vin <> ''") + } 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, args = appendVINListFilter(where, args, "m.vin", query.Get("vins")) + if protocols := parseMileageProtocols(query.Get("protocols")); len(protocols) > 0 { + where, args = appendMileageProtocolFilter(where, args, "m.protocol", protocols) + } else if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" { where = append(where, "m.protocol = ?") args = append(args, protocol) } @@ -477,12 +514,18 @@ func buildMileageSummarySQL(query url.Values) SQLQuery { func buildMileageStatisticsWhere(query url.Values) (string, []any) { where := []string{"1 = 1"} args := []any{} + if strings.EqualFold(strings.TrimSpace(query.Get("vehicleScope")), "bound") { + where = append(where, "b.vin IS NOT NULL", "b.vin <> ''") + } 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, args = appendVINListFilter(where, args, "m.vin", query.Get("vins")) + if protocols := parseMileageProtocols(query.Get("protocols")); len(protocols) > 0 { + where, args = appendMileageProtocolFilter(where, args, "m.protocol", protocols) + } else if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" { where = append(where, "m.protocol = ?") args = append(args, protocol) } @@ -499,9 +542,17 @@ func buildMileageStatisticsWhere(query url.Values) (string, []any) { func buildMileageStatisticsBaseSQL(query url.Values) (string, []any) { where, args := buildMileageStatisticsWhere(query) + protocols := parseMileageProtocols(query.Get("protocols")) + dailyMileageExpression := `MAX(COALESCE(m.daily_mileage_km, 0))` + latestMileageExpression := `MAX(COALESCE(m.latest_total_mileage_km, 0))` + if len(protocols) > 0 { + selectionOrder := mileageProtocolOrder("m.protocol", protocols) + dailyMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.daily_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)` + latestMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.latest_total_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)` + } 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 ` + + dailyMileageExpression + ` AS daily_mileage_km, ` + + latestMileageExpression + ` 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 } @@ -535,22 +586,90 @@ func buildMileageStatisticsSourceCountSQL(query url.Values) SQLQuery { func buildFleetLatestMileageSQL(query url.Values) SQLQuery { where := []string{"l.total_mileage_km IS NOT NULL", "l.total_mileage_km >= 0"} args := []any{} + if strings.EqualFold(strings.TrimSpace(query.Get("vehicleScope")), "bound") { + where = append(where, "b.vin IS NOT NULL", "b.vin <> ''") + } 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, args = appendVINListFilter(where, args, "l.vin", query.Get("vins")) + protocols := parseMileageProtocols(query.Get("protocols")) + if len(protocols) > 0 { + where, args = appendMileageProtocolFilter(where, args, "l.protocol", protocols) + } else if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" { where = append(where, "l.protocol = ?") args = append(args, protocol) } + selectionOrder := `l.updated_at DESC, l.protocol ASC` + if len(protocols) > 0 { + selectionOrder = mileageProtocolOrder("l.protocol", protocols) + `, l.updated_at DESC` + } 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 ` + + `ORDER BY ` + selectionOrder + `), ',', 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 parseMileageProtocols(raw string) []string { + seen := map[string]bool{} + values := make([]string, 0, 3) + for _, item := range strings.Split(raw, ",") { + protocol := strings.ToUpper(strings.TrimSpace(item)) + switch protocol { + case "GB32960", "JT808", "YUTONG_MQTT": + if !seen[protocol] { + seen[protocol] = true + values = append(values, protocol) + } + } + } + return values +} + +func appendMileageProtocolFilter(where []string, args []any, column string, protocols []string) ([]string, []any) { + placeholders := make([]string, len(protocols)) + for index, protocol := range protocols { + placeholders[index] = "?" + args = append(args, protocol) + } + return append(where, column+" IN ("+strings.Join(placeholders, ",")+")"), args +} + +func mileageProtocolOrder(column string, protocols []string) string { + parts := make([]string, 0, len(protocols)+1) + parts = append(parts, "CASE "+column) + for index, protocol := range protocols { + parts = append(parts, "WHEN '"+protocol+"' THEN "+strconv.Itoa(index+1)) + } + parts = append(parts, "ELSE 999 END") + return strings.Join(parts, " ") + ", " + column + " ASC" +} + +func appendVINListFilter(where []string, args []any, column string, raw string) ([]string, []any) { + seen := map[string]bool{} + values := make([]string, 0) + for _, item := range strings.Split(raw, ",") { + vin := strings.TrimSpace(item) + if vin == "" || seen[vin] { + continue + } + seen[vin] = true + values = append(values, vin) + } + if len(values) == 0 { + return where, args + } + placeholders := make([]string, len(values)) + for index, vin := range values { + placeholders[index] = "?" + args = append(args, vin) + } + return append(where, column+" IN ("+strings.Join(placeholders, ",")+")"), 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/query_builders_test.go b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go index 70d61c4d..65aa79e1 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 @@ -1,6 +1,7 @@ package platform import ( + "fmt" "net/url" "strings" "testing" @@ -214,6 +215,19 @@ func TestBuildVehicleRealtimeSQL(t *testing.T) { } } +func TestBuildVehicleRealtimeSQLFiltersMultipleVehicleKeywords(t *testing.T) { + built := buildVehicleRealtimeSQL(url.Values{"keywords": {"粤AG18312, 川AHTWO1, 粤AG18312"}, "limit": {"10"}}) + if !strings.Contains(built.Text, ") OR (l.vin LIKE ?") || !strings.Contains(built.CountText, ") OR (l.vin LIKE ?") { + t.Fatalf("batch vehicle search must match any keyword in rows and count: %s / %s", built.Text, built.CountText) + } + if len(built.Args) != 12 || built.Args[0] != "%粤AG18312%" || built.Args[5] != "%川AHTWO1%" || built.Args[10] != 10 { + t.Fatalf("batch args = %#v", built.Args) + } + if len(built.CountArgs) != 10 || built.CountArgs[0] != "%粤AG18312%" || built.CountArgs[5] != "%川AHTWO1%" { + t.Fatalf("batch count args = %#v", built.CountArgs) + } +} + func TestBuildVehicleRealtimeSQLFiltersServiceStatus(t *testing.T) { query := url.Values{"serviceStatus": {"degraded"}, "limit": {"8"}} built := buildVehicleRealtimeSQL(query) @@ -264,6 +278,96 @@ func TestBuildDailyMileageSQL(t *testing.T) { } } +func TestMileageQueriesFilterExactVINSelection(t *testing.T) { + query := url.Values{"vins": {"VIN001,VIN002,VIN001"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-03"}} + daily := buildDailyMileageSQL(query) + if !strings.Contains(daily.Text, "m.vin IN (?,?)") || daily.Args[0] != "VIN001" || daily.Args[1] != "VIN002" { + t.Fatalf("daily mileage should use an exact de-duplicated VIN list: %s %#v", daily.Text, daily.Args) + } + statistics := buildMileageStatisticsSummarySQL(query) + if !strings.Contains(statistics.Text, "m.vin IN (?,?)") || len(statistics.Args) != 4 { + t.Fatalf("statistics should share the selected VIN scope: %s %#v", statistics.Text, statistics.Args) + } + fleet := buildFleetLatestMileageSQL(query) + if !strings.Contains(fleet.Text, "l.vin IN (?,?)") || len(fleet.Args) != 2 { + t.Fatalf("latest mileage should share the selected VIN scope: %s %#v", fleet.Text, fleet.Args) + } +} + +func TestMileageQueriesCanRestrictFleetScopeToAuthoritativelyBoundVehicles(t *testing.T) { + query := url.Values{"vehicleScope": {"bound"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-03"}} + for name, built := range map[string]SQLQuery{ + "daily": buildDailyMileageSQL(query), + "statistics": buildMileageStatisticsSummarySQL(query), + "latest": buildFleetLatestMileageSQL(query), + } { + if !strings.Contains(built.Text, "b.vin IS NOT NULL") || !strings.Contains(built.Text, "b.vin <> ''") { + t.Fatalf("%s mileage SQL must exclude unbound realtime-only identities: %s", name, built.Text) + } + } +} + +func TestBuildDailyMileageSQLCanMatchStatisticsVehicleDayScope(t *testing.T) { + built := buildDailyMileageSQL(url.Values{"deduplicate": {"1"}, "limit": {"50"}}) + for _, want := range []string{"GROUP BY m.vin, m.stat_date", "MAX(COALESCE(m.daily_mileage_km, 0))", "GROUP_CONCAT(m.protocol ORDER BY m.daily_mileage_km DESC", "vehicle_daily_mileage_count"} { + if !strings.Contains(built.Text+built.CountText, want) { + t.Fatalf("deduplicated daily mileage SQL missing %q: %s / %s", want, built.Text, built.CountText) + } + } + if built.Args[len(built.Args)-2] != 50 || built.Args[len(built.Args)-1] != 0 { + t.Fatalf("deduplicated daily mileage pagination args = %#v", built.Args) + } +} + +func TestMileageQueriesRespectEnabledProtocolPriority(t *testing.T) { + query := url.Values{ + "protocols": {"JT808,GB32960"}, + "dateFrom": {"2026-07-01"}, + "dateTo": {"2026-07-03"}, + "deduplicate": {"1"}, + } + daily := buildDailyMileageSQL(query) + for _, want := range []string{ + "m.protocol IN (?,?)", + "CASE m.protocol WHEN 'JT808' THEN 1 WHEN 'GB32960' THEN 2 ELSE 999 END", + "ELSE 999 END, m.protocol ASC", + "GROUP_CONCAT(CAST(COALESCE(m.daily_mileage_km, 0) AS CHAR) ORDER BY", + } { + if !strings.Contains(daily.Text, want) { + t.Fatalf("priority daily mileage SQL missing %q: %s", want, daily.Text) + } + } + if daily.Args[0] != "JT808" || daily.Args[1] != "GB32960" { + t.Fatalf("enabled protocol filter should preserve priority order: %#v", daily.Args) + } + + statistics := buildMileageStatisticsSummarySQL(query) + for _, want := range []string{ + "m.protocol IN (?,?)", + "CASE m.protocol WHEN 'JT808' THEN 1 WHEN 'GB32960' THEN 2 ELSE 999 END", + "SUBSTRING_INDEX(GROUP_CONCAT", + } { + if !strings.Contains(statistics.Text, want) { + t.Fatalf("priority statistics SQL missing %q: %s", want, statistics.Text) + } + } + if strings.Contains(statistics.Text, "MAX(COALESCE(m.daily_mileage_km, 0))") { + t.Fatalf("configured priority must select the preferred source instead of the maximum mileage: %s", statistics.Text) + } + + fleet := buildFleetLatestMileageSQL(query) + if !strings.Contains(fleet.Text, "l.protocol IN (?,?)") || !strings.Contains(fleet.Text, "CASE l.protocol WHEN 'JT808' THEN 1 WHEN 'GB32960' THEN 2") { + t.Fatalf("latest mileage should share the configured source strategy: %s", fleet.Text) + } +} + +func TestMileageProtocolsRejectUnknownValues(t *testing.T) { + protocols := parseMileageProtocols("JT808,invalid');DROP TABLE vehicle_daily_mileage;--,JT808,YUTONG_MQTT") + if fmt.Sprint(protocols) != "[JT808 YUTONG_MQTT]" { + t.Fatalf("unexpected sanitized mileage protocols: %#v", protocols) + } +} + func TestBuildMileageSummarySQL(t *testing.T) { query := url.Values{"vin": {"粤A"}, "protocol": {"GB32960"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-03"}} built := buildMileageSummarySQL(query) @@ -315,6 +419,20 @@ func TestNormalizeMileageStatisticsWindow(t *testing.T) { } } +func TestNormalizeMileageVINSelection(t *testing.T) { + normalized, err := normalizeMileageVINSelection(url.Values{"vins": {" VIN001,VIN002,VIN001 "}}) + if err != nil || normalized.Get("vins") != "VIN001,VIN002" { + t.Fatalf("normalize VIN selection = %q, %v", normalized.Get("vins"), err) + } + tooMany := make([]string, 21) + for index := range tooMany { + tooMany[index] = fmt.Sprintf("VIN%03d", index) + } + if _, err := normalizeMileageVINSelection(url.Values{"vins": {strings.Join(tooMany, ",")}}); err == nil { + t.Fatal("more than 20 selected vehicles should 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)"} { diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index 2b39fe63..85630aad 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -364,10 +364,11 @@ func (s *Service) DashboardSummary(ctx context.Context) (DashboardSummary, error } const ( - monitorVehicleLimit = 10000 - monitorPointLimit = 2000 - monitorPointZoom = 11 - monitorClusterGridPixels = 64 + monitorVehicleLimit = 10000 + monitorPointLimit = 2000 + monitorPointZoom = 11 + monitorClusterGridPixels = 64 + vehicleSearchKeywordLimit = 100 ) type monitorBounds struct { @@ -428,7 +429,7 @@ func normalizeMonitorQuery(query url.Values) url.Values { } next.Set("limit", strconv.Itoa(monitorVehicleLimit)) next.Set("offset", "0") - if next.Get("vin") == "" && strings.TrimSpace(next.Get("keyword")) != "" { + if next.Get("vin") == "" && strings.TrimSpace(next.Get("keywords")) == "" && strings.TrimSpace(next.Get("keyword")) != "" { next.Set("vin", strings.TrimSpace(next.Get("keyword"))) } switch strings.TrimSpace(next.Get("status")) { @@ -440,6 +441,41 @@ func normalizeMonitorQuery(query url.Values) url.Values { return next } +func vehicleSearchKeywords(query url.Values) []string { + rawValues := query["keywords"] + if len(rawValues) == 0 { + rawValues = []string{firstNonEmpty(query.Get("vin"), query.Get("keyword"))} + } + result := make([]string, 0, len(rawValues)) + seen := map[string]struct{}{} + for _, raw := range rawValues { + parts := strings.FieldsFunc(raw, func(char rune) bool { + switch char { + case ',', ',', '、', ';', ';', '\n', '\r', '\t', ' ': + return true + default: + return false + } + }) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + key := strings.ToLower(part) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + result = append(result, part) + if len(result) == vehicleSearchKeywordLimit { + return result + } + } + } + return result +} + func matchesMonitorStatus(row VehicleRealtimeRow, requested string) bool { requested = strings.TrimSpace(requested) return requested == "" || monitorVehicleStatus(row) == requested || requested == "online" && row.Online @@ -3103,6 +3139,10 @@ func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[Dail if err != nil { return Page[DailyMileageRow]{}, err } + resolvedQuery, err = normalizeMileageVINSelection(resolvedQuery) + if err != nil { + return Page[DailyMileageRow]{}, err + } return s.store.DailyMileage(ctx, resolvedQuery) } @@ -3111,6 +3151,10 @@ func (s *Service) MileageStatistics(ctx context.Context, query url.Values) (Mile if err != nil { return MileageStatistics{}, err } + resolvedQuery, err = normalizeMileageVINSelection(resolvedQuery) + if err != nil { + return MileageStatistics{}, err + } resolvedQuery, err = normalizeMileageStatisticsWindow(resolvedQuery, time.Now()) if err != nil { return MileageStatistics{}, err @@ -3118,6 +3162,29 @@ func (s *Service) MileageStatistics(ctx context.Context, query url.Values) (Mile return s.store.MileageStatistics(ctx, resolvedQuery) } +func normalizeMileageVINSelection(query url.Values) (url.Values, error) { + next := cloneValues(query) + seen := map[string]bool{} + vins := make([]string, 0) + for _, item := range strings.Split(next.Get("vins"), ",") { + vin := strings.TrimSpace(item) + if vin == "" || seen[vin] { + continue + } + seen[vin] = true + vins = append(vins, vin) + } + if len(vins) > 20 { + return nil, clientError{Code: "TOO_MANY_VEHICLES", Message: "里程查询一次最多选择 20 辆车"} + } + if len(vins) == 0 { + next.Del("vins") + } else { + next.Set("vins", strings.Join(vins, ",")) + } + return next, nil +} + func normalizeMileageStatisticsWindow(query url.Values, now time.Time) (url.Values, error) { next := cloneValues(query) dateTo := strings.TrimSpace(next.Get("dateTo")) @@ -3502,6 +3569,9 @@ func cloneStringMap(values map[string]string) map[string]string { func (s *Service) resolveVehicleQuery(ctx context.Context, query url.Values) (url.Values, error) { resolved := cloneValues(query) + if strings.TrimSpace(resolved.Get("keywords")) != "" { + return resolved, nil + } vin := firstNonEmpty(resolved.Get("vin"), resolved.Get("keyword")) if vin == "" { return resolved, nil diff --git a/vehicle-data-platform/apps/web/package.json b/vehicle-data-platform/apps/web/package.json index a16bb016..6a42c94a 100644 --- a/vehicle-data-platform/apps/web/package.json +++ b/vehicle-data-platform/apps/web/package.json @@ -13,6 +13,8 @@ "@tanstack/react-query": "5.101.2", "@vitejs/plugin-react": "^4.3.4", "echarts": "^5.6.0", + "exceljs": "4.4.0", + "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "6.30.1", @@ -21,6 +23,7 @@ "devDependencies": { "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", + "@types/qrcode": "^1.5.6", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "jsdom": "^25.0.1", diff --git a/vehicle-data-platform/apps/web/pnpm-lock.yaml b/vehicle-data-platform/apps/web/pnpm-lock.yaml index a88527b9..ca54d9ce 100644 --- a/vehicle-data-platform/apps/web/pnpm-lock.yaml +++ b/vehicle-data-platform/apps/web/pnpm-lock.yaml @@ -22,10 +22,16 @@ importers: version: 5.101.2(react@18.3.1) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@6.4.3(sass@1.101.0)) + version: 4.7.0(vite@6.4.3(@types/node@26.1.1)(sass@1.101.0)) echarts: specifier: ^5.6.0 version: 5.6.0 + exceljs: + specifier: 4.4.0 + version: 4.4.0 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 react: specifier: ^18.3.1 version: 18.3.1 @@ -37,7 +43,7 @@ importers: version: 6.30.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) vite: specifier: ^6.0.0 - version: 6.4.3(sass@1.101.0) + version: 6.4.3(@types/node@26.1.1)(sass@1.101.0) devDependencies: '@testing-library/jest-dom': specifier: ^6.6.3 @@ -45,6 +51,9 @@ importers: '@testing-library/react': specifier: ^16.1.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/react': specifier: ^18.3.18 version: 18.3.31 @@ -62,7 +71,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.8 - version: 2.1.9(jsdom@25.0.1)(sass@1.101.0) + version: 2.1.9(@types/node@26.1.1)(jsdom@25.0.1)(sass@1.101.0) packages: @@ -537,6 +546,12 @@ packages: cpu: [x64] os: [win32] + '@fast-csv/format@4.3.5': + resolution: {integrity: sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==} + + '@fast-csv/parse@4.3.6': + resolution: {integrity: sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==} + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -1043,9 +1058,18 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@14.18.63': + resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} + + '@types/node@26.1.1': + resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/react-dom@18.3.7': resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} peerDependencies: @@ -1119,10 +1143,26 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + ansi-styles@5.2.0: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} + archiver-utils@2.1.0: + resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==} + engines: {node: '>= 6'} + + archiver-utils@3.0.4: + resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==} + engines: {node: '>= 10'} + + archiver@5.3.2: + resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==} + engines: {node: '>= 10'} + aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} @@ -1141,12 +1181,21 @@ packages: async-validator@3.5.2: resolution: {integrity: sha512-8eLCg00W9pIRZSB781UUX/H6Oskmm8xloZfr09lz5bikRpBVDlJ3hRVuxxP1SxcwsEYfJ4IU8Q19Y8/893r3rQ==} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.10.40: resolution: {integrity: sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==} engines: {node: '>=6.0.0'} @@ -1155,11 +1204,44 @@ packages: bezier-easing@2.1.0: resolution: {integrity: sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==} + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + + binary@0.3.0: + resolution: {integrity: sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + bluebird@3.4.7: + resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} + + brace-expansion@1.1.16: + resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} + + brace-expansion@2.1.2: + resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} + browserslist@4.28.4: resolution: {integrity: sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer-indexof-polyfill@1.0.2: + resolution: {integrity: sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==} + engines: {node: '>=0.10'} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buffers@0.1.1: + resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==} + engines: {node: '>=0.2.0'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -1168,6 +1250,10 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001800: resolution: {integrity: sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==} @@ -1178,6 +1264,9 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chainsaw@0.1.0: + resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -1201,6 +1290,9 @@ packages: classnames@2.5.1: resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -1208,6 +1300,13 @@ packages: collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -1215,9 +1314,16 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + compress-commons@4.1.2: + resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} + engines: {node: '>= 10'} + compute-scroll-into-view@1.0.20: resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==} + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -1225,6 +1331,18 @@ packages: resolution: {integrity: sha512-WRvoIdnTs1rgPMkgA2pUOa/M4Enh2uzCwdKsOMYNAJiz/4ZvEJgmbF4OmninPmlFdAWisfeh0tH+Cpf7ni3RqQ==} engines: {node: '>=6'} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@4.0.3: + resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} + engines: {node: '>= 10'} + css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -1248,6 +1366,9 @@ packages: resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} engines: {node: '>=0.11'} + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1257,6 +1378,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -1282,6 +1407,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -1292,12 +1420,21 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + duplexer2@0.1.4: + resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + echarts@5.6.0: resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==} electron-to-chromium@1.5.384: resolution: {integrity: sha512-g6KAKY1vkYsADvSPWvdJsuYT0ixdcu6lUtD9P/wJKGBEDlZVXh2AX42j1mPqqaQPDluWjara9ziQ7xqAeXCt5A==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} @@ -1366,6 +1503,10 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + exceljs@4.4.0: + resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==} + engines: {node: '>=8.3.0'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -1376,6 +1517,10 @@ packages: fast-copy@3.0.2: resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==} + fast-csv@4.3.6: + resolution: {integrity: sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==} + engines: {node: '>=10.0.0'} + fast-equals@5.4.0: resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} engines: {node: '>=6.0.0'} @@ -1389,15 +1534,30 @@ packages: picomatch: optional: true + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + form-data@4.0.6: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + fstream@1.0.12: + resolution: {integrity: sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==} + engines: {node: '>=0.6'} + deprecated: This package is no longer supported. + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -1405,6 +1565,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -1413,10 +1577,17 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -1454,6 +1625,12 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + immutable@5.1.9: resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==} @@ -1461,6 +1638,13 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -1477,6 +1661,10 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -1491,6 +1679,9 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1516,9 +1707,66 @@ packages: jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + linkifyjs@4.3.3: resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==} + listenercount@1.0.1: + resolution: {integrity: sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.difference@4.5.0: + resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==} + + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + + lodash.flatten@4.4.0: + resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} + + lodash.groupby@4.6.0: + resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + + lodash.isfunction@3.0.9: + resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==} + + lodash.isnil@4.0.0: + resolution: {integrity: sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isundefined@3.0.1: + resolution: {integrity: sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==} + + lodash.union@4.6.0: + resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -1727,6 +1975,20 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1742,6 +2004,10 @@ packages: resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} engines: {node: '>=18'} + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + nwsapi@2.2.24: resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} @@ -1749,15 +2015,41 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + orderedmap@2.1.1: resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -1772,6 +2064,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + postcss@8.5.16: resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} @@ -1784,6 +2080,9 @@ packages: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -1833,6 +2132,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -1884,6 +2188,16 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + readdirp@5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} @@ -1924,6 +2238,18 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + + rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + rollup@4.62.2: resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -1938,6 +2264,12 @@ packages: rrweb-cssom@0.8.0: resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -1946,6 +2278,10 @@ packages: engines: {node: '>=20.19.0'} hasBin: true + saxes@5.0.1: + resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==} + engines: {node: '>=10'} + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -1960,6 +2296,12 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1980,9 +2322,23 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} @@ -1996,6 +2352,10 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -2025,6 +2385,10 @@ packages: resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} hasBin: true + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} + tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} @@ -2033,6 +2397,9 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + traverse@0.3.9: + resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -2050,6 +2417,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -2071,6 +2441,9 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + unzipper@0.10.14: + resolution: {integrity: sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -2082,10 +2455,18 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utility-types@3.11.0: resolution: {integrity: sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==} engines: {node: '>= 4'} + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -2217,11 +2598,21 @@ packages: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.0: resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} @@ -2241,9 +2632,24 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + + zip-stream@4.1.1: + resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} + engines: {node: '>= 10'} + zrender@5.6.1: resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==} @@ -2661,6 +3067,25 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true + '@fast-csv/format@4.3.5': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.isboolean: 3.0.3 + lodash.isequal: 4.5.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + + '@fast-csv/parse@4.3.6': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.groupby: 4.6.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + lodash.isundefined: 3.0.1 + lodash.uniq: 4.5.0 + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -3139,8 +3564,18 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node@14.18.63': {} + + '@types/node@26.1.1': + dependencies: + undici-types: 8.3.0 + '@types/prop-types@15.7.15': {} + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 26.1.1 + '@types/react-dom@18.3.7(@types/react@18.3.31)': dependencies: '@types/react': 18.3.31 @@ -3158,7 +3593,7 @@ snapshots: '@ungap/structured-clone@1.3.2': {} - '@vitejs/plugin-react@4.7.0(vite@6.4.3(sass@1.101.0))': + '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@26.1.1)(sass@1.101.0))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) @@ -3166,7 +3601,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.3(sass@1.101.0) + vite: 6.4.3(@types/node@26.1.1)(sass@1.101.0) transitivePeerDependencies: - supports-color @@ -3177,13 +3612,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 1.2.0 - '@vitest/mocker@2.1.9(vite@5.4.21(sass@1.101.0))': + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@26.1.1)(sass@1.101.0))': dependencies: '@vitest/spy': 2.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 5.4.21(sass@1.101.0) + vite: 5.4.21(@types/node@26.1.1)(sass@1.101.0) '@vitest/pretty-format@2.1.9': dependencies: @@ -3220,8 +3655,48 @@ snapshots: ansi-regex@5.0.1: {} + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + ansi-styles@5.2.0: {} + archiver-utils@2.1.0: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 2.3.8 + + archiver-utils@3.0.4: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + + archiver@5.3.2: + dependencies: + archiver-utils: 2.1.0 + async: 3.2.6 + buffer-crc32: 0.2.13 + readable-stream: 3.6.2 + readdir-glob: 1.1.3 + tar-stream: 2.2.0 + zip-stream: 4.1.1 + aria-query@5.3.0: dependencies: dequal: 2.0.3 @@ -3234,14 +3709,44 @@ snapshots: async-validator@3.5.2: {} + async@3.2.6: {} + asynckit@0.4.0: {} bail@2.0.2: {} + balanced-match@1.0.2: {} + + base64-js@1.5.1: {} + baseline-browser-mapping@2.10.40: {} bezier-easing@2.1.0: {} + big-integer@1.6.52: {} + + binary@0.3.0: + dependencies: + buffers: 0.1.1 + chainsaw: 0.1.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + bluebird@3.4.7: {} + + brace-expansion@1.1.16: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.2: + dependencies: + balanced-match: 1.0.2 + browserslist@4.28.4: dependencies: baseline-browser-mapping: 2.10.40 @@ -3250,6 +3755,17 @@ snapshots: node-releases: 2.0.50 update-browserslist-db: 1.2.3(browserslist@4.28.4) + buffer-crc32@0.2.13: {} + + buffer-indexof-polyfill@1.0.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffers@0.1.1: {} + cac@6.7.14: {} call-bind-apply-helpers@1.0.2: @@ -3257,6 +3773,8 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + camelcase@5.3.1: {} + caniuse-lite@1.0.30001800: {} ccount@2.0.1: {} @@ -3269,6 +3787,10 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + chainsaw@0.1.0: + dependencies: + traverse: 0.3.9 + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -3285,22 +3807,52 @@ snapshots: classnames@2.5.1: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + clsx@2.1.1: {} collapse-white-space@2.1.0: {} + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 comma-separated-tokens@2.0.3: {} + compress-commons@4.1.2: + dependencies: + buffer-crc32: 0.2.13 + crc32-stream: 4.0.3 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + compute-scroll-into-view@1.0.20: {} + concat-map@0.0.1: {} + convert-source-map@2.0.0: {} copy-text-to-clipboard@2.2.0: {} + core-util-is@1.0.3: {} + + crc-32@1.2.2: {} + + crc32-stream@4.0.3: + dependencies: + crc-32: 1.2.2 + readable-stream: 3.6.2 + css.escape@1.5.1: {} cssstyle@4.6.0: @@ -3323,10 +3875,14 @@ snapshots: dependencies: '@babel/runtime': 7.29.7 + dayjs@1.11.21: {} + debug@4.4.3: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decimal.js@10.6.0: {} decode-named-character-reference@1.3.0: @@ -3346,6 +3902,8 @@ snapshots: dependencies: dequal: 2.0.3 + dijkstrajs@1.0.3: {} + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} @@ -3356,6 +3914,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + duplexer2@0.1.4: + dependencies: + readable-stream: 2.3.8 + echarts@5.6.0: dependencies: tslib: 2.3.0 @@ -3363,6 +3925,12 @@ snapshots: electron-to-chromium@1.5.384: {} + emoji-regex@8.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + entities@6.0.1: {} es-define-property@1.0.1: {} @@ -3488,18 +4056,40 @@ snapshots: dependencies: '@types/estree': 1.0.9 + exceljs@4.4.0: + dependencies: + archiver: 5.3.2 + dayjs: 1.11.21 + fast-csv: 4.3.6 + jszip: 3.10.1 + readable-stream: 3.6.2 + saxes: 5.0.1 + tmp: 0.2.7 + unzipper: 0.10.14 + uuid: 8.3.2 + expect-type@1.4.0: {} extend@3.0.2: {} fast-copy@3.0.2: {} + fast-csv@4.3.6: + dependencies: + '@fast-csv/format': 4.3.5 + '@fast-csv/parse': 4.3.6 + fast-equals@5.4.0: {} fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + form-data@4.0.6: dependencies: asynckit: 0.4.0 @@ -3508,13 +4098,26 @@ snapshots: hasown: 2.0.4 mime-types: 2.1.35 + fs-constants@1.0.0: {} + + fs.realpath@1.0.0: {} + fsevents@2.3.3: optional: true + fstream@1.0.12: + dependencies: + graceful-fs: 4.2.11 + inherits: 2.0.4 + mkdirp: 0.5.6 + rimraf: 2.7.1 + function-bind@1.1.2: {} gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -3533,8 +4136,19 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + gopd@1.2.0: {} + graceful-fs@4.2.11: {} + has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -3612,10 +4226,21 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: {} + + immediate@3.0.6: {} + immutable@5.1.9: {} indent-string@4.0.0: {} + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + inline-style-parser@0.2.7: {} is-alphabetical@2.0.1: {} @@ -3630,6 +4255,8 @@ snapshots: is-extglob@2.1.1: optional: true + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -3641,6 +4268,8 @@ snapshots: is-potential-custom-element-name@1.0.1: {} + isarray@1.0.0: {} + js-tokens@4.0.0: {} jsdom@25.0.1: @@ -3677,8 +4306,55 @@ snapshots: jsonc-parser@3.3.1: {} + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + linkifyjs@4.3.3: {} + listenercount@1.0.1: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + lodash.defaults@4.2.0: {} + + lodash.difference@4.5.0: {} + + lodash.escaperegexp@4.1.2: {} + + lodash.flatten@4.4.0: {} + + lodash.groupby@4.6.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isequal@4.5.0: {} + + lodash.isfunction@3.0.9: {} + + lodash.isnil@4.0.0: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isundefined@3.0.1: {} + + lodash.union@4.6.0: {} + + lodash.uniq@4.5.0: {} + lodash@4.18.1: {} longest-streak@3.1.0: {} @@ -4146,6 +4822,20 @@ snapshots: min-indent@1.0.1: {} + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.16 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.2 + + minimist@1.2.8: {} + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + ms@2.1.3: {} nanoid@3.3.15: {} @@ -4155,12 +4845,30 @@ snapshots: node-releases@2.0.50: {} + normalize-path@3.0.0: {} + nwsapi@2.2.24: {} object-assign@4.1.1: {} + once@1.4.0: + dependencies: + wrappy: 1.0.2 + orderedmap@2.1.1: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-try@2.2.0: {} + + pako@1.0.11: {} + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -4175,6 +4883,10 @@ snapshots: dependencies: entities: 6.0.1 + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + pathe@1.1.2: {} pathval@2.0.1: {} @@ -4183,6 +4895,8 @@ snapshots: picomatch@4.0.4: {} + pngjs@5.0.0: {} + postcss@8.5.16: dependencies: nanoid: 3.3.15 @@ -4197,6 +4911,8 @@ snapshots: prismjs@1.30.0: {} + process-nextick-args@2.0.1: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -4281,6 +4997,12 @@ snapshots: punycode@2.3.1: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 @@ -4330,6 +5052,26 @@ snapshots: dependencies: loose-envify: 1.4.0 + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.9 + readdirp@5.0.0: {} recma-build-jsx@1.0.0: @@ -4415,6 +5157,14 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + require-directory@2.1.1: {} + + require-main-filename@2.0.0: {} + + rimraf@2.7.1: + dependencies: + glob: 7.2.3 + rollup@4.62.2: dependencies: '@types/estree': 1.0.9 @@ -4452,6 +5202,10 @@ snapshots: rrweb-cssom@0.8.0: {} + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + safer-buffer@2.1.2: {} sass@1.101.0: @@ -4462,6 +5216,10 @@ snapshots: optionalDependencies: '@parcel/watcher': 2.5.6 + saxes@5.0.1: + dependencies: + xmlchars: 2.2.0 + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -4476,6 +5234,10 @@ snapshots: semver@6.3.1: {} + set-blocking@2.0.0: {} + + setimmediate@1.0.5: {} + siginfo@2.0.0: {} source-map-js@1.2.1: {} @@ -4488,11 +5250,29 @@ snapshots: std-env@3.10.0: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + strip-indent@3.0.0: dependencies: min-indent: 1.0.1 @@ -4507,6 +5287,14 @@ snapshots: symbol-tree@3.2.4: {} + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -4528,6 +5316,8 @@ snapshots: dependencies: tldts-core: 6.1.86 + tmp@0.2.7: {} + tough-cookie@5.1.2: dependencies: tldts: 6.1.86 @@ -4536,6 +5326,8 @@ snapshots: dependencies: punycode: 2.3.1 + traverse@0.3.9: {} + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -4546,6 +5338,8 @@ snapshots: typescript@5.9.3: {} + undici-types@8.3.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -4583,6 +5377,19 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + unzipper@0.10.14: + dependencies: + big-integer: 1.6.52 + binary: 0.3.0 + bluebird: 3.4.7 + buffer-indexof-polyfill: 1.0.2 + duplexer2: 0.1.4 + fstream: 1.0.12 + graceful-fs: 4.2.11 + listenercount: 1.0.1 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + update-browserslist-db@1.2.3(browserslist@4.28.4): dependencies: browserslist: 4.28.4 @@ -4593,8 +5400,12 @@ snapshots: dependencies: react: 18.3.1 + util-deprecate@1.0.2: {} + utility-types@3.11.0: {} + uuid@8.3.2: {} + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -4605,13 +5416,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-node@2.1.9(sass@1.101.0): + vite-node@2.1.9(@types/node@26.1.1)(sass@1.101.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 1.1.2 - vite: 5.4.21(sass@1.101.0) + vite: 5.4.21(@types/node@26.1.1)(sass@1.101.0) transitivePeerDependencies: - '@types/node' - less @@ -4623,16 +5434,17 @@ snapshots: - supports-color - terser - vite@5.4.21(sass@1.101.0): + vite@5.4.21(@types/node@26.1.1)(sass@1.101.0): dependencies: esbuild: 0.21.5 postcss: 8.5.16 rollup: 4.62.2 optionalDependencies: + '@types/node': 26.1.1 fsevents: 2.3.3 sass: 1.101.0 - vite@6.4.3(sass@1.101.0): + vite@6.4.3(@types/node@26.1.1)(sass@1.101.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -4641,13 +5453,14 @@ snapshots: rollup: 4.62.2 tinyglobby: 0.2.17 optionalDependencies: + '@types/node': 26.1.1 fsevents: 2.3.3 sass: 1.101.0 - vitest@2.1.9(jsdom@25.0.1)(sass@1.101.0): + vitest@2.1.9(@types/node@26.1.1)(jsdom@25.0.1)(sass@1.101.0): dependencies: '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21(sass@1.101.0)) + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@26.1.1)(sass@1.101.0)) '@vitest/pretty-format': 2.1.9 '@vitest/runner': 2.1.9 '@vitest/snapshot': 2.1.9 @@ -4663,10 +5476,11 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.1.1 tinyrainbow: 1.2.0 - vite: 5.4.21(sass@1.101.0) - vite-node: 2.1.9(sass@1.101.0) + vite: 5.4.21(@types/node@26.1.1)(sass@1.101.0) + vite-node: 2.1.9(@types/node@26.1.1)(sass@1.101.0) why-is-node-running: 2.3.0 optionalDependencies: + '@types/node': 26.1.1 jsdom: 25.0.1 transitivePeerDependencies: - less @@ -4698,19 +5512,56 @@ snapshots: tr46: 5.1.1 webidl-conversions: 7.0.0 + which-module@2.0.1: {} + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + ws@8.21.0: {} xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} + y18n@4.0.3: {} + yallist@3.1.1: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + + zip-stream@4.1.1: + dependencies: + archiver-utils: 3.0.4 + compress-commons: 4.1.2 + readable-stream: 3.6.2 + zrender@5.6.1: dependencies: tslib: 2.3.0 diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index f8766c5b..c26fe04d 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -194,7 +194,7 @@ export interface HistorySeriesResponse { metrics: HistoryMetricDefinition[]; ser export interface HistoryExportRequest { keywords: string[]; category: string; protocol?: string; dateFrom?: string; dateTo?: string; metrics: string[]; format: 'csv'; } export interface HistoryExportJob { id: string; name: string; status: 'queued' | 'running' | 'completed' | 'failed'; progress: number; format: string; category: string; keywords: string[]; rowCount: number; totalRows: number; processedRows: number; fileSizeBytes: number; error?: string; downloadUrl?: string; createdAt: string; updatedAt: string; completedAt?: string; evidence: string; } -export interface AccessQuery { keyword?: string; protocol?: string; oem?: string; model?: string; provider?: string; firstSeenFrom?: string; firstSeenTo?: string; latestSeenFrom?: string; latestSeenTo?: string; onlineState?: string; delayState?: string; limit?: number; offset?: number; } +export interface AccessQuery { keyword?: string; protocol?: string; oem?: string; model?: string; provider?: string; firstSeenFrom?: string; firstSeenTo?: string; latestSeenFrom?: string; latestSeenTo?: string; onlineState?: string; delayState?: string; connectionState?: string; limit?: number; offset?: number; } export interface AccessUnresolvedIdentityQuery { keyword?: string; protocol?: string; limit?: number; offset?: number; } export interface AccessUnresolvedIdentity { id: string; protocol: string; identifierMasked: string; plate: string; manufacturer: string; sourceEndpoint: string; @@ -207,11 +207,21 @@ export interface AccessVehicleRow { dataDelaySec: number | null; freshnessSec: number | null; onlineState: 'online' | 'offline' | 'never_reported' | 'unknown'; thresholdSec: number; latestMessageType: string; latestEventId: string; latestError: string; delayAbnormal: boolean; firstSeenEvidence: string; firstSeenSource: string; reportIntervalEvidence: string; reportSampleCount: number; + expectedProtocols: string[]; actualProtocols: string[]; missingProtocols: string[]; protocolStatuses: AccessProtocolStatus[]; + connectionState: 'healthy' | 'degraded' | 'incomplete' | 'offline' | 'not_connected'; expectationEvidence: string; +} +export interface AccessProtocolStatus { + protocol: string; expected: boolean; connected: boolean; provider: string; firstSeenAt: string; latestEventAt: string; + latestReceivedAt: string; reportIntervalSec: number | null; dataDelaySec: number | null; freshnessSec: number | null; + onlineState: AccessVehicleRow['onlineState']; thresholdSec: number; delayAbnormal: boolean; + firstSeenEvidence: string; reportIntervalEvidence: string; + longitude?: number; latitude?: number; speedKmh?: number; socPercent?: number; totalMileageKm?: number; dailyMileageKm?: number; } export interface AccessDistribution { name: string; total: number; online: number; onlineRate: number; } export interface AccessSummary { totalVehicles: number; onlineVehicles: number; offlineVehicles: number; longOfflineVehicles: number; neverReported: number; unknownVehicles: number; delayAbnormal: number; reportedToday: number; onlineRate: number; + healthyVehicles: number; incompleteVehicles: number; degradedVehicles: number; protocols: AccessDistribution[]; oems: AccessDistribution[]; asOf: string; thresholdVersion: number; } export interface AccessProtocolThreshold { protocol: string; thresholdSec: number; } diff --git a/vehicle-data-platform/apps/web/src/integrations/amap.ts b/vehicle-data-platform/apps/web/src/integrations/amap.ts index b49889f8..3776587d 100644 --- a/vehicle-data-platform/apps/web/src/integrations/amap.ts +++ b/vehicle-data-platform/apps/web/src/integrations/amap.ts @@ -23,6 +23,7 @@ export type AMapOverlay = { setMap?: (map: AMapMap | null) => void; on?: (eventName: string, handler: () => void) => void; setPosition?: (position: [number, number]) => void; + setPath?: (path: [number, number][]) => void; }; export type AMapMassMarks = { diff --git a/vehicle-data-platform/apps/web/src/pages/Mileage.tsx b/vehicle-data-platform/apps/web/src/pages/Mileage.tsx index 749c582a..22ff6a65 100644 --- a/vehicle-data-platform/apps/web/src/pages/Mileage.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Mileage.tsx @@ -1,675 +1,19 @@ -import { Button, Card, Form, Select, Space, Table, Tag, Toast, Typography } from '@douyinfe/semi-ui'; -import { useEffect, useState } from 'react'; +import { Button, Card, Select, Space, Table, Tag, Toast, Typography } from '@douyinfe/semi-ui'; +import { useEffect, useMemo, useState } from 'react'; import { api } from '../api/client'; -import type { DailyMileageRow, MileageSummary, OnlineStatisticsSummary, OnlineVehicleStatusRow } from '../api/types'; +import type { DailyMileageRow, MileageStatistics, MileageTrendPoint } from '../api/types'; import { PageHeader } from '../components/PageHeader'; -import { buildAppHash } from '../domain/appRoute'; -import { buildCsv, downloadCsv, type CsvColumn } from '../domain/csvExport'; -const emptySummary: MileageSummary = { - vehicleCount: 0, - recordCount: 0, - sourceCount: 0, - totalMileageKm: 0, - averageMileagePerVin: 0 +const DAY = 86_400_000; + +type MileageFilters = { + keyword: string; + protocol: string; + dateFrom: string; + dateTo: string; }; -function numberOrZero(value: unknown) { - const numeric = Number(value); - return Number.isFinite(numeric) ? numeric : 0; -} - -function normalizeMileageSummary(summary?: Partial | null): MileageSummary { - return { - ...emptySummary, - ...(summary ?? {}), - vehicleCount: numberOrZero(summary?.vehicleCount), - recordCount: numberOrZero(summary?.recordCount), - sourceCount: numberOrZero(summary?.sourceCount), - totalMileageKm: numberOrZero(summary?.totalMileageKm), - averageMileagePerVin: numberOrZero(summary?.averageMileagePerVin) - }; -} - -const emptyOnlineSummary: OnlineStatisticsSummary = { - vehicleCount: 0, - onlineVehicleCount: 0, - offlineVehicleCount: 0, - onlineRatePercent: 0, - redisOnlineKeys: null, - protocolStats: [], - evidence: '' -}; - -function mileageParams(values: Record, pageSize?: number, offset?: number) { - const params = new URLSearchParams(); - if (pageSize != null) params.set('limit', String(pageSize)); - if (offset != null) params.set('offset', String(offset)); - if (values?.keyword) params.set('keyword', values.keyword); - if (values?.protocol) params.set('protocol', values.protocol); - if (values?.dateFrom) params.set('dateFrom', values.dateFrom); - if (values?.dateTo) params.set('dateTo', values.dateTo); - return params; -} - -function formatKm(value: number) { - return Number.isFinite(value) ? value.toLocaleString(undefined, { maximumFractionDigits: 1 }) : '0'; -} - -function formatPercent(value: number) { - return Number.isFinite(value) ? `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })}%` : '0%'; -} - -function formatOfflineDuration(minutes?: number | null) { - if (minutes == null || !Number.isFinite(Number(minutes))) return '-'; - const value = Math.max(0, Number(minutes)); - if (value < 60) return `${value.toLocaleString()} 分钟`; - const hours = Math.floor(value / 60); - const rest = Math.round(value % 60); - return rest > 0 ? `${hours.toLocaleString()} 小时 ${rest} 分钟` : `${hours.toLocaleString()} 小时`; -} - -function canOpenVehicle(vin?: string) { - const value = vin?.trim(); - return Boolean(value && value !== 'unknown'); -} - -function nextDate(value: string) { - const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim()); - if (!match) return ''; - const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]) + 1)); - return date.toISOString().slice(0, 10); -} - -function mileageActionRecommendation(row: DailyMileageRow) { - if (row.anomalySeverity) { - return { - label: '核对里程来源', - color: 'orange' as const, - detail: '日里程异常,优先核对当天首末总里程、来源断链和补传数据。' - }; - } - return { - label: '可用于日报统计', - color: 'green' as const, - detail: '该日里程未发现异常,可进入车辆服务查看统计依据。' - }; -} - -function mergeInitialFilters(initialVin: string, initialProtocol?: string, initialFilters: Record = {}) { - return { - keyword: initialVin, - protocol: initialProtocol ?? '', - ...initialFilters - }; -} - -function groupMileageByDate(rows: DailyMileageRow[]) { - const bucket = new Map(); - rows.forEach((row) => { - bucket.set(row.date || '-', (bucket.get(row.date || '-') ?? 0) + row.dailyMileageKm); - }); - return [...bucket.entries()] - .map(([date, value]) => ({ date, value })) - .sort((a, b) => a.date.localeCompare(b.date)); -} - -function groupMileageBySource(rows: DailyMileageRow[]) { - const bucket = new Map(); - rows.forEach((row) => { - bucket.set(row.source || 'UNKNOWN', (bucket.get(row.source || 'UNKNOWN') ?? 0) + row.dailyMileageKm); - }); - return [...bucket.entries()] - .map(([source, value]) => ({ source, value })) - .sort((a, b) => b.value - a.value); -} - -function maxMileageRow(rows: DailyMileageRow[]) { - return rows.reduce((max, row) => { - if (!max || row.dailyMileageKm > max.dailyMileageKm) return row; - return max; - }, undefined); -} - -const mileageExportColumns: CsvColumn[] = [ - { title: '日期', value: (row) => row.date }, - { title: 'VIN', value: (row) => row.vin }, - { title: '车牌', value: (row) => row.plate }, - { title: '来源', value: (row) => row.source }, - { title: '起始里程km', value: (row) => row.startMileageKm }, - { title: '结束里程km', value: (row) => row.endMileageKm }, - { title: '日里程km', value: (row) => row.dailyMileageKm }, - { title: '异常', value: (row) => row.anomalySeverity || '正常' } -]; - -function exportFileName(filters: Record) { - const keyword = filters.keyword?.trim() || 'all'; - const protocol = filters.protocol?.trim() || 'all-source'; - return `daily-mileage-${keyword}-${protocol}.csv`; -} - -function appURL(hash: string) { - return `${window.location.origin}${window.location.pathname}${hash}`; -} - -function mileageSummaryText({ - filters, - summary, - pageMileageTotal, - anomalyCount, - peakMileage, - statisticsURL, - vehicleURL -}: { - filters: Record; - summary: MileageSummary; - pageMileageTotal: number; - anomalyCount: number; - peakMileage?: DailyMileageRow; - statisticsURL: string; - vehicleURL?: string; -}) { - const keyword = filters.keyword?.trim() || '全部车辆'; - const protocol = filters.protocol?.trim() || '全部数据通道'; - const dateFrom = filters.dateFrom?.trim(); - const dateTo = filters.dateTo?.trim(); - const dateRange = dateFrom || dateTo ? `${dateFrom || '-'} 至 ${dateTo || '-'}` : '全部日期'; - const peak = peakMileage - ? `${peakMileage.plate || peakMileage.vin} / ${peakMileage.date} / ${formatKm(peakMileage.dailyMileageKm)} km` - : '-'; - return [ - '【里程统计摘要】', - `车辆范围:${keyword}`, - `数据通道:${protocol}`, - `日期范围:${dateRange}`, - `统计车辆:${summary.vehicleCount.toLocaleString()},记录:${summary.recordCount.toLocaleString()},来源:${summary.sourceCount.toLocaleString()}`, - `累计里程:${formatKm(summary.totalMileageKm)} km`, - `单车平均:${formatKm(summary.averageMileagePerVin)} km`, - `当前页里程:${formatKm(pageMileageTotal)} km`, - `异常记录:${anomalyCount.toLocaleString()}`, - `最大单日:${peak}`, - '里程口径:区间总值等于各日里程之和', - `里程统计:${statisticsURL}`, - ...(vehicleURL ? [`车辆服务:${vehicleURL}`] : []) - ].join('\n'); -} - -function mileageDeliveryPackageText({ - filters, - summary, - pageMileageTotal, - pageCoverageRate, - anomalyCount, - closureStatus, - closureDeltaKm, - confidenceStatus, - publishAuditConclusion, - onlineSummary, - peakMileage, - statisticsURL, - vehicleURL -}: { - filters: Record; - summary: MileageSummary; - pageMileageTotal: number; - pageCoverageRate: number; - anomalyCount: number; - closureStatus: string; - closureDeltaKm?: number; - confidenceStatus: string; - publishAuditConclusion: string; - onlineSummary: OnlineStatisticsSummary; - peakMileage?: DailyMileageRow; - statisticsURL: string; - vehicleURL?: string; -}) { - const keyword = filters.keyword?.trim() || '全部车辆'; - const protocol = filters.protocol?.trim() || '全部数据通道'; - const dateFrom = filters.dateFrom?.trim(); - const dateTo = filters.dateTo?.trim(); - const dateRange = dateFrom || dateTo ? `${dateFrom || '-'} 至 ${dateTo || '-'}` : '全部日期'; - const peak = peakMileage - ? `${peakMileage.plate || peakMileage.vin} / ${peakMileage.date} / ${formatKm(peakMileage.dailyMileageKm)} km` - : '-'; - const deliveryState = publishAuditConclusion === '可发布' && confidenceStatus === '可用于 BI' - ? '可交付' - : publishAuditConclusion === '阻断发布' ? '暂缓交付' : '复核后交付'; - return [ - '【客户里程复核交付包】', - `交付状态:${deliveryState}`, - `车辆范围:${keyword}`, - `数据通道:${protocol}`, - `日期范围:${dateRange}`, - `累计里程:${formatKm(summary.totalMileageKm)} km`, - `当前页明细合计:${formatKm(pageMileageTotal)} km`, - `闭合状态:${closureStatus}${closureDeltaKm == null ? '' : `,差值 ${formatKm(Math.abs(closureDeltaKm))} km`}`, - `记录覆盖率:${formatPercent(pageCoverageRate)}`, - `异常记录:${anomalyCount.toLocaleString()}`, - `最大单日:${peak}`, - `在线影响:${onlineSummary.onlineVehicleCount.toLocaleString()} 在线 / ${onlineSummary.offlineVehicleCount.toLocaleString()} 离线 / 在线率 ${formatPercent(onlineSummary.onlineRatePercent)}`, - `发布判断:${publishAuditConclusion}`, - `可信度:${confidenceStatus}`, - '里程口径:日里程按首末总里程差值计算,区间总值应等于每日里程之和', - `里程统计:${statisticsURL}`, - `轨迹复核:${appURL(buildAppHash({ page: 'history', keyword: filters.keyword?.trim() || '', protocol: filters.protocol?.trim() || '', filters: { ...(dateFrom ? { dateFrom } : {}), ...(dateTo ? { dateTo } : {}) } }))}`, - `历史明细:${appURL(buildAppHash({ page: 'history-query', keyword: filters.keyword?.trim() || '', protocol: filters.protocol?.trim() || '', filters: { ...(dateFrom ? { dateFrom } : {}), ...(dateTo ? { dateTo } : {}), tab: 'raw', includeFields: 'true' } }))}`, - ...(vehicleURL ? [`车辆服务:${vehicleURL}`] : []) - ].join('\n'); -} - -function mileageCustomerDecisionText({ - filters, - summary, - pageMileageTotal, - pageCoverageRate, - anomalyCount, - closureStatus, - closureDeltaKm, - confidenceStatus, - publishAuditConclusion, - onlineSummary, - peakMileage, - statisticsURL, - vehicleURL -}: { - filters: Record; - summary: MileageSummary; - pageMileageTotal: number; - pageCoverageRate: number; - anomalyCount: number; - closureStatus: string; - closureDeltaKm?: number; - confidenceStatus: string; - publishAuditConclusion: string; - onlineSummary: OnlineStatisticsSummary; - peakMileage?: DailyMileageRow; - statisticsURL: string; - vehicleURL?: string; -}) { - const keyword = filters.keyword?.trim() || '全部车辆'; - const protocol = filters.protocol?.trim() || '全部数据通道'; - const dateFrom = filters.dateFrom?.trim(); - const dateTo = filters.dateTo?.trim(); - const dateRange = dateFrom || dateTo ? `${dateFrom || '-'} 至 ${dateTo || '-'}` : '全部日期'; - const deliveryState = publishAuditConclusion === '可发布' && confidenceStatus === '可用于 BI' - ? '可交付' - : publishAuditConclusion === '阻断发布' ? '暂缓交付' : '复核后交付'; - const peak = peakMileage - ? `${peakMileage.plate || peakMileage.vin} / ${peakMileage.date} / ${formatKm(peakMileage.dailyMileageKm)} km` - : '-'; - return [ - '【客户里程决策说明】', - `决策结论:${deliveryState}`, - `车辆范围:${keyword}`, - `数据通道:${protocol}`, - `时间范围:${dateRange}`, - `区间总里程:${formatKm(summary.totalMileageKm)} km`, - `当前明细合计:${formatKm(pageMileageTotal)} km`, - `区间闭合:${closureStatus}${closureDeltaKm == null ? ',当前为分页抽样' : `,差值 ${formatKm(Math.abs(closureDeltaKm))} km`}`, - `记录覆盖:${formatPercent(pageCoverageRate)}`, - `异常记录:${anomalyCount.toLocaleString()} 条`, - `最高单日:${peak}`, - `在线影响:${onlineSummary.onlineVehicleCount.toLocaleString()} 在线 / ${onlineSummary.offlineVehicleCount.toLocaleString()} 离线 / 在线率 ${formatPercent(onlineSummary.onlineRatePercent)}`, - `BI发布判断:${publishAuditConclusion}`, - '', - '客户判断路径:', - '1. 先看区间闭合:区间总值必须等于每日里程之和。', - '2. 再看记录覆盖:发布前需要覆盖全量明细或导出全量。', - '3. 再看异常记录:异常日必须回看轨迹和历史明细。', - '4. 最后看在线影响:离线车辆会影响当天里程解释。', - `里程统计:${statisticsURL}`, - `轨迹复核:${appURL(buildAppHash({ page: 'history', keyword: filters.keyword?.trim() || '', protocol: filters.protocol?.trim() || '', filters: { ...(dateFrom ? { dateFrom } : {}), ...(dateTo ? { dateTo } : {}) } }))}`, - `历史明细:${appURL(buildAppHash({ page: 'history-query', keyword: filters.keyword?.trim() || '', protocol: filters.protocol?.trim() || '', filters: { ...(dateFrom ? { dateFrom } : {}), ...(dateTo ? { dateTo } : {}), tab: 'raw', includeFields: 'true' } }))}`, - ...(vehicleURL ? [`车辆服务:${vehicleURL}`] : []) - ].join('\n'); -} - -function mileageReconciliationControlText({ - filters, - summary, - pageMileageTotal, - closureStatus, - closureDeltaKm, - anomalyCount, - pageCoverageRate, - onlineSummary, - peakMileage, - statisticsURL, - vehicleURL -}: { - filters: Record; - summary: MileageSummary; - pageMileageTotal: number; - closureStatus: string; - closureDeltaKm?: number; - anomalyCount: number; - pageCoverageRate: number; - onlineSummary: OnlineStatisticsSummary; - peakMileage?: DailyMileageRow; - statisticsURL: string; - vehicleURL?: string; -}) { - const keyword = filters.keyword?.trim() || '全部车辆'; - const protocol = filters.protocol?.trim() || '全部数据通道'; - const dateFrom = filters.dateFrom?.trim(); - const dateTo = filters.dateTo?.trim(); - const dateRange = dateFrom || dateTo ? `${dateFrom || '-'} 至 ${dateTo || '-'}` : '全部日期'; - const peak = peakMileage - ? `${peakMileage.plate || peakMileage.vin} / ${peakMileage.date} / ${formatKm(peakMileage.dailyMileageKm)} km` - : '-'; - return [ - '【客户里程对账总控包】', - `服务链路:区间里程 -> 每日闭合 -> 轨迹证明 -> 报表导出`, - `对账范围:${keyword} / ${protocol} / ${dateRange}`, - `区间里程:${formatKm(summary.totalMileageKm)} km`, - `明细合计:${formatKm(pageMileageTotal)} km`, - `闭合状态:${closureStatus}${closureDeltaKm == null ? '' : `,差值 ${formatKm(Math.abs(closureDeltaKm))} km`}`, - `异常记录:${anomalyCount.toLocaleString()} 条`, - `覆盖率:${formatPercent(pageCoverageRate)}`, - `在线影响:${onlineSummary.onlineVehicleCount.toLocaleString()} 在线 / ${onlineSummary.offlineVehicleCount.toLocaleString()} 离线 / 在线率 ${formatPercent(onlineSummary.onlineRatePercent)}`, - `轨迹证明:${peak}`, - `里程统计:${statisticsURL}`, - `报表导出:${statisticsURL}`, - `轨迹回放:${appURL(buildAppHash({ page: 'history', keyword: filters.keyword?.trim() || '', protocol: filters.protocol?.trim() || '', filters: { ...(dateFrom ? { dateFrom } : {}), ...(dateTo ? { dateTo } : {}) } }))}`, - `历史明细:${appURL(buildAppHash({ page: 'history-query', keyword: filters.keyword?.trim() || '', protocol: filters.protocol?.trim() || '', filters: { ...(dateFrom ? { dateFrom } : {}), ...(dateTo ? { dateTo } : {}), tab: 'raw', includeFields: 'true' } }))}`, - ...(vehicleURL ? [`车辆服务:${vehicleURL}`] : []) - ].join('\n'); -} - -function mileageBIPublishText({ - filters, - summary, - pageMileageTotal, - pageCoverageRate, - anomalyCount, - closureStatus, - closureDeltaKm, - confidenceStatus, - sourceConsistencyText, - statisticsURL, - vehicleURL -}: { - filters: Record; - summary: MileageSummary; - pageMileageTotal: number; - pageCoverageRate: number; - anomalyCount: number; - closureStatus: string; - closureDeltaKm?: number; - confidenceStatus: string; - sourceConsistencyText: string; - statisticsURL: string; - vehicleURL?: string; -}) { - const keyword = filters.keyword?.trim() || '全部车辆'; - const protocol = filters.protocol?.trim() || '全部数据通道'; - const dateFrom = filters.dateFrom?.trim(); - const dateTo = filters.dateTo?.trim(); - const dateRange = dateFrom || dateTo ? `${dateFrom || '-'} 至 ${dateTo || '-'}` : '全部日期'; - const publishable = confidenceStatus === '可用于 BI' && closureStatus === '闭合通过'; - return [ - '【BI里程发布口径】', - `发布结论:${publishable ? '可发布' : '需复核后发布'}`, - `车辆范围:${keyword}`, - `数据通道:${protocol}`, - `日期范围:${dateRange}`, - `里程口径:日里程按首末总里程差值计算,区间总值等于每日里程之和`, - `累计里程:${formatKm(summary.totalMileageKm)} km`, - `明细合计:${formatKm(pageMileageTotal)} km`, - `闭合状态:${closureStatus}${closureDeltaKm == null ? '' : `,差值 ${formatKm(Math.abs(closureDeltaKm))} km`}`, - `记录覆盖率:${formatPercent(pageCoverageRate)}`, - `异常记录:${anomalyCount.toLocaleString()}`, - `通道一致性:${sourceConsistencyText}`, - `统计车辆:${summary.vehicleCount.toLocaleString()},记录:${summary.recordCount.toLocaleString()},来源:${summary.sourceCount.toLocaleString()}`, - `发布风险:${publishable ? '当前筛选范围闭合且无异常记录' : '存在分页未全量、异常记录或闭合差值,需要先复核轨迹和历史明细'}`, - `里程统计:${statisticsURL}`, - ...(vehicleURL ? [`车辆服务:${vehicleURL}`] : []) - ].join('\n'); -} - -function statisticsImpactText({ - filters, - summary, - onlineSummary, - closureStatus, - confidenceStatus, - anomalyCount, - pageCoverageRate, - publishAuditConclusion, - statisticsURL, - vehicleURL -}: { - filters: Record; - summary: MileageSummary; - onlineSummary: OnlineStatisticsSummary; - closureStatus: string; - confidenceStatus: string; - anomalyCount: number; - pageCoverageRate: number; - publishAuditConclusion: string; - statisticsURL: string; - vehicleURL?: string; -}) { - const keyword = filters.keyword?.trim() || '全部车辆'; - const protocol = filters.protocol?.trim() || '全部数据通道'; - const dateFrom = filters.dateFrom?.trim(); - const dateTo = filters.dateTo?.trim(); - const dateRange = dateFrom || dateTo ? `${dateFrom || '-'} 至 ${dateTo || '-'}` : '全部日期'; - return [ - '【统计影响范围】', - `车辆范围:${keyword}`, - `数据通道:${protocol}`, - `日期范围:${dateRange}`, - `影响车辆:${summary.vehicleCount.toLocaleString()} 辆`, - `统计记录:${summary.recordCount.toLocaleString()} 条`, - `累计里程:${formatKm(summary.totalMileageKm)} km`, - `在线状态:${onlineSummary.onlineVehicleCount.toLocaleString()} 在线 / ${onlineSummary.offlineVehicleCount.toLocaleString()} 离线 / 在线率 ${formatPercent(onlineSummary.onlineRatePercent)}`, - `闭合状态:${closureStatus}`, - `发布判断:${publishAuditConclusion}`, - `可信度:${confidenceStatus}`, - `异常记录:${anomalyCount.toLocaleString()}`, - `当前页覆盖:${formatPercent(pageCoverageRate)}`, - `业务影响:${publishAuditConclusion === '可发布' ? '当前统计范围可进入运营和BI口径' : '当前统计范围需要先复核轨迹、历史明细和离线车辆影响'}`, - `里程统计:${statisticsURL}`, - ...(vehicleURL ? [`车辆服务:${vehicleURL}`] : []) - ].join('\n'); -} - -type MileagePublishAuditItem = { - key: string; - label: string; - status: 'pass' | 'review' | 'blocked'; - value: string; - detail: string; - action: string; -}; - -function auditStatusColor(status: MileagePublishAuditItem['status']): 'green' | 'orange' | 'red' { - if (status === 'pass') return 'green'; - if (status === 'blocked') return 'red'; - return 'orange'; -} - -function auditStatusLabel(status: MileagePublishAuditItem['status']) { - if (status === 'pass') return '通过'; - if (status === 'blocked') return '阻断'; - return '复核'; -} - -function mileagePublishAuditReport({ - filters, - items, - summary, - statisticsURL, - vehicleURL -}: { - filters: Record; - items: MileagePublishAuditItem[]; - summary: MileageSummary; - statisticsURL: string; - vehicleURL?: string; -}) { - const blocked = items.filter((item) => item.status === 'blocked').length; - const review = items.filter((item) => item.status === 'review').length; - const pass = items.filter((item) => item.status === 'pass').length; - const keyword = filters.keyword?.trim() || '全部车辆'; - const protocol = filters.protocol?.trim() || '全部数据通道'; - return [ - '【统计发布审计】', - `发布结论:${blocked > 0 ? '阻断发布' : review > 0 ? '复核后发布' : '可发布'}`, - `车辆范围:${keyword}`, - `数据通道:${protocol}`, - `统计规模:${summary.vehicleCount.toLocaleString()} 车 / ${summary.recordCount.toLocaleString()} 条 / ${summary.sourceCount.toLocaleString()} 数据通道`, - `审计结果:通过 ${pass.toLocaleString()} / 复核 ${review.toLocaleString()} / 阻断 ${blocked.toLocaleString()}`, - '', - ...items.map((item, index) => [ - `${index + 1}. [${auditStatusLabel(item.status)}] ${item.label}:${item.value}`, - ` 说明:${item.detail}`, - ` 动作:${item.action}` - ].join('\n')), - '', - `里程统计:${statisticsURL}`, - ...(vehicleURL ? [`车辆服务:${vehicleURL}`] : []) - ].join('\n'); -} - -function mileageEvidencePackageText(row: DailyMileageRow) { - const dateFrom = row.date?.trim() ?? ''; - const dateTo = nextDate(dateFrom); - const evidenceFilters = { - ...(dateFrom ? { dateFrom } : {}), - ...(dateTo ? { dateTo } : {}) - }; - const vehicle = row.plate?.trim() ? `${row.plate.trim()} / ${row.vin}` : row.vin; - return [ - '【里程异常复核包】', - `车辆:${vehicle}`, - `数据通道:${row.source || '未知通道'}`, - `统计日期:${row.date || '-'}`, - `起始里程:${formatKm(row.startMileageKm)} km`, - `结束里程:${formatKm(row.endMileageKm)} km`, - `日里程:${formatKm(row.dailyMileageKm)} km`, - `异常等级:${row.anomalySeverity || '正常'}`, - `处置建议:${mileageActionRecommendation(row).detail}`, - `车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: row.vin, protocol: row.source }))}`, - `轨迹证据:${appURL(buildAppHash({ page: 'history', keyword: row.vin, protocol: row.source, filters: evidenceFilters }))}`, - `历史明细:${appURL(buildAppHash({ page: 'history-query', keyword: row.vin, protocol: row.source, filters: { ...evidenceFilters, tab: 'raw', includeFields: 'true' } }))}`, - `里程统计:${appURL(buildAppHash({ page: 'mileage', keyword: row.vin, protocol: row.source, filters: evidenceFilters }))}` - ].join('\n'); -} - -function onlineStatusHandoffText({ - filters, - summary, - rows, - total -}: { - filters: Record; - summary: OnlineStatisticsSummary; - rows: OnlineVehicleStatusRow[]; - total: number; -}) { - const keyword = filters.keyword?.trim() || '全部车辆'; - const protocol = filters.protocol?.trim() || '全部数据通道'; - const handoffRows = Array.isArray(rows) ? rows : []; - const offlineRows = handoffRows.filter((row) => !row.online); - const protocolStats = Array.isArray(summary.protocolStats) ? summary.protocolStats : []; - const protocolLines = protocolStats.length > 0 - ? protocolStats.map((item) => `${item.protocol} ${item.online.toLocaleString()}/${item.total.toLocaleString()}`).join(';') - : '-'; - return [ - '【车辆在线状态说明】', - `车辆范围:${keyword}`, - `数据通道:${protocol}`, - `在线概览:${summary.onlineVehicleCount.toLocaleString()} 在线 / ${summary.offlineVehicleCount.toLocaleString()} 离线 / 在线率 ${formatPercent(summary.onlineRatePercent)}`, - `Redis 在线 Key:${summary.redisOnlineKeys == null ? '-' : summary.redisOnlineKeys.toLocaleString()}`, - `来源在线:${protocolLines}`, - `统计证据:${summary.evidence || '-'}`, - `当前页离线:${offlineRows.length.toLocaleString()} 辆 / 当前筛选 ${total.toLocaleString()} 辆`, - '', - '离线车辆:', - ...(offlineRows.length > 0 ? offlineRows.map((row, index) => [ - `${index + 1}. ${row.plate || '-'} / ${row.vin || '-'} / ${row.oem || '-'}`, - ` 离线时长:${formatOfflineDuration(row.offlineDurationMinutes)};最后上报:${row.lastSeen || '-'}`, - ` 来源覆盖:${row.onlineSourceCount}/${row.sourceCount};来源:${(row.protocols ?? []).join('、') || '-'}`, - ` 车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: row.vin, protocol: filters.protocol?.trim() || row.protocols?.[0] || '' }))}`, - ` 实时监控:${appURL(buildAppHash({ page: 'realtime', keyword: row.vin, protocol: filters.protocol?.trim() || row.protocols?.[0] || '' }))}` - ].join('\n')) : ['当前页暂无离线车辆']) - ].join('\n'); -} - -function onlineOperationsReportText({ - filters, - summary, - onlineRows, - total, - statisticsURL, - vehicleURL -}: { - filters: Record; - summary: OnlineStatisticsSummary; - onlineRows: OnlineVehicleStatusRow[]; - total: number; - statisticsURL: string; - vehicleURL?: string; -}) { - const keyword = filters.keyword?.trim() || '全部车辆'; - const protocol = filters.protocol?.trim() || '全部数据通道'; - const protocolStats = Array.isArray(summary.protocolStats) ? summary.protocolStats : []; - const offlineRows = onlineRows.filter((row) => !row.online); - const longestOffline = offlineRows.reduce((current, row) => { - if (!current) return row; - return Number(row.offlineDurationMinutes ?? 0) > Number(current.offlineDurationMinutes ?? 0) ? row : current; - }, undefined); - return [ - '【在线影响评估】', - `车辆范围:${keyword}`, - `数据通道:${protocol}`, - `在线概览:${summary.onlineVehicleCount.toLocaleString()} 在线 / ${summary.offlineVehicleCount.toLocaleString()} 离线 / 在线率 ${formatPercent(summary.onlineRatePercent)}`, - `Redis 在线 Key:${summary.redisOnlineKeys == null ? '-' : summary.redisOnlineKeys.toLocaleString()}`, - `统计证据:${summary.evidence || '-'}`, - `当前页车辆:${onlineRows.length.toLocaleString()} / 当前筛选 ${total.toLocaleString()}`, - `当前页离线:${offlineRows.length.toLocaleString()}`, - `最长离线:${longestOffline ? `${longestOffline.plate || longestOffline.vin} / ${formatOfflineDuration(longestOffline.offlineDurationMinutes)} / ${longestOffline.lastSeen || '-'}` : '-'}`, - '', - '来源在线:', - ...(protocolStats.length > 0 ? protocolStats.map((item, index) => { - const rate = item.total > 0 ? (item.online / item.total) * 100 : 0; - return `${index + 1}. ${item.protocol}:${item.online.toLocaleString()}/${item.total.toLocaleString()},在线率 ${formatPercent(rate)}`; - }) : ['暂无来源在线统计']), - '', - '处置建议:', - '1. 在线率下降时先检查实时在线状态、数据接入和最后上报时间', - '2. 离线车辆优先打开车辆服务,核对实时、轨迹、历史明细和告警记录', - '3. 统计发布前确认离线车辆是否影响当日里程和区间闭合', - `里程统计:${statisticsURL}`, - ...(vehicleURL ? [`车辆服务:${vehicleURL}`] : []) - ].join('\n'); -} - -async function copyText(value: string, label: string) { - const text = value.trim(); - if (!text) { - Toast.warning(`${label}为空`); - return; - } - try { - await navigator.clipboard.writeText(text); - Toast.success(`已复制${label}`); - } catch { - Toast.error(`复制${label}失败`); - } -} - -export function Mileage({ - initialVin, - initialProtocol, - initialFilters = {}, - onFiltersChange, - onOpenVehicle, - onOpenHistory, - onOpenRaw -}: { +type MileageProps = { initialVin: string; initialProtocol?: string; initialFilters?: Record; @@ -677,2410 +21,294 @@ export function Mileage({ onOpenVehicle: (vin: string, protocol?: string) => void; onOpenHistory?: (filters: Record) => void; onOpenRaw?: (filters: Record) => void; -}) { - const [rows, setRows] = useState([]); - const [onlineRows, setOnlineRows] = useState([]); - const [summary, setSummary] = useState(emptySummary); - const [onlineSummary, setOnlineSummary] = useState(emptyOnlineSummary); - const [loading, setLoading] = useState(true); - const [onlineLoading, setOnlineLoading] = useState(true); - const [summaryLoading, setSummaryLoading] = useState(true); - const [filters, setFilters] = useState>(mergeInitialFilters(initialVin, initialProtocol, initialFilters)); - const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 }); - const [onlinePagination, setOnlinePagination] = useState({ currentPage: 1, pageSize: 10, total: 0 }); - const currentVehicleKeyword = filters.keyword?.trim() ?? ''; - const currentProtocol = filters.protocol?.trim() ?? ''; - const dateSeries = groupMileageByDate(rows); - const sourceSeries = groupMileageBySource(rows); - const anomalyRows = rows.filter((row) => row.anomalySeverity); - const peakMileage = maxMileageRow(rows); - const pageMileageTotal = rows.reduce((total, row) => total + row.dailyMileageKm, 0); - const anomalyRate = rows.length > 0 ? (anomalyRows.length / rows.length) * 100 : 0; - const peakMileageShare = pageMileageTotal > 0 && peakMileage ? (peakMileage.dailyMileageKm / pageMileageTotal) * 100 : 0; - const pageCoverageRate = summary.recordCount > 0 ? (rows.length / summary.recordCount) * 100 : 0; - const hasFullMileagePage = summary.recordCount > 0 && rows.length === summary.recordCount; - const closureDeltaKm = hasFullMileagePage ? summary.totalMileageKm - pageMileageTotal : undefined; - const closurePassed = closureDeltaKm != null && Math.abs(closureDeltaKm) < 0.01; - const closureStatus = closureDeltaKm == null ? '分页抽样' : closurePassed ? '闭合通过' : '闭合异常'; - const closureStatusColor = closurePassed ? 'green' as const : closureDeltaKm == null ? 'grey' as const : 'orange' as const; - const closureActionText = closureDeltaKm == null - ? '当前页未覆盖全量记录,请翻页、导出或缩小筛选范围后核验。' - : closurePassed - ? '当前筛选范围汇总总里程与明细合计一致,可作为统计证据。' - : '汇总总里程与明细合计不一致,请核对分页、来源和异常记录。'; - const confidenceStatus = anomalyRows.length > 0 || pageCoverageRate < 100 ? '需复核' : '可用于 BI'; - const confidenceColor = confidenceStatus === '可用于 BI' ? 'green' as const : 'orange' as const; - const currentEvidenceText = `${summary.vehicleCount.toLocaleString()} 车 / ${summary.sourceCount.toLocaleString()} 数据通道 / ${rows.length.toLocaleString()} 条明细`; - const sourceConsistencyText = summary.sourceCount > 1 ? '可做跨通道核对' : summary.sourceCount === 1 ? '单通道车辆需关注' : '等待数据通道'; - const onlineVehicleCount = Number(onlineSummary.onlineVehicleCount ?? 0); - const offlineVehicleCount = Number(onlineSummary.offlineVehicleCount ?? 0); - const onlineRatePercent = Number(onlineSummary.onlineRatePercent ?? 0); - const redisOnlineKeyText = onlineSummary.redisOnlineKeys == null ? '' : `Redis 在线 ${onlineSummary.redisOnlineKeys.toLocaleString()} key`; - const onlineStatsDetail = [ - `在线 ${onlineVehicleCount.toLocaleString()} 车`, - `离线 ${offlineVehicleCount.toLocaleString()} 车`, - redisOnlineKeyText, - onlineSummary.evidence - ].filter(Boolean).join(','); - const onlineProtocolStats = Array.isArray(onlineSummary.protocolStats) ? onlineSummary.protocolStats : []; - const topOfflineRow = onlineRows.filter((row) => !row.online).reduce((current, row) => { - if (!current) return row; - return Number(row.offlineDurationMinutes ?? 0) > Number(current.offlineDurationMinutes ?? 0) ? row : current; - }, undefined); - const publishAuditItems: MileagePublishAuditItem[] = [ - { - key: 'closure', - label: '区间闭合', - status: closureDeltaKm == null ? 'review' : closurePassed ? 'pass' : 'blocked', - value: closureStatus, - detail: closureDeltaKm == null ? '当前分页未覆盖全量记录,无法直接证明区间总值等于每日之和。' : `闭合差值 ${formatKm(Math.abs(closureDeltaKm))} km。`, - action: closurePassed ? '保留当前统计证据,可继续审计异常和来源。' : '缩小筛选范围、翻页或导出后重新核验闭合。' - }, - { - key: 'coverage', - label: '记录覆盖', - status: pageCoverageRate >= 100 ? 'pass' : summary.recordCount > rows.length ? 'review' : 'pass', - value: formatPercent(pageCoverageRate), - detail: `${rows.length.toLocaleString()} / ${summary.recordCount.toLocaleString()} 条明细在当前页。`, - action: pageCoverageRate >= 100 ? '当前明细已覆盖统计范围。' : '发布前需要导出或拉齐全量明细。' - }, - { - key: 'anomaly', - label: '异常记录', - status: anomalyRows.length > 0 ? 'review' : 'pass', - value: anomalyRows.length.toLocaleString(), - detail: anomalyRows.length > 0 ? '存在日里程异常标记,需核对首末总里程、补传和断链。' : '当前筛选范围未发现异常里程记录。', - action: anomalyRows.length > 0 ? '优先打开异常行的轨迹和历史明细复核。' : '继续检查通道一致性和在线状态。' - }, - { - key: 'source', - label: '通道一致性', - status: summary.sourceCount > 1 ? 'pass' : summary.sourceCount === 1 ? 'review' : 'blocked', - value: sourceConsistencyText, - detail: summary.sourceCount > 1 ? '可使用多通道交叉验证车辆统计。' : summary.sourceCount === 1 ? '只有单一通道,统计可用但可信度低于多通道。' : '当前统计缺少数据通道。', - action: summary.sourceCount > 1 ? '保留通道贡献作为审计附件。' : '核对 32960、808、MQTT 是否有缺失通道。' - }, - { - key: 'online', - label: '在线证据', - status: onlineVehicleCount > 0 ? 'pass' : offlineVehicleCount > 0 ? 'review' : 'review', - value: `${onlineVehicleCount.toLocaleString()} 在线 / ${offlineVehicleCount.toLocaleString()} 离线`, - detail: onlineStatsDetail || '等待在线统计证据。', - action: onlineVehicleCount > 0 ? '实时在线证据可辅助统计发布。' : '统计发布前建议检查离线时长和平台转发状态。' - } - ]; - const publishBlockedCount = publishAuditItems.filter((item) => item.status === 'blocked').length; - const publishReviewCount = publishAuditItems.filter((item) => item.status === 'review').length; - const publishAuditConclusion = publishBlockedCount > 0 ? '阻断发布' : publishReviewCount > 0 ? '复核后发布' : '可发布'; - const publishAuditColor = publishBlockedCount > 0 ? 'red' as const : publishReviewCount > 0 ? 'orange' as const : 'green' as const; - const statisticsImpactColor = publishAuditColor; - const statisticsImpactLevel = publishAuditConclusion === '可发布' ? '可交付报表' : publishAuditConclusion === '阻断发布' ? '发布阻断' : '影响待复核'; - const mileageDeliveryState = publishAuditConclusion === '可发布' && confidenceStatus === '可用于 BI' - ? '可交付' - : publishAuditConclusion === '阻断发布' ? '暂缓交付' : '复核后交付'; - const mileageDeliveryColor = mileageDeliveryState === '可交付' ? 'green' as const : mileageDeliveryState === '暂缓交付' ? 'red' as const : 'orange' as const; - const statisticsImpactItems = [ - { - label: '影响车辆', - value: `${summary.vehicleCount.toLocaleString()} 辆`, - detail: `${onlineVehicleCount.toLocaleString()} 在线 / ${offlineVehicleCount.toLocaleString()} 离线`, - color: offlineVehicleCount > 0 ? 'orange' as const : 'green' as const - }, - { - label: '统计规模', - value: `${summary.recordCount.toLocaleString()} 条`, - detail: `${summary.sourceCount.toLocaleString()} 个来源,累计 ${formatKm(summary.totalMileageKm)} km`, - color: summary.recordCount > 0 ? 'blue' as const : 'grey' as const - }, - { - label: '闭合影响', - value: closureStatus, - detail: closureActionText, - color: closureStatusColor - }, - { - label: '异常影响', - value: `${anomalyRows.length.toLocaleString()} 条`, - detail: anomalyRows.length > 0 ? '异常会影响客户报表和 BI 口径,需要回到证据复核。' : '当前明细未命中异常标记。', - color: anomalyRows.length > 0 ? 'orange' as const : 'green' as const - }, - { - label: '发布判断', - value: publishAuditConclusion, - detail: publishAuditConclusion === '可发布' ? '统计范围闭合且审计项通过。' : '发布前需要补齐审计项证据。', - color: publishAuditColor - } - ]; - const mileageDecisionItems = [ - { - label: '交付结论', - value: mileageDeliveryState, - detail: mileageDeliveryState === '可交付' ? '可进入客户查询、BI发布和报表交付。' : mileageDeliveryState === '暂缓交付' ? '存在阻断项,先完成证据复核。' : '需补齐闭合、覆盖、异常或在线证据后交付。', - color: mileageDeliveryColor, - action: '复制决策', - onClick: () => copyMileageDecision() - }, - { - label: '区间闭合', - value: closureStatus, - detail: closureDeltaKm == null ? '当前页是分页抽样,发布前请拉齐全量明细。' : `闭合差值 ${formatKm(Math.abs(closureDeltaKm))} km。`, - color: closureStatusColor, - action: '统计摘要', - onClick: () => copyStatisticsSummary() - }, - { - label: '覆盖率', - value: formatPercent(pageCoverageRate), - detail: `${rows.length.toLocaleString()} / ${summary.recordCount.toLocaleString()} 条记录,交付前建议覆盖全量。`, - color: pageCoverageRate >= 100 ? 'green' as const : pageCoverageRate >= 60 ? 'orange' as const : 'red' as const, - action: '导出明细', - onClick: () => exportMileage() - }, - { - label: '异常复核', - value: `${anomalyRows.length.toLocaleString()} 条`, - detail: anomalyRows[0] ? `${anomalyRows[0].plate || anomalyRows[0].vin} / ${anomalyRows[0].date} 需要回看轨迹。` : '当前明细未命中异常标记。', - color: anomalyRows.length > 0 ? 'orange' as const : 'green' as const, - action: '异常明细', - onClick: () => anomalyRows[0] ? copyMileageEvidence(anomalyRows[0]) : copyMileageDecision() - }, - { - label: '在线影响', - value: formatPercent(onlineRatePercent), - detail: `${onlineVehicleCount.toLocaleString()} 在线 / ${offlineVehicleCount.toLocaleString()} 离线。`, - color: onlineRatePercent >= 90 ? 'green' as const : onlineRatePercent >= 60 ? 'orange' as const : 'red' as const, - action: '在线态势', - onClick: () => copyOnlineOperationsReport() - } - ]; - const maxDateMileage = Math.max(...dateSeries.map((item) => item.value), 0); - const maxSourceMileage = Math.max(...sourceSeries.map((item) => item.value), 0); - const filterSummary = [ - currentVehicleKeyword ? `车辆:${currentVehicleKeyword}` : '', - currentProtocol ? `数据通道:${currentProtocol}` : '', - filters.dateFrom?.trim() ? `开始日期:${filters.dateFrom.trim()}` : '', - filters.dateTo?.trim() ? `结束日期:${filters.dateTo.trim()}` : '' - ].filter(Boolean); - const mileageRangeText = filters.dateFrom?.trim() || filters.dateTo?.trim() - ? `${filters.dateFrom?.trim() || '-'} 至 ${filters.dateTo?.trim() || '-'}` - : '全部日期'; - const mileageCustomerKpis = [ - { - label: '区间总里程', - value: `${formatKm(summary.totalMileageKm)} km`, - color: 'blue' as const, - detail: `范围:${mileageRangeText}` - }, - { - label: '统计车辆', - value: `${summary.vehicleCount.toLocaleString()} 辆`, - color: summary.vehicleCount > 0 ? 'green' as const : 'grey' as const, - detail: `${summary.recordCount.toLocaleString()} 条日里程记录` - }, - { - label: '异常记录', - value: anomalyRows.length.toLocaleString(), - color: anomalyRows.length > 0 ? 'orange' as const : 'green' as const, - detail: anomalyRows.length > 0 ? '建议先核对轨迹和历史明细' : '当前页未发现异常' - }, - { - label: '在线影响', - value: formatPercent(onlineRatePercent), - color: onlineRatePercent >= 90 ? 'green' as const : onlineRatePercent >= 60 ? 'orange' as const : 'red' as const, - detail: `${onlineVehicleCount.toLocaleString()} 在线 / ${offlineVehicleCount.toLocaleString()} 离线` - } - ]; - const mileageCustomerWorkbenchItems = [ - { - label: '区间里程', - value: `${formatKm(summary.totalMileageKm)} km`, - detail: `${summary.vehicleCount.toLocaleString()} 辆车,${mileageRangeText}。`, - color: 'blue' as const, - action: '复制摘要', - disabled: false, - onClick: () => copyStatisticsSummary() - }, - { - label: '每日闭合', - value: closureStatus, - detail: closureDeltaKm == null ? `当前页覆盖 ${formatPercent(pageCoverageRate)},发布前需要拉齐全量。` : `闭合差值 ${formatKm(Math.abs(closureDeltaKm))} km。`, - color: closureStatusColor, - action: '发布审计', - disabled: false, - onClick: () => copyPublishAudit() - }, - { - label: '轨迹回放', - value: peakMileage?.date || mileageRangeText, - detail: peakMileage ? `${peakMileage.plate || peakMileage.vin} 最大单日 ${formatKm(peakMileage.dailyMileageKm)} km。` : '选择车辆后用同一时间窗回放轨迹。', - color: currentVehicleKeyword ? 'blue' as const : 'grey' as const, - action: '回放轨迹', - disabled: !currentVehicleKeyword || !onOpenHistory, - onClick: () => { - if (peakMileage) { - openMileageEvidence(peakMileage); - return; - } - onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' }); - } - }, - { - label: '历史导出', - value: `${rows.length.toLocaleString()} 条`, - detail: `当前页覆盖 ${formatPercent(pageCoverageRate)},用于客户对账和 BI 复核。`, - color: rows.length > 0 ? 'blue' as const : 'grey' as const, - action: '导出明细', - disabled: rows.length === 0, - onClick: () => exportMileage() - }, - { - label: '告警通知', - value: `${offlineVehicleCount.toLocaleString()} 离线`, - detail: offlineVehicleCount > 0 ? '离线会影响定位、里程解释和客户通知。' : '当前在线状态未形成主要统计风险。', - color: offlineVehicleCount > 0 ? 'orange' as const : 'green' as const, - action: '在线态势', - disabled: false, - onClick: () => copyOnlineOperationsReport() - } - ]; - const mileageConclusionItems = [ - { - label: '区间累计', - value: `${formatKm(summary.totalMileageKm)} km`, - detail: `${summary.vehicleCount.toLocaleString()} 辆车,${mileageRangeText}`, - color: 'blue' as const, - onClick: () => copyStatisticsSummary() - }, - { - label: '明细合计', - displayLabel: '当前明细', - value: `${formatKm(pageMileageTotal)} km`, - detail: `${rows.length.toLocaleString()} 条当前页明细,覆盖 ${formatPercent(pageCoverageRate)}`, - color: pageCoverageRate >= 100 ? 'green' as const : 'orange' as const, - onClick: () => exportMileage() - }, - { - label: '异常记录', - value: `${anomalyRows.length.toLocaleString()} 条`, - detail: anomalyRows[0] ? `${anomalyRows[0].plate || anomalyRows[0].vin} / ${anomalyRows[0].date}` : '当前页未发现异常', - color: anomalyRows.length > 0 ? 'orange' as const : 'green' as const, - onClick: () => anomalyRows[0] ? copyMileageEvidence(anomalyRows[0]) : copyMileageDecision() - }, - { - label: '在线影响', - value: `${offlineVehicleCount.toLocaleString()} 离线`, - detail: `${onlineVehicleCount.toLocaleString()} 在线,在线率 ${formatPercent(onlineRatePercent)}`, - color: offlineVehicleCount > 0 ? 'orange' as const : 'green' as const, - onClick: () => copyOnlineOperationsReport() - } - ]; - const mileageConclusionActions = [ - { - label: '复制结论', - action: '复制摘要', - color: 'blue' as const, - disabled: false, - onClick: () => copyMileageDeliveryPackage() - }, - { - label: '轨迹复核', - action: '轨迹回放', - color: currentVehicleKeyword ? 'blue' as const : 'grey' as const, - disabled: !currentVehicleKeyword || !onOpenHistory, - onClick: () => onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' }) - }, - { - label: '导出明细', - action: '导出CSV', - color: rows.length > 0 ? 'blue' as const : 'grey' as const, - disabled: rows.length === 0, - onClick: () => exportMileage() - }, - { - label: '告警通知', - action: '在线态势', - color: offlineVehicleCount > 0 ? 'orange' as const : 'green' as const, - disabled: false, - onClick: () => copyOnlineOperationsReport() - } - ]; - const mileageCustomerActions = [ - { - label: '导出当前明细', - disabled: rows.length === 0, - action: () => exportMileage() - }, - { - label: '查看轨迹', - disabled: !currentVehicleKeyword || !onOpenHistory, - action: () => onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' }) - }, - { - label: '查看历史明细', - disabled: !currentVehicleKeyword || !onOpenRaw, - action: () => onOpenRaw?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '', includeFields: 'true' }) - }, - { - label: '车辆档案', - disabled: !currentVehicleKeyword, - action: () => onOpenVehicle(currentVehicleKeyword, currentProtocol) - } - ]; - const mileageCustomerQuerySteps = [ - { - step: '01', - title: '选择范围', - value: currentVehicleKeyword || '全部车辆', - detail: `时间范围 ${mileageRangeText},数据通道 ${currentProtocol || '全部数据通道'}。`, - action: '调整筛选', - color: currentVehicleKeyword ? 'green' as const : 'blue' as const, - disabled: false, - onClick: () => { - const input = document.querySelector('input[name="keyword"]'); - input?.focus(); - } - }, - { - step: '02', - title: '区间里程', - value: `${formatKm(summary.totalMileageKm)} km`, - detail: `${summary.vehicleCount.toLocaleString()} 辆车,${summary.recordCount.toLocaleString()} 条日里程记录。`, - action: '复制摘要', - color: 'blue' as const, - disabled: false, - onClick: () => copyStatisticsSummary() - }, - { - step: '03', - title: '发布判断', - value: publishAuditConclusion, - detail: `闭合 ${closureStatus},覆盖 ${formatPercent(pageCoverageRate)}。`, - action: '发布审计', - color: publishAuditColor, - disabled: false, - onClick: () => copyPublishAudit() - }, - { - step: '04', - title: '复核证据', - value: anomalyRows.length > 0 ? `${anomalyRows.length.toLocaleString()} 异常` : '轨迹/原始', - detail: anomalyRows.length > 0 ? '异常日优先回看轨迹和历史明细。' : '可从同一时间窗进入轨迹和历史明细。', - action: '轨迹复核', - color: anomalyRows.length > 0 ? 'orange' as const : 'green' as const, - disabled: !currentVehicleKeyword || !onOpenHistory, - onClick: () => onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' }) - }, - { - step: '05', - title: '明细导出', - value: `${rows.length.toLocaleString()} 当前页`, - detail: '导出明细并附带里程口径,支撑 BI 核对、客户复盘和报表交付。', - action: '导出明细', - color: rows.length > 0 ? 'blue' as const : 'grey' as const, - disabled: rows.length === 0, - onClick: () => exportMileage() - } - ]; - const mileageReconciliationConclusionItems = [ - { - label: '区间总里程', - value: `${formatKm(summary.totalMileageKm)} km`, - detail: `${summary.vehicleCount.toLocaleString()} 辆车,${mileageRangeText}。`, - action: '复制摘要', - color: 'blue' as const, - disabled: false, - onClick: () => copyStatisticsSummary() - }, - { - label: '闭合状态', - value: closureStatus, - detail: closureActionText, - action: '发布审计', - color: closureStatusColor, - disabled: false, - onClick: () => copyPublishAudit() - }, - { - label: '异常记录', - value: `${anomalyRows.length.toLocaleString()} 条`, - detail: anomalyRows[0] - ? `${anomalyRows[0].plate || anomalyRows[0].vin} / ${anomalyRows[0].date}` - : '当前明细没有异常标记。', - action: '复制决策', - color: anomalyRows.length > 0 ? 'orange' as const : 'green' as const, - disabled: false, - onClick: () => copyMileageDecision() - }, - { - label: '下一步', - value: mileageDeliveryState, - detail: mileageDeliveryState === '可交付' ? '可以进入客户交付、BI复核或导出。' : '先补齐闭合、覆盖、异常或在线影响证据。', - action: '查看证据', - color: mileageDeliveryColor, - disabled: !currentVehicleKeyword || (!onOpenHistory && !onOpenRaw), - onClick: () => { - if (currentVehicleKeyword && onOpenHistory) { - onOpenHistory({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' }); - return; - } - if (currentVehicleKeyword && onOpenRaw) { - onOpenRaw({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '', includeFields: 'true' }); - return; - } - copyMileageDeliveryPackage(); - } - } - ]; - const mileageDeliveryNavigation = [ - { - title: '确认里程口径', - value: `${formatKm(summary.totalMileageKm)} km`, - detail: `范围 ${mileageRangeText},闭合状态 ${closureStatus},发布判断 ${publishAuditConclusion}。`, - action: '复制统计摘要', - color: publishAuditColor, - disabled: false, - onClick: () => copyStatisticsSummary() - }, - { - title: '回放里程轨迹', - value: currentVehicleKeyword || '请选择车辆', - detail: '用同一车辆和时间窗回看位置、速度、停驶和里程断点。', - action: '轨迹复核', - color: currentVehicleKeyword ? 'blue' as const : 'grey' as const, - disabled: !currentVehicleKeyword || !onOpenHistory, - onClick: () => onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' }) - }, - { - title: '导出明细证据', - value: `${rows.length.toLocaleString()} 条当前页`, - detail: `覆盖 ${formatPercent(pageCoverageRate)},用于客户复盘、BI 核对和报表附件。`, - action: '导出CSV', - color: rows.length > 0 ? 'blue' as const : 'grey' as const, - disabled: rows.length === 0, - onClick: () => exportMileage() - }, - { - title: '发送交付结论', - value: mileageDeliveryState, - detail: mileageDeliveryState === '可交付' ? '复制交付包后可直接发给客户或BI使用。' : '交付前需要补齐闭合、异常、覆盖或在线影响说明。', - action: '复制交付包', - color: mileageDeliveryColor, - disabled: false, - onClick: () => copyMileageDeliveryPackage() - } - ]; - const mileageAcceptanceItems = [ - { - label: '范围确认', - value: currentVehicleKeyword || '全部车辆', - detail: `${mileageRangeText} / ${currentProtocol || '全部数据通道'}`, - color: currentVehicleKeyword ? 'blue' as const : 'orange' as const, - disabled: false, - onClick: () => copyStatisticsSummary() - }, - { - label: '区间闭合', - value: closureStatus, - detail: closureDeltaKm == null ? '当前为分页抽样,交付前需要拉齐全量明细。' : `闭合差值 ${formatKm(Math.abs(closureDeltaKm))} km。`, - color: closureStatusColor, - disabled: false, - onClick: () => copyPublishAudit() - }, - { - label: '明细覆盖', - value: formatPercent(pageCoverageRate), - detail: `${rows.length.toLocaleString()} / ${summary.recordCount.toLocaleString()} 条记录,覆盖全量后再交付更稳。`, - color: pageCoverageRate >= 100 ? 'green' as const : 'orange' as const, - disabled: rows.length === 0, - onClick: () => exportMileage() - }, - { - label: '异常复核', - value: `${anomalyRows.length.toLocaleString()} 条`, - detail: anomalyRows[0] ? `${anomalyRows[0].plate || anomalyRows[0].vin} / ${anomalyRows[0].date} 优先回放。` : '当前明细未发现异常日里程。', - color: anomalyRows.length > 0 ? 'orange' as const : 'green' as const, - disabled: false, - onClick: () => anomalyRows[0] ? openMileageEvidence(anomalyRows[0]) : copyMileageDecision() - }, - { - label: '在线影响', - value: `${offlineVehicleCount.toLocaleString()} 离线`, - detail: offlineVehicleCount > 0 ? '离线车辆会影响当日解释和客户通知。' : '在线状态未形成主要统计风险。', - color: offlineVehicleCount > 0 ? 'orange' as const : 'green' as const, - disabled: false, - onClick: () => copyOnlineOperationsReport() - } - ]; - const mileageAcceptanceActions = [ - { - label: '复制验收', - action: '交付说明', - color: mileageDeliveryColor, - disabled: false, - onClick: () => copyMileageDeliveryPackage() - }, - { - label: '复核轨迹', - action: '轨迹', - color: currentVehicleKeyword ? 'blue' as const : 'grey' as const, - disabled: !currentVehicleKeyword || !onOpenHistory, - onClick: () => onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' }) - }, - { - label: '导出明细', - action: 'CSV', - color: rows.length > 0 ? 'blue' as const : 'grey' as const, - disabled: rows.length === 0, - onClick: () => exportMileage() - } - ]; +}; - const loadSummary = (values: Record = filters) => { - setSummaryLoading(true); - api.mileageSummary(mileageParams(values)) - .then((nextSummary) => setSummary(normalizeMileageSummary(nextSummary))) - .catch((error: Error) => Toast.error(error.message)) - .finally(() => setSummaryLoading(false)); - api.onlineStatisticsSummary(mileageParams(values)) - .then((nextSummary) => setOnlineSummary({ - ...emptyOnlineSummary, - ...(nextSummary ?? {}), - protocolStats: Array.isArray(nextSummary?.protocolStats) ? nextSummary.protocolStats : [] - })) - .catch((error: Error) => Toast.error(error.message)); - }; +function localDate(value = new Date()) { + const offset = value.getTimezoneOffset() * 60_000; + return new Date(value.getTime() - offset).toISOString().slice(0, 10); +} - const load = (values: Record = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => { - setLoading(true); - api.dailyMileage(mileageParams(values, pageSize, (page - 1) * pageSize)) - .then((nextPage) => { - setRows(Array.isArray(nextPage.items) ? nextPage.items : []); - setPagination({ currentPage: page, pageSize, total: nextPage.total }); - }) - .catch((error: Error) => Toast.error(error.message)) - .finally(() => setLoading(false)); +function defaultWindow(days = 30) { + const end = new Date(); + return { + dateFrom: localDate(new Date(end.getTime() - (days - 1) * DAY)), + dateTo: localDate(end) }; +} - const loadOnlineRows = (values: Record = filters, page = onlinePagination.currentPage, pageSize = onlinePagination.pageSize) => { - setOnlineLoading(true); - api.onlineVehicleStatuses(mileageParams(values, pageSize, (page - 1) * pageSize)) - .then((nextPage) => { - setOnlineRows(Array.isArray(nextPage.items) ? nextPage.items : []); - setOnlinePagination({ currentPage: page, pageSize, total: nextPage.total }); - }) - .catch((error: Error) => Toast.error(error.message)) - .finally(() => setOnlineLoading(false)); +function initialMileageFilters(initialVin: string, initialProtocol = '', initialFilters: Record = {}): MileageFilters { + const defaults = defaultWindow(); + return { + keyword: initialFilters.keyword ?? initialVin, + protocol: initialFilters.protocol ?? initialProtocol, + dateFrom: initialFilters.dateFrom ?? defaults.dateFrom, + dateTo: initialFilters.dateTo ?? defaults.dateTo }; +} - const applyFilters = (nextFilters: Record) => { - setFilters(nextFilters); - onFiltersChange?.(nextFilters); - loadSummary(nextFilters); - load(nextFilters, 1, pagination.pageSize); - loadOnlineRows(nextFilters, 1, onlinePagination.pageSize); - }; +function queryParams(filters: MileageFilters, pageSize?: number, offset?: number) { + const params = new URLSearchParams({ dateFrom: filters.dateFrom, dateTo: filters.dateTo }); + if (filters.keyword.trim()) params.set('keyword', filters.keyword.trim()); + if (filters.protocol) params.set('protocol', filters.protocol); + if (pageSize != null) params.set('limit', String(pageSize)); + if (offset != null) params.set('offset', String(offset)); + return params; +} - const clearRangeFilters = () => { - applyFilters(currentVehicleKeyword ? { keyword: currentVehicleKeyword } : {}); - }; +function sharedFilters(filters: MileageFilters): Record { + return Object.fromEntries(Object.entries(filters).filter(([, value]) => value.trim())); +} - const openMileageEvidence = (row: DailyMileageRow) => { - const dateFrom = row.date?.trim() ?? ''; - const dateTo = nextDate(dateFrom); - onOpenHistory?.({ - keyword: row.vin, - protocol: row.source, - ...(dateFrom ? { dateFrom } : {}), - ...(dateTo ? { dateTo } : {}) - }); - }; - const openMileageRawEvidence = (row: DailyMileageRow) => { - const dateFrom = row.date?.trim() ?? ''; - const dateTo = nextDate(dateFrom); - onOpenRaw?.({ - keyword: row.vin, - protocol: row.source, - ...(dateFrom ? { dateFrom } : {}), - ...(dateTo ? { dateTo } : {}) - }); - }; - const copyMileageEvidence = (row: DailyMileageRow) => { - copyText(mileageEvidencePackageText(row), '里程证据包'); - }; - const exportMileage = () => { - if (rows.length === 0) { - Toast.warning('当前没有可导出的里程明细'); - return; - } - downloadCsv(exportFileName(filters), buildCsv(mileageExportColumns, rows)); - Toast.success(`已导出 ${rows.length} 条里程明细`); - }; - const copyStatisticsSummary = () => { - const vehicleURL = currentVehicleKeyword - ? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol })) - : undefined; - copyText( - mileageSummaryText({ - filters, - summary, - pageMileageTotal, - anomalyCount: anomalyRows.length, - peakMileage, - statisticsURL: appURL(window.location.hash || buildAppHash({ page: 'mileage', keyword: currentVehicleKeyword, protocol: currentProtocol })), - vehicleURL - }), - '统计摘要' - ); - }; - const copyMileageDeliveryPackage = () => { - const vehicleURL = currentVehicleKeyword - ? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol })) - : undefined; - copyText( - mileageDeliveryPackageText({ - filters, - summary, - pageMileageTotal, - pageCoverageRate, - anomalyCount: anomalyRows.length, - closureStatus, - closureDeltaKm, - confidenceStatus, - publishAuditConclusion, - onlineSummary, - peakMileage, - statisticsURL: appURL(window.location.hash || buildAppHash({ page: 'mileage', keyword: currentVehicleKeyword, protocol: currentProtocol })), - vehicleURL - }), - '客户里程复核交付包' - ); - }; - const copyMileageDecision = () => { - const vehicleURL = currentVehicleKeyword - ? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol })) - : undefined; - copyText( - mileageCustomerDecisionText({ - filters, - summary, - pageMileageTotal, - pageCoverageRate, - anomalyCount: anomalyRows.length, - closureStatus, - closureDeltaKm, - confidenceStatus, - publishAuditConclusion, - onlineSummary, - peakMileage, - statisticsURL: appURL(window.location.hash || buildAppHash({ page: 'mileage', keyword: currentVehicleKeyword, protocol: currentProtocol })), - vehicleURL - }), - '客户里程决策说明' - ); - }; - const copyMileageReconciliationControl = () => { - const vehicleURL = currentVehicleKeyword - ? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol })) - : undefined; - copyText( - mileageReconciliationControlText({ - filters, - summary, - pageMileageTotal, - closureStatus, - closureDeltaKm, - anomalyCount: anomalyRows.length, - pageCoverageRate, - onlineSummary, - peakMileage, - statisticsURL: appURL(window.location.hash || buildAppHash({ page: 'mileage', keyword: currentVehicleKeyword, protocol: currentProtocol })), - vehicleURL - }), - '客户里程对账总控包' - ); - }; - const copyBIPublishText = () => { - const vehicleURL = currentVehicleKeyword - ? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol })) - : undefined; - copyText( - mileageBIPublishText({ - filters, - summary, - pageMileageTotal, - pageCoverageRate, - anomalyCount: anomalyRows.length, - closureStatus, - closureDeltaKm, - confidenceStatus, - sourceConsistencyText, - statisticsURL: appURL(window.location.hash || buildAppHash({ page: 'mileage', keyword: currentVehicleKeyword, protocol: currentProtocol })), - vehicleURL - }), - 'BI里程发布口径' - ); - }; - const copyPublishAudit = () => { - const vehicleURL = currentVehicleKeyword - ? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol })) - : undefined; - copyText( - mileagePublishAuditReport({ - filters, - items: publishAuditItems, - summary, - statisticsURL: appURL(window.location.hash || buildAppHash({ page: 'mileage', keyword: currentVehicleKeyword, protocol: currentProtocol })), - vehicleURL - }), - '统计发布审计' - ); - }; - const copyStatisticsImpact = () => { - const vehicleURL = currentVehicleKeyword - ? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol })) - : undefined; - copyText( - statisticsImpactText({ - filters, - summary, - onlineSummary, - closureStatus, - confidenceStatus, - anomalyCount: anomalyRows.length, - pageCoverageRate, - publishAuditConclusion, - statisticsURL: appURL(window.location.hash || buildAppHash({ page: 'mileage', keyword: currentVehicleKeyword, protocol: currentProtocol })), - vehicleURL - }), - '统计影响范围' - ); - }; - const copyOnlineStatusHandoff = () => { - copyText( - onlineStatusHandoffText({ - filters, - summary: onlineSummary, - rows: onlineRows, - total: onlinePagination.total - }), - '在线状态说明' - ); - }; - const copyOnlineOperationsReport = () => { - const vehicleURL = currentVehicleKeyword - ? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol })) - : undefined; - copyText( - onlineOperationsReportText({ - filters, - summary: onlineSummary, - onlineRows, - total: onlinePagination.total, - statisticsURL: appURL(window.location.hash || buildAppHash({ page: 'mileage', keyword: currentVehicleKeyword, protocol: currentProtocol })), - vehicleURL - }), - '在线影响评估' - ); - }; - const copyMileageReportJourneyPackage = () => { - const vehicleURL = currentVehicleKeyword - ? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol })) - : undefined; - const reportPeriod = mileageRangeText; - const trajectoryProof = peakMileage - ? `${peakMileage.plate || peakMileage.vin} / ${peakMileage.date} / ${formatKm(peakMileage.dailyMileageKm)} km` - : '暂无轨迹证明样本'; - copyText([ - '【里程报表交付旅程包】', - `报表周期:${reportPeriod}`, - `车辆范围:${currentVehicleKeyword || '全部车辆'}`, - `数据通道:${currentProtocol || '全部数据通道'}`, - `区间里程:${formatKm(summary.totalMileageKm)} km`, - `区间闭合:${closureStatus}`, - `记录覆盖:${formatPercent(pageCoverageRate)}`, - `异常记录:${anomalyRows.length.toLocaleString()} 条`, - `轨迹证明:${trajectoryProof}`, - `导出发布:${mileageDeliveryState} / ${publishAuditConclusion}`, - `在线影响:${onlineVehicleCount.toLocaleString()} 在线 / ${offlineVehicleCount.toLocaleString()} 离线 / 在线率 ${formatPercent(onlineRatePercent)}`, - `里程统计:${appURL(window.location.hash || buildAppHash({ page: 'mileage', keyword: currentVehicleKeyword, protocol: currentProtocol }))}`, - `轨迹回放:${appURL(buildAppHash({ page: 'history', keyword: currentVehicleKeyword, protocol: currentProtocol, filters: { ...(filters.dateFrom ? { dateFrom: filters.dateFrom } : {}), ...(filters.dateTo ? { dateTo: filters.dateTo } : {}) } }))}`, - `历史明细:${appURL(buildAppHash({ page: 'history-query', keyword: currentVehicleKeyword, protocol: currentProtocol, filters: { ...(filters.dateFrom ? { dateFrom: filters.dateFrom } : {}), ...(filters.dateTo ? { dateTo: filters.dateTo } : {}), tab: 'raw', includeFields: 'true' } }))}`, - ...(vehicleURL ? [`车辆服务:${vehicleURL}`] : []) - ].join('\n'), '里程报表交付旅程包'); - }; - const mileageReportJourneyItems = [ - { - step: '1', - title: '报表周期', - value: mileageRangeText, - detail: `${currentVehicleKeyword || '全部车辆'} / ${currentProtocol || '全部数据通道'},先确认客户要看的车辆和时间段。`, - color: currentVehicleKeyword ? 'blue' as const : 'orange' as const, - disabled: false, - onClick: copyStatisticsSummary - }, - { - step: '2', - title: '区间闭合', - value: closureStatus, - detail: closureDeltaKm == null ? `当前明细覆盖 ${formatPercent(pageCoverageRate)},发布前需要拉齐全量。` : `闭合差值 ${formatKm(Math.abs(closureDeltaKm))} km。`, - color: closureStatusColor, - disabled: false, - onClick: copyPublishAudit - }, - { - step: '3', - title: '轨迹证明', - value: peakMileage?.plate || peakMileage?.vin || '待选车辆', - detail: peakMileage ? `${peakMileage.date} 最大单日 ${formatKm(peakMileage.dailyMileageKm)} km,建议回放轨迹证明。` : '选择车辆后可进入同一时间窗轨迹回放。', - color: currentVehicleKeyword ? 'blue' as const : 'grey' as const, - disabled: !currentVehicleKeyword || !onOpenHistory, - onClick: () => { - if (peakMileage) { - openMileageEvidence(peakMileage); - return; - } - onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' }); - } - }, - { - step: '4', - title: '导出发布', - value: mileageDeliveryState, - detail: `${publishAuditConclusion},导出明细和交付包给客户或 BI 复核。`, - color: mileageDeliveryColor, - disabled: false, - onClick: copyMileageReportJourneyPackage - } - ]; - const mileageDeliveryConsoleItems = [ - { - label: '交付状态', - value: mileageDeliveryState, - detail: mileageDeliveryState === '可交付' - ? '可进入客户查询、BI发布和报表交付。' - : mileageDeliveryState === '暂缓交付' - ? '存在阻断项,先完成闭合和异常复核。' - : '复核闭合、覆盖、异常或在线影响后再交付。', - color: mileageDeliveryColor, - action: '复制交付', - onClick: copyMileageDeliveryPackage - }, - { - label: '区间里程', - value: `${formatKm(summary.totalMileageKm)} km`, - detail: `范围 ${mileageRangeText},统计车辆 ${summary.vehicleCount.toLocaleString()} 辆。`, - color: 'blue' as const, - action: '复制摘要', - onClick: copyStatisticsSummary - }, - { - label: '闭合校验', - value: closureStatus, - detail: closureDeltaKm == null ? '当前页未覆盖全量记录,发布前需要导出或缩小范围。' : `闭合差值 ${formatKm(Math.abs(closureDeltaKm))} km。`, - color: closureStatusColor, - action: '发布审计', - onClick: copyPublishAudit - }, - { - label: '异常复核', - value: `${anomalyRows.length.toLocaleString()} 条`, - detail: anomalyRows[0] ? `${anomalyRows[0].plate || anomalyRows[0].vin} / ${anomalyRows[0].date} 需要复核。` : '当前明细没有异常标记。', - color: anomalyRows.length > 0 ? 'orange' as const : 'green' as const, - action: '轨迹复核', - onClick: () => { - if (anomalyRows[0]) { - openMileageEvidence(anomalyRows[0]); - return; - } - onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' }); - } - }, - { - label: '数据导出', - value: `${rows.length.toLocaleString()} 条明细`, - detail: `当前页覆盖 ${formatPercent(pageCoverageRate)},用于客户复盘和BI核对。`, - color: rows.length > 0 ? 'blue' as const : 'grey' as const, - action: '导出明细', - onClick: exportMileage - } - ]; - const mileageReconciliationControlItems = [ - { - title: '区间里程', - value: `${formatKm(summary.totalMileageKm)} km`, - detail: `${summary.vehicleCount.toLocaleString()} 辆车,${mileageRangeText}。`, - action: '复制摘要', - color: 'blue' as const, - disabled: false, - onClick: copyStatisticsSummary - }, - { - title: '每日闭合', - value: closureStatus, - detail: closureDeltaKm == null ? `当前页覆盖 ${formatPercent(pageCoverageRate)},交付前需要拉齐全量明细。` : `闭合差值 ${formatKm(Math.abs(closureDeltaKm))} km。`, - action: '闭合审计', - color: closureStatusColor, - disabled: false, - onClick: copyPublishAudit - }, - { - title: '轨迹证明', - value: peakMileage?.date || mileageRangeText, - detail: peakMileage ? `${peakMileage.plate || peakMileage.vin} 最大单日 ${formatKm(peakMileage.dailyMileageKm)} km。` : '选择车辆后按同一时间窗回放轨迹。', - action: '轨迹复核', - color: currentVehicleKeyword ? 'blue' as const : 'grey' as const, - disabled: !currentVehicleKeyword || !onOpenHistory, - onClick: () => { - if (peakMileage) { - openMileageEvidence(peakMileage); - return; - } - onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' }); - } - }, - { - title: '报表导出', - value: `${rows.length.toLocaleString()} 条`, - detail: '导出当前里程明细和口径说明,作为客户对账附件。', - action: '导出报表', - color: rows.length > 0 ? 'blue' as const : 'grey' as const, - disabled: rows.length === 0, - onClick: exportMileage - } - ]; - const mileageReconciliationItems = [ - { - label: '区间里程', - value: `${formatKm(summary.totalMileageKm)} km`, - detail: `车辆 ${summary.vehicleCount.toLocaleString()} 辆,时间窗 ${mileageRangeText}。`, - color: 'blue' as const, - action: '复制摘要', - onClick: copyStatisticsSummary - }, - { - label: '日报闭合', - value: closureStatus, - detail: closureDeltaKm == null ? `当前页覆盖 ${formatPercent(pageCoverageRate)},交付前需要拉齐明细。` : `闭合差值 ${formatKm(Math.abs(closureDeltaKm))} km。`, - color: closureStatusColor, - action: '闭合审计', - onClick: copyPublishAudit - }, - { - label: '轨迹证据', - value: peakMileage?.date || mileageRangeText, - detail: peakMileage ? `${peakMileage.plate || peakMileage.vin} 最大单日 ${formatKm(peakMileage.dailyMileageKm)} km。` : '选择车辆后可按同一时间窗回放轨迹。', - color: currentVehicleKeyword ? 'blue' as const : 'grey' as const, - action: '轨迹回放', - disabled: !currentVehicleKeyword || !onOpenHistory, - onClick: () => { - if (peakMileage) { - openMileageEvidence(peakMileage); - return; - } - onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' }); - } - }, - { - label: '告警通知', - value: `${offlineVehicleCount.toLocaleString()} 离线`, - detail: offlineVehicleCount > 0 ? '离线车辆会影响当日里程、定位和客户通知。' : '当前在线状态未形成主要统计风险。', - color: offlineVehicleCount > 0 ? 'orange' as const : 'green' as const, - action: '在线态势', - onClick: copyOnlineOperationsReport - }, - { - label: '报表导出', - value: `${rows.length.toLocaleString()} 条`, - detail: '导出当前明细,作为客户对账、BI 核对和报表附件。', - color: rows.length > 0 ? 'blue' as const : 'grey' as const, - action: '导出CSV', - disabled: rows.length === 0, - onClick: exportMileage - } - ]; - const mileageDefinitionMetrics = [ - { - label: '区间总值', - value: `${formatKm(summary.totalMileageKm)} km`, - detail: `来自汇总口径,覆盖 ${summary.vehicleCount.toLocaleString()} 辆车。`, - color: 'blue' as const, - onClick: copyStatisticsSummary - }, - { - label: '明细合计', - value: `${formatKm(pageMileageTotal)} km`, - detail: `${rows.length.toLocaleString()} / ${summary.recordCount.toLocaleString()} 条明细,覆盖 ${formatPercent(pageCoverageRate)}。`, - color: hasFullMileagePage ? 'green' as const : 'orange' as const, - onClick: copyPublishAudit - }, - { - label: '闭合差值', - value: closureDeltaKm == null ? '需全量' : `${formatKm(Math.abs(closureDeltaKm))} km`, - detail: closureDeltaKm == null ? '当前为分页抽样,交付前需要拉齐全量明细。' : closureActionText, - color: closureStatusColor, - onClick: copyPublishAudit - }, - { - label: '异常记录', - value: `${anomalyRows.length.toLocaleString()} 条`, - detail: anomalyRows[0] ? `${anomalyRows[0].plate || anomalyRows[0].vin} / ${anomalyRows[0].date} 优先复核。` : '当前页没有异常日里程。', - color: anomalyRows.length > 0 ? 'orange' as const : 'green' as const, - onClick: () => anomalyRows[0] ? copyMileageEvidence(anomalyRows[0]) : copyMileageDecision() - } - ]; - const mileageDefinitionActions = [ - { - label: '复制口径', - action: '决策说明', - color: 'blue' as const, - disabled: false, - onClick: copyMileageDecision - }, - { - label: '复制交付', - action: '交付包', - color: mileageDeliveryColor, - disabled: false, - onClick: copyMileageDeliveryPackage - }, - { - label: '轨迹复核', - action: '轨迹', - color: currentVehicleKeyword ? 'blue' as const : 'grey' as const, - disabled: !currentVehicleKeyword || !onOpenHistory, - onClick: () => onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' }) - }, - { - label: '导出明细', - action: 'CSV', - color: rows.length > 0 ? 'blue' as const : 'grey' as const, - disabled: rows.length === 0, - onClick: exportMileage - } - ]; - const mileageServiceTasks = [ - { - title: '发布判断', - value: publishAuditConclusion, - detail: publishAuditConclusion === '可发布' ? '当前统计范围可进入客户报表或 BI 口径。' : '发布前需要补齐闭合、覆盖、异常或在线证据。', - color: publishAuditColor, - primaryAction: 'BI口径', - secondaryAction: '发布审计', - disabled: false, - secondaryDisabled: false, - onPrimary: copyBIPublishText, - onSecondary: copyPublishAudit - }, - { - title: '区间复核', - value: closureStatus, - detail: `范围 ${mileageRangeText},${closureActionText}`, - color: closureStatusColor, - primaryAction: '轨迹证据', - secondaryAction: '历史明细', - disabled: !currentVehicleKeyword || !onOpenHistory, - secondaryDisabled: !currentVehicleKeyword || !onOpenRaw, - onPrimary: () => onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' }), - onSecondary: () => onOpenRaw?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '', includeFields: 'true' }) - }, - { - title: '异常处理', - value: `${anomalyRows.length.toLocaleString()} 条异常`, - detail: anomalyRows[0] ? `${anomalyRows[0].plate || anomalyRows[0].vin} / ${anomalyRows[0].date} 需要复核。` : '当前页未发现异常里程记录。', - color: anomalyRows.length > 0 ? 'orange' as const : 'green' as const, - primaryAction: '复制证据', - secondaryAction: '车辆档案', - disabled: !anomalyRows[0], - secondaryDisabled: !currentVehicleKeyword, - onPrimary: () => anomalyRows[0] && copyMileageEvidence(anomalyRows[0]), - onSecondary: () => currentVehicleKeyword && onOpenVehicle(currentVehicleKeyword, currentProtocol) - }, - { - title: '明细导出', - value: `${rows.length.toLocaleString()} 条当前页`, - detail: `累计 ${formatKm(summary.totalMileageKm)} km,当前页 ${formatKm(pageMileageTotal)} km。`, - color: 'blue' as const, - primaryAction: '导出明细', - secondaryAction: '统计摘要', - disabled: rows.length === 0, - secondaryDisabled: false, - onPrimary: exportMileage, - onSecondary: copyStatisticsSummary - } - ]; - const mileageNextActions = [ - { - title: publishAuditConclusion === '阻断发布' ? '先解除发布阻断' : publishAuditConclusion === '复核后发布' ? '先完成发布复核' : '可交付里程报告', - value: publishAuditConclusion, - detail: publishAuditConclusion === '可发布' - ? '闭合、覆盖和异常检查通过后,可复制交付包或导出明细给客户。' - : '先处理闭合、覆盖、异常和在线影响,再进入客户交付。', - color: publishAuditColor, - action: publishAuditConclusion === '可发布' ? '复制交付包' : '发布审计', - disabled: false, - onClick: publishAuditConclusion === '可发布' ? copyMileageDeliveryPackage : copyPublishAudit - }, - { - title: anomalyRows.length > 0 ? '优先复核异常日' : '按时间窗核对轨迹', - value: anomalyRows.length > 0 ? `${anomalyRows.length.toLocaleString()} 条异常` : mileageRangeText, - detail: anomalyRows[0] - ? `${anomalyRows[0].plate || anomalyRows[0].vin} / ${anomalyRows[0].date} 建议先回放轨迹。` - : '用同一个车辆和时间窗进入轨迹回放,核对位置、速度和里程变化。', - color: anomalyRows.length > 0 ? 'orange' as const : 'blue' as const, - action: '轨迹回放', - disabled: !currentVehicleKeyword || !onOpenHistory, - onClick: () => { - if (anomalyRows[0]) { - openMileageEvidence(anomalyRows[0]); - return; - } - onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' }); - } - }, - { - title: pageCoverageRate >= 100 ? '明细覆盖完整' : '拉齐全量明细', - value: formatPercent(pageCoverageRate), - detail: pageCoverageRate >= 100 - ? '当前页已经覆盖筛选范围内全部明细,可直接导出复核。' - : `${rows.length.toLocaleString()} / ${summary.recordCount.toLocaleString()} 条记录在当前页,导出前建议拉齐。`, - color: pageCoverageRate >= 100 ? 'green' as const : 'orange' as const, - action: '导出明细', - disabled: rows.length === 0, - onClick: exportMileage - }, - { - title: offlineVehicleCount > 0 ? '关注离线影响' : '在线状态正常', - value: `${offlineVehicleCount.toLocaleString()} 离线`, - detail: offlineVehicleCount > 0 - ? '离线车辆会影响实时位置、当日里程和告警触达,建议同步运营处理。' - : '当前筛选范围内在线状态未形成主要统计风险。', - color: offlineVehicleCount > 0 ? 'orange' as const : 'green' as const, - action: '在线态势', - disabled: false, - onClick: copyOnlineOperationsReport - } - ]; - const mileageCustomerQuestions = [ - { - question: '这段时间总共跑了多少?', - answer: `${formatKm(summary.totalMileageKm)} km / ${summary.vehicleCount.toLocaleString()} 辆车`, - action: '复制摘要', - color: 'blue' as const, - disabled: false, - onClick: copyStatisticsSummary - }, - { - question: '这份里程能给客户或 BI 吗?', - answer: `${mileageDeliveryState} / ${publishAuditConclusion}`, - action: mileageDeliveryState === '可交付' ? '复制交付包' : '发布审计', - color: mileageDeliveryColor, - disabled: false, - onClick: mileageDeliveryState === '可交付' ? copyMileageDeliveryPackage : copyPublishAudit - }, - { - question: '哪天或哪辆车有异常?', - answer: anomalyRows[0] ? `${anomalyRows[0].plate || anomalyRows[0].vin} / ${anomalyRows[0].date}` : '当前明细无异常', - action: anomalyRows[0] ? '异常证据' : '复制决策', - color: anomalyRows.length > 0 ? 'orange' as const : 'green' as const, - disabled: false, - onClick: () => anomalyRows[0] ? copyMileageEvidence(anomalyRows[0]) : copyMileageDecision() - }, - { - question: '这段路能回放轨迹吗?', - answer: currentVehicleKeyword ? mileageRangeText : '请先选择车辆', - action: '轨迹复核', - color: currentVehicleKeyword ? 'blue' as const : 'grey' as const, - disabled: !currentVehicleKeyword || !onOpenHistory, - onClick: () => onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' }) - }, - { - question: '明细数据能导出吗?', - answer: `${rows.length.toLocaleString()} 条当前页 / 覆盖 ${formatPercent(pageCoverageRate)}`, - action: '导出明细', - color: rows.length > 0 ? 'blue' as const : 'grey' as const, - disabled: rows.length === 0, - onClick: exportMileage - } - ]; +function formatKm(value?: number) { + if (value == null || !Number.isFinite(value)) return '—'; + return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 }).format(value); +} - useEffect(() => { - const nextFilters = mergeInitialFilters(initialVin, initialProtocol, initialFilters); - setFilters(nextFilters); - loadSummary(nextFilters); - load(nextFilters, 1, pagination.pageSize); - loadOnlineRows(nextFilters, 1, onlinePagination.pageSize); - }, [initialVin, initialProtocol, JSON.stringify(initialFilters)]); +function MileageTrendChart({ points }: { points: MileageTrendPoint[] }) { + if (!points.length) { + return
当前日期范围没有里程数据
; + } + + const width = 960; + const height = 280; + const left = 62; + const right = 22; + const top = 18; + const bottom = 42; + const max = Math.max(...points.map((point) => point.mileageKm), 1); + const x = (index: number) => left + (width - left - right) * (points.length === 1 ? 0.5 : index / (points.length - 1)); + const y = (value: number) => top + (height - top - bottom) * (1 - value / max); + const path = points.map((point, index) => `${index === 0 ? 'M' : 'L'}${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, 0.5, 1].map((ratio) => ( + + + + {ratio === 0 ? '0' : formatKm(max * ratio)} + + + ))} + + + {points.length <= 45 ? points.map((point, index) => ( + + {point.date}:{formatKm(point.mileageKm)} km,{point.vehicles} 辆 + + )) : null} + {labelIndexes.map((index) => ( + + {points[index].date} + + ))} + +
+ ); +} + +export function Mileage({ + initialVin, + initialProtocol, + initialFilters = {}, + onFiltersChange +}: MileageProps) { + const initial = useMemo( + () => initialMileageFilters(initialVin, initialProtocol, initialFilters), + [initialVin, initialProtocol, JSON.stringify(initialFilters)] + ); + const [draft, setDraft] = useState(initial); + const [filters, setFilters] = useState(initial); + const [statistics, setStatistics] = useState(); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 }); + + const load = async (nextFilters: MileageFilters, page = 1, pageSize = pagination.pageSize) => { + setLoading(true); + try { + const [stats, detail] = await Promise.all([ + api.mileageStatistics(queryParams(nextFilters)), + api.dailyMileage(queryParams(nextFilters, pageSize, (page - 1) * pageSize)) + ]); + setStatistics(stats); + setRows(detail.items ?? []); + setPagination({ currentPage: page, pageSize, total: detail.total ?? 0 }); + } catch (error) { + Toast.error(error instanceof Error ? error.message : '里程数据加载失败'); + setStatistics(undefined); + setRows([]); + setPagination((current) => ({ ...current, currentPage: page, pageSize, total: 0 })); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + setDraft(initial); + setFilters(initial); + void load(initial, 1, pagination.pageSize); + }, [initial]); + + const applyFilters = (next: MileageFilters) => { + if (!next.dateFrom || !next.dateTo) { + Toast.warning('请选择完整的日期范围'); + return; + } + if (next.dateFrom > next.dateTo) { + Toast.warning('开始日期不能晚于结束日期'); + return; + } + const days = Math.floor((new Date(next.dateTo).getTime() - new Date(next.dateFrom).getTime()) / DAY) + 1; + if (days > 366) { + Toast.warning('单次最多查询 366 个自然日'); + return; + } + const normalized = { ...next, keyword: next.keyword.trim() }; + setDraft(normalized); + setFilters(normalized); + onFiltersChange?.(sharedFilters(normalized)); + void load(normalized, 1, pagination.pageSize); + }; + + const setQuickRange = (days: number) => { + const range = defaultWindow(days); + applyFilters({ ...draft, ...range }); + }; + + const summaryItems = [ + { label: '区间总里程', value: `${formatKm(statistics?.periodMileageKm)} km`, note: '按车辆和自然日归并' }, + { label: '统计车辆', value: statistics ? `${statistics.vehicleCount.toLocaleString()} 辆` : '—', note: `${statistics?.recordCount ?? 0} 个有效车辆日` }, + { label: '单车平均', value: `${formatKm(statistics?.averageMileagePerVin)} km`, note: '区间总里程 / 车辆数' }, + { label: '车日均里程', value: `${formatKm(statistics?.averageDailyMileageKm)} km`, note: '有效车辆日平均' } + ]; + + return ( +
onOpenVehicle(currentVehicleKeyword, currentProtocol)}> - 当前车辆服务 - - )} + title="车辆里程统计" + description="选择车辆和日期范围,查看每日里程趋势与明细列表" /> -
-
- - 客户里程对账总控 - {mileageDeliveryState} - {closureStatus} - - - 把区间里程、每日闭合、轨迹证明、异常通知和报表导出收敛成客户可验收的对账链路。 - - - 客户只需要知道这段时间跑了多少、是否闭合、哪里需要复核,以及能拿到哪份导出报表。 - - - - - - -
-
- {mileageReconciliationControlItems.map((item) => ( - +
+ + +
-
-
- {mileageCustomerWorkbenchItems.map((item) => ( - - ))} -
-
-
-
- - 里程对账结论 - {mileageDeliveryState} - - - 先回答客户这段时间跑了多少、闭合是否通过、异常是否需要复核,再进入轨迹、历史和导出。 - -
-
- {mileageReconciliationConclusionItems.map((item) => ( - - ))} -
-
-
-
- - 客户里程对账台 - {mileageDeliveryState} - {publishAuditConclusion} - - 用同一辆车和同一时间窗完成区间里程、日报闭合、轨迹证据、告警通知和报表导出。 - - 面向客户的统计页只围绕车辆服务展开:先看里程结论,再用轨迹和在线态势解释风险,最后交付 CSV 或复核说明。 - -
-
- {mileageReconciliationItems.map((item) => ( - - ))} -
-
-
-
- - 客户里程交付台 - {mileageDeliveryState} - {closureStatus} - - 按自定义时间范围交付里程、闭合校验、异常复核和导出证据。 - 客户看统计页时先拿到可交付结论,再按同一车辆和时间窗进入轨迹、历史明细、发布审计和 CSV 导出。 -
-
- {mileageDeliveryConsoleItems.map((item) => ( - - ))} -
-
-
- 当前车辆:{currentVehicleKeyword || '-'} - 数据通道:{currentProtocol || '全部数据通道'} - 里程统计以车辆为对象,当前先覆盖里程、在线影响、闭合审计和异常复核。 -
- -
- - 客户里程统计 - {mileageDeliveryState} - {closureStatus} - - 先定车辆和时间范围,再判断里程能否交付 - - 里程统计从车辆服务出发:选择车辆或车队、设定时间窗、判断区间里程和发布状态,再进入轨迹、历史明细和导出明细。 - - - - - - - -
-
- {mileageCustomerQuerySteps.map((item) => ( - - ))} -
+
- -
-
- - {mileageDeliveryState} - {publishAuditConclusion} - {closureStatus} - - 把统计结果变成客户可交付的里程说明 - - 把里程统计变成客户能看懂的交付路径:先确认口径,再回放轨迹,最后导出明细和结论。 - -
-
- {mileageDeliveryNavigation.map((item) => ( - - ))} -
-
-
-
-
- - 里程客户验收清单 - {mileageDeliveryState} - {publishAuditConclusion} - - - 客户交付前只看五个门禁:范围、闭合、覆盖、异常、在线影响;任何一项不通过都能直接进入复核动作。 - - - 这份清单把里程统计从技术查询变成客户验收口径,确保区间总值、每日明细、异常解释和导出证据能讲清楚。 - -
-
- {mileageAcceptanceItems.map((item) => ( - - ))} -
-
- {mileageAcceptanceActions.map((item) => ( - - ))} -
-
- -
- {mileageCustomerQuestions.map((item) => ( - - ))} -
-
- -
-
- - {publishAuditConclusion} - {mileageDeliveryState} - {confidenceStatus} - - 从车辆服务视角安排下一步 - - 里程页不再让客户先理解不同接入协议的差异,而是直接给出交付、轨迹回放、历史查询、导出和在线影响的下一步动作。 - -
-
- {mileageNextActions.map((item) => ( - - ))} -
-
-
- -
- - 当前统计证据 - {currentEvidenceText} - 三类数据源最终汇总为一个车辆服务统计视图 - -
-
- {[ - { - title: '里程统计', - value: `${formatKm(summary.totalMileageKm)} km`, - color: 'blue' as const, - detail: '每日里程、区间里程、异常差值和可审计证据。' - }, - { - title: '在线率与离线时长', - value: formatPercent(onlineRatePercent), - color: 'green' as const, - detail: onlineStatsDetail || '按 VIN 统计在线率、离线时间和无更新时长。' - }, - { - title: '数据完整性', - value: confidenceStatus, - color: confidenceColor, - detail: '围绕位置、里程、SOC、速度和数据通道新鲜度判断统计可信度。' - }, - { - title: '通道一致性', - value: sourceConsistencyText, - color: summary.sourceCount > 1 ? 'green' as const : 'orange' as const, - detail: '不同接入来源只作为同一车辆服务的数据通道,用来支撑统计可信度。' - } - ].map((item) => ( -
- {item.title} -
{item.value}
- {item.detail} -
- ))} -
-
- 客户里程决策台} - style={{ marginTop: 16 }} - > -
-
- - {mileageDeliveryState} - {publishAuditConclusion} - {confidenceStatus} - - 先判断能不能交付,再进入轨迹、历史明细和导出复核 - - 客户关心的是区间里程是否可信、异常是否解释清楚、离线是否影响统计。数据通道只作为可追溯依据,不作为页面主入口。 - - - - - - - -
-
- {mileageDecisionItems.map((item) => ( - - ))} -
-
-
- -
-
- - {mileageDeliveryState} - {closureStatus} - -
{formatKm(summary.totalMileageKm)} km
- - 面向客户交付里程时,先确认区间里程、闭合状态、异常记录和在线影响,再导出明细或进入轨迹与历史明细复核。 - - - - - - -
-
- {[ - { - label: '区间里程', - value: `${formatKm(summary.totalMileageKm)} km`, - detail: `范围 ${mileageRangeText},当前页明细 ${formatKm(pageMileageTotal)} km。`, - color: 'blue' as const, - action: '复制交付', - onClick: copyMileageDeliveryPackage - }, - { - label: '闭合校验', - value: closureStatus, - detail: closureDeltaKm == null ? '当前页未覆盖全量记录,交付前需导出或缩小范围复核。' : `闭合差值 ${formatKm(Math.abs(closureDeltaKm))} km。`, - color: closureStatusColor, - action: '闭合摘要', - onClick: copyStatisticsSummary - }, - { - label: '异常记录', - value: anomalyRows.length.toLocaleString(), - detail: anomalyRows[0] ? `${anomalyRows[0].plate || anomalyRows[0].vin} / ${anomalyRows[0].date} 需要复核。` : '当前页没有异常里程记录。', - color: anomalyRows.length > 0 ? 'orange' as const : 'green' as const, - action: '异常明细', - onClick: () => anomalyRows[0] ? copyMileageEvidence(anomalyRows[0]) : copyMileageDeliveryPackage - }, - { - label: '在线影响', - value: formatPercent(onlineRatePercent), - detail: `${onlineVehicleCount.toLocaleString()} 在线 / ${offlineVehicleCount.toLocaleString()} 离线,影响当日里程完整性判断。`, - color: onlineRatePercent >= 90 ? 'green' as const : onlineRatePercent >= 60 ? 'orange' as const : 'red' as const, - action: '在线态势', - onClick: copyOnlineOperationsReport - } - ].map((item) => ( - - ))} -
-
-
- 统计影响范围} - style={{ marginTop: 16 }} - > -
-
- {statisticsImpactLevel} -
{summary.vehicleCount.toLocaleString()} 辆车
- - 当前筛选会影响 {summary.recordCount.toLocaleString()} 条里程记录、{formatKm(summary.totalMileageKm)} km 里程口径和 {offlineVehicleCount.toLocaleString()} 辆离线车辆判断。 - - - {confidenceStatus} - {closureStatus} - {publishAuditConclusion} - -
-
- {statisticsImpactItems.map((item) => ( -
- {item.label} - {item.value} - {item.detail} -
- ))} -
-
-
- -
- {mileageServiceTasks.map((item) => ( -
-
- {item.title} - {item.value} -
- {item.detail} - - - - -
- ))} -
-
- -
{ - const nextFilters = values as Record; - applyFilters(nextFilters); - }}> - - - GB32960 - JT808 - YUTONG_MQTT - - - - - - - - -
- {filterSummary.length > 0 ? ( - - - {filterSummary.map((item) => ( - {item} - ))} - - - - ) : null} - -
-
-
-
- 车辆里程核对 - - 面向车辆运营查看区间里程、异常记录、在线影响和复核动作。 - -
- - {closureStatus} - {publishAuditConclusion} - -
-
- {mileageCustomerKpis.map((item) => ( - - ))} -
-
-
-
日里程趋势
-
- {dateSeries.length > 0 ? dateSeries.slice(0, 10).map((item) => ( -
- {item.date} -
0 ? Math.max(4, (item.value / maxDateMileage) * 100) : 0}%` }} />
- {formatKm(item.value)} km -
- )) : 暂无日里程趋势。} -
-
-
-
来源贡献
-
- {sourceSeries.length > 0 ? sourceSeries.slice(0, 5).map((item) => ( -
- {item.source} -
0 ? Math.max(4, (item.value / maxSourceMileage) * 100) : 0}%` }} />
- {formatKm(item.value)} km -
- )) : 暂无来源贡献。} -
-
-
-
- -
-
-
- {[ - { label: '车辆数', value: summary.vehicleCount.toLocaleString() }, - { label: '累计里程 km', value: formatKm(summary.totalMileageKm) }, - { label: '单车平均 km', value: formatKm(summary.averageMileagePerVin) }, - { label: '来源 / 记录', value: `${summary.sourceCount}/${summary.recordCount.toLocaleString()}` } - ].map((item) => ( - -
{item.value}
-
{item.label}
-
+ +
+ {summaryItems.map((item, index) => ( +
+ {item.label} + {item.value} + {item.note} +
))} -
+ + 在线影响评估} - style={{ marginTop: 16 }} + loading={loading} + className="vp-mileage-report-chart-card" + title={( +
+
每日里程趋势{filters.dateFrom} 至 {filters.dateTo}
+ {statistics?.trend.length ?? 0} 个有效自然日 +
+ )} > -
-
- - = 90 ? 'green' : onlineRatePercent >= 60 ? 'orange' : 'red'}> - 在线率 {formatPercent(onlineRatePercent)} - - - Redis {onlineSummary.redisOnlineKeys == null ? '-' : onlineSummary.redisOnlineKeys.toLocaleString()} - - -
{onlineVehicleCount.toLocaleString()} 在线
- - 在线状态用于解释统计缺口、离线车辆影响和当日里程是否需要延迟发布。 - -
-
- {[ - { - label: '离线车辆', - value: offlineVehicleCount.toLocaleString(), - detail: '离线车辆会影响实时统计、位置查询和当日里程完整性。', - color: offlineVehicleCount > 0 ? 'orange' as const : 'green' as const - }, - { - label: '来源覆盖', - value: onlineProtocolStats.length.toLocaleString(), - detail: onlineProtocolStats.length > 0 ? onlineProtocolStats.map((item) => `${item.protocol} ${item.online}/${item.total}`).join(';') : '暂无来源在线统计。', - color: onlineProtocolStats.length > 0 ? 'blue' as const : 'grey' as const - }, - { - label: '当前页离线', - value: onlineRows.filter((row) => !row.online).length.toLocaleString(), - detail: `${onlineRows.length.toLocaleString()} 条在线状态明细参与当前页复核。`, - color: onlineRows.some((row) => !row.online) ? 'orange' as const : 'green' as const - }, - { - label: '最长离线', - value: topOfflineRow ? formatOfflineDuration(topOfflineRow.offlineDurationMinutes) : '-', - detail: topOfflineRow ? `${topOfflineRow.plate || topOfflineRow.vin} / ${topOfflineRow.lastSeen || '-'}` : '当前页暂无离线车辆。', - color: topOfflineRow ? 'orange' as const : 'green' as const - }, - { - label: '统计证据', - value: onlineSummary.evidence || '-', - detail: '后端在线统计证据,可与 Redis key 和车辆快照互相校验。', - color: onlineSummary.evidence ? 'blue' as const : 'grey' as const - } - ].map((item) => ( -
- {item.label} - {item.value} - {item.detail} -
- ))} -
-
+
- -
- {[ - { label: '汇总总里程', value: `${formatKm(summary.totalMileageKm)} km`, color: 'blue' as const, detail: '后端按当前筛选范围返回的区间里程总值。' }, - { label: '明细合计', value: `${formatKm(pageMileageTotal)} km`, color: 'green' as const, detail: '当前页日里程明细求和,用于快速复核。' }, - { label: '闭合差值', value: closureDeltaKm == null ? '需全量' : `${formatKm(Math.abs(closureDeltaKm))} km`, color: closureStatusColor, detail: closureActionText }, - { label: '记录覆盖率', value: formatPercent(pageCoverageRate), color: pageCoverageRate >= 100 ? 'green' as const : 'grey' as const, detail: `${rows.length.toLocaleString()} / ${summary.recordCount.toLocaleString()} 条明细在当前页。` } - ].map((item) => ( -
- {item.label} -
{item.value}
- {item.detail} -
- ))} -
-
- {closureStatus} - - {closurePassed && anomalyRows.length === 0 ? '当前里程口径可直接用于 BI 里程口径;若存在跨通道差异,再进入车辆服务查看统计依据。' : '统计进入 BI 前需要结合异常记录、轨迹回放和历史明细完成复核。'} - - - - - - -
-
- -
-
- {confidenceStatus} -
- {confidenceStatus === '可用于 BI' && closureStatus === '闭合通过' ? '可发布' : '需复核后发布'} -
- - 日里程按首末总里程差值计算,区间总值必须等于每日里程之和;发布前同步检查异常记录、分页覆盖和通道一致性。 - + + +
里程明细按日期倒序展示每辆车的日里程
+ 共 {pagination.total.toLocaleString()} 条
-
-
闭合状态{closureStatus}
-
记录覆盖{formatPercent(pageCoverageRate)}
-
异常记录{anomalyRows.length.toLocaleString()}
-
通道一致性{sourceConsistencyText}
-
- - - - -
-
- -
-
- {publishAuditConclusion} -
- 通过 {publishAuditItems.length - publishReviewCount - publishBlockedCount} / 复核 {publishReviewCount} / 阻断 {publishBlockedCount} -
- - 发布前统一审计闭合、覆盖、异常、数据通道和在线状态;不满足时先回到轨迹和历史明细复核。 - - - - - -
-
- {publishAuditItems.map((item) => ( -
- - {auditStatusLabel(item.status)} - {item.label} - - {item.value} - {item.detail} -
{item.action}
-
- ))} -
-
-
-
- -
- {dateSeries.length > 0 ? dateSeries.map((item) => ( -
- {item.date} -
- 0 ? Math.max(4, (item.value / maxDateMileage) * 100) : 0}%` }} /> -
- {formatKm(item.value)} km -
- )) : ( - 当前筛选范围暂无可展示趋势。 - )} -
-
- -
- {sourceSeries.length > 0 ? sourceSeries.map((item) => ( -
- {item.source} -
- 0 ? Math.max(4, (item.value / maxSourceMileage) * 100) : 0}%` }} /> -
- {formatKm(item.value)} km -
- )) : ( - 当前筛选范围暂无来源贡献。 - )} -
-
-
-
- {[ - { label: '统计可信度', value: confidenceStatus, color: confidenceColor, detail: anomalyRows.length > 0 ? '存在异常里程记录,建议先核对统计依据。' : '当前分页未发现异常记录。' }, - { label: '异常率', value: formatPercent(anomalyRate), color: anomalyRows.length > 0 ? 'orange' as const : 'green' as const, detail: `${anomalyRows.length.toLocaleString()} / ${rows.length.toLocaleString()} 条明细命中异常标记。` }, - { label: '最大单日占比', value: formatPercent(peakMileageShare), color: peakMileageShare > 50 ? 'orange' as const : 'blue' as const, detail: peakMileage ? `${peakMileage.plate || peakMileage.vin} 贡献最高单日里程。` : '暂无明细。' }, - { label: '当前页里程', value: `${formatKm(pageMileageTotal)} km`, color: 'blue' as const, detail: '用于快速核对当前分页明细合计。' }, - { label: '异常记录', value: anomalyRows.length.toLocaleString(), color: anomalyRows.length > 0 ? 'orange' as const : 'green' as const, detail: '来自异常标记,优先核对首末总里程和断链。' }, - { label: '最大单日', value: peakMileage ? `${formatKm(peakMileage.dailyMileageKm)} km` : '-', color: 'blue' as const, detail: peakMileage ? `${peakMileage.plate || peakMileage.vin} / ${peakMileage.date}` : '暂无明细。' }, - { label: '分页覆盖率', value: formatPercent(pageCoverageRate), color: pageCoverageRate >= 100 ? 'green' as const : 'grey' as const, detail: '当前分页记录数 / 全量统计记录数。' }, - { - label: '闭合校验', - value: closureDeltaKm == null ? '需全量核验' : `${formatKm(Math.abs(closureDeltaKm))} km`, - color: closurePassed ? 'green' as const : closureDeltaKm == null ? 'grey' as const : 'orange' as const, - detail: closureDeltaKm == null ? '当前分页未覆盖全量记录,需翻页或导出全量后核验。' : '汇总总里程与当前明细合计的差值。' - } - ].map((item) => ( - - {item.label} -
{item.value}
- {item.detail} -
- ))} -
- -
- - 在线 {onlineVehicleCount.toLocaleString()} 车 - 离线 {offlineVehicleCount.toLocaleString()} 车 - 按车辆服务归并 VIN,展示最近上报时间、离线时长和来源覆盖。 - - -
- row?.vin ?? ''} - dataSource={onlineRows} - pagination={{ - currentPage: onlinePagination.currentPage, - pageSize: onlinePagination.pageSize, - total: onlinePagination.total, - showSizeChanger: true, - onPageChange: (page) => loadOnlineRows(filters, page, onlinePagination.pageSize), - onPageSizeChange: (pageSize) => loadOnlineRows(filters, 1, pageSize) - }} - columns={[ - { title: 'VIN', dataIndex: 'vin', width: 190 }, - { title: '车牌', dataIndex: 'plate', width: 120 }, - { title: 'OEM', dataIndex: 'oem', width: 120 }, - { - title: '在线', - width: 90, - render: (_: unknown, row: OnlineVehicleStatusRow) => {row.online ? '在线' : '离线'} - }, - { title: '最近上报', dataIndex: 'lastSeen', width: 180 }, - { - title: '离线时长', - width: 130, - render: (_: unknown, row: OnlineVehicleStatusRow) => formatOfflineDuration(row.offlineDurationMinutes) - }, - { - title: '来源覆盖', - width: 150, - render: (_: unknown, row: OnlineVehicleStatusRow) => `${row.onlineSourceCount}/${row.sourceCount}` - }, - { - title: '来源明细', - width: 240, - render: (_: unknown, row: OnlineVehicleStatusRow) => ( - - {(row.protocols ?? []).map((protocol) => {protocol})} - - ) - }, - { title: '绑定状态', dataIndex: 'bindingStatus', width: 130 }, - { - title: '操作', - width: 120, - render: (_: unknown, row: OnlineVehicleStatusRow) => ( - - ) - } - ]} - /> - - -
- - 可复制日报口径 - - -
-
-
- 日里程 -

按车辆、来源、日期聚合,以总里程差值作为日统计结果,避免中间断链导致分段累计不闭合。

-
-
- 区间里程 -

按当前筛选范围汇总日里程,区间总值应等于各日里程之和,用于 BI 和运营报表口径。

-
-
- 异常复核 -

异常优先检查补传、通道切换、总里程回退和跨通道差异,必要时回到车辆服务查看历史明细。

-
-
-
- -
- - 当前页 {rows.length.toLocaleString()} 条 - - -
+ )} + >
row ? `${row.vin}-${row.date}-${row.source}` : 'mileage-row'} loading={loading} - rowKey={(row?: DailyMileageRow) => `${row?.date ?? ''}-${row?.vin ?? ''}-${row?.source ?? ''}`} dataSource={rows} + scroll={{ x: 900 }} pagination={{ currentPage: pagination.currentPage, pageSize: pagination.pageSize, total: pagination.total, showSizeChanger: true, - onPageChange: (page) => load(filters, page, pagination.pageSize), - onPageSizeChange: (pageSize) => load(filters, 1, pageSize) + onPageChange: (page) => void load(filters, page, pagination.pageSize), + onPageSizeChange: (pageSize) => void load(filters, 1, pageSize) }} + empty={
当前日期范围没有里程明细
} columns={[ - { title: '日期', dataIndex: 'date' }, - { title: 'VIN', dataIndex: 'vin' }, - { title: '车牌', dataIndex: 'plate' }, - { title: '起始里程', dataIndex: 'startMileageKm' }, - { title: '结束里程', dataIndex: 'endMileageKm' }, - { title: '日里程', dataIndex: 'dailyMileageKm' }, - { title: '来源', dataIndex: 'source' }, - { title: '异常', render: (_: unknown, row: DailyMileageRow) => row.anomalySeverity ? {row.anomalySeverity} : 正常 }, - { - title: '处置建议', - width: 260, - render: (_: unknown, row: DailyMileageRow) => { - const action = mileageActionRecommendation(row); - return ( -
- {action.label} -
{action.detail}
-
- ); - } - }, - { - title: '操作', - width: 360, - render: (_: unknown, row: DailyMileageRow) => ( - - - - - - - ) - } + { title: '日期', dataIndex: 'date', width: 120 }, + { title: '车辆', width: 220, render: (_value, row: DailyMileageRow) =>
{row.plate || '未绑定车牌'}{row.vin}
}, + { title: '起始里程 (km)', dataIndex: 'startMileageKm', width: 140, render: (value: number) => formatKm(value) }, + { title: '结束里程 (km)', dataIndex: 'endMileageKm', width: 140, render: (value: number) => formatKm(value) }, + { title: '日里程 (km)', dataIndex: 'dailyMileageKm', width: 130, render: (value: number) => {formatKm(value)} }, + { title: '来源', dataIndex: 'source', width: 120, render: (value: string) => {value || '未知'} } ]} /> + +
+ 趋势按车辆和自然日去重汇总 + 更新时间:{statistics?.asOf || '—'} + 日期范围最多 366 天 +
); } diff --git a/vehicle-data-platform/apps/web/src/styles/global.css b/vehicle-data-platform/apps/web/src/styles/global.css index 358f05d2..353b607a 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -752,6 +752,277 @@ body { font-size: 13px; } +/* Mileage report: one query, one trend, one detail list. */ +.vp-mileage-report { + max-width: 1680px; + margin: 0 auto; +} + +.vp-mileage-report-filter-card { + overflow: visible; +} + +.vp-mileage-report-filter { + display: grid; + grid-template-columns: minmax(260px, 1.8fr) repeat(2, minmax(160px, 0.8fr)) minmax(160px, 0.8fr) auto; + align-items: end; + gap: 14px; + padding: 18px 20px; +} + +.vp-mileage-report-filter label { + display: grid; + min-width: 0; + gap: 7px; + color: var(--vp-text-muted); + font-size: 13px; +} + +.vp-mileage-report-filter input { + width: 100%; + height: 32px; + box-sizing: border-box; + padding: 0 12px; + border: 1px solid var(--semi-color-border); + border-radius: 6px; + outline: none; + background: var(--semi-color-fill-0); + color: var(--vp-text); + font: inherit; + transition: border-color 0.16s ease, box-shadow 0.16s ease; +} + +.vp-mileage-report-filter input:hover { + border-color: var(--semi-color-primary); +} + +.vp-mileage-report-filter input:focus { + border-color: var(--semi-color-primary); + box-shadow: 0 0 0 2px rgba(51, 112, 255, 0.12); +} + +.vp-mileage-report-quick-ranges { + grid-column: 1 / -1; + display: flex; + gap: 8px; + margin-top: -2px; +} + +.vp-mileage-report-quick-ranges button { + padding: 4px 10px; + border: 0; + border-radius: 6px; + background: #f2f5fa; + color: var(--vp-text-muted); + cursor: pointer; + font: inherit; + font-size: 12px; +} + +.vp-mileage-report-quick-ranges button:hover, +.vp-mileage-report-quick-ranges button:focus-visible { + background: rgba(51, 112, 255, 0.1); + color: var(--semi-color-primary); + outline: none; +} + +.vp-mileage-report-kpis { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; + margin: 16px 0; +} + +.vp-mileage-report-kpis article { + display: grid; + gap: 6px; + min-width: 0; + padding: 18px 20px; + border: 1px solid var(--vp-border); + border-radius: var(--vp-radius); + background: var(--vp-surface); + box-shadow: var(--vp-shadow-sm); +} + +.vp-mileage-report-kpis article.is-primary { + border-color: rgba(51, 112, 255, 0.26); + background: linear-gradient(135deg, #f7faff 0%, #ffffff 72%); +} + +.vp-mileage-report-kpis span, +.vp-mileage-report-kpis small { + overflow: hidden; + color: var(--vp-text-muted); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vp-mileage-report-kpis strong { + overflow: hidden; + color: var(--vp-text); + font-size: clamp(22px, 2vw, 30px); + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vp-mileage-report-chart-card, +.vp-mileage-report-table-card { + margin-top: 16px; +} + +.vp-mileage-report-card-title { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.vp-mileage-report-card-title > div { + display: grid; + gap: 3px; +} + +.vp-mileage-report-card-title strong { + color: var(--vp-text); + font-size: 15px; +} + +.vp-mileage-report-card-title span { + color: var(--vp-text-muted); + font-size: 12px; + font-weight: 400; +} + +.vp-mileage-report-chart-wrap { + width: 100%; + min-height: 260px; + overflow: hidden; +} + +.vp-mileage-report-chart-wrap svg { + display: block; + width: 100%; + height: auto; + min-height: 260px; +} + +.vp-mileage-report-grid-line { + stroke: #e8edf5; + stroke-width: 1; +} + +.vp-mileage-report-axis-text { + fill: #8491a5; + font-size: 11px; +} + +.vp-mileage-report-area { + fill: url(#mileage-area); +} + +.vp-mileage-report-line { + fill: none; + stroke: #3370ff; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 3; +} + +.vp-mileage-report-point { + fill: #fff; + stroke: #3370ff; + stroke-width: 2; +} + +.vp-mileage-report-empty { + min-height: 180px; + display: grid; + place-items: center; + color: var(--vp-text-muted); + font-size: 13px; +} + +.vp-mileage-report-vehicle { + display: grid; + gap: 3px; +} + +.vp-mileage-report-vehicle strong { + color: var(--vp-text); +} + +.vp-mileage-report-vehicle span { + overflow: hidden; + max-width: 210px; + color: var(--vp-text-muted); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vp-mileage-report-daily-value { + color: #245bdb; +} + +.vp-mileage-report-footnote { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px 20px; + padding: 12px 2px 0; + color: var(--vp-text-muted); + font-size: 12px; +} + +@media (max-width: 1180px) { + .vp-mileage-report-filter { + grid-template-columns: minmax(240px, 1.5fr) repeat(2, minmax(150px, 1fr)); + } + + .vp-mileage-report-filter label:nth-of-type(4) { + grid-column: 1 / 2; + } +} + +@media (max-width: 760px) { + .vp-mileage-report-filter { + grid-template-columns: 1fr 1fr; + padding: 16px; + } + + .vp-mileage-report-vehicle-field, + .vp-mileage-report-filter label:nth-of-type(4), + .vp-mileage-report-filter > .semi-button, + .vp-mileage-report-quick-ranges { + grid-column: 1 / -1; + } + + .vp-mileage-report-kpis { + grid-template-columns: 1fr 1fr; + gap: 10px; + } + + .vp-mileage-report-kpis article { + padding: 14px; + } + + .vp-mileage-report-card-title { + align-items: flex-start; + } + + .vp-mileage-report-chart-wrap, + .vp-mileage-report-chart-wrap svg { + min-height: 220px; + } + + .vp-mileage-report-footnote { + justify-content: flex-start; + } +} + .vp-dashboard-secondary-details, .vp-dashboard-evidence-details { margin-bottom: 16px; diff --git a/vehicle-data-platform/apps/web/src/test/Mileage.test.tsx b/vehicle-data-platform/apps/web/src/test/Mileage.test.tsx new file mode 100644 index 00000000..cf9afb65 --- /dev/null +++ b/vehicle-data-platform/apps/web/src/test/Mileage.test.tsx @@ -0,0 +1,46 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, expect, test, vi } from 'vitest'; +import { Mileage } from '../pages/Mileage'; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +test('renders date-range mileage summary, trend and daily detail list', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const path = String(input); + const data = path.includes('/api/v2/statistics/mileage') + ? { + dateFrom: '2026-07-01', dateTo: '2026-07-03', vehicleCount: 1, recordCount: 3, sourceCount: 1, + periodMileageKm: 210.5, fleetLatestMileageKm: 48000, averageMileagePerVin: 210.5, + averageDailyMileageKm: 70.2, trend: [ + { date: '2026-07-01', mileageKm: 60, vehicles: 1 }, + { date: '2026-07-02', mileageKm: 70, vehicles: 1 }, + { date: '2026-07-03', mileageKm: 80.5, vehicles: 1 } + ], ranking: [], asOf: '2026-07-04 00:05:00', evidence: 'vehicle_daily_mileage' + } + : { + items: [{ vin: 'VIN-MILEAGE-001', plate: '粤A12345', date: '2026-07-03', startMileageKm: 47919.5, endMileageKm: 48000, dailyMileageKm: 80.5, source: 'JT808' }], + total: 1, limit: 20, offset: 0 + }; + return { ok: true, json: async () => ({ data, traceId: 'trace-mileage', timestamp: Date.now() }) } as Response; + }); + + render( + undefined} + /> + ); + + expect(screen.getByRole('heading', { name: '车辆里程统计' })).toBeInTheDocument(); + expect(await screen.findByLabelText('每日里程趋势图')).toBeInTheDocument(); + expect((await screen.findAllByText('210.5 km')).length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('粤A12345')).toBeInTheDocument(); + expect(screen.getAllByText('80.5').length).toBeGreaterThanOrEqual(1); + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateFrom=2026-07-01'), undefined)); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('keyword=%E7%B2%A4A12345'), undefined); +}); diff --git a/vehicle-data-platform/apps/web/src/v2/domain/access.test.ts b/vehicle-data-platform/apps/web/src/v2/domain/access.test.ts index 33968a81..c3f6a7ee 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/access.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/access.test.ts @@ -17,9 +17,10 @@ describe('access domain helpers', () => { }); it('exports explicit state and evidence fields', () => { - const csv = accessRowsToCSV([{ vin: 'VIN1', plate: '粤A1', oem: '', model: '', company: '示范企业', protocol: 'JT808', provider: '', source: '', firstSeenAt: '', latestEventAt: '', latestReceivedAt: '', reportIntervalSec: null, dataDelaySec: 2, freshnessSec: 3, onlineState: 'online', thresholdSec: 60, latestMessageType: '位置,数据', latestEventId: '', latestError: '', delayAbnormal: false, firstSeenEvidence: '', firstSeenSource: '', reportIntervalEvidence: '', reportSampleCount: 2 }]); + const csv = accessRowsToCSV([{ vin: 'VIN1', plate: '粤A1', oem: '', model: '', company: '示范企业', protocol: 'JT808', provider: '', source: '', firstSeenAt: '', latestEventAt: '', latestReceivedAt: '', reportIntervalSec: null, dataDelaySec: 2, freshnessSec: 3, onlineState: 'online', thresholdSec: 60, latestMessageType: '位置,数据', latestEventId: '', latestError: '', delayAbnormal: false, firstSeenEvidence: '', firstSeenSource: '', reportIntervalEvidence: '', reportSampleCount: 2, expectedProtocols: ['GB32960', 'JT808', 'YUTONG_MQTT'], actualProtocols: ['JT808'], missingProtocols: ['GB32960', 'YUTONG_MQTT'], protocolStatuses: [{ protocol: 'JT808', expected: true, connected: true, provider: 'G7', firstSeenAt: '2026-07-01T00:00:00+08:00', latestEventAt: '', latestReceivedAt: '2026-07-15T09:00:00+08:00', reportIntervalSec: 10, dataDelaySec: 2, freshnessSec: 3, onlineState: 'online', thresholdSec: 60, delayAbnormal: false, firstSeenEvidence: '网关首次观测', reportIntervalEvidence: '连续样本' }], connectionState: 'incomplete', expectationEvidence: '平台标准接入基线' }]); expect(csv).toContain('在线'); - expect(csv).toContain('"位置,数据"'); + expect(csv).toContain('"GB32960/YUTONG_MQTT"'); + expect(csv).toContain('"2026-07-15T09:00:00+08:00"'); expect(csv).toContain('"示范企业"'); }); }); diff --git a/vehicle-data-platform/apps/web/src/v2/domain/access.ts b/vehicle-data-platform/apps/web/src/v2/domain/access.ts index 51a77e2b..cc28c9ff 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/access.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/access.ts @@ -38,8 +38,15 @@ export function updateProtocolThreshold(items: AccessProtocolThreshold[], protoc } export function accessRowsToCSV(rows: AccessVehicleRow[]) { - const columns = ['在线状态', '车牌', 'VIN', '厂家', '车型', '企业', '协议', '接入厂家', '首次接入', '首次接入证据', '最新事件时间', '最新接收时间', '上报间隔(秒)', '持久样本数', '上报间隔证据', '数据延迟(秒)', '动态阈值(秒)', '最新消息类型', '最近错误']; + const protocolColumns = ['GB32960', 'JT808', 'YUTONG_MQTT'].flatMap((protocol) => [`${protocol}接入状态`, `${protocol}接入厂家`, `${protocol}首次接入`, `${protocol}最新上报`, `${protocol}离线秒数`]); + const columns = ['综合状态', '车牌', 'VIN', '品牌', '车型', '企业', '应接协议', '实际接入协议', '缺失协议', ...protocolColumns]; const quote = (value: unknown) => `"${String(value ?? '').replace(/"/g, '""')}"`; - const lines = rows.map((row) => [accessStateLabels[row.onlineState], row.plate, row.vin, row.oem, row.model, row.company, row.protocol, row.provider, row.firstSeenAt, row.firstSeenEvidence, row.latestEventAt, row.latestReceivedAt, row.reportIntervalSec, row.reportSampleCount, row.reportIntervalEvidence, row.dataDelaySec, row.thresholdSec, row.latestMessageType, row.latestError].map(quote).join(',')); + const lines = rows.map((row) => { + const protocolValues = ['GB32960', 'JT808', 'YUTONG_MQTT'].flatMap((protocol) => { + const status = row.protocolStatuses.find((item) => item.protocol === protocol); + return [status?.connected ? accessStateLabels[status.onlineState] : '未接入', status?.provider, status?.firstSeenAt, status?.latestReceivedAt, status?.freshnessSec]; + }); + return [row.connectionState, row.plate, row.vin, row.oem, row.model, row.company, row.expectedProtocols.join('/'), row.actualProtocols.join('/'), row.missingProtocols.join('/'), ...protocolValues].map(quote).join(','); + }); return `\uFEFF${columns.map(quote).join(',')}\n${lines.join('\n')}`; } diff --git a/vehicle-data-platform/apps/web/src/v2/domain/mileageExport.test.ts b/vehicle-data-platform/apps/web/src/v2/domain/mileageExport.test.ts new file mode 100644 index 00000000..fcabb7c0 --- /dev/null +++ b/vehicle-data-platform/apps/web/src/v2/domain/mileageExport.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from 'vitest'; +import { createMileageWorkbook } from './mileageExport'; + +test('creates a styled numeric mileage workbook with formulas and frozen panes', async () => { + const workbook = await createMileageWorkbook({ + dateFrom: '2026-07-13', + dateTo: '2026-07-14', + dates: ['2026-07-13', '2026-07-14'], + vehicles: [ + { vin: 'LTEST000000000001', plate: '粤A12345' }, + { vin: 'LTEST000000000002', plate: '粤A54321' } + ], + mileageRows: [ + { vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 100, endMileageKm: 188.7, dailyMileageKm: 88.7, source: 'GB32960' }, + { vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 188.7, endMileageKm: 293.3, dailyMileageKm: 104.6, source: 'GB32960' } + ], + sources: [ + { protocol: 'GB32960', label: '国标 GB32960', mileageType: '仪表盘里程' }, + { protocol: 'JT808', label: '交通部 JT/T 808', mileageType: 'GPS 里程' } + ], + exportedAt: new Date('2026-07-15T08:00:00+08:00') + }); + const sheet = workbook.getWorksheet('里程查询')!; + + expect(sheet.getCell('A1').value).toBe('车辆里程查询明细'); + expect(sheet.getCell('C6').value).toBeInstanceOf(Date); + expect(sheet.getCell('C6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'center' }); + expect(sheet.getCell('D6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'center' }); + expect(sheet.getCell('A6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'left' }); + expect(sheet.getCell('E6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'right' }); + expect(sheet.getCell('C7').value).toBe(88.7); + expect(sheet.getCell('E7').value).toMatchObject({ formula: 'SUM(C7:D7)', result: 193.3 }); + expect(sheet.getCell('E7').numFmt).toBe('#,##0.0" km"'); + expect(sheet.views[0]).toMatchObject({ state: 'frozen', xSplit: 2, ySplit: 6, showGridLines: false }); + expect(sheet.autoFilter).toEqual({ from: { row: 6, column: 1 }, to: { row: 8, column: 5 } }); + expect((sheet as unknown as { conditionalFormattings: unknown[] }).conditionalFormattings).toHaveLength(1); + expect((await workbook.xlsx.writeBuffer()).byteLength).toBeGreaterThan(5_000); +}); diff --git a/vehicle-data-platform/apps/web/src/v2/domain/mileageExport.ts b/vehicle-data-platform/apps/web/src/v2/domain/mileageExport.ts new file mode 100644 index 00000000..f5a4c4ea --- /dev/null +++ b/vehicle-data-platform/apps/web/src/v2/domain/mileageExport.ts @@ -0,0 +1,161 @@ +import type { DailyMileageRow } from '../../api/types'; + +export type MileageExportVehicle = { vin: string; plate: string }; +export type MileageExportSource = { protocol: string; label: string; mileageType: string }; + +export type MileageExportInput = { + dateFrom: string; + dateTo: string; + dates: string[]; + vehicles: MileageExportVehicle[]; + mileageRows: DailyMileageRow[]; + sources: MileageExportSource[]; + exportedAt?: Date; +}; + +function excelColumn(index: number) { + let value = index; + let result = ''; + while (value > 0) { + value -= 1; + result = String.fromCharCode(65 + value % 26) + result; + value = Math.floor(value / 26); + } + return result; +} + +function localDateTime(value: Date) { + return new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false + }).format(value).split('/').join('-'); +} + +export async function createMileageWorkbook(input: MileageExportInput) { + const { Workbook } = await import('exceljs'); + const workbook = new Workbook(); + workbook.creator = '灵牛车辆数据中台'; + workbook.company = '灵牛科技'; + workbook.created = input.exportedAt ?? new Date(); + workbook.modified = input.exportedAt ?? new Date(); + workbook.calcProperties.fullCalcOnLoad = true; + + const sheet = workbook.addWorksheet('里程查询', { + views: [{ state: 'frozen', xSplit: 2, ySplit: 6, activeCell: 'C7', showGridLines: false }], + pageSetup: { orientation: 'landscape', fitToPage: true, fitToWidth: 1, fitToHeight: 0, paperSize: 9, margins: { left: .25, right: .25, top: .45, bottom: .45, header: .2, footer: .2 } }, + properties: { defaultRowHeight: 21 } + }); + const firstDateColumn = 3; + const lastDateColumn = firstDateColumn + input.dates.length - 1; + const totalColumn = lastDateColumn + 1; + const lastColumnLetter = excelColumn(totalColumn); + const headerRowNumber = 6; + const firstDataRow = headerRowNumber + 1; + const lastDataRow = firstDataRow + input.vehicles.length - 1; + const exportedAt = input.exportedAt ?? new Date(); + const sourceDescription = input.sources.map((source, index) => `${index + 1}. ${source.label}(${source.mileageType})`).join(' > '); + + const title = sheet.getCell('A1'); + title.value = '车辆里程查询明细'; + title.font = { name: 'Microsoft YaHei', size: 18, bold: true, color: { argb: 'FF17345C' } }; + title.alignment = { vertical: 'middle', horizontal: 'left' }; + title.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8F1FC' } }; + for (let column = 1; column <= totalColumn; column += 1) { + const cell = sheet.getCell(1, column); + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8F1FC' } }; + cell.border = { bottom: { style: 'medium', color: { argb: 'FFBDD0E8' } } }; + } + sheet.getRow(1).height = 38; + + sheet.getRow(2).values = ['日期范围', `${input.dateFrom} 至 ${input.dateTo}`, '车辆范围', `${input.vehicles.length} 辆`]; + sheet.getRow(3).values = ['来源优先级', sourceDescription]; + const periodTotal = input.mileageRows.reduce((sum, row) => sum + (Number.isFinite(row.dailyMileageKm) ? row.dailyMileageKm : 0), 0); + sheet.getRow(4).values = ['区间总里程', null, '有效车辆日', `${input.mileageRows.length} 条`]; + sheet.getCell('B4').value = input.vehicles.length ? { formula: `SUM(${lastColumnLetter}${firstDataRow}:${lastColumnLetter}${lastDataRow})`, result: periodTotal } : 0; + sheet.getCell('B4').numFmt = '#,##0.0" km"'; + for (let rowNumber = 2; rowNumber <= 4; rowNumber += 1) { + const row = sheet.getRow(rowNumber); + row.height = 25; + row.eachCell({ includeEmpty: true }, (cell, columnNumber) => { + cell.font = { name: 'Microsoft YaHei', size: 10, bold: columnNumber === 1 || columnNumber === 3, color: { argb: columnNumber === 1 || columnNumber === 3 ? 'FF53657D' : 'FF213149' } }; + cell.alignment = { vertical: 'middle', horizontal: columnNumber === 1 || columnNumber === 3 ? 'left' : 'right' }; + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: rowNumber % 2 ? 'FFF8FAFD' : 'FFF2F6FB' } }; + cell.border = { bottom: { style: 'thin', color: { argb: 'FFE1E8F1' } } }; + }); + } + sheet.getRow(5).height = 8; + + const header = sheet.getRow(headerRowNumber); + header.values = ['车牌', 'VIN', ...input.dates.map((date) => new Date(`${date}T12:00:00Z`)), '区间总里程']; + header.height = 30; + header.eachCell((cell, columnNumber) => { + const isDateHeader = columnNumber >= firstDateColumn && columnNumber <= lastDateColumn; + cell.font = { name: 'Microsoft YaHei', size: 10, bold: true, color: { argb: columnNumber === totalColumn ? 'FF1C4F91' : 'FF304158' } }; + cell.alignment = { + vertical: 'middle', + horizontal: columnNumber <= 2 ? 'left' : isDateHeader ? 'center' : 'right' + }; + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: columnNumber === totalColumn ? 'FFDCEBFF' : 'FFEAF0F7' } }; + cell.border = { bottom: { style: 'medium', color: { argb: 'FFC3D0DF' } } }; + if (isDateHeader) cell.numFmt = 'm/d'; + }); + + const rowsByVin = new Map>(); + for (const row of input.mileageRows) { + if (!rowsByVin.has(row.vin)) rowsByVin.set(row.vin, new Map()); + rowsByVin.get(row.vin)!.set(row.date, row); + } + input.vehicles.forEach((vehicle, index) => { + const rowNumber = firstDataRow + index; + const mileageByDate = rowsByVin.get(vehicle.vin) ?? new Map(); + const dailyValues = input.dates.map((date) => mileageByDate.get(date)?.dailyMileageKm ?? null); + const total = dailyValues.reduce((sum, value) => sum + (value ?? 0), 0); + const row = sheet.getRow(rowNumber); + row.values = [vehicle.plate || '未绑定', vehicle.vin, ...dailyValues, null]; + row.height = 25; + row.eachCell({ includeEmpty: true }, (cell, columnNumber) => { + cell.font = { name: columnNumber === 2 ? 'Consolas' : 'Microsoft YaHei', size: columnNumber === 2 ? 9 : 10, color: { argb: columnNumber === totalColumn ? 'FF1D4E89' : 'FF34445A' }, bold: columnNumber === 1 || columnNumber === totalColumn }; + cell.alignment = { vertical: 'middle', horizontal: columnNumber <= 2 ? 'left' : 'right' }; + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFF9FBFD' : 'FFFFFFFF' } }; + cell.border = { bottom: { style: 'thin', color: { argb: 'FFE6ECF3' } } }; + if (columnNumber >= firstDateColumn) cell.numFmt = '#,##0.0" km"'; + }); + const dateStart = excelColumn(firstDateColumn); + const dateEnd = excelColumn(lastDateColumn); + const totalCell = sheet.getCell(rowNumber, totalColumn); + totalCell.value = { formula: `SUM(${dateStart}${rowNumber}:${dateEnd}${rowNumber})`, result: total }; + totalCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFEDF5FF' : 'FFF4F8FE' } }; + row.commit(); + }); + + sheet.getColumn(1).width = 15; + sheet.getColumn(2).width = 24; + for (let column = firstDateColumn; column <= lastDateColumn; column += 1) sheet.getColumn(column).width = 12; + sheet.getColumn(totalColumn).width = 17; + if (input.vehicles.length) { + sheet.autoFilter = { from: { row: headerRowNumber, column: 1 }, to: { row: lastDataRow, column: totalColumn } }; + sheet.addConditionalFormatting({ + ref: `${excelColumn(firstDateColumn)}${firstDataRow}:${excelColumn(lastDateColumn)}${lastDataRow}`, + rules: [{ + type: 'colorScale', priority: 1, + cfvo: [{ type: 'min' }, { type: 'percentile', value: 50 }, { type: 'max' }], + color: [{ argb: 'FFFFFFFF' }, { argb: 'FFE8F2FF' }, { argb: 'FFB9D8FF' }] + }] + }); + } + sheet.headerFooter.oddFooter = '&L灵牛车辆数据中台&C第 &P / &N 页&R导出于 ' + localDateTime(exportedAt); + return workbook; +} + +export async function downloadMileageWorkbook(input: MileageExportInput) { + const workbook = await createMileageWorkbook(input); + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `车辆里程查询_${input.dateFrom.split('-').join('')}-${input.dateTo.split('-').join('')}_${input.vehicles.length}辆.xlsx`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 1_000); +} diff --git a/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.test.ts b/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.test.ts index ec7abe91..67db1171 100644 --- a/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { MONITOR_REFRESH, monitorMapQueryParams, monitorQueryParams } from './useMonitorData'; +import { MAX_MONITOR_SEARCH_TERMS, MONITOR_REFRESH, monitorMapQueryParams, monitorQueryParams, parseMonitorSearchTerms } from './useMonitorData'; describe('monitor query params', () => { it('keeps server-owned status filters and bounded list size', () => { @@ -20,6 +20,14 @@ describe('monitor query params', () => { expect(monitorMapQueryParams({ keyword: '粤A1', protocol: '', status: '' }, viewport).has('bounds')).toBe(false); expect(monitorMapQueryParams({ keyword: '', protocol: '', status: '' }, viewport).get('bounds')).toBe(viewport.bounds); }); + + it('normalizes pasted plates, removes duplicates, and sends one batch parameter', () => { + expect(parseMonitorSearchTerms('粤ag18312\n川AHTWO1,粤AG18312 京A00001')).toEqual(['粤AG18312', '川AHTWO1', '京A00001']); + const params = monitorQueryParams({ keyword: '粤AG18312\n川AHTWO1,粤AG18312', protocol: '', status: '' }, 200); + expect(params.get('keywords')).toBe('粤AG18312,川AHTWO1'); + expect(params.get('keyword')).toBeNull(); + expect(parseMonitorSearchTerms(Array.from({ length: MAX_MONITOR_SEARCH_TERMS + 5 }, (_, index) => `粤A${index}`).join('\n'))).toHaveLength(MAX_MONITOR_SEARCH_TERMS); + }); }); describe('monitor refresh cadence', () => { diff --git a/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.ts b/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.ts index 8ecb1572..c85ba58c 100644 --- a/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.ts +++ b/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.ts @@ -20,9 +20,28 @@ export const MONITOR_REFRESH = { alerts: 15_000 } as const; +export const MAX_MONITOR_SEARCH_TERMS = 100; + +export function parseMonitorSearchTerms(value: string) { + const terms: string[] = []; + const seen = new Set(); + for (const raw of value.split(/[\s,,、;;]+/)) { + const term = raw.trim(); + if (!term) continue; + const key = term.toLocaleUpperCase(); + if (seen.has(key)) continue; + seen.add(key); + terms.push(term.toLocaleUpperCase()); + if (terms.length === MAX_MONITOR_SEARCH_TERMS) break; + } + return terms; +} + export function monitorQueryParams(filters: MonitorFilters, limit: number) { const params = new URLSearchParams({ limit: String(limit) }); - if (filters.keyword.trim()) params.set('keyword', filters.keyword.trim()); + const terms = parseMonitorSearchTerms(filters.keyword); + if (terms.length === 1) params.set('keyword', terms[0]); + if (terms.length > 1) params.set('keywords', terms.join(',')); if (filters.protocol) params.set('protocol', filters.protocol); if (filters.status) params.set('status', filters.status); if (filters.status === 'online' || filters.status === 'offline') params.set('online', filters.status); @@ -32,11 +51,11 @@ export function monitorQueryParams(filters: MonitorFilters, limit: number) { export function monitorMapQueryParams(filters: MonitorFilters, viewport: MonitorViewport) { const params = monitorQueryParams(filters, 10_000); params.set('zoom', String(viewport.zoom)); - if (viewport.bounds && !filters.keyword.trim()) params.set('bounds', viewport.bounds); + if (viewport.bounds && parseMonitorSearchTerms(filters.keyword).length === 0) params.set('bounds', viewport.bounds); return params; } -export function useMonitorData(filters: MonitorFilters, viewport: MonitorViewport, selectedVin: string) { +export function useMonitorData(filters: MonitorFilters, viewport: MonitorViewport, selectedVin: string, mapEnabled = true, vehicleEnabled = true) { const params = monitorQueryParams(filters, 200); const mapParams = monitorMapQueryParams(filters, viewport); @@ -48,12 +67,15 @@ export function useMonitorData(filters: MonitorFilters, viewport: MonitorViewpor const vehicles = useQuery({ queryKey: ['monitor', 'vehicles', params.toString()], queryFn: () => api.vehicleRealtime(params), + enabled: vehicleEnabled, + placeholderData: (previous) => previous, refetchInterval: MONITOR_REFRESH.fleet }); const map = useQuery({ queryKey: ['monitor', 'map', mapParams.toString()], queryFn: () => api.monitorMap(mapParams), + enabled: mapEnabled, placeholderData: (previous) => previous, staleTime: 5_000, refetchInterval: MONITOR_REFRESH.fleet 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 97dbd4a5..9edfdc70 100644 --- a/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx +++ b/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx @@ -20,7 +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: '/statistics', label: '里程查询', icon: IconBarChartHStroked }, { to: '/alerts', label: '告警中心', icon: IconAlarm }, { to: '/access', label: '接入管理', icon: IconBox } ]; @@ -30,7 +30,7 @@ const pageNames: Record = { vehicles: '车辆查询', tracks: '轨迹回放', history: '历史数据', - statistics: '车辆统计', + statistics: '里程查询', alerts: '告警中心', access: '接入管理', operations: '运维质量' diff --git a/vehicle-data-platform/apps/web/src/v2/map/TrackMap.tsx b/vehicle-data-platform/apps/web/src/v2/map/TrackMap.tsx index 35358660..7a779329 100644 --- a/vehicle-data-platform/apps/web/src/v2/map/TrackMap.tsx +++ b/vehicle-data-platform/apps/web/src/v2/map/TrackMap.tsx @@ -1,29 +1,36 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import type { HistoryLocationRow, TrackPlaybackEvent } from '../../api/types'; +import type { HistoryLocationRow, TrackStop } from '../../api/types'; import { getAMapConfig, isAMapConfigured } from '../../config/appConfig'; import { isValidAMapCoordinate, loadAMap, wgs84ToGcj02, type AMapLike, type AMapMap, type AMapOverlay } from '../../integrations/amap'; -function markerContent(kind: string, label?: string) { - if (kind === 'current') return '
'; - return `
${label ?? ''}
`; +function markerContent(kind: 'start' | 'end' | 'stop' | 'current', label?: string) { + if (kind === 'current') return ''; + return `
${label ?? ''}
`; } -export function TrackMap({ points, events, activeIndex, onSelectIndex }: { +export function TrackMap({ points, stops, activeIndex, showStops, follow, onSelectIndex, onFollowChange }: { points: HistoryLocationRow[]; - events: TrackPlaybackEvent[]; + stops: TrackStop[]; activeIndex: number; + showStops: boolean; + follow: boolean; onSelectIndex: (index: number) => void; + onFollowChange: (follow: boolean) => void; }) { const containerRef = useRef(null); const mapRef = useRef(null); const amapRef = useRef(null); const overlaysRef = useRef([]); const currentMarkerRef = useRef(null); + const passedPathRef = useRef(null); + const pathRef = useRef<[number, number][]>([]); const selectRef = useRef(onSelectIndex); + const followChangeRef = useRef(onFollowChange); const [state, setState] = useState<'loading' | 'ready' | 'fallback' | 'error'>('loading'); const valid = useMemo(() => points.map((point, index) => ({ point, index })).filter(({ point }) => isValidAMapCoordinate(point.longitude, point.latitude)), [points]); useEffect(() => { selectRef.current = onSelectIndex; }, [onSelectIndex]); + useEffect(() => { followChangeRef.current = onFollowChange; }, [onFollowChange]); useEffect(() => { if (!containerRef.current || !isAMapConfigured(getAMapConfig())) { setState('fallback'); return; } @@ -34,10 +41,12 @@ export function TrackMap({ points, events, activeIndex, onSelectIndex }: { const map = new AMap.Map(containerRef.current, { zoom: first ? 13 : 5, center: first ? wgs84ToGcj02(first.longitude, first.latitude) : wgs84ToGcj02(105.4, 35.9), - viewMode: '2D', mapStyle: 'amap://styles/whitesmoke', showLabel: true, resizeEnable: true + viewMode: '2D', mapStyle: 'amap://styles/whitesmoke', showLabel: true, resizeEnable: true, + zooms: [3, 20] }); map.addControl(new AMap.Scale()); - if (AMap.ToolBar) map.addControl(new AMap.ToolBar({ position: { right: '18px', bottom: '22px' } })); + if (AMap.ToolBar) map.addControl(new AMap.ToolBar({ position: { right: '18px', bottom: '148px' } })); + map.on?.('dragstart', () => followChangeRef.current(false)); mapRef.current = map; amapRef.current = AMap; setState('ready'); @@ -49,6 +58,8 @@ export function TrackMap({ points, events, activeIndex, onSelectIndex }: { mapRef.current?.destroy(); overlaysRef.current = []; currentMarkerRef.current = null; + passedPathRef.current = null; + pathRef.current = []; mapRef.current = null; amapRef.current = null; }; @@ -56,45 +67,54 @@ export function TrackMap({ points, events, activeIndex, onSelectIndex }: { useEffect(() => { const AMap = amapRef.current; - if (state !== 'ready' || !mapRef.current || !valid.length || !AMap) return; - overlaysRef.current.forEach((overlay) => overlay.setMap?.(null)); - currentMarkerRef.current?.setMap?.(null); - const path = valid.map(({ point }) => wgs84ToGcj02(point.longitude, point.latitude)); - const polyline = new AMap.Polyline({ path, strokeColor: '#1268f3', strokeWeight: 5, strokeOpacity: 0.92, lineJoin: 'round', lineCap: 'round', showDir: true, zIndex: 80 }); - const first = valid[0]; - const last = valid[valid.length - 1]; - const overlays: AMapOverlay[] = [polyline]; - const start = new AMap.Marker({ position: wgs84ToGcj02(first.point.longitude, first.point.latitude), anchor: 'center', content: markerContent('start', '始'), zIndex: 110 }); - const end = new AMap.Marker({ position: wgs84ToGcj02(last.point.longitude, last.point.latitude), anchor: 'center', content: markerContent('end', '终'), zIndex: 110 }); - start.on?.('click', () => selectRef.current(first.index)); - end.on?.('click', () => selectRef.current(last.index)); - overlays.push(start, end); - events.slice(1, -1).forEach((event, eventIndex) => { - if (!isValidAMapCoordinate(event.longitude, event.latitude)) return; - const exactIndex = points.findIndex((point) => point.deviceTime === event.time); - const targetIndex = exactIndex >= 0 ? exactIndex : points.reduce((closest, point, index) => { - const best = points[closest]; - const distance = (point.longitude - event.longitude) ** 2 + (point.latitude - event.latitude) ** 2; - const bestDistance = (best.longitude - event.longitude) ** 2 + (best.latitude - event.latitude) ** 2; - return distance < bestDistance ? index : closest; - }, 0); - const marker = new AMap.Marker({ position: wgs84ToGcj02(event.longitude, event.latitude), anchor: 'center', content: markerContent('event', String(eventIndex + 1)), zIndex: 105 }); - marker.on?.('click', () => selectRef.current(targetIndex)); - overlays.push(marker); + const map = mapRef.current; + if (state !== 'ready' || !map || !valid.length || !AMap) return; + overlaysRef.current.forEach((overlay) => overlay.setMap?.(null)); + currentMarkerRef.current?.setMap?.(null); + + const path = valid.map(({ point }) => wgs84ToGcj02(point.longitude, point.latitude)); + pathRef.current = path; + const nextValidIndex = valid.findIndex(({ index }) => index >= activeIndex); + const activeValidIndex = nextValidIndex >= 0 ? nextValidIndex : valid.length - 1; + const fullPath = new AMap.Polyline({ path, strokeColor: '#2563eb', strokeWeight: 6, strokeOpacity: 0.82, lineJoin: 'round', lineCap: 'round', showDir: true, zIndex: 70 }); + const passedPath = new AMap.Polyline({ path: path.slice(0, activeValidIndex + 1), strokeColor: '#18a86b', strokeWeight: 7, strokeOpacity: 0.96, lineJoin: 'round', lineCap: 'round', zIndex: 80 }); + passedPathRef.current = passedPath; + + const first = valid[0]; + const last = valid[valid.length - 1]; + const start = new AMap.Marker({ position: path[0], anchor: 'center', content: markerContent('start', '始'), zIndex: 115 }); + const end = new AMap.Marker({ position: path[path.length - 1], anchor: 'center', content: markerContent('end', '终'), zIndex: 115 }); + start.on?.('click', () => selectRef.current(first.index)); + end.on?.('click', () => selectRef.current(last.index)); + const overlays: AMapOverlay[] = [fullPath, passedPath, start, end]; + + if (showStops) stops.slice(0, 80).forEach((stop, index) => { + if (!isValidAMapCoordinate(stop.longitude, stop.latitude)) return; + const marker = new AMap.Marker({ + position: wgs84ToGcj02(stop.longitude, stop.latitude), anchor: 'center', + content: markerContent('stop', String(index + 1)), zIndex: 105 }); - const active = valid.find(({ index }) => index === activeIndex) ?? first; - const current = new AMap.Marker({ position: wgs84ToGcj02(active.point.longitude, active.point.latitude), anchor: 'center', content: markerContent('current'), zIndex: 130 }); - currentMarkerRef.current = current; - overlaysRef.current = overlays; - mapRef.current.add([...overlays, current]); - mapRef.current.setFitView(overlays, false, [52, 52, 52, 52]); - }, [events, points, state, valid]); + marker.on?.('click', () => selectRef.current(Math.min(points.length - 1, Math.max(0, stop.sampledIndex)))); + overlays.push(marker); + }); + + const active = valid.find(({ index }) => index === activeIndex) ?? first; + const current = new AMap.Marker({ position: wgs84ToGcj02(active.point.longitude, active.point.latitude), anchor: 'center', content: markerContent('current'), zIndex: 130 }); + currentMarkerRef.current = current; + overlaysRef.current = overlays; + map.add([...overlays, current]); + map.setFitView([fullPath], false, [72, 72, 168, 72]); + }, [points, showStops, state, stops, valid]); useEffect(() => { const point = points[activeIndex]; if (!point || !isValidAMapCoordinate(point.longitude, point.latitude)) return; - currentMarkerRef.current?.setPosition?.(wgs84ToGcj02(point.longitude, point.latitude)); - }, [activeIndex, points]); + const position = wgs84ToGcj02(point.longitude, point.latitude); + currentMarkerRef.current?.setPosition?.(position); + const passedCount = valid.reduce((count, entry) => count + (entry.index <= activeIndex ? 1 : 0), 0); + passedPathRef.current?.setPath?.(pathRef.current.slice(0, Math.max(1, passedCount))); + if (follow) mapRef.current?.panTo?.(position, 180); + }, [activeIndex, follow, points, valid]); return
@@ -103,6 +123,5 @@ export function TrackMap({ points, events, activeIndex, onSelectIndex }: { {state === 'fallback' ? `地图未配置,已载入 ${valid.length} 个有效轨迹点` : null} {state === 'error' ? '地图加载失败,请检查高德地图配置' : null}
: null} -
开始当前点结束{valid.length} 个有效点位
; } diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx index c7ad6243..6c1a7483 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx @@ -1,117 +1,126 @@ +import { IconClose, IconDownload, IconRefresh, IconSave, IconSearch } from '@douyinfe/semi-icons'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { IconDownload, IconRefresh, IconSave, IconSearch, IconSetting } from '@douyinfe/semi-icons'; import { FormEvent, useEffect, useMemo, useState } from 'react'; import { Link, useSearchParams } from 'react-router-dom'; import { api } from '../../api/client'; -import type { AccessQuery, AccessSummary, AccessThresholdConfig, AccessThresholdUpdate, AccessUnresolvedIdentity, AccessVehicleRow } from '../../api/types'; -import { accessRowsToCSV, accessStateLabels, formatAccessTime, formatSeconds, updateProtocolThreshold } from '../domain/access'; +import type { AccessProtocolStatus, AccessQuery, AccessSummary, AccessThresholdConfig, AccessThresholdUpdate, AccessUnresolvedIdentity, AccessVehicleRow } from '../../api/types'; +import { accessRowsToCSV, formatAccessTime, formatSeconds, updateProtocolThreshold } from '../domain/access'; import { InlineError } from '../shared/AsyncState'; import { usePlatformSession } from '../auth/AuthGate'; import { canAdminister } from '../auth/session'; -const EMPTY_FILTERS = { keyword: '', protocol: '', oem: '', model: '', provider: '', firstSeenFrom: '', firstSeenTo: '', latestSeenFrom: '', latestSeenTo: '', onlineState: '', delayState: '' }; -const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT']; -const protocolColors = ['#1685c5', '#6f2da8', '#15a46d', '#7c8fd6', '#9aa4b2']; - +const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'] as const; +const EMPTY_FILTERS = { keyword: '', protocol: '', oem: '', connectionState: '', onlineState: '', model: '', provider: '', firstSeenFrom: '', firstSeenTo: '', latestSeenFrom: '', latestSeenTo: '', delayState: '' }; type Filters = typeof EMPTY_FILTERS; -function StatusLabel({ state }: { state: AccessVehicleRow['onlineState'] }) { - return {accessStateLabels[state]}; +const connectionLabels: Record = { + healthy: '三协议正常', degraded: '协议离线', incomplete: '接入不完整', offline: '全部离线', not_connected: '尚未接入' +}; + +function statusByProtocol(row: AccessVehicleRow, protocol: string) { + return row.protocolStatuses.find((item) => item.protocol === protocol); } -function ProtocolDistribution({ summary }: { summary?: AccessSummary }) { - const rows = summary?.protocols ?? []; - const total = Math.max(1, rows.reduce((sum, item) => sum + item.total, 0)); - return
协议分布同一筛选口径 · 在线率按车辆计算
-
{rows.map((item, index) => )}
-
{rows.map((item, index) => {item.name}{item.total.toLocaleString('zh-CN')} 台{item.onlineRate.toFixed(1)}% 在线)}{!rows.length ? 暂无协议分布 : null}
-
; +function compactTime(value: string) { + if (!value) return '—'; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return '—'; + return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(parsed).replace(/\//g, '-'); } -function IdentityQueue({ items, total, loading }: { items: AccessUnresolvedIdentity[]; total: number; loading: boolean }) { - const [copied, setCopied] = useState(''); - if (!loading && total === 0) return null; - const copyEvidence = async (item: AccessUnresolvedIdentity) => { - const text = [`身份待绑定:${item.identifierMasked}`, `协议:${item.protocol}`, `车牌:${item.plate || '待核对'}`, `厂家:${item.manufacturer || '待核对'}`, `来源:${item.sourceEndpoint || '未知'}`, `最近上报:${formatAccessTime(item.latestSeenAt)}`, `问题:${item.issueCode}`, `建议动作:${item.recommendedAction}`].join('\n'); - await navigator.clipboard?.writeText(text); - setCopied(item.id); - }; - return
0}>身份待绑定{loading ? '…' : total.toLocaleString('zh-CN')}不会参与车辆告警 · 需核对后维护权威 VIN -
{items.slice(0, 5).map((item) =>
{item.identifierMasked}
证据
{[item.plate, item.manufacturer, item.sourceEndpoint].filter(Boolean).join(' · ') || '仅有终端上报'}
最近上报
{formatAccessTime(item.latestSeenAt)} · {formatSeconds(item.freshnessSec)}
)}
-
; +function ProtocolState({ status, detailed = false }: { status?: AccessProtocolStatus; detailed?: boolean }) { + const state = !status?.connected ? 'missing' : status.onlineState; + const label = state === 'missing' ? '未接入' : state === 'online' ? '在线' : state === 'offline' ? '离线' : state === 'unknown' ? '未知' : '从未上报'; + if (detailed) { + return
+
{status?.protocol}{label}
+
+
接入厂家
{status?.provider || '—'}
+
首次接入
{formatAccessTime(status?.firstSeenAt || '')}
+
最新上报
{formatAccessTime(status?.latestReceivedAt || '')}
+
当前离线
{formatSeconds(status?.freshnessSec)}
+
上报间隔
{formatSeconds(status?.reportIntervalSec)}
+
数据延迟
{formatSeconds(status?.dataDelaySec)}
+
+

{status?.firstSeenEvidence || '应接协议尚未形成实时快照'}

+
; + } + return
+ {label} + {status?.connected ? compactTime(status.latestReceivedAt) : '等待接入'} + {status?.provider || (status?.connected ? '接入方未维护' : '无实时快照')} +
; } -function AccessInspector({ row }: { row?: AccessVehicleRow }) { - if (!row) return
选中车辆
选择一行查看接入证据和时间口径。
; - return
选中车辆
-
车牌
{row.plate || '—'}
VIN
{row.vin}
车型 / 企业
{[row.model, row.company].filter(Boolean).join(' / ') || '—'}
协议
{row.protocol || '—'}
厂家 / 接入方
{[row.oem, row.provider].filter(Boolean).join(' / ') || '—'}
- 查看车辆详情 -

事件与接收对比

最新事件时间
{formatAccessTime(row.latestEventAt)}
最新接收时间
{formatAccessTime(row.latestReceivedAt)}
数据延迟
{formatSeconds(row.dataDelaySec)}
上报间隔
{formatSeconds(row.reportIntervalSec)}
当前新鲜度
{formatSeconds(row.freshnessSec)}
动态阈值
{formatSeconds(row.thresholdSec)}
-

最新消息与错误

消息类型
{row.latestMessageType || '—'}
事件 ID
{row.latestEventId || '—'}
最近错误
{row.latestError || '无已知错误'}
数据来源
{row.source || '—'}
-

证据完整性

首次接入{row.firstSeenAt ? `${formatAccessTime(row.firstSeenAt)} · ${row.firstSeenEvidence}` : row.firstSeenEvidence}

上报间隔{row.reportIntervalEvidence || (row.reportIntervalSec !== null ? `由 ${row.reportSampleCount} 个持久样本计算` : '等待连续样本')}

-
; +function ConnectionState({ row }: { row: AccessVehicleRow }) { + return
{connectionLabels[row.connectionState]}{row.actualProtocols.length} / {row.expectedProtocols.length} 已接入
; } -function ThresholdPanel({ config, draft, saving, error, editable, onChange, onSave }: { config?: AccessThresholdConfig; draft?: AccessThresholdUpdate; saving: boolean; error?: string; editable: boolean; onChange: (next: AccessThresholdUpdate) => void; onSave: () => void }) { - return
在线阈值v{config?.version ?? '—'}
- {draft ?
- {PROTOCOLS.map((protocol) => )} - {error ?

{error}

: null}{editable ? :

只读角色不可修改阈值

}
:
正在读取阈值版本…
} - {config?.audit[0] ?
最近变更{config.audit[0].actor} · {formatAccessTime(config.audit[0].changedAt)}
:
配置来源MySQL 版本化配置
} -
; +function ProtocolCoverage({ summary }: { summary?: AccessSummary }) { + return
+ {PROTOCOLS.map((protocol) => { + const actual = summary?.protocols.find((item) => item.name === protocol); + const total = summary?.totalVehicles ?? 0; + return {protocol}{(actual?.total ?? 0).toLocaleString('zh-CN')} / {total.toLocaleString('zh-CN')}; + })} +
; +} + +function VehicleInspector({ row, onClose }: { row: AccessVehicleRow; onClose: () => void }) { + return ; +} + +function IdentityQueue({ items, total }: { items: AccessUnresolvedIdentity[]; total: number }) { + if (!total) return null; + return
另有 {total.toLocaleString('zh-CN')} 条来源身份待绑定这些来源不计入主车辆,绑定权威 VIN 后再归档
{items.slice(0, 6).map((item) =>
{item.identifierMasked}{item.protocol} · {item.plate || '车牌待核对'} · {formatAccessTime(item.latestSeenAt)}{item.recommendedAction}
)}
; +} + +function ThresholdSettings({ config, draft, editable, saving, error, onChange, onSave }: { config?: AccessThresholdConfig; draft?: AccessThresholdUpdate; editable: boolean; saving: boolean; error?: string; onChange: (next: AccessThresholdUpdate) => void; onSave: () => void }) { + if (!draft) return null; + return
在线判定阈值 · v{config?.version ?? '—'}
{PROTOCOLS.map((protocol) => )}{error ?

{error}

: null}{editable ? : 当前账户为只读角色}
; } function downloadRows(rows: AccessVehicleRow[]) { const blob = new Blob([accessRowsToCSV(rows)], { type: 'text/csv;charset=utf-8' }); - const href = URL.createObjectURL(blob); - const anchor = document.createElement('a'); - anchor.href = href; anchor.download = `vehicle-access-${new Date().toISOString().slice(0, 10)}.csv`; anchor.click(); - URL.revokeObjectURL(href); + const href = URL.createObjectURL(blob); const anchor = document.createElement('a'); + anchor.href = href; anchor.download = `vehicle-access-${new Date().toISOString().slice(0, 10)}.csv`; anchor.click(); URL.revokeObjectURL(href); } export default function AccessPage() { - const { session } = usePlatformSession(); const thresholdEditable = canAdminister(session); + const { session } = usePlatformSession(); const editable = canAdminister(session); const [searchParams, setSearchParams] = useSearchParams(); - const initial: Filters = Object.fromEntries(Object.keys(EMPTY_FILTERS).map((key) => [key, searchParams.get(key) ?? ''])) as Filters; - const [draft, setDraft] = useState(initial); - const [criteria, setCriteria] = useState(initial); - const [offset, setOffset] = useState(0); - const [limit, setLimit] = useState(50); - const [selectedVIN, setSelectedVIN] = useState(''); - const [thresholdDraft, setThresholdDraft] = useState(); - const queryClient = useQueryClient(); - const baseQuery: AccessQuery = useMemo(() => Object.fromEntries(Object.entries(criteria).filter(([, value]) => value)) as AccessQuery, [criteria]); - const summaryQuery = useQuery({ queryKey: ['access-summary', baseQuery], queryFn: () => api.accessSummary(baseQuery), staleTime: 10_000 }); + const initial = Object.fromEntries(Object.keys(EMPTY_FILTERS).map((key) => [key, searchParams.get(key) ?? ''])) as Filters; + const [draft, setDraft] = useState(initial); const [criteria, setCriteria] = useState(initial); + const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(50); const [selectedVIN, setSelectedVIN] = useState(''); + const [thresholdDraft, setThresholdDraft] = useState(); const queryClient = useQueryClient(); + const baseQuery = useMemo(() => Object.fromEntries(Object.entries(criteria).filter(([, value]) => value)) as AccessQuery, [criteria]); + const summaryQuery = useQuery({ queryKey: ['access-summary'], queryFn: () => api.accessSummary({}), staleTime: 15_000 }); const vehiclesQuery = useQuery({ queryKey: ['access-vehicles', baseQuery, limit, offset], queryFn: () => api.accessVehicles({ ...baseQuery, limit, offset }), placeholderData: (previous) => previous }); - const unresolvedQuery = useQuery({ queryKey: ['access-unresolved-identities', criteria.keyword, criteria.protocol], queryFn: () => api.accessUnresolvedIdentities({ keyword: criteria.keyword || undefined, protocol: criteria.protocol || undefined, limit: 20, offset: 0 }), staleTime: 10_000 }); + const unresolvedQuery = useQuery({ queryKey: ['access-unresolved-identities', criteria.keyword, criteria.protocol], queryFn: () => api.accessUnresolvedIdentities({ keyword: criteria.keyword || undefined, protocol: criteria.protocol || undefined, limit: 20, offset: 0 }), staleTime: 15_000 }); const thresholdQuery = useQuery({ queryKey: ['access-thresholds'], queryFn: api.accessThresholds, staleTime: 60_000 }); - const updateThreshold = useMutation({ mutationFn: api.updateAccessThresholds, onSuccess: async (config) => { queryClient.setQueryData(['access-thresholds'], config); setThresholdDraft({ version: config.version, defaultThresholdSec: config.defaultThresholdSec, delayThresholdSec: config.delayThresholdSec, longOfflineSec: config.longOfflineSec, protocols: config.protocols }); await Promise.all([queryClient.invalidateQueries({ queryKey: ['access-summary'] }), queryClient.invalidateQueries({ queryKey: ['access-vehicles'] })]); } }); - const rows = vehiclesQuery.data?.items ?? []; - const selected = rows.find((row) => row.vin === selectedVIN) ?? rows[0]; - - useEffect(() => { if (rows.length && !rows.some((row) => row.vin === selectedVIN)) setSelectedVIN(rows[0].vin); }, [rows, selectedVIN]); - useEffect(() => { const config = thresholdQuery.data; if (config && !thresholdDraft) setThresholdDraft({ version: config.version, defaultThresholdSec: config.defaultThresholdSec, delayThresholdSec: config.delayThresholdSec, longOfflineSec: config.longOfflineSec, protocols: config.protocols }); }, [thresholdDraft, thresholdQuery.data]); - + useEffect(() => { if (thresholdQuery.data && !thresholdDraft) setThresholdDraft({ version: thresholdQuery.data.version, defaultThresholdSec: thresholdQuery.data.defaultThresholdSec, delayThresholdSec: thresholdQuery.data.delayThresholdSec, longOfflineSec: thresholdQuery.data.longOfflineSec, protocols: thresholdQuery.data.protocols }); }, [thresholdDraft, thresholdQuery.data]); + const updateThreshold = useMutation({ mutationFn: api.updateAccessThresholds, onSuccess: async (config) => { setThresholdDraft({ version: config.version, defaultThresholdSec: config.defaultThresholdSec, delayThresholdSec: config.delayThresholdSec, longOfflineSec: config.longOfflineSec, protocols: config.protocols }); await Promise.all([queryClient.invalidateQueries({ queryKey: ['access-summary'] }), queryClient.invalidateQueries({ queryKey: ['access-vehicles'] })]); } }); + const rows = vehiclesQuery.data?.items ?? []; const selected = rows.find((row) => row.vin === selectedVIN); const syncURL = (filters: Filters) => { const next = new URLSearchParams(); Object.entries(filters).forEach(([key, value]) => { if (value) next.set(key, value); }); setSearchParams(next, { replace: true }); }; - const submit = (event: FormEvent) => { event.preventDefault(); setCriteria(draft); setOffset(0); syncURL(draft); }; - const reset = () => { setDraft(EMPTY_FILTERS); setCriteria(EMPTY_FILTERS); setOffset(0); setSearchParams({}, { replace: true }); }; - const applyState = (onlineState: string, delayState = '') => { const next = { ...criteria, onlineState, delayState }; setDraft(next); setCriteria(next); setOffset(0); syncURL(next); }; - const showIdentityQueue = () => document.getElementById('access-identity-queue')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - const page = Math.floor(offset / limit) + 1; - const totalPages = Math.max(1, Math.ceil((vehiclesQuery.data?.total ?? 0) / limit)); - const summary = summaryQuery.data; + const apply = (next: Filters) => { setDraft(next); setCriteria(next); setOffset(0); setSelectedVIN(''); syncURL(next); }; + const submit = (event: FormEvent) => { event.preventDefault(); apply(draft); }; + const page = Math.floor(offset / limit) + 1; const totalPages = Math.max(1, Math.ceil((vehiclesQuery.data?.total ?? 0) / limit)); const summary = summaryQuery.data; - return
-
更多筛选 · 车型 / 接入厂家 / 接入与上报时间
+ return
+

车辆接入管理

以主车辆为对象,对照应接协议、实际接入和各协议最新上报时间

数据时间 {summary?.asOf ? formatAccessTime(summary.asOf) : '—'}
+
{summaryQuery.isError ? summaryQuery.refetch()} /> : null} -
{[ - ['接入车辆', summary?.totalVehicles ?? 0, '', () => applyState('')], ['在线', summary?.onlineVehicles ?? 0, 'online', () => applyState('online')], ['长离线', summary?.longOfflineVehicles ?? 0, 'offline', () => applyState('offline')], ['从未上报', summary?.neverReported ?? 0, 'never', () => applyState('never_reported')], ['延迟异常', summary?.delayAbnormal ?? 0, 'delay', () => applyState('', 'abnormal')], ['身份待绑定', unresolvedQuery.data?.total ?? 0, 'identity', showIdentityQueue], ['今日上报', summary?.reportedToday ?? 0, 'today', () => applyState('')] - ].map(([label, value, tone, action]) => )}
- - {unresolvedQuery.isError ? unresolvedQuery.refetch()} /> : null} - +
{[ + ['主车辆', summary?.totalVehicles ?? 0, 'all', ''], ['有接入差异', Math.max(0, (summary?.totalVehicles ?? 0) - (summary?.healthyVehicles ?? 0)), 'attention', 'attention'], ['全部离线', summary?.offlineVehicles ?? 0, 'offline', 'offline'], ['尚未接入', summary?.neverReported ?? 0, 'never', 'not_connected'] + ].map(([label, value, tone, connectionState]) => )}
{vehiclesQuery.isError ? vehiclesQuery.refetch()} /> : null} -
车辆接入状态
阈值版本 v{summary?.thresholdVersion ?? '—'}
{rows.map((row) => )}
在线状态车牌VIN厂家协议首次接入最新事件时间最新接收时间上报间隔数据延迟动态阈值最新消息类型最近错误操作
setSelectedVIN(row.vin)} aria-label={`选择 ${row.plate || row.vin}`} />{row.plate || '—'}{row.vin}{row.oem || '—'}{row.protocol || '—'}{formatAccessTime(row.firstSeenAt)}{formatAccessTime(row.latestEventAt)}{formatAccessTime(row.latestReceivedAt)}{formatSeconds(row.reportIntervalSec)}{formatSeconds(row.dataDelaySec)}{formatSeconds(row.thresholdSec)}{row.latestMessageType || '—'}{row.latestError || '—'}
{vehiclesQuery.isFetching ?
正在更新接入状态…
: null}{!vehiclesQuery.isFetching && !rows.length ?
当前筛选条件没有车辆接入记录
: null}
第 {page} / {totalPages} 页,共 {(vehiclesQuery.data?.total ?? 0).toLocaleString('zh-CN')} 条
-
+
车辆协议接入差异优先展示应接与实接差异;时间为各协议最后接收时间
{PROTOCOLS.map((item) => )}{rows.map((row) => setSelectedVIN(row.vin)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') setSelectedVIN(row.vin); }}>{PROTOCOLS.map((protocol) => )})}
车辆品牌 / 车型应接协议{item}综合状态
{row.plate || '未绑定车牌'}{row.vin}{row.oem || '品牌未维护'}{row.model || row.company || '车型未维护'}{row.expectedProtocols.length} 项{row.expectedProtocols.join(' / ')}
{vehiclesQuery.isFetching ?
正在更新车辆接入状态…
: null}{!vehiclesQuery.isFetching && !rows.length ?
当前筛选条件没有车辆
: null}
第 {page} / {totalPages} 页,共 {(vehiclesQuery.data?.total ?? 0).toLocaleString('zh-CN')} 辆主车辆
{selected ? setSelectedVIN('')} /> : null}
+ + thresholdDraft && updateThreshold.mutate(thresholdDraft)} /> ; } diff --git a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx index 7bf39484..992ea917 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx @@ -1,7 +1,9 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, expect, test, vi } from 'vitest'; import { MemoryRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { VehicleRealtimeRow } from '../../api/types'; +import { api } from '../../api/client'; import MonitorPage from './MonitorPage'; const vehicles = [{ @@ -26,7 +28,18 @@ vi.mock('../map/FleetMap', () => ({ })); vi.mock('../hooks/useMonitorData', () => ({ + MAX_MONITOR_SEARCH_TERMS: 100, MONITOR_REFRESH: { selected: 10_000, fleet: 15_000, summary: 30_000 }, + parseMonitorSearchTerms: (value: string) => Array.from(new Set(value.split(/[\s,,、;;]+/).map((item) => item.trim().toLocaleUpperCase()).filter(Boolean))).slice(0, 100), + monitorQueryParams: (filters: { keyword: string; protocol: string; status: string }, limit: number) => { + const params = new URLSearchParams({ limit: String(limit) }); + const terms = Array.from(new Set(filters.keyword.split(/[\s,,、;;]+/).map((item) => item.trim().toLocaleUpperCase()).filter(Boolean))).slice(0, 100); + if (terms.length === 1) params.set('keyword', terms[0]); + if (terms.length > 1) params.set('keywords', terms.join(',')); + if (filters.protocol) params.set('protocol', filters.protocol); + if (filters.status) params.set('status', filters.status); + return params; + }, useMonitorData: () => ({ summary: { data: { totalVehicles: 2, onlineVehicles: 2, offlineVehicles: 0, drivingVehicles: 1, idleVehicles: 1, frameToday: 10 } }, vehicles: { data: { items: vehicles, total: 2 }, isError: false, isLoading: false, isFetching: false }, @@ -39,7 +52,8 @@ vi.mock('../hooks/useMonitorData', () => ({ afterEach(cleanup); test('starts without a selection and supports expand, collapse, reselection, and clear', () => { - const view = render(); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const view = render(); const workspace = view.container.querySelector('.v2-monitor-workspace')!; const firstVehicle = screen.getByRole('button', { name: /粤A12345 LTEST000000000001/ }); const secondVehicle = screen.getByRole('button', { name: /粤B67890 LTEST000000000002/ }); @@ -78,3 +92,45 @@ test('starts without a selection and supports expand, collapse, reselection, and expect(workspace).toHaveClass('is-detail-open'); expect(secondVehicle).toHaveClass('is-selected'); }); + +test('switches to a lightweight realtime list and resolves addresses only on demand', async () => { + const row = vehicles[0]; + vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [row], total: 1, limit: 50, offset: 0 }); + const reverseGeocode = vi.spyOn(api, 'reverseGeocode').mockResolvedValue({ provider: 'AMap', longitude: 113.26, latitude: 23.13, formattedAddress: '广东省广州市天河区测试路' }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + fireEvent.click(screen.getByRole('button', { name: /列表/ })); + expect(await screen.findByText('车辆实时列表')).toBeInTheDocument(); + expect(screen.getByText('速度、里程和坐标实时刷新,文字地址按需解析')).toBeInTheDocument(); + expect(await screen.findAllByText('粤A12345')).toHaveLength(2); + expect(screen.getAllByText('42')).toHaveLength(2); + expect(screen.getAllByText(/1,234/)).toHaveLength(2); + expect(screen.getAllByText(/113\.260000/)).toHaveLength(2); + expect(reverseGeocode).not.toHaveBeenCalled(); + + fireEvent.click(screen.getAllByRole('button', { name: '解析粤A12345位置' })[0]); + expect(await screen.findByText('广东省广州市天河区测试路')).toBeInTheDocument(); + expect(reverseGeocode).toHaveBeenCalledTimes(1); + expect(screen.queryByText('综合状态')).not.toBeInTheDocument(); + expect(screen.queryByText('三路正常')).not.toBeInTheDocument(); + expect(screen.queryByTestId('fleet-map')).not.toBeInTheDocument(); +}); + +test('pastes, deduplicates, and submits multiple plates as one batch search', async () => { + const vehicleRealtime = vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: vehicles, total: 2, limit: 50, offset: 0 }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + + const input = screen.getByRole('textbox', { name: '搜索车辆' }); + fireEvent.paste(input, { clipboardData: { getData: () => '粤a12345\n粤B67890,粤A12345' } }); + expect(input).toHaveValue('粤A12345,粤B67890'); + expect(screen.getByText('已识别 2 辆')).toBeInTheDocument(); + expect(screen.getByText('正在批量筛选 2 辆')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /列表/ })); + await waitFor(() => { + const params = vehicleRealtime.mock.calls[vehicleRealtime.mock.calls.length - 1]?.[0]; + expect(params?.get('keywords')).toBe('粤A12345,粤B67890'); + expect(params?.has('keyword')).toBe(false); + }); +}); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx index d9923b05..de954a49 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx @@ -1,15 +1,80 @@ -import { IconChevronLeft, IconChevronRight, IconClose, IconFilter, IconRefresh, IconSearch } from '@douyinfe/semi-icons'; -import { memo, useCallback, useDeferredValue, useMemo, useState } from 'react'; +import { IconChevronLeft, IconChevronRight, IconClose, IconFilter, IconList, IconMapPin, IconQrCode, IconRefresh, IconSearch } from '@douyinfe/semi-icons'; +import { useQuery } from '@tanstack/react-query'; +import QRCode from 'qrcode'; +import { memo, useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; +import { api } from '../../api/client'; import type { AlertEvent, MapReverseGeocode, VehicleDetail as VehicleDetailData, VehicleRealtimeRow } from '../../api/types'; import { FleetMap } from '../map/FleetMap'; import { EmptyState, InlineError } from '../shared/AsyncState'; import { formatNumber, relativeFreshness, statusLabel, vehicleStatus } from '../domain/monitor'; -import { MONITOR_REFRESH, useMonitorData, useMonitorVehicleCard, type MonitorViewport } from '../hooks/useMonitorData'; +import { MAX_MONITOR_SEARCH_TERMS, MONITOR_REFRESH, monitorQueryParams, parseMonitorSearchTerms, useMonitorData, useMonitorVehicleCard, type MonitorViewport } from '../hooks/useMonitorData'; const protocols = ['', 'GB32960', 'JT808', 'YUTONG_MQTT']; const statuses = ['', 'online', 'offline', 'driving', 'idle']; +type AddressCoordinate = { longitude: number; latitude: number; key: string }; + +function addressCoordinate(vehicle: VehicleRealtimeRow): AddressCoordinate | undefined { + if (!Number.isFinite(vehicle.longitude) || !Number.isFinite(vehicle.latitude) + || Math.abs(vehicle.longitude) > 180 || Math.abs(vehicle.latitude) > 90 + || (vehicle.longitude === 0 && vehicle.latitude === 0)) return undefined; + const longitude = Number(vehicle.longitude.toFixed(4)); + const latitude = Number(vehicle.latitude.toFixed(4)); + return { longitude, latitude, key: `${longitude.toFixed(4)},${latitude.toFixed(4)}` }; +} + +const MonitorAddressCell = memo(function MonitorAddressCell({ vehicle }: { vehicle: VehicleRealtimeRow }) { + const current = addressCoordinate(vehicle); + const [requested, setRequested] = useState(); + const addressQuery = useQuery({ + queryKey: ['monitor', 'list-address', requested?.key], + queryFn: () => api.reverseGeocode(new URLSearchParams({ + longitude: requested!.longitude.toFixed(4), + latitude: requested!.latitude.toFixed(4) + })), + enabled: Boolean(requested), + staleTime: 6 * 60 * 60_000, + gcTime: 24 * 60 * 60_000, + refetchOnWindowFocus: false, + retry: 1 + }); + const moved = Boolean(current && requested && current.key !== requested.key); + + if (!current) return 无有效坐标; + if (!requested) return ; + if (addressQuery.isFetching && !addressQuery.data) return 解析中; + if (addressQuery.isError) return ; + return
{addressQuery.data?.formattedAddress || '暂无地址'}{moved ? : null}
; +}); + +function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, onSelect, onPage, onLimit }: { rows: VehicleRealtimeRow[]; total: number; page: number; totalPages: number; limit: number; loading: boolean; onSelect: (vin: string) => void; onPage: (page: number) => void; onLimit: (limit: number) => void }) { + return
+
车辆实时列表速度、里程和坐标实时刷新,文字地址按需解析
{total.toLocaleString('zh-CN')} 辆车辆
+
+ {rows.map((row) => + + + + + + )}
车辆速度总里程经纬度地理位置
{formatNumber(row.speedKmh, 1)}km/h{formatNumber(row.totalMileageKm, 1)}km{row.longitude.toFixed(6)}
{row.latitude.toFixed(6)}
{loading ?
正在更新车辆实时数据…
: null}{!loading && !rows.length ? : null}
+
{rows.map((row) =>
+
{row.plate || '未绑定车牌'}{row.vin}
{formatNumber(row.speedKmh, 1)}km/h
+
总里程
{formatNumber(row.totalMileageKm, 1)} km
经纬度
{row.longitude.toFixed(6)}, {row.latitude.toFixed(6)}
地理位置
+
+
)}{loading ?
正在更新车辆实时数据…
: null}{!loading && !rows.length ? : null}
+
第 {page} / {totalPages} 页
+
; +} + +function MobileEntry({ onClose }: { onClose: () => void }) { + const [qr, setQr] = useState(''); + const url = `${window.location.origin}/monitor`; + useEffect(() => { void QRCode.toDataURL(url, { width: 240, margin: 2, color: { dark: '#122033', light: '#ffffff' } }).then(setQr); }, [url]); + return

手机端全局监控

扫码后使用现有账号鉴权,不在二维码中保存 Token

{qr ? 全局监控手机端二维码 : }{url}
; +} + const VehicleRow = memo(function VehicleRow({ vehicle, selected, onSelect }: { vehicle: VehicleRealtimeRow; selected: boolean; onSelect: (vin: string) => void }) { const status = vehicleStatus(vehicle); return ( @@ -93,8 +158,13 @@ function VehicleDetailCard({ } export default function MonitorPage() { + const [mode, setMode] = useState<'map' | 'list'>('map'); + const [mobileEntryOpen, setMobileEntryOpen] = useState(false); + const [listOffset, setListOffset] = useState(0); + const [listLimit, setListLimit] = useState(50); const [keyword, setKeyword] = useState(''); const deferredKeyword = useDeferredValue(keyword); + const searchTerms = useMemo(() => parseMonitorSearchTerms(keyword), [keyword]); const [protocol, setProtocol] = useState(''); const [status, setStatus] = useState(''); const [selectedVin, setSelectedVin] = useState(''); @@ -103,7 +173,18 @@ export default function MonitorPage() { const updateViewport = useCallback((next: MonitorViewport) => { setViewport((current) => current.zoom === next.zoom && current.bounds === next.bounds ? current : next); }, []); - const { summary, vehicles, map, selectedVehicle } = useMonitorData({ keyword: deferredKeyword, protocol, status }, viewport, selectedVin); + const filters = useMemo(() => ({ keyword: deferredKeyword, protocol, status }), [deferredKeyword, protocol, status]); + const listParams = useMemo(() => { + const params = monitorQueryParams(filters, listLimit); + params.set('offset', String(listOffset)); + return params; + }, [filters, listLimit, listOffset]); + const { summary, vehicles, map, selectedVehicle } = useMonitorData(filters, viewport, selectedVin, mode === 'map', mode === 'map'); + const realtimeListQuery = useQuery({ + queryKey: ['monitor', 'vehicle-list', listParams.toString()], + queryFn: () => api.vehicleRealtime(listParams), + enabled: mode === 'list', placeholderData: (previous) => previous, refetchInterval: mode === 'list' ? MONITOR_REFRESH.fleet : false + }); const rows = useMemo(() => { const data = vehicles.data?.items ?? []; if (status === 'driving' || status === 'idle') return data.filter((vehicle) => vehicleStatus(vehicle) === status); @@ -115,6 +196,7 @@ export default function MonitorPage() { const selectVehicle = useCallback((vin: string) => { setSelectedVin(vin); setDetailOpen(true); + setMode('map'); }, []); const clearSelection = useCallback(() => { setSelectedVin(''); @@ -131,15 +213,33 @@ export default function MonitorPage() { return (
- - { setKeyword(event.target.value); setListOffset(0); }} + onPaste={(event) => { + const pastedTerms = parseMonitorSearchTerms(event.clipboardData.getData('text')); + if (pastedTerms.length <= 1) return; + event.preventDefault(); + setKeyword(pastedTerms.join(',')); + setListOffset(0); + }} + placeholder="车牌 / VIN;可批量粘贴车牌" + /> + {searchTerms.length > 1 ? 已识别 {searchTerms.length} 辆 : null} + + - { setStatus(event.target.value); setListOffset(0); }} aria-label="在线状态"> {statuses.map((item) => )} - + +
+
@@ -155,10 +255,11 @@ export default function MonitorPage() {
{vehicles.isError ? vehicles.refetch()} /> : null} -
+ {mode === 'list' && realtimeListQuery.isError ? realtimeListQuery.refetch()} /> : null} + {mode === 'map' ?
车辆列表{formatNumber(vehicles.data?.total ?? rows.length)} 辆
-
{deferredKeyword ? `正在筛选“${deferredKeyword}”` : '按最新上报排序'}
+
{searchTerms.length > 1 ? `正在批量筛选 ${searchTerms.length} 辆` : deferredKeyword ? `正在筛选“${deferredKeyword}”` : '按最新上报排序'}
{vehicles.isLoading ?
加载车辆
: null} {!vehicles.isLoading && rows.length === 0 ? : null} @@ -193,15 +294,16 @@ export default function MonitorPage() { ) : null} -
+
: setListOffset((page - 1) * listLimit)} onLimit={(next) => { setListLimit(next); setListOffset(0); }} />}
实时数据状态 {vehicles.isFetching ? '正在刷新' : '实时车辆已同步'} - 列表 {rows.length} 条 · 地图 {map.data?.clusters.length ? `${map.data.clusters.length} 个聚合 + ${map.data.points.length} 个车辆点` : `${map.data?.points.length ?? 0} 个点`} + {mode === 'map' ? `列表 ${rows.length} 条 · 地图 ${map.data?.clusters.length ? `${map.data.clusters.length} 个聚合 + ${map.data.points.length} 个车辆点` : `${map.data?.points.length ?? 0} 个点`}` : `实时列表 ${realtimeListQuery.data?.items.length ?? 0} / ${realtimeListQuery.data?.total ?? 0} 辆`} 智能刷新 重点车辆 {MONITOR_REFRESH.selected / 1000} 秒 · 车队 {MONITOR_REFRESH.fleet / 1000} 秒 · 统计 {MONITOR_REFRESH.summary / 1000} 秒
+ {mobileEntryOpen ? setMobileEntryOpen(false)} /> : null}
); } 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 index f94493c4..20869397 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.test.tsx @@ -1,30 +1,126 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { cleanup, fireEvent, 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 } })); +const mocks = vi.hoisted(() => ({ mileageStatistics: vi.fn(), dailyMileage: vi.fn(), vehicles: vi.fn(), vehicleCoverage: vi.fn() })); +const exportMocks = vi.hoisted(() => ({ downloadMileageWorkbook: vi.fn() })); +vi.mock('../../api/client', () => ({ api: mocks })); +vi.mock('../domain/mileageExport', () => exportMocks); -afterEach(() => { cleanup(); mileageStatistics.mockReset(); }); +afterEach(() => { cleanup(); window.localStorage.clear(); Object.values(mocks).forEach((mock) => mock.mockReset()); exportMocks.downloadMileageWorkbook.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 }], +function renderPage(initialEntry = '/statistics') { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render(); +} + +function prepareData() { + mocks.mileageStatistics.mockResolvedValue({ + dateFrom: '2026-07-01', dateTo: '2026-07-14', vehicleCount: 1, recordCount: 2, sourceCount: 2, + periodMileageKm: 193.3, fleetLatestMileageKm: 119925, averageMileagePerVin: 193.3, averageDailyMileageKm: 96.65, + trend: [], ranking: [{ vin: 'LTEST000000000001', plate: '粤A12345', mileageKm: 193.3, latestMileageKm: 119925, activeDays: 2 }], asOf: '2026-07-14 13:20:00', evidence: 'production mileage evidence' }); - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - render(); + mocks.dailyMileage.mockResolvedValue({ items: [ + { vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 119731.7, endMileageKm: 119820.4, dailyMileageKm: 88.7, source: 'GB32960' }, + { vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 119820.4, endMileageKm: 119925, dailyMileageKm: 104.6, source: 'GB32960' } + ], total: 2, limit: 10000, offset: 0 }); + mocks.vehicles.mockResolvedValue({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345', phone: '', oem: '', protocol: 'GB32960', online: true, lastSeen: '', locationText: '', bindingScore: 100 }], total: 1, limit: 12, offset: 0 }); + mocks.vehicleCoverage.mockResolvedValue({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345' }], total: 1, limit: 20, offset: 0 }); +} +test('renders one vehicle per row with dates as columns and a period total', async () => { + prepareData(); + renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14'); + expect(await screen.findByText('车辆每日里程')).toBeInTheDocument(); + expect(screen.getAllByText('区间总里程').length).toBeGreaterThan(1); 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}$/); + expect(screen.getAllByText('104.6 km').length).toBeGreaterThan(0); + expect(screen.getAllByText('88.7 km').length).toBeGreaterThan(0); + expect(screen.getAllByText('7/13').length).toBeGreaterThan(0); + expect(screen.getAllByText('7/14').length).toBeGreaterThan(0); + expect(screen.getAllByText('粤A12345').length).toBeGreaterThan(0); + expect(screen.queryByText('当日起始里程')).not.toBeInTheDocument(); + expect(screen.queryByText('数据来源')).not.toBeInTheDocument(); + expect(screen.queryByRole('img', { name: '每日行驶里程趋势图' })).not.toBeInTheDocument(); + expect(screen.queryByText('车辆里程排名')).not.toBeInTheDocument(); + await waitFor(() => expect(mocks.dailyMileage).toHaveBeenCalledTimes(1)); + expect(mocks.dailyMileage.mock.calls[0][0].get('limit')).toBe('10000'); + expect(mocks.dailyMileage.mock.calls[0][0].get('protocols')).toBe('GB32960,JT808,YUTONG_MQTT'); +}); + +test('supports searching and selecting license plates before querying exact VINs', async () => { + prepareData(); + renderPage(); + const search = screen.getByRole('textbox', { name: '搜索车牌' }); + fireEvent.focus(search); + fireEvent.change(search, { target: { value: '粤A12' } }); + expect(await screen.findByRole('option', { name: /粤A12345/ })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('option', { name: /粤A12345/ })); + fireEvent.click(screen.getByRole('button', { name: '查询' })); + await waitFor(() => { + const calls = mocks.mileageStatistics.mock.calls; + expect(calls[calls.length - 1]?.[0].get('vins')).toBe('LTEST000000000001'); + }); + expect(screen.getByTitle('LTEST000000000001')).toHaveTextContent('粤A12345'); +}); + +test('lets users disable mileage sources and persists the source priority', async () => { + prepareData(); + renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14'); + fireEvent.click(screen.getByRole('button', { name: /数据源/ })); + expect(screen.getByRole('dialog', { name: '数据源策略配置' })).toBeInTheDocument(); + expect(screen.getByText('GPS 里程')).toBeInTheDocument(); + expect(screen.getAllByText('仪表盘里程')).toHaveLength(2); + fireEvent.click(screen.getByRole('switch', { name: '禁用 国标 GB32960' })); + fireEvent.click(screen.getByRole('button', { name: '上移 宇通 MQTT' })); + fireEvent.click(screen.getByRole('button', { name: '查询' })); + await waitFor(() => { + const calls = mocks.dailyMileage.mock.calls; + expect(calls[calls.length - 1]?.[0].get('protocols')).toBe('YUTONG_MQTT,JT808'); + }); + expect(window.localStorage.getItem('vehicle-platform:mileage-source-strategy')).toContain('YUTONG_MQTT'); + expect(screen.getByText('来源优先级:YUTONG_MQTT > JT808')).toBeInTheDocument(); +}); + +test('paginates all unique vehicles when no license plate is selected', async () => { + prepareData(); + const firstPage = Array.from({ length: 20 }, (_, index) => ({ vin: `VIN${String(index + 1).padStart(14, '0')}`, plate: `粤A${String(index + 1).padStart(5, '0')}` })); + const secondPage = Array.from({ length: 12 }, (_, index) => ({ vin: `VIN${String(index + 21).padStart(14, '0')}`, plate: `粤A${String(index + 21).padStart(5, '0')}` })); + mocks.vehicleCoverage.mockImplementation((params: URLSearchParams) => Promise.resolve({ + items: params.get('offset') === '20' ? secondPage : firstPage, + total: 32, + limit: 20, + offset: Number(params.get('offset') ?? 0) + })); + mocks.dailyMileage.mockResolvedValue({ items: [], total: 0, limit: 10000, offset: 0 }); + renderPage('/statistics?dateFrom=2026-07-13&dateTo=2026-07-14'); + + expect((await screen.findAllByText('粤A00001')).length).toBeGreaterThan(0); + expect(screen.getByText('第 1 / 2 页 · 共 32 辆 · 每页 20 辆')).toBeInTheDocument(); + expect(mocks.vehicleCoverage.mock.calls[0][0].get('bindingStatus')).toBe('bound'); + await waitFor(() => expect(mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1]?.[0].get('vins')).toContain('VIN00000000000001')); + + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + expect((await screen.findAllByText('粤A00021')).length).toBeGreaterThan(0); + expect(screen.getByText('第 2 / 2 页 · 共 32 辆 · 每页 20 辆')).toBeInTheDocument(); + await waitFor(() => expect(mocks.vehicleCoverage.mock.calls[mocks.vehicleCoverage.mock.calls.length - 1]?.[0].get('offset')).toBe('20')); +}); + +test('exports the full fleet mileage with one paginated query instead of VIN batches', async () => { + prepareData(); + exportMocks.downloadMileageWorkbook.mockResolvedValue(undefined); + renderPage('/statistics?dateFrom=2026-07-13&dateTo=2026-07-14'); + await waitFor(() => expect(screen.getByRole('button', { name: /导出 Excel/ })).toBeEnabled()); + + fireEvent.click(screen.getByRole('button', { name: /导出 Excel/ })); + + await waitFor(() => expect(exportMocks.downloadMileageWorkbook).toHaveBeenCalledTimes(1)); + const exportMileageCall = mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1][0] as URLSearchParams; + expect(exportMileageCall.get('vins')).toBeNull(); + expect(exportMileageCall.get('vehicleScope')).toBe('bound'); + expect(exportMocks.downloadMileageWorkbook.mock.calls[0][0].vehicles).toHaveLength(1); + expect(screen.getByText(/已导出 1 辆车/)).toBeInTheDocument(); }); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx index c519dc95..05a072d6 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx @@ -1,12 +1,30 @@ -import { IconRefresh, IconSearch } from '@douyinfe/semi-icons'; +import { IconArrowDown, IconArrowUp, IconClose, IconDownload, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons'; import { useQuery } from '@tanstack/react-query'; -import { FormEvent, useMemo, useState } from 'react'; -import { Link, useSearchParams } from 'react-router-dom'; +import { FormEvent, useEffect, useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { api } from '../../api/client'; -import type { MileageStatistics, MileageTrendPoint } from '../../api/types'; +import type { DailyMileageRow, MileageStatistics, VehicleRow } from '../../api/types'; +import { downloadMileageWorkbook } from '../domain/mileageExport'; import { InlineError } from '../shared/AsyncState'; const DAY = 86_400_000; +const DETAIL_LIMIT = 10_000; +const MAX_SELECTED_VEHICLES = 20; +const PAGE_SIZE = 20; +const EXPORT_VEHICLE_PAGE_SIZE = 2_000; +const EXPORT_VIN_BATCH_SIZE = 50; + +type VehicleOption = Pick; +type MileageProtocol = 'GB32960' | 'JT808' | 'YUTONG_MQTT'; +type MileageSourceOption = { protocol: MileageProtocol; label: string; mileageType: string; enabled: boolean }; +type Criteria = { vehicles: VehicleOption[]; dateFrom: string; dateTo: string; sources: MileageSourceOption[] }; + +const SOURCE_STORAGE_KEY = 'vehicle-platform:mileage-source-strategy'; +const DEFAULT_SOURCES: MileageSourceOption[] = [ + { protocol: 'GB32960', label: '国标 GB32960', mileageType: '仪表盘里程', enabled: true }, + { protocol: 'JT808', label: '交通部 JT/T 808', mileageType: 'GPS 里程', enabled: true }, + { protocol: 'YUTONG_MQTT', label: '宇通 MQTT', mileageType: '仪表盘里程', enabled: true } +]; function localDate(value = new Date()) { const offset = value.getTimezoneOffset() * 60_000; @@ -18,73 +36,293 @@ function defaultWindow(days = 30) { return { dateFrom: localDate(new Date(end.getTime() - (days - 1) * DAY)), dateTo: localDate(end) }; } -function formatKm(value?: number, compact = false) { +function formatKm(value?: number) { if (value == null || !Number.isFinite(value)) return '—'; - return new Intl.NumberFormat('zh-CN', compact ? { notation: 'compact', maximumFractionDigits: 1 } : { maximumFractionDigits: 1 }).format(value); + return new Intl.NumberFormat('zh-CN', { 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 inclusiveDays(dateFrom: string, dateTo: string) { + const from = Date.parse(`${dateFrom}T00:00:00`); + const to = Date.parse(`${dateTo}T00:00:00`); + return Number.isFinite(from) && Number.isFinite(to) ? Math.max(1, Math.round((to - from) / DAY) + 1) : 0; } -function Kpis({ data }: { data?: MileageStatistics }) { +function mileageParams(criteria: Criteria, offset = 0) { + const params = new URLSearchParams({ dateFrom: criteria.dateFrom, dateTo: criteria.dateTo }); + if (criteria.vehicles.length) params.set('vins', criteria.vehicles.map((vehicle) => vehicle.vin).join(',')); + else params.set('vehicleScope', 'bound'); + params.set('protocols', criteria.sources.filter((source) => source.enabled).map((source) => source.protocol).join(',')); + if (offset >= 0) { + params.set('deduplicate', '1'); + params.set('limit', String(DETAIL_LIMIT)); + params.set('offset', String(offset)); + } + return params; +} + +function initialCriteria(searchParams: URLSearchParams): Criteria { + const defaults = defaultWindow(30); + const vins = (searchParams.get('vins') ?? '').split(',').map((vin) => vin.trim()).filter(Boolean).slice(0, MAX_SELECTED_VEHICLES); + const requestedProtocols = (searchParams.get('protocols') ?? '').split(',').filter((protocol): protocol is MileageProtocol => DEFAULT_SOURCES.some((source) => source.protocol === protocol)); + let sources = DEFAULT_SOURCES.map((source) => ({ ...source })); + if (requestedProtocols.length) { + sources = [...requestedProtocols.map((protocol) => ({ ...DEFAULT_SOURCES.find((source) => source.protocol === protocol)!, enabled: true })), ...DEFAULT_SOURCES.filter((source) => !requestedProtocols.includes(source.protocol)).map((source) => ({ ...source, enabled: false }))]; + } else { + try { + const stored = JSON.parse(window.localStorage.getItem(SOURCE_STORAGE_KEY) ?? '[]') as Partial[]; + const normalized = stored.flatMap((item) => { + const source = DEFAULT_SOURCES.find((candidate) => candidate.protocol === item.protocol); + return source ? [{ ...source, enabled: item.enabled !== false }] : []; + }); + if (normalized.length === DEFAULT_SOURCES.length && normalized.some((source) => source.enabled)) sources = normalized; + } catch { /* retain safe defaults */ } + } + return { + vehicles: vins.map((vin) => ({ vin, plate: '' })), + dateFrom: searchParams.get('dateFrom') ?? defaults.dateFrom, + dateTo: searchParams.get('dateTo') ?? defaults.dateTo, + sources + }; +} + +function SourceStrategy({ value, onChange }: { value: MileageSourceOption[]; onChange: (sources: MileageSourceOption[]) => void }) { + const [open, setOpen] = useState(false); + const enabled = value.filter((source) => source.enabled); + const update = (sources: MileageSourceOption[]) => { + onChange(sources); + try { window.localStorage.setItem(SOURCE_STORAGE_KEY, JSON.stringify(sources)); } catch { /* preference persistence is optional */ } + }; + const move = (index: number, direction: -1 | 1) => { + const target = index + direction; + if (target < 0 || target >= value.length) return; + const next = [...value]; + [next[index], next[target]] = [next[target], next[index]]; + update(next); + }; + const toggle = (protocol: MileageProtocol) => { + const current = value.find((source) => source.protocol === protocol); + if (current?.enabled && enabled.length === 1) return; + update(value.map((source) => source.protocol === protocol ? { ...source, enabled: !source.enabled } : source)); + }; + + return
+ + {open ?
+
数据源策略同车同日按优先级选择一个里程来源
+
{value.map((source, index) =>
+ +
{source.label}{source.mileageType}{source.protocol}
+ {source.enabled ? `优先级 ${enabled.findIndex((item) => item.protocol === source.protocol) + 1}` : '已禁用'} +

+
)}
+
同车同日只使用一个来源,避免重复累计;至少保留一个来源。
+
: null} +
; +} + +function VehicleMultiSelect({ value, onChange }: { value: VehicleOption[]; onChange: (vehicles: VehicleOption[]) => void }) { + const [search, setSearch] = useState(''); + const [debounced, setDebounced] = useState(''); + const [open, setOpen] = useState(false); + useEffect(() => { const timer = window.setTimeout(() => setDebounced(search.trim()), 220); return () => window.clearTimeout(timer); }, [search]); + const candidateParams = useMemo(() => { + const params = new URLSearchParams({ limit: '12', offset: '0' }); + if (debounced) params.set('keyword', debounced); + return params; + }, [debounced]); + const candidates = useQuery({ + queryKey: ['mileage-vehicle-options', candidateParams.toString()], + queryFn: () => api.vehicles(candidateParams), + enabled: open, + staleTime: 60_000 + }); + const selected = useMemo(() => new Set(value.map((vehicle) => vehicle.vin)), [value]); + const options = (candidates.data?.items ?? []).filter((vehicle, index, rows) => rows.findIndex((item) => item.vin === vehicle.vin) === index); + + const add = (vehicle: VehicleRow) => { + if (selected.has(vehicle.vin) || value.length >= MAX_SELECTED_VEHICLES) return; + onChange([...value, { vin: vehicle.vin, plate: vehicle.plate }]); + setSearch(''); + }; + + return ; +} + +function SummaryRail({ data, criteria, fleetTotal }: { data?: MileageStatistics; criteria: Criteria; fleetTotal?: number }) { + const days = inclusiveDays(criteria.dateFrom, criteria.dateTo); + const vehicleCount = criteria.vehicles.length ? data?.vehicleCount ?? 0 : fleetTotal ?? 0; const items = [ - ['统计期行驶里程', `${formatKm(data?.periodMileageKm)} km`, '按车辆与自然日去重'], - ['最新里程表总和', `${formatKm(data?.fleetLatestMileageKm)} km`, '每车取最新一次上报'], - ['有里程车辆', formatKm(data?.vehicleCount), `${data?.sourceCount ?? 0} 个数据来源`], - ['车均行驶里程', `${formatKm(data?.averageMileagePerVin)} km`, '统计期累计 / 车辆'], - ['车日均里程', `${formatKm(data?.averageDailyMileageKm)} km`, '有效车辆日平均'] + ['查询车辆', `${vehicleCount} 辆`, criteria.vehicles.length ? `已选择 ${criteria.vehicles.length} 辆` : `全部车辆 · ${data?.vehicleCount ?? 0} 辆有里程`], + ['统计天数', `${days} 天`, `${criteria.dateFrom} 至 ${criteria.dateTo}`], + ['区间总里程', `${formatKm(data?.periodMileageKm)} km`, `${data?.recordCount ?? 0} 条车辆日记录`], + ['日均里程', `${formatKm(data?.averageDailyMileageKm)} km`, '按有效车辆日平均'] ]; - return
{items.map(([label, value, note], index) =>
{label}{value}{note}
)}
; + return
{items.map(([label, value, note], index) =>
{label}{value}{note}
)}
; +} + +type VehicleMileageMatrix = VehicleOption & { days: Map; sources: Map; totalMileageKm: number }; + +function rangeDates(dateFrom: string, dateTo: string) { + const dates: string[] = []; + const cursor = new Date(`${dateFrom}T00:00:00`); + const end = new Date(`${dateTo}T00:00:00`); + while (cursor <= end) { dates.push(localDate(cursor)); cursor.setDate(cursor.getDate() + 1); } + return dates; +} + +function dateLabel(date: string) { + const [, month, day] = date.split('-'); + return `${Number(month)}/${Number(day)}`; +} + +function MileageTable({ rows, dates }: { rows: VehicleMileageMatrix[]; dates: string[] }) { + const maxDailyMileage = Math.max(1, ...rows.flatMap((row) => Array.from(row.days.values()))); + return <> +
+ + {dates.map((date) => )} + {rows.map((row) => {dates.map((date) => { + const mileage = row.days.get(date); + const intensity = mileage && mileage > 0 ? .035 + mileage / maxDailyMileage * .13 : 0; + return ; + })})} +
车牌VIN{dateLabel(date)}区间总里程
{row.plate || '未绑定'}{row.vin}{mileage != null ? `${formatKm(mileage)} km` : '—'}{formatKm(row.totalMileageKm)} km
+
+
{rows.map((row) =>
{row.plate || '未绑定'}{row.vin}
{formatKm(row.totalMileageKm)} km区间总里程
{dates.map((date) =>
{row.days.has(date) ? `${formatKm(row.days.get(date))} km` : '—'}
)}
)}
+ ; } 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); + const [draft, setDraft] = useState(() => initialCriteria(searchParams)); + const [criteria, setCriteria] = useState(() => initialCriteria(searchParams)); + const [page, setPage] = useState(1); + const [isExporting, setIsExporting] = useState(false); + const [exportFeedback, setExportFeedback] = useState(''); + const hasVehicles = criteria.vehicles.length > 0; + const fleetParams = useMemo(() => new URLSearchParams({ limit: String(PAGE_SIZE), offset: String((page - 1) * PAGE_SIZE), bindingStatus: 'bound' }), [page]); + const fleetVehicles = useQuery({ + queryKey: ['mileage-fleet-page', fleetParams.toString()], + queryFn: () => api.vehicleCoverage(fleetParams), + enabled: !hasVehicles, + staleTime: 60_000 + }); + const displayVehicles = useMemo(() => hasVehicles + ? criteria.vehicles + : (fleetVehicles.data?.items ?? []).map((vehicle) => ({ vin: vehicle.vin, plate: vehicle.plate })), [criteria.vehicles, fleetVehicles.data?.items, hasVehicles]); + const statisticsParams = useMemo(() => mileageParams(criteria, -1), [criteria]); + const rowsCriteria = useMemo(() => ({ ...criteria, vehicles: displayVehicles }), [criteria, displayVehicles]); + const rowsParams = useMemo(() => mileageParams(rowsCriteria, 0), [rowsCriteria]); + const statistics = useQuery({ queryKey: ['mileage-statistics', statisticsParams.toString()], queryFn: () => api.mileageStatistics(statisticsParams), staleTime: 60_000, placeholderData: (previous) => previous }); + const mileage = useQuery({ queryKey: ['daily-mileage-query', rowsParams.toString()], queryFn: () => api.dailyMileage(rowsParams), enabled: displayVehicles.length > 0, staleTime: 60_000, placeholderData: (previous) => previous }); + const totals = useMemo(() => new Map((statistics.data?.ranking ?? []).map((row) => [row.vin, row.mileageKm])), [statistics.data?.ranking]); + const dates = useMemo(() => rangeDates(criteria.dateFrom, criteria.dateTo), [criteria.dateFrom, criteria.dateTo]); + const matrixRows = useMemo(() => displayVehicles.map((vehicle) => { + const days = new Map(); + const sources = new Map(); + const dailyRows = (mileage.data?.items ?? []).filter((row) => row.vin === vehicle.vin); + for (const row of dailyRows) { days.set(row.date, row.dailyMileageKm); sources.set(row.date, row.source); } + const plate = vehicle.plate || dailyRows.find((row) => row.plate)?.plate || statistics.data?.ranking.find((row) => row.vin === vehicle.vin)?.plate || ''; + return { ...vehicle, plate, days, sources, totalMileageKm: totals.get(vehicle.vin) ?? Array.from(days.values()).reduce((sum, value) => sum + value, 0) }; + }), [displayVehicles, mileage.data?.items, statistics.data?.ranking, totals]); + const totalVehicles = hasVehicles ? criteria.vehicles.length : fleetVehicles.data?.total ?? 0; + const totalPages = Math.max(1, Math.ceil(totalVehicles / PAGE_SIZE)); + const submit = (event: FormEvent) => { event.preventDefault(); setPage(1); setExportFeedback(''); setCriteria(draft); setSearchParams(mileageParams(draft, -1), { replace: true }); }; + const setDays = (days: number) => { const range = defaultWindow(days); const next = { ...draft, ...range }; setPage(1); setExportFeedback(''); setDraft(next); setCriteria(next); setSearchParams(mileageParams(next, -1), { replace: true }); }; + const refreshing = statistics.isFetching || mileage.isFetching || fleetVehicles.isFetching; - 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 分钟自动更新
+ const exportExcel = async () => { + if (isExporting || !totalVehicles) return; + setIsExporting(true); + setExportFeedback(''); + try { + let vehicles: VehicleOption[] = criteria.vehicles.map((vehicle) => ({ ...vehicle })); + if (!vehicles.length) { + vehicles = []; + let offset = 0; + while (offset < totalVehicles) { + const result = await api.vehicleCoverage(new URLSearchParams({ limit: String(EXPORT_VEHICLE_PAGE_SIZE), offset: String(offset), bindingStatus: 'bound' })); + vehicles.push(...result.items.map((vehicle) => ({ vin: vehicle.vin, plate: vehicle.plate }))); + if (!result.items.length) break; + offset += result.items.length; + if (offset >= result.total) break; + } + } + const mileageRows: DailyMileageRow[] = []; + const vehicleBatches = criteria.vehicles.length + ? Array.from({ length: Math.ceil(vehicles.length / EXPORT_VIN_BATCH_SIZE) }, (_, index) => vehicles.slice(index * EXPORT_VIN_BATCH_SIZE, (index + 1) * EXPORT_VIN_BATCH_SIZE)) + : [[] as VehicleOption[]]; + for (const vehicleBatch of vehicleBatches) { + let offset = 0; + while (true) { + const params = mileageParams({ ...criteria, vehicles: vehicleBatch }, offset); + const result = await api.dailyMileage(params); + mileageRows.push(...result.items); + offset += result.items.length; + if (!result.items.length || offset >= result.total) break; + } + } + const plateByVin = new Map(mileageRows.filter((row) => row.plate).map((row) => [row.vin, row.plate])); + vehicles = vehicles.map((vehicle) => ({ ...vehicle, plate: vehicle.plate || plateByVin.get(vehicle.vin) || '' })); + await downloadMileageWorkbook({ + dateFrom: criteria.dateFrom, + dateTo: criteria.dateTo, + dates, + vehicles, + mileageRows, + sources: criteria.sources.filter((source) => source.enabled), + exportedAt: new Date() + }); + setExportFeedback(`已导出 ${vehicles.length} 辆车`); + } catch (error) { + setExportFeedback(error instanceof Error ? `导出失败:${error.message}` : '导出失败,请稍后重试'); + } finally { + setIsExporting(false); + } + }; + + return
+

里程查询

未选择车牌时分页展示全部车辆;选择车牌后查询指定车辆。

+
+
+ setDraft((current) => ({ ...current, vehicles }))} /> + + + + setDraft((current) => ({ ...current, sources }))} /> +
快捷范围
+ + +
+ {statistics.isError || mileage.isError || fleetVehicles.isError ? { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} /> : null} +
+
车辆每日里程{criteria.dateFrom} 至 {criteria.dateTo}
{hasVehicles ? `${totalVehicles} 辆车` : `当前 ${displayVehicles.length} 辆 / 共 ${totalVehicles} 辆`} · {dates.length} 个自然日
+ + {!fleetVehicles.isLoading && !displayVehicles.length ?
当前没有可展示的车辆
: null} +
{hasVehicles ? `已选择 ${totalVehicles} 辆车辆` : `第 ${page} / ${totalPages} 页 · 共 ${totalVehicles} 辆 · 每页 ${PAGE_SIZE} 辆`}{exportFeedback ? ` · ${exportFeedback}` : ''}{!hasVehicles && totalVehicles ?
: null}
+
+
数据更新时间:{statistics.data?.asOf || '—'}来源优先级:{criteria.sources.filter((source) => source.enabled).map((source) => source.protocol).join(' > ')}查询结果缓存 1 分钟
; } - -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/pages/TrackPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx new file mode 100644 index 00000000..bbc257a4 --- /dev/null +++ b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx @@ -0,0 +1,60 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { MemoryRouter } from 'react-router-dom'; +import type { TrackPlaybackResponse } from '../../api/types'; +import TrackPage from './TrackPage'; + +const mocks = vi.hoisted(() => ({ trackPlayback: vi.fn(), reverseGeocode: vi.fn(), vehicles: vi.fn() })); +vi.mock('../../api/client', () => ({ api: mocks })); +vi.mock('../map/TrackMap', () => ({ TrackMap: ({ activeIndex }: { activeIndex: number }) =>
})); + +const track = { + vin: 'LTEST000000000001', plate: '粤A12345', total: 3, truncated: false, sampled: false, asOf: '2026-07-15T08:00:00Z', + points: [ + { vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'JT808', deviceTime: '2026-07-15T00:00:00Z', serverTime: '2026-07-15T00:00:01Z', longitude: 113.1, latitude: 23.1, speedKmh: 0, totalMileageKm: 100, socPercent: 80, socAvailable: true }, + { vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'JT808', deviceTime: '2026-07-15T00:10:00Z', serverTime: '2026-07-15T00:10:01Z', longitude: 113.2, latitude: 23.2, speedKmh: 35, totalMileageKm: 104, socPercent: 79, socAvailable: true }, + { vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'JT808', deviceTime: '2026-07-15T00:20:00Z', serverTime: '2026-07-15T00:20:01Z', longitude: 113.3, latitude: 23.3, speedKmh: 10, totalMileageKm: 108, socPercent: 78, socAvailable: true } + ], + events: [ + { index: 0, sampledIndex: 0, type: 'start', title: '开始行驶', time: '2026-07-15T00:00:00Z', speedKmh: 0, longitude: 113.1, latitude: 23.1 }, + { index: 1, sampledIndex: 1, type: 'acceleration', title: '急加速', time: '2026-07-15T00:10:00Z', speedKmh: 35, longitude: 113.2, latitude: 23.2 }, + { index: 2, sampledIndex: 2, type: 'end', title: '结束行驶', time: '2026-07-15T00:20:00Z', speedKmh: 10, longitude: 113.3, latitude: 23.3 } + ], + sources: [{ protocol: 'JT808', pointCount: 3, startTime: '2026-07-15T00:00:00Z', endTime: '2026-07-15T00:20:00Z' }], + segments: [{ index: 0, type: 'moving', title: '行驶', startTime: '2026-07-15T00:00:00Z', endTime: '2026-07-15T00:20:00Z', durationSeconds: 1200, distanceKm: 8, pointCount: 3, startIndex: 0, endIndex: 2, sampledStartIndex: 0, sampledEndIndex: 2 }], + stops: [{ index: 0, startTime: '2026-07-15T00:00:00Z', endTime: '2026-07-15T00:05:00Z', durationSeconds: 300, pointCount: 1, longitude: 113.1, latitude: 23.1, sampledIndex: 0, evidence: 'GPS 推断' }], + summary: { startTime: '2026-07-15T00:00:00Z', endTime: '2026-07-15T00:20:00Z', distanceKm: 8, durationSeconds: 1200, averageSpeedKmh: 24, maximumSpeedKmh: 35, pointCount: 3, movingSeconds: 900, stoppedSeconds: 300, stopCount: 1, segmentCount: 1 }, + coverage: { requestedStart: '', requestedEnd: '', actualStart: '', actualEnd: '', totalPoints: 3, fetchedPoints: 3, processedPoints: 3, returnedPoints: 3, complete: true, limitReasons: [], evidence: '时间窗完整' }, + quality: { status: 'good', selectedProtocol: 'JT808', rawPoints: 3, validPoints: 3, alternateSourcePoints: 0, invalidCoordinatePoints: 0, duplicatePoints: 0, driftPoints: 0, sourceSwitches: 0, largeGapCount: 0, maximumGapSeconds: 0, evidence: '轨迹质量正常' } +} as unknown as TrackPlaybackResponse; + +beforeEach(() => { + Object.defineProperty(window, 'matchMedia', { configurable: true, value: vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })) }); + mocks.trackPlayback.mockResolvedValue(track); + mocks.reverseGeocode.mockResolvedValue({ formattedAddress: '广东省广州市测试道路' }); + mocks.vehicles.mockResolvedValue({ items: [], total: 0, limit: 10, offset: 0 }); +}); +afterEach(() => { cleanup(); Object.values(mocks).forEach((mock) => mock.mockReset()); }); + +test('renders a map-first replay workspace and connects stop, event, and panel interactions', async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + + expect((await screen.findAllByText('粤A12345')).length).toBeGreaterThan(0); + expect(screen.getByTestId('track-map')).toHaveAttribute('data-active-index', '0'); + expect(screen.getByText('停留 00:05:00 · 1 个点')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '暂停轨迹播放' })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /事件点/ })); + fireEvent.click(screen.getByRole('button', { name: /急加速/ })); + expect(screen.getByTestId('track-map')).toHaveAttribute('data-active-index', '1'); + + fireEvent.click(screen.getByRole('button', { name: '收起查询面板' })); + expect(screen.getByRole('button', { name: /查询与明细/ })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /查询与明细/ })); + expect(screen.getByRole('button', { name: '收起查询面板' })).toBeInTheDocument(); + + await waitFor(() => expect(mocks.trackPlayback).toHaveBeenCalledTimes(1)); + expect(mocks.trackPlayback.mock.calls[0][0].get('maxPoints')).toBe('1600'); +}); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx index 95274200..896b560d 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx @@ -1,17 +1,20 @@ import { useQuery } from '@tanstack/react-query'; import { - IconBox, IconChevronLeft, IconChevronRight, IconDownload, IconPause, IconPlay, IconSearch + IconBox, IconChevronLeft, IconChevronRight, IconClose, IconDownload, IconEyeClosed, + IconEyeOpened, IconList, IconMapPin, IconPause, IconPlay, IconRefresh, IconSearch } from '@douyinfe/semi-icons'; -import { FormEvent, useEffect, useMemo, useState } from 'react'; +import { FormEvent, useEffect, useMemo, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { api } from '../../api/client'; -import type { TrackPlaybackEvent, TrackPlaybackResponse } from '../../api/types'; +import type { TrackPlaybackEvent, TrackPlaybackResponse, VehicleRow } from '../../api/types'; import { downloadTrackCsv, formatDuration, sampledEventIndex } from '../domain/track'; import { TrackMap } from '../map/TrackMap'; import { InlineError } from '../shared/AsyncState'; -const speedOptions = [1, 2, 4] as const; -const visibleSegmentLimit = 120; +const speedOptions = [0.5, 1, 2, 4] as const; +type PlaybackSpeed = (typeof speedOptions)[number]; +type PanelTab = 'stops' | 'events' | 'overview'; +type Draft = { keyword: string; dateFrom: string; dateTo: string; protocol: string }; function number(value: number, digits = 1) { return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: digits }).format(Number.isFinite(value) ? value : 0); @@ -33,8 +36,14 @@ function time(value?: string) { if (!value) return '—'; const parsed = new Date(value); if (!Number.isNaN(parsed.getTime())) return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(parsed); - const clock = value.split(' ').pop(); - return clock?.slice(0, 8) || '—'; + return value.split(' ').pop()?.slice(0, 8) || '—'; +} + +function dateTime(value?: string) { + if (!value) return '—'; + const parsed = new Date(value); + if (!Number.isNaN(parsed.getTime())) return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false, month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(parsed); + return value.replace('T', ' ').slice(5, 19); } function localDateTime(value: Date) { @@ -42,11 +51,17 @@ function localDateTime(value: Date) { return local.toISOString().slice(0, 16); } -function defaultTrackWindow() { - const now = new Date(); - const start = new Date(now); +function trackWindow(daysBack = 0, fullDay = false) { + const end = new Date(); + end.setDate(end.getDate() - daysBack); + if (fullDay) end.setHours(23, 59, 59, 999); + const start = new Date(end); start.setHours(0, 0, 0, 0); - return { dateFrom: localDateTime(start), dateTo: localDateTime(now) }; + return { dateFrom: localDateTime(start), dateTo: localDateTime(end) }; +} + +function defaultTrackWindow() { + return trackWindow(); } function eventTone(type: string) { @@ -56,60 +71,122 @@ function eventTone(type: string) { return 'info'; } -function EmptyTrack({ queried }: { queried: boolean }) { - return
{queried ? '当前条件没有轨迹点' : '选择车辆开始查询轨迹'}

{queried ? '请扩大时间范围或切换数据来源。' : '支持车牌、VIN 或终端标识,结果不会回退到本地样例。'}

; -} +function VehiclePicker({ value, onChange, onSelect }: { value: string; onChange: (value: string) => void; onSelect: (vehicle: VehicleRow) => void }) { + const [open, setOpen] = useState(false); + const [debounced, setDebounced] = useState(value.trim()); + useEffect(() => { const timer = window.setTimeout(() => setDebounced(value.trim()), 220); return () => window.clearTimeout(timer); }, [value]); + const params = useMemo(() => { + const next = new URLSearchParams({ limit: '10', offset: '0' }); + if (debounced) next.set('keyword', debounced); + return next; + }, [debounced]); + const candidates = useQuery({ queryKey: ['track-vehicle-options', params.toString()], queryFn: () => api.vehicles(params), enabled: open, staleTime: 60_000 }); -function CoverageStrip({ track }: { track: TrackPlaybackResponse }) { - return
- {track.coverage.complete ? '时间窗完整' : '仅展示最新切片'} - {track.coverage.evidence} - {track.coverage.processedPoints} 个有效点 → {track.coverage.returnedPoints} 个地图点 + return
+ + setOpen(true)} onBlur={() => window.setTimeout(() => setOpen(false), 140)} + onChange={(event) => { onChange(event.target.value); setOpen(true); }} + /> + {value ? : null} + {open ?
+
车辆候选支持车牌或 VIN
+ {candidates.isFetching ?

正在搜索车辆

: null} + {!candidates.isFetching && (candidates.data?.items ?? []).map((vehicle) => )} + {!candidates.isFetching && !(candidates.data?.items.length) ?

没有匹配车辆

: null} +
: null}
; } -function SegmentTimeline({ track, onSelectIndex }: { track: TrackPlaybackResponse; onSelectIndex: (index: number) => void }) { - const segments = track.segments.slice(0, visibleSegmentLimit); - return
-
活动时间轴{track.segments.length} 段 · {track.stops.length} 次有效停车
-
{segments.map((segment) => )}
- {track.segments.length > visibleSegmentLimit ? 时间轴仅渲染前 {visibleSegmentLimit} 段,完整统计仍基于全部已加工点。 : null} -
; -} - -function TripInspector({ track, onEvent }: { track: TrackPlaybackResponse; onEvent: (event: TrackPlaybackEvent) => void }) { - return