feat(platform): consolidate production vehicle data workflows

This commit is contained in:
lingniu
2026-07-15 23:26:29 +08:00
parent 6cddc0a43d
commit 3fabcf181a
59 changed files with 6849 additions and 3532 deletions

View File

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

View File

@@ -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")
}
}

View File

@@ -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)

View File

@@ -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())
}
}

View File

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

View File

@@ -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")
}
}

View File

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

View File

@@ -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)
}
}

View File

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

View File

@@ -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)
}
}
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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 })
}

View File

@@ -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"`

View File

@@ -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)
}

View File

@@ -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)"} {

View File

@@ -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

View File

@@ -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",

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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 = {

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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(
<Mileage
initialVin="粤A12345"
initialProtocol="JT808"
initialFilters={{ dateFrom: '2026-07-01', dateTo: '2026-07-03' }}
onOpenVehicle={() => 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);
});

View File

@@ -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('"示范企业"');
});
});

View File

@@ -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')}`;
}

View File

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

View File

@@ -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<string, Map<string, DailyMileageRow>>();
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<string, DailyMileageRow>();
const dailyValues = input.dates.map((date) => mileageByDate.get(date)?.dailyMileageKm ?? null);
const total = dailyValues.reduce<number>((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);
}

View File

@@ -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', () => {

View File

@@ -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<string>();
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

View File

@@ -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<string, string> = {
vehicles: '车辆查询',
tracks: '轨迹回放',
history: '历史数据',
statistics: '车辆统计',
statistics: '里程查询',
alerts: '告警中心',
access: '接入管理',
operations: '运维质量'

View File

@@ -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 '<div class="v2-track-current-marker"><span></span></div>';
return `<div class="v2-track-marker is-${kind}">${label ?? ''}</div>`;
function markerContent(kind: 'start' | 'end' | 'stop' | 'current', label?: string) {
if (kind === 'current') return '<div class="v2-track-current-marker" aria-hidden="true"><i></i><span></span></div>';
return `<div class="v2-track-marker is-${kind}"><span>${label ?? ''}</span></div>`;
}
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<HTMLDivElement>(null);
const mapRef = useRef<AMapMap | null>(null);
const amapRef = useRef<AMapLike | null>(null);
const overlaysRef = useRef<AMapOverlay[]>([]);
const currentMarkerRef = useRef<AMapOverlay | null>(null);
const passedPathRef = useRef<AMapOverlay | null>(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 <div className="v2-track-map">
<div ref={containerRef} className="v2-track-map-canvas" aria-label="历史轨迹地图" />
@@ -103,6 +123,5 @@ export function TrackMap({ points, events, activeIndex, onSelectIndex }: {
{state === 'fallback' ? `地图未配置,已载入 ${valid.length} 个有效轨迹点` : null}
{state === 'error' ? '地图加载失败,请检查高德地图配置' : null}
</div> : null}
<div className="v2-track-map-legend"><span><i className="is-start" /></span><span><i className="is-current" /></span><span><i className="is-end" /></span><b>{valid.length} </b></div>
</div>;
}

View File

@@ -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 <span className={`v2-access-status is-${state}`}><i />{accessStateLabels[state]}</span>;
const connectionLabels: Record<AccessVehicleRow['connectionState'], string> = {
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 <section className="v2-access-protocols"><header><strong></strong><span> · 线</span></header>
<div className="v2-access-segments">{rows.map((item, index) => <i key={item.name} style={{ width: `${item.total / total * 100}%`, background: protocolColors[index % protocolColors.length] }} title={`${item.name} ${item.total}`} />)}</div>
<div className="v2-access-legends">{rows.map((item, index) => <span key={item.name}><i style={{ background: protocolColors[index % protocolColors.length] }} /><b>{item.name}</b>{item.total.toLocaleString('zh-CN')} <em>{item.onlineRate.toFixed(1)}% 线</em></span>)}{!rows.length ? <span></span> : null}</div>
</section>;
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 <details id="access-identity-queue" className="v2-access-identity-queue" open={total > 0}><summary><span><b></b><strong>{loading ? '…' : total.toLocaleString('zh-CN')}</strong></span><em> · VIN</em></summary>
<div>{items.slice(0, 5).map((item) => <article key={item.id}><span className="v2-access-identity-code">{item.identifierMasked}</span><dl><div><dt></dt><dd>{[item.plate, item.manufacturer, item.sourceEndpoint].filter(Boolean).join(' · ') || '仅有终端上报'}</dd></div><div><dt></dt><dd>{formatAccessTime(item.latestSeenAt)} · {formatSeconds(item.freshnessSec)}</dd></div></dl><button type="button" onClick={() => void copyEvidence(item)}>{copied === item.id ? '已复制' : '复制处置证据'}</button></article>)}</div>
</details>;
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 <article className={`v2-access-protocol-detail is-${state}`}>
<header><strong>{status?.protocol}</strong><span><i />{label}</span></header>
<dl>
<div><dt></dt><dd>{status?.provider || '—'}</dd></div>
<div><dt></dt><dd>{formatAccessTime(status?.firstSeenAt || '')}</dd></div>
<div><dt></dt><dd>{formatAccessTime(status?.latestReceivedAt || '')}</dd></div>
<div><dt>线</dt><dd>{formatSeconds(status?.freshnessSec)}</dd></div>
<div><dt></dt><dd>{formatSeconds(status?.reportIntervalSec)}</dd></div>
<div><dt></dt><dd className={status?.delayAbnormal ? 'is-danger' : ''}>{formatSeconds(status?.dataDelaySec)}</dd></div>
</dl>
<p>{status?.firstSeenEvidence || '应接协议尚未形成实时快照'}</p>
</article>;
}
return <div className={`v2-access-protocol-cell is-${state}`} title={status?.latestReceivedAt ? `最新上报:${formatAccessTime(status.latestReceivedAt)}` : '应接协议尚未形成实时快照'}>
<span><i />{label}</span>
<strong>{status?.connected ? compactTime(status.latestReceivedAt) : '等待接入'}</strong>
<small>{status?.provider || (status?.connected ? '接入方未维护' : '无实时快照')}</small>
</div>;
}
function AccessInspector({ row }: { row?: AccessVehicleRow }) {
if (!row) return <section className="v2-access-inspector"><header><strong></strong></header><div className="v2-access-side-empty"></div></section>;
return <section className="v2-access-inspector"><header><strong></strong><StatusLabel state={row.onlineState} /></header>
<dl className="v2-access-identity"><div><dt></dt><dd>{row.plate || '—'}</dd></div><div><dt>VIN</dt><dd>{row.vin}</dd></div><div><dt> / </dt><dd>{[row.model, row.company].filter(Boolean).join(' / ') || '—'}</dd></div><div><dt></dt><dd>{row.protocol || '—'}</dd></div><div><dt> / </dt><dd>{[row.oem, row.provider].filter(Boolean).join(' / ') || '—'}</dd></div></dl>
<Link className="v2-access-vehicle-link" to={`/vehicles/${encodeURIComponent(row.vin)}`}></Link>
<section><h3></h3><dl><div><dt></dt><dd>{formatAccessTime(row.latestEventAt)}</dd></div><div><dt></dt><dd>{formatAccessTime(row.latestReceivedAt)}</dd></div><div><dt></dt><dd className={row.delayAbnormal ? 'is-danger' : 'is-good'}>{formatSeconds(row.dataDelaySec)}</dd></div><div><dt></dt><dd>{formatSeconds(row.reportIntervalSec)}</dd></div><div><dt></dt><dd>{formatSeconds(row.freshnessSec)}</dd></div><div><dt></dt><dd>{formatSeconds(row.thresholdSec)}</dd></div></dl></section>
<section><h3></h3><dl><div><dt></dt><dd>{row.latestMessageType || '—'}</dd></div><div><dt> ID</dt><dd>{row.latestEventId || '—'}</dd></div><div><dt></dt><dd className={row.latestError ? 'is-danger' : ''}>{row.latestError || '无已知错误'}</dd></div><div><dt></dt><dd>{row.source || '—'}</dd></div></dl></section>
<section className="v2-access-proof"><h3></h3><p><b></b>{row.firstSeenAt ? `${formatAccessTime(row.firstSeenAt)} · ${row.firstSeenEvidence}` : row.firstSeenEvidence}</p><p><b></b>{row.reportIntervalEvidence || (row.reportIntervalSec !== null ? `${row.reportSampleCount} 个持久样本计算` : '等待连续样本')}</p></section>
</section>;
function ConnectionState({ row }: { row: AccessVehicleRow }) {
return <div className={`v2-access-connection is-${row.connectionState}`}><strong>{connectionLabels[row.connectionState]}</strong><span>{row.actualProtocols.length} / {row.expectedProtocols.length} </span></div>;
}
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 <section className="v2-access-threshold"><header><strong>线</strong><span>v{config?.version ?? '—'}</span></header>
{draft ? <fieldset className="v2-threshold-form" disabled={!editable}><label><span></span><select value={draft.defaultThresholdSec} onChange={(event) => onChange({ ...draft, defaultThresholdSec: Number(event.target.value) })}><option value="60">1 </option><option value="300">5 </option><option value="600">10 </option><option value="1800">30 </option></select></label><label><span></span><input type="number" min="1" max="3600" value={draft.delayThresholdSec} onChange={(event) => onChange({ ...draft, delayThresholdSec: Number(event.target.value) })} /><em></em></label><label><span>线</span><select value={draft.longOfflineSec} onChange={(event) => onChange({ ...draft, longOfflineSec: Number(event.target.value) })}><option value="1800">30 </option><option value="3600">1 </option><option value="21600">6 </option><option value="86400">24 </option></select></label>
{PROTOCOLS.map((protocol) => <label key={protocol}><span>{protocol}</span><input type="number" min="30" max="86400" value={draft.protocols.find((item) => item.protocol === protocol)?.thresholdSec ?? draft.defaultThresholdSec} onChange={(event) => onChange({ ...draft, protocols: updateProtocolThreshold(draft.protocols, protocol, Number(event.target.value)) })} /><em></em></label>)}
{error ? <p className="v2-threshold-error">{error}</p> : null}{editable ? <button type="button" disabled={saving} onClick={onSave}><IconSave />{saving ? '保存中' : '保存并重算'}</button> : <p className="v2-role-notice"></p>}</fieldset> : <div className="v2-access-side-empty"></div>}
{config?.audit[0] ? <footer><span></span><b>{config.audit[0].actor} · {formatAccessTime(config.audit[0].changedAt)}</b></footer> : <footer><span></span><b>MySQL </b></footer>}
</section>;
function ProtocolCoverage({ summary }: { summary?: AccessSummary }) {
return <div className="v2-access-protocol-coverage" aria-label="三协议接入概览">
{PROTOCOLS.map((protocol) => {
const actual = summary?.protocols.find((item) => item.name === protocol);
const total = summary?.totalVehicles ?? 0;
return <span key={protocol}><b>{protocol}</b><em>{(actual?.total ?? 0).toLocaleString('zh-CN')} / {total.toLocaleString('zh-CN')}</em></span>;
})}
</div>;
}
function VehicleInspector({ row, onClose }: { row: AccessVehicleRow; onClose: () => void }) {
return <aside className="v2-access-inspector-v3">
<header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div><button type="button" onClick={onClose} aria-label="关闭车辆接入详情"><IconClose /></button></header>
<section className="v2-access-inspector-summary"><div><span> / </span><strong>{[row.oem, row.model].filter(Boolean).join(' / ') || '未维护'}</strong></div><div><span></span><strong>{row.expectedProtocols.join(' / ')}</strong></div><div><span></span><strong>{row.actualProtocols.length ? row.actualProtocols.join(' / ') : '尚未接入'}</strong></div><div><span></span><ConnectionState row={row} /></div></section>
<div className="v2-access-protocol-details">{PROTOCOLS.map((protocol) => <ProtocolState key={protocol} status={statusByProtocol(row, protocol)} detailed />)}</div>
<footer><span>{row.expectationEvidence}</span><Link to={`/vehicles/${encodeURIComponent(row.vin)}`}></Link></footer>
</aside>;
}
function IdentityQueue({ items, total }: { items: AccessUnresolvedIdentity[]; total: number }) {
if (!total) return null;
return <details id="access-identity-queue" className="v2-access-identity-queue-v3"><summary><strong> {total.toLocaleString('zh-CN')} </strong><span> VIN </span></summary><div>{items.slice(0, 6).map((item) => <article key={item.id}><b>{item.identifierMasked}</b><span>{item.protocol} · {item.plate || '车牌待核对'} · {formatAccessTime(item.latestSeenAt)}</span><small>{item.recommendedAction}</small></article>)}</div></details>;
}
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 <details className="v2-access-settings"><summary>线 · v{config?.version ?? '—'}</summary><fieldset disabled={!editable}><label><span></span><input type="number" value={draft.defaultThresholdSec} onChange={(event) => onChange({ ...draft, defaultThresholdSec: Number(event.target.value) })} /></label><label><span>线</span><input type="number" value={draft.longOfflineSec} onChange={(event) => onChange({ ...draft, longOfflineSec: Number(event.target.value) })} /></label>{PROTOCOLS.map((protocol) => <label key={protocol}><span>{protocol}</span><input type="number" value={draft.protocols.find((item) => item.protocol === protocol)?.thresholdSec ?? draft.defaultThresholdSec} onChange={(event) => onChange({ ...draft, protocols: updateProtocolThreshold(draft.protocols, protocol, Number(event.target.value)) })} /></label>)}{error ? <p>{error}</p> : null}{editable ? <button type="button" onClick={onSave} disabled={saving}><IconSave />{saving ? '保存中' : '保存阈值'}</button> : <small></small>}</fieldset></details>;
}
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<AccessThresholdUpdate>();
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<AccessThresholdUpdate>(); 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 <div className="v2-access-page">
<form className="v2-access-filter" onSubmit={submit}><label><span></span><div><IconSearch /><input value={draft.keyword} onChange={(event) => setDraft((current) => ({ ...current, keyword: event.target.value }))} placeholder="车牌 / VIN" /></div></label><label><span></span><select value={draft.protocol} onChange={(event) => setDraft((current) => ({ ...current, protocol: event.target.value }))}><option value=""></option>{PROTOCOLS.map((item) => <option key={item}>{item}</option>)}</select></label><label><span></span><select value={draft.oem} onChange={(event) => setDraft((current) => ({ ...current, oem: event.target.value }))}><option value=""></option>{summary?.oems.filter((item) => item.name !== '未维护').map((item) => <option key={item.name}>{item.name}</option>)}</select></label><label><span>线</span><select value={draft.onlineState} onChange={(event) => setDraft((current) => ({ ...current, onlineState: event.target.value }))}><option value=""></option><option value="online">线</option><option value="offline">线</option><option value="never_reported"></option><option value="unknown"></option></select></label><label><span></span><select value={draft.delayState} onChange={(event) => setDraft((current) => ({ ...current, delayState: event.target.value }))}><option value=""></option><option value="normal"></option><option value="abnormal"></option></select></label><button className="v2-primary-button" type="submit"></button><button className="v2-secondary-button" type="button" onClick={reset}></button><details className="v2-access-advanced"><summary> · / / </summary><div><label><span></span><input value={draft.model} onChange={(event) => setDraft((current) => ({ ...current, model: event.target.value }))} placeholder="输入车型关键词" /></label><label><span></span><input value={draft.provider} onChange={(event) => setDraft((current) => ({ ...current, provider: event.target.value }))} placeholder="输入平台名称" /></label><label><span></span><input type="datetime-local" value={draft.firstSeenFrom} onChange={(event) => setDraft((current) => ({ ...current, firstSeenFrom: event.target.value }))} /></label><label><span></span><input type="datetime-local" value={draft.firstSeenTo} onChange={(event) => setDraft((current) => ({ ...current, firstSeenTo: event.target.value }))} /></label><label><span></span><input type="datetime-local" value={draft.latestSeenFrom} onChange={(event) => setDraft((current) => ({ ...current, latestSeenFrom: event.target.value }))} /></label><label><span></span><input type="datetime-local" value={draft.latestSeenTo} onChange={(event) => setDraft((current) => ({ ...current, latestSeenTo: event.target.value }))} /></label></div></details></form>
return <div className="v2-access-page v2-access-page-v3">
<header className="v2-access-heading"><div><h2></h2><p></p></div><div><span> {summary?.asOf ? formatAccessTime(summary.asOf) : '—'}</span><button type="button" onClick={() => void Promise.all([summaryQuery.refetch(), vehiclesQuery.refetch()])}><IconRefresh /></button></div></header>
<form className="v2-access-filter-v3" onSubmit={submit}><label className="is-search"><span></span><div><IconSearch /><input aria-label="车辆" value={draft.keyword} onChange={(event) => setDraft({ ...draft, keyword: event.target.value })} placeholder="车牌 / VIN" /></div></label><label><span></span><select aria-label="接入状态" value={draft.connectionState} onChange={(event) => setDraft({ ...draft, connectionState: event.target.value })}><option value=""></option><option value="attention"></option><option value="healthy"></option><option value="incomplete"></option><option value="degraded">线</option><option value="offline">线</option><option value="not_connected"></option></select></label><label><span></span><select aria-label="关注协议" value={draft.protocol} onChange={(event) => setDraft({ ...draft, protocol: event.target.value })}><option value=""></option>{PROTOCOLS.map((item) => <option key={item}>{item}</option>)}</select></label><label><span></span><select aria-label="车辆品牌" value={draft.oem} onChange={(event) => setDraft({ ...draft, oem: event.target.value })}><option value=""></option>{summary?.oems.filter((item) => item.name !== '未维护').map((item) => <option key={item.name}>{item.name}</option>)}</select></label><button className="v2-primary-button" type="submit"></button><button className="v2-secondary-button" type="button" onClick={() => apply(EMPTY_FILTERS)}></button></form>
{summaryQuery.isError ? <InlineError message={summaryQuery.error instanceof Error ? summaryQuery.error.message : '接入汇总读取失败'} onRetry={() => summaryQuery.refetch()} /> : null}
<section className="v2-access-kpis">{[
['接入车辆', 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]) => <button key={String(label)} type="button" className={`is-${tone}`} onClick={action as () => void}><small>{label as string}</small><strong>{Number(value).toLocaleString('zh-CN')}</strong>{label === '在线' ? <em>{(summary?.onlineRate ?? 0).toFixed(1)}%</em> : null}</button>)}</section>
<ProtocolDistribution summary={summary} />
{unresolvedQuery.isError ? <InlineError message={unresolvedQuery.error instanceof Error ? unresolvedQuery.error.message : '身份待绑定队列读取失败'} onRetry={() => unresolvedQuery.refetch()} /> : null}
<IdentityQueue items={unresolvedQuery.data?.items ?? []} total={unresolvedQuery.data?.total ?? 0} loading={unresolvedQuery.isLoading} />
<section className="v2-access-kpis-v3">{[
['车辆', 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]) => <button key={String(label)} className={`is-${tone}`} type="button" onClick={() => apply({ ...criteria, connectionState: String(connectionState) })}><small>{label}</small><strong>{Number(value).toLocaleString('zh-CN')}</strong>{label === '主车辆' ? <em></em> : null}</button>)}</section>
{vehiclesQuery.isError ? <InlineError message={vehiclesQuery.error instanceof Error ? vehiclesQuery.error.message : '接入车辆读取失败'} onRetry={() => vehiclesQuery.refetch()} /> : null}
<div className="v2-access-workspace"><section className="v2-access-table-card"><header><strong></strong><div><span> v{summary?.thresholdVersion ?? '—'}</span><button type="button" onClick={() => vehiclesQuery.refetch()}><IconRefresh /></button><button type="button" onClick={() => downloadRows(rows)} disabled={!rows.length}><IconDownload /></button><button type="button"><IconSetting /></button></div></header><div className="v2-access-table-scroll"><table><thead><tr><th /><th>线</th><th></th><th>VIN</th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead><tbody>{rows.map((row) => <tr key={row.vin} className={selected?.vin === row.vin ? 'is-selected' : ''}><td><input type="radio" name="access-row" checked={selected?.vin === row.vin} onChange={() => setSelectedVIN(row.vin)} aria-label={`选择 ${row.plate || row.vin}`} /></td><td><StatusLabel state={row.onlineState} /></td><td>{row.plate || ''}</td><td title={row.vin}>{row.vin}</td><td>{row.oem || ''}</td><td>{row.protocol || '—'}</td><td title={row.firstSeenEvidence}>{formatAccessTime(row.firstSeenAt)}</td><td>{formatAccessTime(row.latestEventAt)}</td><td>{formatAccessTime(row.latestReceivedAt)}</td><td title={row.reportIntervalEvidence}>{formatSeconds(row.reportIntervalSec)}</td><td className={row.delayAbnormal ? 'is-danger' : 'is-good'}>{formatSeconds(row.dataDelaySec)}</td><td>{formatSeconds(row.thresholdSec)}</td><td>{row.latestMessageType || '—'}</td><td className={row.latestError ? 'is-danger' : ''} title={row.latestError}>{row.latestError || '—'}</td><td><button type="button" onClick={() => setSelectedVIN(row.vin)}></button></td></tr>)}</tbody></table>{vehiclesQuery.isFetching ? <div className="v2-access-loading"><i /></div> : null}{!vehiclesQuery.isFetching && !rows.length ? <div className="v2-access-empty"></div> : null}</div><footer><span> {page} / {totalPages} {(vehiclesQuery.data?.total ?? 0).toLocaleString('zh-CN')} </span><div><button type="button" disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}></button><button type="button" disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}></button><select value={limit} onChange={(event) => { setLimit(Number(event.target.value)); setOffset(0); }}><option value="20">20 /</option><option value="50">50 /</option><option value="100">100 /</option></select></div></footer></section>
<aside className="v2-access-side"><AccessInspector row={selected} /><ThresholdPanel config={thresholdQuery.data} draft={thresholdDraft} saving={updateThreshold.isPending} error={updateThreshold.error instanceof Error ? updateThreshold.error.message : undefined} editable={thresholdEditable} onChange={setThresholdDraft} onSave={() => thresholdDraft && updateThreshold.mutate(thresholdDraft)} /></aside></div>
<div className={`v2-access-workspace-v3 ${selected ? 'is-inspector-open' : ''}`}><section className="v2-access-table-v3"><header><div className="v2-access-table-title"><strong></strong><span></span></div><div className="v2-access-table-actions"><ProtocolCoverage summary={summary} /><button type="button" onClick={() => downloadRows(rows)} disabled={!rows.length}><IconDownload /></button></div></header><div className="v2-access-table-scroll-v3"><table><thead><tr><th></th><th> / </th><th></th>{PROTOCOLS.map((item) => <th key={item}>{item}</th>)}<th></th></tr></thead><tbody>{rows.map((row) => <tr key={row.vin} data-testid={`access-row-${row.vin}`} tabIndex={0} className={selected?.vin === row.vin ? 'is-selected' : ''} onClick={() => setSelectedVIN(row.vin)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') setSelectedVIN(row.vin); }}><td><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></td><td><strong>{row.oem || '品牌未维护'}</strong><span>{row.model || row.company || '车型未维护'}</span></td><td><b>{row.expectedProtocols.length} </b><span>{row.expectedProtocols.join(' / ')}</span></td>{PROTOCOLS.map((protocol) => <td key={protocol}><ProtocolState status={statusByProtocol(row, protocol)} /></td>)}<td><ConnectionState row={row} /></td></tr>)}</tbody></table>{vehiclesQuery.isFetching ? <div className="v2-access-loading"><i /></div> : null}{!vehiclesQuery.isFetching && !rows.length ? <div className="v2-access-empty"></div> : null}</div><footer><span> {page} / {totalPages} {(vehiclesQuery.data?.total ?? 0).toLocaleString('zh-CN')} </span><div><button type="button" disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}></button><button type="button" disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}></button><select aria-label="每页数量" value={limit} onChange={(event) => { setLimit(Number(event.target.value)); setOffset(0); }}><option value="20">20 /</option><option value="50">50 /</option><option value="100">100 /</option></select></div></footer></section>{selected ? <VehicleInspector row={selected} onClose={() => setSelectedVIN('')} /> : null}</div>
<IdentityQueue items={unresolvedQuery.data?.items ?? []} total={unresolvedQuery.data?.total ?? 0} />
<ThresholdSettings config={thresholdQuery.data} draft={thresholdDraft} editable={editable} saving={updateThreshold.isPending} error={updateThreshold.error instanceof Error ? updateThreshold.error.message : undefined} onChange={setThresholdDraft} onSave={() => thresholdDraft && updateThreshold.mutate(thresholdDraft)} />
</div>;
}

View File

@@ -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(<MemoryRouter><MonitorPage /></MemoryRouter>);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(<QueryClientProvider client={queryClient}><MemoryRouter><MonitorPage /></MemoryRouter></QueryClientProvider>);
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(<QueryClientProvider client={queryClient}><MemoryRouter><MonitorPage /></MemoryRouter></QueryClientProvider>);
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(<QueryClientProvider client={queryClient}><MemoryRouter><MonitorPage /></MemoryRouter></QueryClientProvider>);
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);
});
});

View File

@@ -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<AddressCoordinate>();
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 <span className="v2-monitor-address-empty"></span>;
if (!requested) return <button type="button" className="v2-monitor-address-action" aria-label={`解析${vehicle.plate || vehicle.vin}位置`} title="按需调用高德逆地理编码,不随实时数据刷新重复请求" onClick={() => setRequested(current)}><IconMapPin /></button>;
if (addressQuery.isFetching && !addressQuery.data) return <span className="v2-monitor-address-loading"><i className="v2-spinner" /></span>;
if (addressQuery.isError) return <button type="button" className="v2-monitor-address-action is-error" onClick={() => void addressQuery.refetch()}></button>;
return <div className="v2-monitor-address-result"><span title={addressQuery.data?.formattedAddress}>{addressQuery.data?.formattedAddress || '暂无地址'}</span>{moved ? <button type="button" title="车辆已移动,按需更新地址" onClick={() => setRequested(current)}> · </button> : null}</div>;
});
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 <section className="v2-monitor-table-panel">
<header><div><strong></strong><span></span></div><b>{total.toLocaleString('zh-CN')} </b></header>
<div className="v2-monitor-table-scroll"><table><thead><tr><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>{rows.map((row) => <tr key={row.vin}>
<td className="v2-monitor-table-vehicle"><button type="button" title="在地图中定位" onClick={() => onSelect(row.vin)}><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></button></td>
<td><strong className="v2-monitor-live-value">{formatNumber(row.speedKmh, 1)}</strong><small className="v2-monitor-live-unit">km/h</small></td>
<td><strong className="v2-monitor-live-value">{formatNumber(row.totalMileageKm, 1)}</strong><small className="v2-monitor-live-unit">km</small></td>
<td><code className="v2-monitor-coordinate">{row.longitude.toFixed(6)}<br />{row.latitude.toFixed(6)}</code></td>
<td><MonitorAddressCell vehicle={row} /></td>
</tr>)}</tbody></table>{loading ? <div className="v2-monitor-table-loading"><i className="v2-spinner" /></div> : null}{!loading && !rows.length ? <EmptyState /> : null}</div>
<div className="v2-monitor-mobile-cards">{rows.map((row) => <article key={row.vin}>
<header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div><b>{formatNumber(row.speedKmh, 1)}<small>km/h</small></b></header>
<dl><div><dt></dt><dd>{formatNumber(row.totalMileageKm, 1)} km</dd></div><div><dt></dt><dd><code>{row.longitude.toFixed(6)}, {row.latitude.toFixed(6)}</code></dd></div><div className="is-address"><dt></dt><dd><MonitorAddressCell vehicle={row} /></dd></div></dl>
<footer><button type="button" onClick={() => onSelect(row.vin)}><IconMapPin /></button></footer>
</article>)}{loading ? <div className="v2-monitor-table-loading"><i className="v2-spinner" /></div> : null}{!loading && !rows.length ? <EmptyState /> : null}</div>
<footer><span> {page} / {totalPages} </span><div><button type="button" disabled={page <= 1} onClick={() => onPage(page - 1)}></button><button type="button" disabled={page >= totalPages} onClick={() => onPage(page + 1)}></button><select aria-label="每页车辆数" value={limit} onChange={(event) => onLimit(Number(event.target.value))}><option value="20">20 /</option><option value="50">50 /</option><option value="100">100 /</option></select></div></footer>
</section>;
}
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 <div className="v2-monitor-qr-backdrop" role="dialog" aria-modal="true" aria-label="手机端入口"><section><button type="button" onClick={onClose} aria-label="关闭手机端入口"><IconClose /></button><IconQrCode /><h3></h3><p>使 Token</p>{qr ? <img src={qr} alt="全局监控手机端二维码" /> : <span className="v2-spinner" />}<code>{url}</code><button type="button" onClick={() => void navigator.clipboard?.writeText(url)}>访</button></section></div>;
}
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 (
<div className="v2-monitor-page">
<section className="v2-filterbar" aria-label="车辆筛选">
<label className="v2-search-field"><IconSearch /><input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="车牌 / VIN / 厂家" /></label>
<select value={protocol} onChange={(event) => setProtocol(event.target.value)} aria-label="协议">
<label className={`v2-search-field${searchTerms.length > 1 ? ' is-batch' : ''}`}>
<IconSearch />
<input
aria-label="搜索车辆"
value={keyword}
onChange={(event) => { 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 ? <span className="v2-search-batch-count" aria-live="polite" title={searchTerms.length === MAX_MONITOR_SEARCH_TERMS ? `最多支持 ${MAX_MONITOR_SEARCH_TERMS} 条;${searchTerms.join('、')}` : searchTerms.join('、')}> {searchTerms.length} </span> : null}
</label>
<select value={protocol} onChange={(event) => { setProtocol(event.target.value); setListOffset(0); }} aria-label="协议">
{protocols.map((item) => <option key={item} value={item}>{item || '全部协议'}</option>)}
</select>
<select value={status} onChange={(event) => setStatus(event.target.value)} aria-label="在线状态">
<select value={status} onChange={(event) => { setStatus(event.target.value); setListOffset(0); }} aria-label="在线状态">
{statuses.map((item) => <option key={item} value={item}>{item ? statusLabel(item as never) : '全部状态'}</option>)}
</select>
<button type="button" className="v2-secondary-button" onClick={() => { setKeyword(''); setProtocol(''); setStatus(''); }}><IconRefresh /></button>
<button type="button" className="v2-secondary-button" onClick={() => { setKeyword(''); setProtocol(''); setStatus(''); setListOffset(0); }}><IconRefresh /></button>
<button type="button" className="v2-primary-button"><IconFilter /></button>
<div className="v2-monitor-mode" aria-label="监控视图"><button type="button" className={mode === 'map' ? 'is-active' : ''} onClick={() => setMode('map')}><IconMapPin /></button><button type="button" className={mode === 'list' ? 'is-active' : ''} onClick={() => { setMode('list'); setDetailOpen(false); }}><IconList /></button></div>
<button type="button" className="v2-monitor-mobile-entry" onClick={() => setMobileEntryOpen(true)}><IconQrCode /></button>
</section>
<section className="v2-kpis" aria-label="车辆整体统计">
@@ -155,10 +255,11 @@ export default function MonitorPage() {
</section>
{vehicles.isError ? <InlineError message={vehicles.error instanceof Error ? vehicles.error.message : '车辆数据加载失败'} onRetry={() => vehicles.refetch()} /> : null}
<section className={`v2-monitor-workspace${selected && detailOpen ? ' is-detail-open' : ''}${selected && !detailOpen ? ' is-detail-collapsed' : ''}`}>
{mode === 'list' && realtimeListQuery.isError ? <InlineError message={realtimeListQuery.error instanceof Error ? realtimeListQuery.error.message : '车辆列表加载失败'} onRetry={() => realtimeListQuery.refetch()} /> : null}
{mode === 'map' ? <section className={`v2-monitor-workspace${selected && detailOpen ? ' is-detail-open' : ''}${selected && !detailOpen ? ' is-detail-collapsed' : ''}`}>
<div className="v2-vehicle-rail">
<header><strong></strong><span>{formatNumber(vehicles.data?.total ?? rows.length)} </span></header>
<div className="v2-rail-search"><IconSearch /><span>{deferredKeyword ? `正在筛选“${deferredKeyword}` : '按最新上报排序'}</span></div>
<div className="v2-rail-search"><IconSearch /><span>{searchTerms.length > 1 ? `正在批量筛选 ${searchTerms.length}` : deferredKeyword ? `正在筛选“${deferredKeyword}` : '按最新上报排序'}</span></div>
<div className="v2-vehicle-scroll">
{vehicles.isLoading ? <div className="v2-list-loading"><span className="v2-spinner" /></div> : null}
{!vehicles.isLoading && rows.length === 0 ? <EmptyState /> : null}
@@ -193,15 +294,16 @@ export default function MonitorPage() {
</button>
</aside>
) : null}
</section>
</section> : <MonitorVehicleTable rows={realtimeListQuery.data?.items ?? []} total={realtimeListQuery.data?.total ?? 0} page={Math.floor(listOffset / listLimit) + 1} totalPages={Math.max(1, Math.ceil((realtimeListQuery.data?.total ?? 0) / listLimit))} limit={listLimit} loading={realtimeListQuery.isFetching} onSelect={selectVehicle} onPage={(page) => setListOffset((page - 1) * listLimit)} onLimit={(next) => { setListLimit(next); setListOffset(0); }} />}
<section className="v2-event-strip">
<strong></strong>
<span><i className="is-online" />{vehicles.isFetching ? '正在刷新' : '实时车辆已同步'}</span>
<span> {rows.length} · {map.data?.clusters.length ? `${map.data.clusters.length} 个聚合 + ${map.data.points.length} 个车辆点` : `${map.data?.points.length ?? 0} 个点`}</span>
<span>{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}`}</span>
<span className="v2-refresh-cadence"><b></b> {MONITOR_REFRESH.selected / 1000} · {MONITOR_REFRESH.fleet / 1000} · {MONITOR_REFRESH.summary / 1000} </span>
<time>{new Date().toLocaleString('zh-CN', { hour12: false })}</time>
</section>
{mobileEntryOpen ? <MobileEntry onClose={() => setMobileEntryOpen(false)} /> : null}
</div>
);
}

View File

@@ -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(<QueryClientProvider client={client}><MemoryRouter initialEntries={[initialEntry]}><StatisticsPage /></MemoryRouter></QueryClientProvider>);
}
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(<QueryClientProvider client={client}><MemoryRouter><StatisticsPage /></MemoryRouter></QueryClientProvider>);
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();
});

View File

@@ -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<VehicleRow, 'vin' | 'plate'>;
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 <div className="v2-stat-empty"></div>;
const width = 860; const height = 238; const left = 58; const right = 18; const top = 16; const bottom = 34;
const max = Math.max(...points.map((point) => point.mileageKm), 1);
const x = (index: number) => left + (width - left - right) * (points.length === 1 ? .5 : index / (points.length - 1));
const y = (value: number) => top + (height - top - bottom) * (1 - value / max);
const path = points.map((point, index) => `${index ? 'L' : 'M'}${x(index).toFixed(1)},${y(point.mileageKm).toFixed(1)}`).join(' ');
const labelIndexes = Array.from(new Set([0, Math.floor((points.length - 1) / 2), points.length - 1]));
return <div className="v2-stat-chart-wrap"><svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label="每日行驶里程趋势图">
<g className="v2-stat-grid">{[0, .5, 1].map((ratio) => <line key={ratio} x1={left} x2={width - right} y1={y(max * ratio)} y2={y(max * ratio)} />)}</g>
<g className="v2-stat-axis"><text x={left - 8} y={y(max) + 4} textAnchor="end">{formatKm(max, true)}</text><text x={left - 8} y={y(max / 2) + 4} textAnchor="end">{formatKm(max / 2, true)}</text><text x={left - 8} y={y(0) + 4} textAnchor="end">0</text>{labelIndexes.map((index) => <text key={index} x={x(index)} y={height - 8} textAnchor={index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'middle'}>{points[index].date.slice(5)}</text>)}</g>
<path className="v2-stat-area" d={`${path} L${x(points.length - 1)},${y(0)} L${x(0)},${y(0)} Z`} />
<path className="v2-stat-line" d={path} />
{points.map((point, index) => <circle key={point.date} className="v2-stat-point" cx={x(index)} cy={y(point.mileageKm)} r="3"><title>{point.date}{formatKm(point.mileageKm)} km{point.vehicles} </title></circle>)}
</svg></div>;
function 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<MileageSourceOption>[];
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 <div className="v2-mileage-source-strategy">
<button type="button" className="v2-mileage-source-trigger" aria-haspopup="dialog" aria-expanded={open} onClick={() => setOpen((current) => !current)}>
<IconSetting /><span></span><b>{enabled.length}/3</b>
</button>
{open ? <section className="v2-mileage-source-popover" role="dialog" aria-label="数据源策略配置">
<header><div><strong></strong><span></span></div><button type="button" aria-label="关闭数据源策略" onClick={() => setOpen(false)}><IconClose /></button></header>
<div className="v2-mileage-source-list">{value.map((source, index) => <article key={source.protocol} className={source.enabled ? '' : 'is-disabled'}>
<button type="button" className={`v2-mileage-source-switch${source.enabled ? ' is-on' : ''}`} role="switch" aria-checked={source.enabled} aria-label={`${source.enabled ? '禁用' : '启用'} ${source.label}`} onClick={() => toggle(source.protocol)}><i /></button>
<div><strong>{source.label}</strong><small><b>{source.mileageType}</b><code>{source.protocol}</code></small></div>
<em>{source.enabled ? `优先级 ${enabled.findIndex((item) => item.protocol === source.protocol) + 1}` : '已禁用'}</em>
<p><button type="button" aria-label={`上移 ${source.label}`} disabled={index === 0} onClick={() => move(index, -1)}><IconArrowUp /></button><button type="button" aria-label={`下移 ${source.label}`} disabled={index === value.length - 1} onClick={() => move(index, 1)}><IconArrowDown /></button></p>
</article>)}</div>
<footer><span>使</span><button type="button" onClick={() => setOpen(false)}></button></footer>
</section> : null}
</div>;
}
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 <label className="v2-mileage-vehicle-field">
<span></span>
<div className={`v2-mileage-multiselect${open ? ' is-open' : ''}`}>
<IconSearch />
<div className="v2-mileage-selection">
{value.map((vehicle) => <button key={vehicle.vin} type="button" className="v2-mileage-chip" title={vehicle.vin} onClick={() => onChange(value.filter((item) => item.vin !== vehicle.vin))}>
<span>{vehicle.plate || vehicle.vin}</span><IconClose />
</button>)}
<input value={search} onFocus={() => setOpen(true)} onBlur={() => window.setTimeout(() => setOpen(false), 120)} onChange={(event) => { setSearch(event.target.value); setOpen(true); }} placeholder={value.length ? '继续添加车牌' : '输入车牌搜索,可多选'} aria-label="搜索车牌" />
</div>
{open ? <div className="v2-mileage-options" role="listbox">
<header><span></span><em>{value.length}/{MAX_SELECTED_VEHICLES} </em></header>
{candidates.isLoading ? <p></p> : null}
{!candidates.isLoading && options.map((vehicle) => <button type="button" role="option" aria-selected={selected.has(vehicle.vin)} key={vehicle.vin} disabled={selected.has(vehicle.vin)} onMouseDown={(event) => event.preventDefault()} onClick={() => add(vehicle)}>
<strong>{vehicle.plate || '未绑定车牌'}</strong><span>{vehicle.vin}</span>{selected.has(vehicle.vin) ? <em></em> : null}
</button>)}
{!candidates.isLoading && !options.length ? <p></p> : null}
</div> : null}
</div>
<small> {MAX_SELECTED_VEHICLES} </small>
</label>;
}
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 <section className="v2-stat-kpis">{items.map(([label, value, note], index) => <article key={label} className={index < 2 ? 'is-primary' : ''}><small>{label}</small><strong>{value}</strong><span>{note}</span></article>)}</section>;
return <section className="v2-mileage-summary" aria-label="里程查询统计信息">{items.map(([label, value, note], index) => <article key={label} className={index === 2 ? 'is-primary' : ''}><small>{label}</small><strong>{value}</strong><span>{note}</span></article>)}</section>;
}
type VehicleMileageMatrix = VehicleOption & { days: Map<string, number>; sources: Map<string, string>; 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 <>
<div className="v2-mileage-table-wrap">
<table className="v2-mileage-table">
<thead><tr><th className="is-sticky is-plate"></th><th className="is-sticky is-vin">VIN</th>{dates.map((date) => <th key={date} className="is-number is-date" title={date}>{dateLabel(date)}</th>)}<th className="is-number is-total"></th></tr></thead>
<tbody>{rows.map((row) => <tr key={row.vin}><td className="is-sticky is-plate"><strong>{row.plate || '未绑定'}</strong></td><td className="is-sticky is-vin"><code>{row.vin}</code></td>{dates.map((date) => {
const mileage = row.days.get(date);
const intensity = mileage && mileage > 0 ? .035 + mileage / maxDailyMileage * .13 : 0;
return <td key={date} className={`is-number${mileage != null ? ' is-daily' : ' is-empty'}`} title={mileage != null ? `来源:${row.sources.get(date) || '—'}` : undefined} style={intensity ? { backgroundColor: `rgba(37, 99, 235, ${intensity.toFixed(3)})` } : undefined}>{mileage != null ? `${formatKm(mileage)} km` : '—'}</td>;
})}<td className="is-number is-period is-total">{formatKm(row.totalMileageKm)} km</td></tr>)}</tbody>
</table>
</div>
<div className="v2-mileage-mobile-list">{rows.map((row) => <article key={row.vin}><header><div><strong>{row.plate || '未绑定'}</strong><span>{row.vin}</span></div><b>{formatKm(row.totalMileageKm)} km<small></small></b></header><div className="v2-mileage-mobile-days">{dates.map((date) => <div key={date}><time>{dateLabel(date)}</time><strong>{row.days.has(date) ? `${formatKm(row.days.get(date))} km` : '—'}</strong></div>)}</div></article>)}</div>
</>;
}
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<Criteria>(() => initialCriteria(searchParams));
const [criteria, setCriteria] = useState<Criteria>(() => 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<VehicleOption[]>(() => 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<string, number>();
const sources = new Map<string, string>();
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 <div className="v2-stat-page">
<header className="v2-stat-heading"><div><h2></h2><p></p></div><button type="button" onClick={() => query.refetch()} disabled={query.isFetching}><IconRefresh />{query.isFetching ? '更新中' : '刷新数据'}</button></header>
<form className="v2-stat-filter" onSubmit={submit}>
<label className="v2-stat-search"><span></span><div><IconSearch /><input value={draft.vin} onChange={(event) => setDraft((current) => ({ ...current, vin: event.target.value }))} placeholder="车牌 / VIN留空统计全车队" /></div></label>
<label><span></span><input type="date" value={draft.dateFrom} max={draft.dateTo} onChange={(event) => setDraft((current) => ({ ...current, dateFrom: event.target.value }))} /></label>
<label><span></span><input type="date" value={draft.dateTo} min={draft.dateFrom} onChange={(event) => setDraft((current) => ({ ...current, dateTo: event.target.value }))} /></label>
<label><span></span><select value={draft.protocol} onChange={(event) => setDraft((current) => ({ ...current, protocol: event.target.value }))}><option value=""></option><option value="GB32960">GB32960</option><option value="JT808">JT808</option><option value="YUTONG_MQTT">YUTONG_MQTT</option></select></label>
<button className="v2-primary-button" type="submit"></button>
<div className="v2-stat-ranges"><button type="button" onClick={() => setDays(7)}> 7 </button><button type="button" onClick={() => setDays(30)}> 30 </button><button type="button" onClick={() => setDays(90)}> 90 </button></div>
</form>
{query.isError ? <InlineError message={query.error instanceof Error ? query.error.message : '统计数据加载失败'} onRetry={() => query.refetch()} /> : null}
<Kpis data={data} />
<div className="v2-stat-grid-layout">
<section className="v2-stat-card v2-stat-trend"><header><div><strong></strong><span>{data?.dateFrom || criteria.dateFrom} {data?.dateTo || criteria.dateTo}</span></div><em>{data?.trend.length ?? 0} </em></header><MileageChart points={data?.trend ?? []} /><div className="v2-stat-daily-list" aria-label="每日里程精确数据">{[...(data?.trend ?? [])].reverse().slice(0, 10).map((point) => <div key={point.date}><time>{point.date}</time><strong>{formatKm(point.mileageKm)} km</strong><span>{point.vehicles} </span></div>)}</div></section>
<section className="v2-stat-card v2-stat-ranking"><header><div><strong></strong><span> 20 </span></div></header><div>{data?.ranking.map((item, index) => <article key={item.vin}><b>{index + 1}</b><div><header><Link to={`/vehicles/${encodeURIComponent(item.vin)}`}>{item.plate || item.vin}</Link><strong>{formatKm(item.mileageKm)} km</strong></header><span><i style={{ width: `${Math.max(2, item.mileageKm / maximumRank * 100)}%` }} /></span><footer><small>{item.plate ? item.vin : '未绑定车牌'}</small><em>{item.activeDays} · {formatKm(item.latestMileageKm)} km</em></footer></div></article>)}{!query.isLoading && !data?.ranking.length ? <div className="v2-stat-empty"></div> : null}</div></section>
</div>
<footer className="v2-stat-evidence"><span>{data?.asOf || '—'}</span><span>{data?.evidence || '正在读取生产统计证据'}</span><span> 5 </span></footer>
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 <div className="v2-mileage-page">
<header className="v2-mileage-heading"><div><h2></h2><p></p></div><button type="button" onClick={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} disabled={refreshing}><IconRefresh />{refreshing ? '更新中' : '刷新数据'}</button></header>
<section className="v2-mileage-query-panel">
<form className="v2-mileage-filter" onSubmit={submit}>
<VehicleMultiSelect value={draft.vehicles} onChange={(vehicles) => setDraft((current) => ({ ...current, vehicles }))} />
<label><span></span><input type="date" value={draft.dateFrom} max={draft.dateTo} onChange={(event) => setDraft((current) => ({ ...current, dateFrom: event.target.value }))} /></label>
<label><span></span><input type="date" value={draft.dateTo} min={draft.dateFrom} onChange={(event) => setDraft((current) => ({ ...current, dateTo: event.target.value }))} /></label>
<button className="v2-primary-button" type="submit"></button>
<SourceStrategy value={draft.sources} onChange={(sources) => setDraft((current) => ({ ...current, sources }))} />
<div className="v2-mileage-ranges"><span></span><button type="button" onClick={() => setDays(7)}> 7 </button><button type="button" onClick={() => setDays(30)}> 30 </button><button type="button" onClick={() => setDays(90)}> 90 </button></div>
</form>
<SummaryRail data={statistics.data} criteria={criteria} fleetTotal={fleetVehicles.data?.total} />
</section>
{statistics.isError || mileage.isError || fleetVehicles.isError ? <InlineError message={(statistics.error ?? mileage.error ?? fleetVehicles.error) instanceof Error ? (statistics.error ?? mileage.error ?? fleetVehicles.error as Error).message : '里程数据加载失败'} onRetry={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} /> : null}
<section className="v2-mileage-results">
<header><div><strong></strong><span>{criteria.dateFrom} {criteria.dateTo}</span></div><div className="v2-mileage-result-actions"><em>{hasVehicles ? `${totalVehicles} 辆车` : `当前 ${displayVehicles.length} 辆 / 共 ${totalVehicles}`} · {dates.length} </em><button type="button" onClick={exportExcel} disabled={isExporting || !totalVehicles}><IconDownload />{isExporting ? '正在导出…' : '导出 Excel'}</button></div></header>
<MileageTable rows={matrixRows} dates={dates} />
{!fleetVehicles.isLoading && !displayVehicles.length ? <div className="v2-mileage-empty"></div> : null}
<footer><span>{hasVehicles ? `已选择 ${totalVehicles} 辆车辆` : `${page} / ${totalPages} 页 · 共 ${totalVehicles} 辆 · 每页 ${PAGE_SIZE}`}{exportFeedback ? ` · ${exportFeedback}` : ''}</span>{!hasVehicles && totalVehicles ? <div><button type="button" disabled={page <= 1 || fleetVehicles.isFetching} onClick={() => setPage((current) => Math.max(1, current - 1))}></button><button type="button" disabled={page >= totalPages || fleetVehicles.isFetching} onClick={() => setPage((current) => Math.min(totalPages, current + 1))}></button></div> : null}</footer>
</section>
<footer className="v2-mileage-evidence"><span>{statistics.data?.asOf || '—'}</span><span>{criteria.sources.filter((source) => source.enabled).map((source) => source.protocol).join(' ')}</span><span> 1 </span></footer>
</div>;
}
function paramsFrom(criteria: { vin: string; protocol: string; dateFrom: string; dateTo: string }) {
const next = new URLSearchParams({ dateFrom: criteria.dateFrom, dateTo: criteria.dateTo });
if (criteria.vin.trim()) next.set('vin', criteria.vin.trim());
if (criteria.protocol) next.set('protocol', criteria.protocol);
return next;
}

View File

@@ -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 }) => <div data-testid="track-map" data-active-index={activeIndex} /> }));
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(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59']}><TrackPage /></MemoryRouter></QueryClientProvider>);
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');
});

View File

@@ -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 <div className="v2-track-empty"><IconSearch size="extra-large" /><strong>{queried ? '当前条件没有轨迹点' : '选择车辆开始查询轨迹'}</strong><p>{queried ? '请扩大时间范围或切换数据来源。' : '支持车牌、VIN 或终端标识,结果不会回退到本地样例。'}</p></div>;
}
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 <div className={`v2-track-coverage ${track.coverage.complete ? 'is-complete' : 'is-limited'}`}>
<strong>{track.coverage.complete ? '时间窗完整' : '仅展示最新切片'}</strong>
<span>{track.coverage.evidence}</span>
<em>{track.coverage.processedPoints} {track.coverage.returnedPoints} </em>
return <div className={`v2-track-vehicle-picker${open ? ' is-open' : ''}`}>
<IconSearch />
<input
aria-label="搜索轨迹车辆" autoComplete="off" placeholder="输入车牌 / VIN / 终端标识"
value={value} onFocus={() => setOpen(true)} onBlur={() => window.setTimeout(() => setOpen(false), 140)}
onChange={(event) => { onChange(event.target.value); setOpen(true); }}
/>
{value ? <button type="button" aria-label="清空车辆" onMouseDown={(event) => event.preventDefault()} onClick={() => onChange('')}><IconClose /></button> : null}
{open ? <div className="v2-track-vehicle-options" role="listbox">
<header><span></span><em> VIN</em></header>
{candidates.isFetching ? <p><span className="v2-spinner" /></p> : null}
{!candidates.isFetching && (candidates.data?.items ?? []).map((vehicle) => <button
type="button" role="option" aria-selected={false} key={vehicle.vin}
onMouseDown={(event) => event.preventDefault()} onClick={() => { onSelect(vehicle); setOpen(false); }}
><strong>{vehicle.plate || '未绑定车牌'}</strong><span>{vehicle.vin}</span><em></em></button>)}
{!candidates.isFetching && !(candidates.data?.items.length) ? <p></p> : null}
</div> : null}
</div>;
}
function SegmentTimeline({ track, onSelectIndex }: { track: TrackPlaybackResponse; onSelectIndex: (index: number) => void }) {
const segments = track.segments.slice(0, visibleSegmentLimit);
return <div className="v2-track-timeline">
<header><strong></strong><span>{track.segments.length} · {track.stops.length} </span></header>
<div>{segments.map((segment) => <button
aria-label={`${segment.title} ${time(segment.startTime)}${time(segment.endTime)}`}
className={`is-${segment.type}`}
key={`${segment.index}-${segment.startTime}`}
onClick={() => onSelectIndex(segment.sampledStartIndex)}
title={`${segment.title} · ${formatDuration(segment.durationSeconds)} · ${number(segment.distanceKm)} km`}
type="button"
><i /><span>{segment.title}</span></button>)}</div>
{track.segments.length > visibleSegmentLimit ? <em> {visibleSegmentLimit} </em> : null}
</div>;
}
function TripInspector({ track, onEvent }: { track: TrackPlaybackResponse; onEvent: (event: TrackPlaybackEvent) => void }) {
return <aside className="v2-track-inspector">
<section><header><strong></strong><span>{track.sampled ? '地图已抽稀' : '完整点集'}</span></header><div className="v2-track-vehicle"><span><IconBox /></span><div><strong>{track.plate || track.vin}</strong><small>VIN {track.vin}</small></div></div></section>
<section><header><strong></strong></header><dl className="v2-track-summary">
<div><dt></dt><dd>{track.summary.startTime || '—'}</dd></div><div><dt></dt><dd>{track.summary.endTime || '—'}</dd></div><div><dt></dt><dd>{number(track.summary.distanceKm)} km</dd></div><div><dt></dt><dd>{formatDuration(track.summary.durationSeconds)}</dd></div><div><dt> / </dt><dd>{formatDuration(track.summary.movingSeconds)} / {formatDuration(track.summary.stoppedSeconds)}</dd></div><div><dt> / </dt><dd>{track.summary.stopCount} / {track.summary.segmentCount}</dd></div><div><dt></dt><dd>{number(track.summary.averageSpeedKmh)} km/h</dd></div><div><dt></dt><dd>{number(track.summary.maximumSpeedKmh)} km/h</dd></div>
function OverviewPanel({ track }: { track: TrackPlaybackResponse }) {
return <div className="v2-track-overview-panel">
<section><header><strong></strong><span>{track.sampled ? '地图已抽稀' : '完整点集'}</span></header><dl>
<div><dt></dt><dd>{dateTime(track.summary.startTime)}</dd></div>
<div><dt></dt><dd>{dateTime(track.summary.endTime)}</dd></div>
<div><dt></dt><dd>{number(track.summary.distanceKm)} km</dd></div>
<div><dt> / </dt><dd>{formatDuration(track.summary.movingSeconds)} / {formatDuration(track.summary.stoppedSeconds)}</dd></div>
<div><dt> / </dt><dd>{number(track.summary.averageSpeedKmh)} / {number(track.summary.maximumSpeedKmh)} km/h</dd></div>
<div><dt> / </dt><dd>{track.summary.stopCount} / {track.summary.segmentCount}</dd></div>
</dl></section>
<section className="v2-track-sources"><header><strong></strong><b>{track.coverage.totalPoints} </b></header><div>{track.sources.map((source) => <span key={source.protocol}><strong>{source.protocol}</strong><small>{source.pointCount} · {time(source.startTime)}{time(source.endTime)}</small></span>)}</div>{!track.coverage.complete ? <p>{track.coverage.evidence} 7 </p> : null}</section>
<section className={`v2-track-quality is-${track.quality.status}`}><header><strong></strong><span>{track.quality.status === 'good' ? '通过' : '需关注'}</span></header><dl className="v2-track-summary"><div><dt> / </dt><dd>{track.quality.selectedProtocol || 'UNKNOWN'} / {track.quality.alternateSourcePoints}</dd></div><div><dt> / </dt><dd>{track.quality.validPoints} / {track.quality.rawPoints}</dd></div><div><dt> / / </dt><dd>{track.quality.invalidCoordinatePoints} / {track.quality.duplicatePoints} / {track.quality.driftPoints}</dd></div><div><dt> / </dt><dd>{track.quality.largeGapCount} / {formatDuration(track.quality.maximumGapSeconds)}</dd></div></dl><p>{track.quality.evidence}</p></section>
<section className="v2-track-events"><header><strong></strong><span>{track.events.length} </span></header><div>{track.events.map((event, index) => <button key={`${event.type}-${event.index}-${event.time}`} onClick={() => onEvent(event)} type="button"><i className={`is-${eventTone(event.type)}`}>{index + 1}</i><span><strong>{event.title}</strong><small>{time(event.time)}</small></span><em>{number(event.speedKmh, 0)} km/h</em></button>)}</div></section>
<section><header><strong></strong><span>{track.coverage.totalPoints.toLocaleString('zh-CN')} </span></header><div className="v2-track-source-list">{track.sources.map((source) => <article key={source.protocol}><strong>{source.protocol}</strong><span>{source.pointCount.toLocaleString('zh-CN')} </span><small>{time(source.startTime)}{time(source.endTime)}</small></article>)}</div></section>
<section className={`v2-track-quality-card is-${track.quality.status}`}><header><strong></strong><span>{track.quality.status === 'good' ? '通过' : '需关注'}</span></header><p>{track.quality.evidence}</p><small> {track.quality.invalidCoordinatePoints} · {track.quality.duplicatePoints} · {track.quality.driftPoints} · {track.quality.largeGapCount}</small></section>
</div>;
}
function TrackRail({ draft, track, activeIndex, tab, onDraft, onSubmit, onTab, onSelectIndex, onCollapse }: {
draft: Draft;
track?: TrackPlaybackResponse;
activeIndex: number;
tab: PanelTab;
onDraft: (draft: Draft) => void;
onSubmit: (event: FormEvent) => void;
onTab: (tab: PanelTab) => void;
onSelectIndex: (index: number) => void;
onCollapse: () => void;
}) {
const choosePreset = (daysBack: number) => onDraft({ ...draft, ...trackWindow(daysBack, daysBack > 0) });
return <aside className="v2-track-rail">
<form className="v2-track-query" onSubmit={onSubmit}>
<header><div><strong></strong><span> 7 </span></div><button type="button" aria-label="收起查询面板" onClick={onCollapse}><IconChevronLeft /></button></header>
<label><span></span><VehiclePicker value={draft.keyword} onChange={(keyword) => onDraft({ ...draft, keyword })} onSelect={(vehicle) => onDraft({ ...draft, keyword: vehicle.plate || vehicle.vin })} /></label>
<div className="v2-track-presets"><button type="button" onClick={() => choosePreset(0)}></button><button type="button" onClick={() => choosePreset(1)}></button><button type="button" onClick={() => { const end = new Date(); const start = new Date(end); start.setDate(start.getDate() - 2); start.setHours(0, 0, 0, 0); onDraft({ ...draft, dateFrom: localDateTime(start), dateTo: localDateTime(end) }); }}> 3 </button></div>
<div className="v2-track-date-grid"><label><span></span><input type="datetime-local" value={draft.dateFrom} onChange={(event) => onDraft({ ...draft, dateFrom: event.target.value })} /></label><label><span></span><input type="datetime-local" value={draft.dateTo} onChange={(event) => onDraft({ ...draft, dateTo: event.target.value })} /></label></div>
<label><span></span><select value={draft.protocol} onChange={(event) => onDraft({ ...draft, protocol: event.target.value })}><option value=""></option><option value="GB32960">GB32960 · </option><option value="JT808">JT808 · GPS </option><option value="YUTONG_MQTT">YUTONG · </option></select></label>
<button className="v2-track-query-button" type="submit" disabled={!draft.keyword.trim()}><IconSearch /></button>
</form>
<div className="v2-track-rail-result">
{track ? <div className="v2-track-rail-vehicle"><span><IconBox /></span><div><strong>{track.plate || track.vin}</strong><small>{track.vin}</small></div><em>{number(track.summary.distanceKm)} km</em></div> : null}
<nav aria-label="轨迹明细分类"><button type="button" className={tab === 'stops' ? 'is-active' : ''} onClick={() => onTab('stops')}> <b>{track?.stops.length ?? 0}</b></button><button type="button" className={tab === 'events' ? 'is-active' : ''} onClick={() => onTab('events')}> <b>{track?.events.length ?? 0}</b></button><button type="button" className={tab === 'overview' ? 'is-active' : ''} onClick={() => onTab('overview')}></button></nav>
<div className="v2-track-rail-scroll">
{!track ? <div className="v2-track-rail-empty"><IconMapPin /><strong></strong><p></p></div> : null}
{track && tab === 'stops' ? <div className="v2-track-stop-list">{track.stops.map((stop, index) => <button type="button" className={Math.abs(stop.sampledIndex - activeIndex) < 2 ? 'is-active' : ''} key={`${stop.startTime}-${index}`} onClick={() => onSelectIndex(stop.sampledIndex)}><i>{index + 1}</i><span><strong>{dateTime(stop.startTime)}</strong><small> {formatDuration(stop.durationSeconds)} · {stop.pointCount} </small></span><em>{time(stop.endTime)}</em></button>)}{!track.stops.length ? <p className="v2-track-list-empty"> 3 </p> : null}</div> : null}
{track && tab === 'events' ? <div className="v2-track-event-list">{track.events.map((event, index) => { const sampled = sampledEventIndex(event, track.points.length, track.summary.pointCount); return <button type="button" className={sampled === activeIndex ? 'is-active' : ''} key={`${event.type}-${event.time}-${index}`} onClick={() => onSelectIndex(sampled)}><i className={`is-${eventTone(event.type)}`}>{index + 1}</i><span><strong>{event.title}</strong><small>{dateTime(event.time)}</small></span><em>{number(event.speedKmh, 0)} km/h</em></button>; })}</div> : null}
{track && tab === 'overview' ? <OverviewPanel track={track} /> : null}
</div>
</div>
</aside>;
}
function SegmentRail({ track, onSelectIndex }: { track: TrackPlaybackResponse; onSelectIndex: (index: number) => void }) {
const segments = track.segments.slice(0, 160);
const total = Math.max(1, segments.reduce((sum, segment) => sum + Math.max(1, segment.durationSeconds), 0));
return <div className="v2-track-segment-rail" aria-label="轨迹活动分段">{segments.map((segment) => <button
aria-label={`${segment.title} ${time(segment.startTime)}${time(segment.endTime)}`}
className={`is-${segment.type}`} key={`${segment.index}-${segment.startTime}`}
onClick={() => onSelectIndex(segment.sampledStartIndex)} style={{ flexGrow: Math.max(1, segment.durationSeconds) / total }}
title={`${segment.title} · ${formatDuration(segment.durationSeconds)} · ${number(segment.distanceKm)} km`}
type="button"
/>)}</div>;
}
export default function TrackPage() {
const [searchParams, setSearchParams] = useSearchParams();
const initialKeyword = searchParams.get('vin') || searchParams.get('keyword') || '';
const [draft, setDraft] = useState(() => {
const fallback = defaultTrackWindow();
return { keyword: initialKeyword, dateFrom: searchParams.get('dateFrom') || fallback.dateFrom, dateTo: searchParams.get('dateTo') || fallback.dateTo, protocol: searchParams.get('protocol') || '' };
});
const [criteria, setCriteria] = useState(draft);
const fallback = useMemo(defaultTrackWindow, []);
const initialDraft = useMemo<Draft>(() => ({
keyword: searchParams.get('vin') || searchParams.get('keyword') || '',
dateFrom: searchParams.get('dateFrom') || fallback.dateFrom,
dateTo: searchParams.get('dateTo') || fallback.dateTo,
protocol: searchParams.get('protocol') || ''
}), []);
const [draft, setDraft] = useState(initialDraft);
const [criteria, setCriteria] = useState(initialDraft);
const [activeIndex, setActiveIndex] = useState(0);
const [playing, setPlaying] = useState(false);
const [playbackSpeed, setPlaybackSpeed] = useState<(typeof speedOptions)[number]>(1);
const [playbackSpeed, setPlaybackSpeed] = useState<PlaybackSpeed>(1);
const [follow, setFollow] = useState(true);
const [showStops, setShowStops] = useState(true);
const [panelTab, setPanelTab] = useState<PanelTab>('stops');
const [railCollapsed, setRailCollapsed] = useState(false);
const animationRef = useRef<number>();
const lastFrameRef = useRef(0);
const params = useMemo(() => {
const next = new URLSearchParams({ keyword: criteria.keyword, maxPoints: '1200' });
const next = new URLSearchParams({ keyword: criteria.keyword, maxPoints: '1600' });
if (criteria.dateFrom) next.set('dateFrom', criteria.dateFrom);
if (criteria.dateTo) next.set('dateTo', criteria.dateTo);
if (criteria.protocol) next.set('protocol', criteria.protocol);
@@ -118,12 +195,13 @@ export default function TrackPage() {
const query = useQuery({ queryKey: ['track-playback', params.toString()], enabled: Boolean(criteria.keyword), queryFn: () => api.trackPlayback(params) });
const track = query.data;
const points = track?.points ?? [];
const current = points[Math.min(activeIndex, Math.max(points.length - 1, 0))];
const boundedIndex = Math.min(activeIndex, Math.max(points.length - 1, 0));
const current = points[boundedIndex];
const [addressPoint, setAddressPoint] = useState<{ longitude: number; latitude: number }>();
useEffect(() => {
if (playing || !current) return;
const timer = window.setTimeout(() => setAddressPoint({ longitude: current.longitude, latitude: current.latitude }), 350);
const timer = window.setTimeout(() => setAddressPoint({ longitude: current.longitude, latitude: current.latitude }), 360);
return () => window.clearTimeout(timer);
}, [current?.latitude, current?.longitude, playing]);
const addressQuery = useQuery({
@@ -132,14 +210,26 @@ export default function TrackPage() {
queryFn: () => api.reverseGeocode(new URLSearchParams({ longitude: addressPoint!.longitude.toFixed(6), latitude: addressPoint!.latitude.toFixed(6) }))
});
useEffect(() => { setActiveIndex(0); setPlaying(false); }, [track?.asOf]);
useEffect(() => { setActiveIndex(0); setPlaying(false); setFollow(true); if (track?.points.length) setRailCollapsed(window.matchMedia('(max-width: 700px)').matches); }, [track?.asOf]);
useEffect(() => {
if (!playing || points.length < 2) return;
const timer = window.setInterval(() => setActiveIndex((index) => {
if (index >= points.length - 1) { setPlaying(false); return points.length - 1; }
return index + 1;
}), Math.max(120, 800 / playbackSpeed));
return () => window.clearInterval(timer);
lastFrameRef.current = 0;
const interval = Math.max(55, 300 / playbackSpeed);
const tick = (timestamp: number) => {
if (!lastFrameRef.current) lastFrameRef.current = timestamp;
if (timestamp - lastFrameRef.current >= interval) {
const steps = Math.max(1, Math.floor((timestamp - lastFrameRef.current) / interval));
lastFrameRef.current = timestamp;
setActiveIndex((index) => {
const next = Math.min(points.length - 1, index + steps);
if (next >= points.length - 1) setPlaying(false);
return next;
});
}
animationRef.current = window.requestAnimationFrame(tick);
};
animationRef.current = window.requestAnimationFrame(tick);
return () => { if (animationRef.current) window.cancelAnimationFrame(animationRef.current); };
}, [playing, playbackSpeed, points.length]);
const submit = (event: FormEvent) => {
@@ -148,44 +238,64 @@ export default function TrackPage() {
if (!keyword) return;
const next = { ...draft, keyword };
setCriteria(next);
const url = new URLSearchParams({ vin: keyword });
const url = new URLSearchParams({ keyword });
if (next.dateFrom) url.set('dateFrom', next.dateFrom);
if (next.dateTo) url.set('dateTo', next.dateTo);
if (next.protocol) url.set('protocol', next.protocol);
setSearchParams(url, { replace: true });
};
const selectEvent = (event: TrackPlaybackEvent) => {
if (!track) return;
setActiveIndex(sampledEventIndex(event, points.length, track.summary.pointCount));
setPlaying(false);
const selectIndex = (index: number) => { setPlaying(false); setActiveIndex(Math.max(0, Math.min(points.length - 1, index))); };
const togglePlayback = () => {
if (points.length < 2) return;
if (!playing && boundedIndex >= points.length - 1) setActiveIndex(0);
setFollow(true);
setPlaying((value) => !value);
};
const progress = points.length > 1 ? boundedIndex / (points.length - 1) * 100 : 0;
return <div className="v2-track-page">
<form className="v2-track-toolbar" onSubmit={submit}>
<label className="v2-track-vehicle-input"><span></span><div><IconSearch /><input value={draft.keyword} onChange={(event) => setDraft((value) => ({ ...value, keyword: event.target.value }))} placeholder="车牌 / VIN / 终端标识" /></div></label>
<label><span></span><input type="datetime-local" value={draft.dateFrom} onChange={(event) => setDraft((value) => ({ ...value, dateFrom: event.target.value }))} /></label>
<label><span></span><input type="datetime-local" value={draft.dateTo} onChange={(event) => setDraft((value) => ({ ...value, dateTo: event.target.value }))} /></label>
<label><span></span><select value={draft.protocol} onChange={(event) => setDraft((value) => ({ ...value, protocol: event.target.value }))}><option value=""></option><option value="GB32960">GB32960</option><option value="JT808">JT808</option><option value="YUTONG_MQTT">YUTONG_MQTT</option></select></label>
<button className="v2-primary-button" type="submit" disabled={!draft.keyword.trim()}></button>
<button className="v2-secondary-button" type="button" disabled={!track?.points.length} onClick={() => track && downloadTrackCsv(track)}><IconDownload /></button>
</form>
return <div className={`v2-track-page${railCollapsed ? ' is-rail-collapsed' : ''}`}>
<TrackRail draft={draft} track={track} activeIndex={boundedIndex} tab={panelTab} onDraft={setDraft} onSubmit={submit} onTab={setPanelTab} onSelectIndex={selectIndex} onCollapse={() => setRailCollapsed(true)} />
<section className="v2-track-stage">
<TrackMap points={points} stops={track?.stops ?? []} activeIndex={boundedIndex} showStops={showStops} follow={follow} onSelectIndex={selectIndex} onFollowChange={setFollow} />
{query.isError ? <InlineError message={query.error instanceof Error ? query.error.message : '轨迹查询失败'} onRetry={() => query.refetch()} /> : null}
<div className="v2-track-workspace">
<div className="v2-track-main">
{track && points.length ? <CoverageStrip track={track} /> : <div className="v2-track-coverage is-empty"><span> 7 </span></div>}
<div className="v2-track-canvas-wrap">
{query.isFetching ? <div className="v2-track-loading"><span className="v2-spinner" /></div> : null}
{points.length ? <TrackMap points={points} events={track?.events ?? []} activeIndex={activeIndex} onSelectIndex={setActiveIndex} /> : <EmptyTrack queried={Boolean(track)} />}
</div>
{track && points.length ? <SegmentTimeline track={track} onSelectIndex={(index) => { setPlaying(false); setActiveIndex(index); }} /> : <div className="v2-track-timeline is-empty"><span></span></div>}
<div className="v2-track-playback">
<div className="v2-play-controls"><small></small><p><button type="button" onClick={() => setPlaying((value) => !value)} disabled={points.length < 2}>{playing ? <IconPause /> : <IconPlay />}</button><button type="button" onClick={() => { setPlaying(false); setActiveIndex((value) => Math.max(0, value - 1)); }} disabled={!activeIndex}><IconChevronLeft /></button><button type="button" onClick={() => { setPlaying(false); setActiveIndex((value) => Math.min(points.length - 1, value + 1)); }} disabled={!points.length || activeIndex >= points.length - 1}><IconChevronRight /></button><select value={playbackSpeed} onChange={(event) => setPlaybackSpeed(Number(event.target.value) as 1 | 2 | 4)}>{speedOptions.map((speed) => <option value={speed} key={speed}>{speed}×</option>)}</select></p></div>
<div className="v2-play-progress"><header><strong>{time(current?.deviceTime)}</strong><span>{track?.summary.endTime ? time(track.summary.endTime) : '—'}</span></header><input aria-label="轨迹播放进度" type="range" min="0" max={Math.max(0, points.length - 1)} value={Math.min(activeIndex, Math.max(0, points.length - 1))} onChange={(event) => { setPlaying(false); setActiveIndex(Number(event.target.value)); }} disabled={!points.length} /><footer> {points.length ? activeIndex + 1 : 0} / {points.length}</footer></div>
<dl className="v2-current-metrics"><div><dt> / </dt><dd>{number(current?.speedKmh ?? 0)}<em>km/h · {direction(current?.directionDeg)}</em></dd></div><div><dt>SOC / </dt><dd>{current?.socAvailable ? `${number(current.socPercent)}%` : '—'}<em> · {alarm(current?.alarmFlag)}</em></dd></div><div><dt> / </dt><dd>{number(current?.totalMileageKm ?? 0)}<em>km · {current?.protocol || '—'}</em></dd></div><div><dt></dt><dd title={addressQuery.data?.formattedAddress}>{playing ? '播放中暂停解析' : addressQuery.isFetching ? '地址解析中…' : addressQuery.data?.formattedAddress || (current ? `${current.longitude.toFixed(6)}, ${current.latitude.toFixed(6)}` : '—')}</dd></div></dl>
</div>
<div className="v2-track-stage-tools">
{railCollapsed ? <button type="button" aria-label="展开查询与明细" onClick={() => setRailCollapsed(false)}><IconList /><span></span></button> : null}
<button type="button" aria-label={follow ? '关闭车辆跟随' : '开启车辆跟随'} className={follow ? 'is-active' : ''} disabled={!points.length} onClick={() => setFollow((value) => !value)}><IconMapPin /><span>{follow ? '跟随车辆' : '自由浏览'}</span></button>
<button type="button" aria-label={showStops ? '隐藏停留点' : '显示停留点'} className={showStops ? 'is-active' : ''} disabled={!track?.stops.length} onClick={() => setShowStops((value) => !value)}>{showStops ? <IconEyeOpened /> : <IconEyeClosed />}<span></span></button>
<button type="button" aria-label="导出轨迹 CSV" disabled={!track?.points.length} onClick={() => track && downloadTrackCsv(track)}><IconDownload /><span></span></button>
</div>
{track && points.length ? <TripInspector track={track} onEvent={selectEvent} /> : <aside className="v2-track-inspector is-empty"><strong></strong><p></p></aside>}
</div>
{track?.points.length ? <>
<div className={`v2-track-coverage-float${track.coverage.complete ? '' : ' is-warning'}`}><i />
<span><strong>{track.coverage.complete ? '时间窗完整' : '仅展示最新切片'}</strong>{track.coverage.evidence}</span>
<em>{track.coverage.processedPoints.toLocaleString('zh-CN')} {track.coverage.returnedPoints.toLocaleString('zh-CN')} </em>
</div>
<article className="v2-track-current-card">
<header><div><strong>{track.plate || track.vin}</strong><span>{dateTime(current?.deviceTime)}</span></div><b>{number(progress, 0)}%</b></header>
<div><span><small></small><strong>{number(current?.speedKmh ?? 0)}<em> km/h</em></strong></span><span><small></small><strong>{direction(current?.directionDeg)}</strong></span><span><small>SOC</small><strong>{current?.socAvailable ? `${number(current.socPercent)}%` : '—'}</strong></span></div>
<p title={addressQuery.data?.formattedAddress}>{playing ? '播放中,暂停地址解析' : addressQuery.isFetching ? '地址解析中…' : addressQuery.data?.formattedAddress || `${current?.longitude.toFixed(6)}, ${current?.latitude.toFixed(6)}`}</p>
</article>
</> : <div className="v2-track-empty-state"><IconMapPin /><strong></strong><p></p><button type="button" onClick={() => setRailCollapsed(false)}><IconSearch /></button></div>}
{query.isFetching ? <div className="v2-track-loading"><span className="v2-spinner" /></div> : null}
{query.isError ? <div className="v2-track-error"><InlineError message={query.error instanceof Error ? query.error.message : '轨迹查询失败'} onRetry={() => query.refetch()} /></div> : null}
<footer className="v2-track-playback-dock">
<div className="v2-track-dock-summary"><strong>{track ? `${number(track.summary.distanceKm)} km` : '等待查询'}</strong><span>{track ? `${formatDuration(track.summary.durationSeconds)} · ${track.summary.stopCount} 次停留` : '查询后可播放完整轨迹'}</span></div>
<div className="v2-track-dock-progress">
{track ? <SegmentRail track={track} onSelectIndex={selectIndex} /> : <div className="v2-track-segment-placeholder" />}
<input aria-label="轨迹播放进度" type="range" min="0" max={Math.max(0, points.length - 1)} value={boundedIndex} onChange={(event) => selectIndex(Number(event.target.value))} disabled={!points.length} style={{ '--track-progress': `${progress}%` } as React.CSSProperties} />
<div><time>{time(current?.deviceTime)}</time><span> {points.length ? boundedIndex + 1 : 0} / {points.length}</span><time>{time(track?.summary.endTime)}</time></div>
</div>
<div className="v2-track-dock-controls">
<button type="button" aria-label="上一个轨迹点" onClick={() => selectIndex(boundedIndex - 1)} disabled={!boundedIndex}><IconChevronLeft /></button>
<button type="button" className="is-primary" aria-label={playing ? '暂停轨迹播放' : '开始轨迹播放'} onClick={togglePlayback} disabled={points.length < 2}>{playing ? <IconPause /> : <IconPlay />}</button>
<button type="button" aria-label="下一个轨迹点" onClick={() => selectIndex(boundedIndex + 1)} disabled={!points.length || boundedIndex >= points.length - 1}><IconChevronRight /></button>
<label><span></span><select value={playbackSpeed} onChange={(event) => setPlaybackSpeed(Number(event.target.value) as PlaybackSpeed)}>{speedOptions.map((speed) => <option value={speed} key={speed}>{speed}×</option>)}</select></label>
<button type="button" aria-label="回到起点" onClick={() => selectIndex(0)} disabled={!boundedIndex}><IconRefresh /></button>
</div>
<div className="v2-track-dock-metrics"><span><small></small><strong>{number(current?.totalMileageKm ?? 0)} km</strong></span><span><small></small><strong>{current?.protocol || '—'}</strong></span><span><small></small><strong>{alarm(current?.alarmFlag)}</strong></span></div>
</footer>
</section>
</div>;
}

View File

@@ -55,14 +55,20 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-sidebar.is-collapsed .v2-collapse { justify-content: center; padding: 0; transform: rotate(180deg); }
.v2-monitor-page { display: flex; height: 100%; min-height: 0; overflow: hidden; flex-direction: column; gap: clamp(10px, 1vh, 14px); padding: clamp(10px, 1vw, 18px); }
.v2-filterbar { display: grid; grid-template-columns: minmax(240px, 1.4fr) minmax(130px, .55fr) minmax(130px, .55fr) auto auto; gap: 10px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; padding: 10px 12px; box-shadow: var(--v2-shadow); }
.v2-filterbar { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(120px, 150px) minmax(120px, 150px) auto auto auto auto; gap: 10px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; padding: 10px 12px; box-shadow: var(--v2-shadow); }
.v2-search-field { display: flex; height: 36px; align-items: center; gap: 8px; border: 1px solid #dce4ef; border-radius: 7px; padding: 0 10px; color: #8a98aa; }
.v2-search-field:focus-within { border-color: #8bb6fb; box-shadow: 0 0 0 3px rgba(18,104,243,.08); }
.v2-search-field input { min-width: 0; flex: 1; border: 0; outline: 0; color: var(--v2-text); font-size: 12px; }
.v2-search-field.is-batch { border-color: #9fc0f7; background: #fbfdff; }
.v2-search-batch-count { flex: 0 0 auto; border-radius: 999px; background: var(--v2-blue-soft); padding: 3px 7px; color: var(--v2-blue); font-size: 10px; font-weight: 700; line-height: 1; white-space: nowrap; }
.v2-filterbar select { height: 36px; border: 1px solid #dce4ef; border-radius: 7px; background: #fff; padding: 0 30px 0 11px; color: #4d5c70; outline: 0; font-size: 12px; }
.v2-primary-button, .v2-secondary-button { display: flex; height: 36px; align-items: center; justify-content: center; gap: 7px; border-radius: 7px; padding: 0 14px; cursor: pointer; font-size: 12px; font-weight: 700; }
.v2-primary-button { border: 1px solid var(--v2-blue); background: var(--v2-blue); color: #fff; box-shadow: 0 5px 12px rgba(18,104,243,.18); }
.v2-secondary-button { border: 1px solid #dce4ef; background: #fff; color: #59677c; }
.v2-monitor-mode { display: flex; height: 36px; overflow: hidden; border: 1px solid #dce4ef; border-radius: 7px; background: #f7f9fc; padding: 2px; }
.v2-monitor-mode button, .v2-monitor-mobile-entry { display: flex; align-items: center; justify-content: center; gap: 5px; border: 0; border-radius: 5px; background: transparent; padding: 0 10px; color: #65758a; font-size: 12px; cursor: pointer; white-space: nowrap; }
.v2-monitor-mode button.is-active { background: #fff; color: var(--v2-blue); box-shadow: 0 1px 4px rgba(21,32,51,.12); font-weight: 700; }
.v2-monitor-mobile-entry { height: 36px; border: 1px solid #dce4ef; background: #fff; }
.v2-kpis { display: grid; grid-template-columns: repeat(7, minmax(90px, 1fr)); border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
.v2-kpi { position: relative; min-width: 0; padding: 10px 14px; }
@@ -100,6 +106,46 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-vehicle-rail > footer { display: flex; height: 34px; align-items: center; justify-content: center; border-top: 1px solid var(--v2-border); }
.v2-list-loading { display: flex; min-height: 100px; align-items: center; justify-content: center; gap: 8px; color: var(--v2-muted); font-size: 11px; }
.v2-monitor-table-panel { display: flex; min-height: 0; flex: 1; flex-direction: column; overflow: hidden; border: 1px solid #dce4ee; border-radius: 9px; background: #fff; box-shadow: 0 4px 16px rgba(21,32,51,.04); }
.v2-monitor-table-panel > header { display: flex; min-height: 54px; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--v2-border); padding: 8px 14px; }
.v2-monitor-table-panel > header div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.v2-monitor-table-panel > header strong { font-size: 14px; }
.v2-monitor-table-panel > header span, .v2-monitor-table-panel > header > b { color: var(--v2-muted); font-size: 11px; font-weight: 500; }
.v2-monitor-table-scroll { position: relative; min-height: 0; flex: 1; overflow: auto; overscroll-behavior: contain; }
.v2-monitor-table-scroll table { width: 100%; min-width: 720px; border-collapse: separate; border-spacing: 0; table-layout: fixed; }
.v2-monitor-table-scroll th { position: sticky; top: 0; z-index: 3; height: 42px; border-bottom: 1px solid #dce4ee; background: #f7f9fc; padding: 0 14px; color: #607086; font-size: 11px; font-weight: 650; text-align: left; }
.v2-monitor-table-scroll th:nth-child(1) { width: 180px; }.v2-monitor-table-scroll th:nth-child(2) { width: 92px; }.v2-monitor-table-scroll th:nth-child(3) { width: 128px; }.v2-monitor-table-scroll th:nth-child(4) { width: 164px; }.v2-monitor-table-scroll th:nth-child(5) { width: auto; }
.v2-monitor-table-scroll td { height: 62px; border-bottom: 1px solid #edf1f6; padding: 7px 14px; vertical-align: middle; }
.v2-monitor-table-scroll tbody tr { transition: background .14s ease; }
.v2-monitor-table-scroll tbody tr:hover { background: #f8fbff; }
.v2-monitor-table-scroll td > strong, .v2-monitor-table-scroll td > span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.v2-monitor-table-vehicle > button { display: flex; max-width: 100%; flex-direction: column; align-items: flex-start; border: 0; background: transparent; padding: 0; cursor: pointer; text-align: left; }.v2-monitor-table-vehicle > button:hover strong { color: var(--v2-blue); }
.v2-monitor-table-vehicle strong, .v2-monitor-table-vehicle span { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-monitor-table-vehicle strong { color: #263448; font-size: 13px; transition: color .14s ease; }.v2-monitor-table-vehicle span { margin-top: 4px; color: #8996a8; font-family: ui-monospace,SFMono-Regular,Menlo,monospace; font-size: 9px; }
.v2-monitor-live-value { display: inline !important; color: #213044; font-size: 14px !important; font-variant-numeric: tabular-nums; }
.v2-monitor-live-unit { margin-left: 4px; color: #8b98aa; font-size: 9px; }
.v2-monitor-coordinate { color: #52637a; font-family: ui-monospace,SFMono-Regular,Menlo,monospace; font-size: 10px; font-variant-numeric: tabular-nums; line-height: 1.55; }
.v2-monitor-address-action { display: inline-flex; height: 28px; align-items: center; gap: 5px; border: 1px solid #d5e0ee; border-radius: 6px; background: #fff; padding: 0 9px; color: #3568a9; cursor: pointer; font-size: 10px; font-weight: 650; transition: border-color .14s ease, background .14s ease; }
.v2-monitor-address-action:hover { border-color: #9bb8dd; background: #f4f8fe; }.v2-monitor-address-action.is-error { border-color: #f0c9c9; color: #b74b4b; }
.v2-monitor-address-empty, .v2-monitor-address-loading { display: inline-flex; align-items: center; gap: 6px; color: #929dac; font-size: 10px; }
.v2-monitor-address-result { display: flex; min-width: 0; flex-direction: column; align-items: flex-start; gap: 4px; }
.v2-monitor-address-result > span { display: block; width: 100%; overflow: hidden; color: #47576c; font-size: 10px; line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; }
.v2-monitor-address-result > button { border: 0; background: transparent; padding: 0; color: #c27a13; cursor: pointer; font-size: 9px; }
.v2-monitor-source { min-width: 0; border-left: 2px solid #d6dee9; padding-left: 8px; }
.v2-monitor-source.is-online { border-color: #1fb573; }.v2-monitor-source.is-offline, .v2-monitor-source.is-pending, .v2-monitor-source.is-unknown { border-color: #f59e0b; }.v2-monitor-source.is-not-required { opacity: .62; }
.v2-monitor-source header, .v2-monitor-source header span, .v2-monitor-source > div { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
.v2-monitor-source header span { justify-content: flex-start; font-size: 10px; font-weight: 700; }.v2-monitor-source header i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.v2-monitor-source.is-online header span { color: #16885a; }.v2-monitor-source.is-abnormal header span { color: #c87905; }.v2-monitor-source header time { color: #8996a8; font-size: 9px; white-space: nowrap; }
.v2-monitor-source > div { margin-top: 7px; justify-content: flex-start; }.v2-monitor-source > div b { font-size: 11px; }.v2-monitor-source > div span { color: #59677c; font-size: 10px; }
.v2-monitor-source > small { display: block; margin-top: 5px; overflow: hidden; color: #8996a8; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
.v2-monitor-overall { display: inline-flex; border-radius: 10px; background: #edf2f7; padding: 3px 8px; color: #637186; font-size: 10px; }.v2-monitor-overall.is-healthy { background: #e7f8f0; color: #16885a; }.v2-monitor-overall.is-degraded, .v2-monitor-overall.is-incomplete { background: #fff4df; color: #a86400; }.v2-monitor-overall.is-offline { background: #f1f3f6; color: #68778b; }
.v2-monitor-table-loading { position: sticky; bottom: 0; display: flex; height: 34px; align-items: center; justify-content: center; gap: 7px; background: rgba(255,255,255,.92); color: #607086; font-size: 10px; }
.v2-monitor-mobile-cards { display: none; }
.v2-monitor-table-panel > footer { display: flex; min-height: 44px; align-items: center; justify-content: space-between; border-top: 1px solid var(--v2-border); padding: 6px 14px; color: #718096; font-size: 11px; }
.v2-monitor-table-panel > footer div { display: flex; gap: 7px; }.v2-monitor-table-panel > footer button, .v2-monitor-table-panel > footer select { height: 30px; border: 1px solid #dce4ef; border-radius: 6px; background: #fff; padding: 0 10px; color: #536277; font-size: 10px; cursor: pointer; }.v2-monitor-table-panel > footer button:disabled { cursor: not-allowed; opacity: .45; }
.v2-monitor-qr-backdrop { position: fixed; inset: 0; z-index: 200; display: grid; place-items: center; background: rgba(15,23,42,.42); padding: 18px; backdrop-filter: blur(4px); }
.v2-monitor-qr-backdrop > section { position: relative; display: flex; width: min(360px, 100%); align-items: center; flex-direction: column; border-radius: 16px; background: #fff; padding: 26px; box-shadow: 0 24px 70px rgba(15,23,42,.26); text-align: center; }
.v2-monitor-qr-backdrop > section > button:first-child { position: absolute; top: 12px; right: 12px; border: 0; background: transparent; color: #6b7a8f; cursor: pointer; }.v2-monitor-qr-backdrop > section > svg { color: var(--v2-blue); font-size: 26px; }.v2-monitor-qr-backdrop h3 { margin: 9px 0 4px; font-size: 17px; }.v2-monitor-qr-backdrop p { margin: 0 0 13px; color: #718096; font-size: 11px; }.v2-monitor-qr-backdrop img { width: 220px; height: 220px; }.v2-monitor-qr-backdrop code { width: 100%; overflow: hidden; margin-top: 10px; border-radius: 6px; background: #f5f7fa; padding: 8px; color: #5f6e82; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }.v2-monitor-qr-backdrop > section > button:last-child { width: 100%; height: 38px; margin-top: 10px; border: 0; border-radius: 7px; background: var(--v2-blue); color: #fff; font-weight: 700; cursor: pointer; }
.v2-fleet-map { position: relative; min-width: 0; min-height: 0; overflow: hidden; background: #e8f0f6; }
.v2-fleet-map-canvas { position: absolute; inset: 0; }
.v2-map-controls { position: absolute; top: 12px; right: 12px; z-index: 5; display: flex; align-items: stretch; gap: 8px; }
@@ -615,6 +661,68 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-access-threshold > footer { display: flex; justify-content: space-between; gap: 8px; border-top: 1px solid var(--v2-border); padding: 8px 11px; color: #7f8c9f; font-size: 6px; }
.v2-access-threshold > footer b { color: #536177; text-align: right; font-weight: 600; }
/* Vehicle-first access matrix. Text deliberately starts at 11px instead of the legacy 7-9px scale. */
.v2-access-page-v3 { gap: 12px; overflow: auto; padding: 16px 18px 20px; background: #f6f8fb; }
.v2-access-heading { display: flex; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 20px; }
.v2-access-heading h2 { margin: 0; color: #26354b; font-size: 22px; line-height: 1.25; letter-spacing: -.03em; }
.v2-access-heading p { margin: 5px 0 0; color: #6f7d90; font-size: 13px; line-height: 1.5; }
.v2-access-heading > div:last-child { display: flex; align-items: center; gap: 12px; color: #7b889a; font-size: 12px; }
.v2-access-heading button, .v2-access-table-v3 > header button { display: inline-flex; height: 34px; align-items: center; gap: 6px; border: 1px solid #dbe3ed; border-radius: 7px; background: #fff; padding: 0 12px; color: #536176; cursor: pointer; font-size: 12px; font-weight: 700; }
.v2-access-heading button:hover, .v2-access-table-v3 > header button:hover { border-color: #9fc1f5; color: var(--v2-blue); }
.v2-access-filter-v3 { display: grid; flex: 0 0 auto; grid-template-columns: minmax(260px, 1.4fr) repeat(3, minmax(150px, .7fr)) auto auto; align-items: end; gap: 10px; border: 1px solid var(--v2-border); border-radius: 9px; background: #fff; padding: 13px 14px; box-shadow: var(--v2-shadow); }
.v2-access-filter-v3 label { display: flex; min-width: 0; flex-direction: column; gap: 6px; color: #647287; font-size: 12px; font-weight: 650; }
.v2-access-filter-v3 label > div { display: flex; height: 38px; align-items: center; gap: 8px; border: 1px solid #d9e2ed; border-radius: 7px; padding: 0 10px; color: #8997aa; }
.v2-access-filter-v3 input, .v2-access-filter-v3 select { min-width: 0; width: 100%; height: 38px; border: 1px solid #d9e2ed; border-radius: 7px; background: #fff; padding: 0 10px; color: #35445b; outline: 0; font-size: 13px; }
.v2-access-filter-v3 label > div input { height: auto; border: 0; padding: 0; }
.v2-access-filter-v3 label > div:focus-within, .v2-access-filter-v3 select:focus { border-color: #83aff4; box-shadow: 0 0 0 3px rgba(18,104,243,.09); }
.v2-access-filter-v3 > button { height: 38px; padding: 0 15px; font-size: 13px; }
.v2-access-kpis-v3 { display: grid; min-height: 56px; flex: 0 0 auto; grid-template-columns: repeat(4, minmax(140px, 1fr)); overflow: hidden; border: 1px solid var(--v2-border); border-radius: 9px; background: #fff; box-shadow: var(--v2-shadow); }
.v2-access-kpis-v3 button { position: relative; display: flex; min-width: 0; align-items: center; gap: 12px; border: 0; background: #fff; padding: 10px 18px; text-align: left; cursor: pointer; }
.v2-access-kpis-v3 button + button::before { position: absolute; inset: 10px auto 10px 0; width: 1px; background: #e6ebf2; content: ''; }
.v2-access-kpis-v3 button:hover { background: #f8fbff; }
.v2-access-kpis-v3 small { color: #748195; font-size: 12px; }
.v2-access-kpis-v3 strong { margin-left: auto; color: #29384e; font-size: 22px; line-height: 1; font-variant-numeric: tabular-nums; }
.v2-access-kpis-v3 em { color: #8995a6; font-size: 11px; font-style: normal; }
.v2-access-kpis-v3 .is-healthy strong, .v2-access-kpis-v3 .is-today strong { color: var(--v2-green); }
.v2-access-kpis-v3 .is-incomplete strong { color: #d47b12; }
.v2-access-kpis-v3 .is-degraded strong { color: #bd6423; }
.v2-access-kpis-v3 .is-attention strong { color: #d47b12; }
.v2-access-kpis-v3 .is-offline strong, .v2-access-kpis-v3 .is-never strong { color: #5f6f83; }
.v2-access-protocol-overview { display: grid; flex: 0 0 auto; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
.v2-access-protocol-overview article { min-width: 0; border: 1px solid var(--v2-border); border-radius: 9px; background: #fff; padding: 13px 15px; box-shadow: var(--v2-shadow); }
.v2-access-protocol-overview header, .v2-access-protocol-overview div, .v2-access-protocol-overview footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.v2-access-protocol-overview header strong { color: #33445a; font-size: 14px; }.v2-access-protocol-overview header span { color: #8995a6; font-size: 11px; }
.v2-access-protocol-overview div { justify-content: flex-start; margin-top: 9px; }.v2-access-protocol-overview div b { color: #25364c; font-size: 24px; }.v2-access-protocol-overview div small { color: #708095; font-size: 12px; }.v2-access-protocol-overview div em { margin-left: auto; color: #c66a0a; font-size: 12px; font-style: normal; }
.v2-access-protocol-overview footer { margin-top: 10px; }.v2-access-protocol-overview footer > i { height: 5px; flex: 1; overflow: hidden; border-radius: 5px; background: #edf1f6; }.v2-access-protocol-overview footer > i span { display: block; height: 100%; border-radius: inherit; background: #3787ee; }.v2-access-protocol-overview footer > span { color: #728095; font-size: 11px; white-space: nowrap; }
.v2-access-workspace-v3 { display: grid; min-height: 540px; flex: 1 0 540px; grid-template-columns: minmax(0, 1fr); gap: 12px; }
.v2-access-workspace-v3.is-inspector-open { grid-template-columns: minmax(760px, 1fr) 390px; }
.v2-access-table-v3 { display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; border: 1px solid var(--v2-border); border-radius: 9px; background: #fff; box-shadow: var(--v2-shadow); }
.v2-access-table-v3 > header { display: flex; min-height: 52px; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--v2-border); padding: 0 13px 0 15px; }
.v2-access-table-title { display: flex; min-width: 0; align-items: baseline; gap: 10px; }.v2-access-table-title strong { color: #334258; font-size: 15px; white-space: nowrap; }.v2-access-table-title > span { overflow: hidden; color: #8692a3; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.v2-access-table-actions { display: flex; min-width: 0; align-items: center; gap: 9px; }
.v2-access-protocol-coverage { display: flex; min-width: 0; align-items: center; gap: 6px; }
.v2-access-protocol-coverage > span { display: inline-flex; align-items: center; gap: 5px; border: 1px solid #e2e8f0; border-radius: 6px; background: #f8fafc; padding: 5px 7px; white-space: nowrap; }
.v2-access-protocol-coverage b { color: #526178; font-size: 10px; }.v2-access-protocol-coverage em { color: #7e8b9d; font-size: 10px; font-style: normal; font-variant-numeric: tabular-nums; }
.v2-access-table-scroll-v3 { position: relative; min-height: 0; flex: 1; overflow: auto; overscroll-behavior: contain; }
.v2-access-table-scroll-v3 table { width: 100%; min-width: 1030px; border-collapse: separate; border-spacing: 0; table-layout: fixed; color: #48576c; font-size: 13px; }
.v2-access-table-scroll-v3 th { position: sticky; z-index: 2; top: 0; height: 40px; border-bottom: 1px solid #dfe5ed; background: #f7f9fc; color: #647287; text-align: left; font-size: 12px; font-weight: 700; }
.v2-access-table-scroll-v3 th, .v2-access-table-scroll-v3 td { padding: 8px 11px; vertical-align: middle; }
.v2-access-table-scroll-v3 th:nth-child(1) { width: 165px; }.v2-access-table-scroll-v3 th:nth-child(2) { width: 140px; }.v2-access-table-scroll-v3 th:nth-child(3) { width: 115px; }.v2-access-table-scroll-v3 th:nth-child(4), .v2-access-table-scroll-v3 th:nth-child(5), .v2-access-table-scroll-v3 th:nth-child(6) { width: 160px; }.v2-access-table-scroll-v3 th:nth-child(7) { width: 120px; }
.v2-access-table-scroll-v3 tbody tr { cursor: pointer; transition: background .14s ease; }.v2-access-table-scroll-v3 tbody tr:hover, .v2-access-table-scroll-v3 tbody tr.is-selected { background: #f2f7ff; }.v2-access-table-scroll-v3 tbody td { height: 67px; border-bottom: 1px solid #edf1f5; }
.v2-access-table-scroll-v3 td > strong, .v2-access-table-scroll-v3 td > b { display: block; overflow: hidden; color: #314158; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }.v2-access-table-scroll-v3 td > span { display: block; overflow: hidden; margin-top: 5px; color: #8793a5; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.v2-access-protocol-cell { display: grid; min-width: 0; gap: 4px; }.v2-access-protocol-cell > span { display: inline-flex; width: fit-content; align-items: center; gap: 5px; color: #6c788b; font-size: 12px; }.v2-access-protocol-cell > span i { width: 7px; height: 7px; border-radius: 50%; background: #9aa6b5; }.v2-access-protocol-cell > strong { color: #3d4d63; font-size: 12px; font-weight: 650; }.v2-access-protocol-cell > small { overflow: hidden; color: #8b97a7; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.v2-access-protocol-cell.is-online > span { color: #16815f; }.v2-access-protocol-cell.is-online > span i { background: #19a974; box-shadow: 0 0 0 3px rgba(25,169,116,.11); }.v2-access-protocol-cell.is-offline > span { color: #b26512; }.v2-access-protocol-cell.is-offline > span i { background: #e6922e; }.v2-access-protocol-cell.is-missing > span { color: #c14b4b; }.v2-access-protocol-cell.is-missing > span i { border: 1px solid #dd7777; background: #fff; }
.v2-access-connection { display: grid; gap: 5px; }.v2-access-connection strong { width: fit-content; border-radius: 12px; background: #eef2f6; padding: 4px 8px; color: #607086; font-size: 11px; }.v2-access-connection span { color: #8793a5; font-size: 11px; }.v2-access-connection.is-healthy strong { background: #eaf8f2; color: #15815e; }.v2-access-connection.is-incomplete strong, .v2-access-connection.is-degraded strong { background: #fff4e5; color: #b5660c; }.v2-access-connection.is-not_connected strong { background: #fff0f0; color: #bc4d4d; }
.v2-access-table-v3 > footer { display: flex; min-height: 46px; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 12px; border-top: 1px solid var(--v2-border); padding: 0 12px; color: #788699; font-size: 12px; }.v2-access-table-v3 > footer div { display: flex; align-items: center; gap: 7px; }.v2-access-table-v3 > footer button, .v2-access-table-v3 > footer select { height: 30px; border: 1px solid #dbe3ed; border-radius: 6px; background: #fff; padding: 0 9px; color: #5d6b80; font-size: 12px; }
.v2-access-inspector-v3 { min-width: 0; overflow: auto; border: 1px solid var(--v2-border); border-radius: 9px; background: #fff; box-shadow: var(--v2-shadow); }
.v2-access-inspector-v3 > header { position: sticky; z-index: 2; top: 0; display: flex; min-height: 58px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--v2-border); background: #fff; padding: 0 14px; }.v2-access-inspector-v3 > header > div { display: grid; gap: 4px; }.v2-access-inspector-v3 > header strong { color: #2f3f55; font-size: 16px; }.v2-access-inspector-v3 > header span { color: #8290a3; font-size: 11px; }.v2-access-inspector-v3 > header button { display: grid; width: 30px; height: 30px; place-items: center; border: 0; border-radius: 6px; background: #f2f5f8; color: #6d7a8e; cursor: pointer; }
.v2-access-inspector-summary { display: grid; grid-template-columns: 1fr 1fr; border-bottom: 1px solid var(--v2-border); padding: 10px 14px; }.v2-access-inspector-summary > div { min-width: 0; padding: 7px 4px; }.v2-access-inspector-summary span { display: block; color: #8793a5; font-size: 11px; }.v2-access-inspector-summary strong { display: block; margin-top: 5px; color: #3f4e63; font-size: 12px; line-height: 1.45; overflow-wrap: anywhere; }.v2-access-inspector-summary .v2-access-connection { margin-top: 4px; }
.v2-access-protocol-details { display: grid; gap: 9px; padding: 12px; }.v2-access-protocol-detail { overflow: hidden; border: 1px solid #e1e7ef; border-radius: 8px; }.v2-access-protocol-detail > header { display: flex; height: 38px; align-items: center; justify-content: space-between; background: #f8fafc; padding: 0 10px; }.v2-access-protocol-detail > header strong { color: #3b4a60; font-size: 13px; }.v2-access-protocol-detail > header span { display: inline-flex; align-items: center; gap: 5px; color: #69778b; font-size: 11px; }.v2-access-protocol-detail > header i { width: 7px; height: 7px; border-radius: 50%; background: #9aa6b5; }.v2-access-protocol-detail.is-online > header i { background: var(--v2-green); }.v2-access-protocol-detail.is-offline > header i { background: var(--v2-orange); }.v2-access-protocol-detail.is-missing { border-color: #efd7d7; }.v2-access-protocol-detail.is-missing > header { background: #fff8f8; }.v2-access-protocol-detail.is-missing > header i { border: 1px solid #d96c6c; background: #fff; }
.v2-access-protocol-detail dl { display: grid; grid-template-columns: 1fr 1fr; margin: 0; padding: 7px 10px; }.v2-access-protocol-detail dl > div { min-width: 0; padding: 5px 3px; }.v2-access-protocol-detail dt { color: #8995a6; font-size: 11px; }.v2-access-protocol-detail dd { overflow: hidden; margin: 4px 0 0; color: #46556a; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }.v2-access-protocol-detail dd.is-danger { color: var(--v2-red); }.v2-access-protocol-detail p { margin: 0; border-top: 1px solid #edf1f5; padding: 7px 10px; color: #7c899b; font-size: 11px; line-height: 1.5; }
.v2-access-inspector-v3 > footer { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; border-top: 1px solid var(--v2-border); padding: 10px 14px; }.v2-access-inspector-v3 > footer span { color: #8591a2; font-size: 11px; line-height: 1.5; }.v2-access-inspector-v3 > footer a { flex: 0 0 auto; color: var(--v2-blue); font-size: 12px; text-decoration: none; }
.v2-access-identity-queue-v3, .v2-access-settings { flex: 0 0 auto; border: 1px solid var(--v2-border); border-radius: 8px; background: #fff; }.v2-access-identity-queue-v3 summary, .v2-access-settings summary { display: flex; min-height: 40px; align-items: center; gap: 10px; padding: 0 12px; color: #5c6b80; cursor: pointer; font-size: 11px; }.v2-access-identity-queue-v3 summary span { color: #8995a6; font-size: 10px; }.v2-access-identity-queue-v3 > div { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; border-top: 1px solid var(--v2-border); padding: 10px; }.v2-access-identity-queue-v3 article { display: grid; gap: 4px; border: 1px solid #e4e9f0; border-radius: 7px; padding: 9px; }.v2-access-identity-queue-v3 article b { font-size: 11px; }.v2-access-identity-queue-v3 article span, .v2-access-identity-queue-v3 article small { color: #7d8a9c; font-size: 9px; line-height: 1.5; }
.v2-access-settings fieldset { display: flex; flex-wrap: wrap; align-items: end; gap: 9px; margin: 0; border: 0; border-top: 1px solid var(--v2-border); padding: 11px; }.v2-access-settings label { display: grid; gap: 5px; color: #718095; font-size: 10px; }.v2-access-settings input { width: 110px; height: 32px; border: 1px solid #dbe3ed; border-radius: 6px; padding: 0 8px; font-size: 11px; }.v2-access-settings button { display: inline-flex; height: 32px; align-items: center; gap: 5px; border: 0; border-radius: 6px; background: var(--v2-blue); padding: 0 11px; color: #fff; font-size: 10px; }.v2-access-settings p { width: 100%; margin: 0; color: var(--v2-red); font-size: 10px; }
.v2-alert-page { display: flex; height: 100%; min-height: 0; flex-direction: column; overflow: hidden; background: #f5f7fa; padding: 0 12px 12px; }
.v2-alert-heading { display: flex; min-height: 48px; flex: 0 0 auto; align-items: center; justify-content: space-between; background: #fff; margin: 0 -12px; padding: 0 16px; }
.v2-alert-heading h2 { margin: 0; color: #26354b; font-size: 17px; letter-spacing: -.02em; }
@@ -811,65 +919,118 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-ops-kpis { grid-template-columns: repeat(3,1fr); }.v2-ops-grid { grid-template-columns: 1fr; }.v2-ops-sources > div { grid-template-columns: 1fr; }.v2-ops-sources article + article { border-top: 1px solid var(--v2-border); border-left: 0; }
}
.v2-stat-page { display: flex; min-height: 100%; flex-direction: column; gap: 12px; padding: 16px 18px 20px; }
.v2-stat-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.v2-stat-heading h2 { margin: 0; font-size: 20px; letter-spacing: -.03em; }
.v2-stat-heading p { margin: 5px 0 0; color: var(--v2-muted); font-size: 11px; }
.v2-stat-heading > button { display: flex; height: 34px; align-items: center; gap: 6px; border: 1px solid #dce4ef; border-radius: 7px; background: #fff; padding: 0 12px; color: #59677c; cursor: pointer; font-size: 11px; font-weight: 700; }
.v2-stat-filter { display: grid; grid-template-columns: minmax(250px, 1.3fr) minmax(130px, .65fr) minmax(130px, .65fr) minmax(130px, .65fr) auto; align-items: end; gap: 10px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; padding: 12px; box-shadow: var(--v2-shadow); }
.v2-stat-filter label { display: flex; min-width: 0; flex-direction: column; gap: 6px; color: #68768a; font-size: 10px; font-weight: 600; }
.v2-stat-filter input, .v2-stat-filter select { width: 100%; height: 36px; border: 1px solid #dce4ef; border-radius: 7px; background: #fff; padding: 0 10px; color: var(--v2-text); outline: 0; font-size: 12px; }
.v2-stat-search > div { display: flex; height: 36px; align-items: center; gap: 7px; border: 1px solid #dce4ef; border-radius: 7px; padding: 0 10px; color: #8a98aa; }
.v2-stat-search > div:focus-within, .v2-stat-filter input:focus, .v2-stat-filter select:focus { border-color: #8bb6fb; box-shadow: 0 0 0 3px rgba(18,104,243,.08); }
.v2-stat-search input { height: auto; border: 0; padding: 0; box-shadow: none !important; }
.v2-stat-ranges { display: flex; grid-column: 1 / -1; gap: 6px; }
.v2-stat-ranges button { height: 26px; border: 1px solid #dce4ef; border-radius: 13px; background: #fff; padding: 0 11px; color: #64748b; cursor: pointer; font-size: 9px; }
.v2-stat-ranges button:hover { border-color: #a9c8f8; color: var(--v2-blue); }
.v2-stat-kpis { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
.v2-stat-kpis article { position: relative; min-width: 0; padding: 14px 16px; }
.v2-stat-kpis article + article::before { position: absolute; inset: 14px auto 14px 0; width: 1px; background: var(--v2-border); content: ""; }
.v2-stat-kpis small, .v2-stat-kpis span { display: block; overflow: hidden; color: var(--v2-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.v2-stat-kpis strong { display: block; margin: 7px 0 5px; overflow: hidden; font-size: clamp(17px, 1.55vw, 24px); font-variant-numeric: tabular-nums; letter-spacing: -.03em; text-overflow: ellipsis; white-space: nowrap; }
.v2-stat-kpis .is-primary strong { color: var(--v2-blue); }
.v2-stat-grid-layout { display: grid; min-height: 490px; flex: 1; grid-template-columns: minmax(0, 1.65fr) minmax(320px, .85fr); gap: 12px; }
.v2-stat-card { min-width: 0; overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
.v2-stat-card > header { display: flex; min-height: 48px; align-items: center; justify-content: space-between; gap: 10px; border-bottom: 1px solid var(--v2-border); padding: 8px 14px; }
.v2-stat-card > header div { display: flex; flex-direction: column; gap: 3px; }
.v2-stat-card > header strong { font-size: 12px; }
.v2-stat-card > header span, .v2-stat-card > header em { color: var(--v2-muted); font-size: 9px; font-style: normal; }
.v2-stat-chart-wrap { padding: 14px 12px 2px; }
.v2-stat-chart-wrap svg { display: block; width: 100%; max-height: 260px; overflow: visible; }
.v2-stat-grid line { stroke: #e9eef5; stroke-width: 1; vector-effect: non-scaling-stroke; }
.v2-stat-axis text { fill: #8290a3; font-size: 10px; }
.v2-stat-area { fill: url(#none); fill: rgba(18,104,243,.07); stroke: none; }
.v2-stat-line { fill: none; stroke: var(--v2-blue); stroke-linecap: round; stroke-linejoin: round; stroke-width: 2.2; vector-effect: non-scaling-stroke; }
.v2-stat-point { fill: #fff; stroke: var(--v2-blue); stroke-width: 2; vector-effect: non-scaling-stroke; }
.v2-stat-daily-list { display: grid; grid-template-columns: repeat(5, minmax(0,1fr)); margin: 0 14px 14px; border: 1px solid #edf1f6; border-radius: 8px; overflow: hidden; }
.v2-stat-daily-list > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; border-right: 1px solid #edf1f6; border-bottom: 1px solid #edf1f6; padding: 8px 9px; }
.v2-stat-daily-list time, .v2-stat-daily-list span { color: var(--v2-muted); font-size: 8px; }
.v2-stat-daily-list strong { overflow: hidden; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.v2-stat-ranking > div { max-height: 440px; overflow: auto; }
.v2-stat-ranking article { display: grid; grid-template-columns: 24px minmax(0, 1fr); gap: 8px; border-bottom: 1px solid #eef2f7; padding: 10px 13px; }
.v2-stat-ranking article > b { display: grid; width: 22px; height: 22px; place-items: center; border-radius: 6px; background: #f2f5f9; color: #728096; font-size: 9px; }
.v2-stat-ranking article:nth-child(-n+3) > b { background: var(--v2-blue-soft); color: var(--v2-blue); }
.v2-stat-ranking article header, .v2-stat-ranking article footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.v2-stat-ranking article header a { overflow: hidden; color: var(--v2-text); font-size: 11px; font-weight: 700; text-decoration: none; text-overflow: ellipsis; white-space: nowrap; }
.v2-stat-ranking article header strong { flex: 0 0 auto; color: var(--v2-blue); font-size: 10px; }
.v2-stat-ranking article > div > span { display: block; height: 5px; margin: 7px 0; overflow: hidden; border-radius: 3px; background: #eef2f7; }
.v2-stat-ranking article > div > span i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #4f93fa, #1268f3); }
.v2-stat-ranking article footer small, .v2-stat-ranking article footer em { overflow: hidden; color: var(--v2-muted); font-size: 8px; font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
.v2-stat-ranking article footer small { max-width: 46%; }
.v2-stat-empty { display: grid; min-height: 180px; place-items: center; padding: 20px; color: var(--v2-muted); text-align: center; font-size: 11px; }
.v2-stat-evidence { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6px 16px; color: var(--v2-muted); font-size: 9px; }
.v2-mileage-page { display: flex; min-height: 100%; flex-direction: column; gap: 14px; padding: 22px 24px 20px; }
.v2-mileage-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.v2-mileage-heading h2 { margin: 0; color: #172033; font-size: 24px; font-weight: 720; letter-spacing: -.04em; }
.v2-mileage-heading p { margin: 6px 0 0; color: #748196; font-size: 12px; line-height: 1.5; }
.v2-mileage-heading > button { display: flex; height: 36px; align-items: center; gap: 7px; border: 1px solid #d8e0eb; border-radius: 8px; background: #fff; padding: 0 13px; color: #56657a; cursor: pointer; font-size: 12px; font-weight: 600; transition: border-color .16s ease, color .16s ease, background .16s ease; }
.v2-mileage-heading > button:hover { border-color: #aebed3; background: #f8fafc; color: #1f4e8c; }
.v2-mileage-query-panel { overflow: visible; border: 1px solid #dce3ec; border-radius: 11px; background: #fff; box-shadow: 0 1px 2px rgba(15,23,42,.025); }
.v2-mileage-filter { display: grid; grid-template-columns: minmax(320px,1.5fr) minmax(132px,.55fr) minmax(132px,.55fr) auto auto auto; align-items: start; gap: 12px; padding: 18px 20px 16px; }
.v2-mileage-filter > label { display: flex; min-width: 0; flex-direction: column; gap: 7px; color: #4f5f75; font-size: 11px; font-weight: 650; }
.v2-mileage-filter > label > input { width: 100%; height: 40px; border: 1px solid #d7e0ea; border-radius: 7px; background: #fff; padding: 0 11px; color: #253248; outline: 0; font-family: inherit; font-size: 13px; transition: border-color .16s ease, box-shadow .16s ease; }
.v2-mileage-filter > label > input:focus, .v2-mileage-multiselect:focus-within { border-color: #6d9ff1; box-shadow: 0 0 0 3px rgba(37,99,235,.08); }
.v2-mileage-filter > .v2-primary-button { height: 40px; margin-top: 18px; border-radius: 7px; padding: 0 22px; box-shadow: 0 4px 10px rgba(37,99,235,.16); font-size: 12px; }
.v2-mileage-vehicle-field { position: relative; }
.v2-mileage-vehicle-field > small { color: #96a1b0; font-size: 9px; font-weight: 400; }
.v2-mileage-multiselect { position: relative; display: flex; min-height: 40px; align-items: flex-start; gap: 8px; border: 1px solid #d7e0ea; border-radius: 7px; background: #fff; padding: 6px 10px; color: #8a98aa; transition: border-color .16s ease, box-shadow .16s ease; }
.v2-mileage-multiselect > svg { flex: 0 0 auto; margin-top: 5px; }
.v2-mileage-selection { display: flex; min-width: 0; flex: 1; flex-wrap: wrap; gap: 4px; }
.v2-mileage-selection > input { width: 120px; min-width: 90px; height: 26px; flex: 1; border: 0; background: transparent; padding: 0 3px; color: var(--v2-text); outline: 0; font-family: inherit; font-size: 12px; }
.v2-mileage-chip { display: inline-flex; max-width: 150px; height: 26px; align-items: center; gap: 4px; border: 1px solid #c8daf6; border-radius: 5px; background: #eff5ff; padding: 0 6px 0 8px; color: #245da8; cursor: pointer; font-size: 10px; }
.v2-mileage-chip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.v2-mileage-chip svg { flex: 0 0 auto; font-size: 9px; }
.v2-mileage-options { position: absolute; z-index: 40; top: calc(100% + 6px); right: 0; left: 0; overflow: hidden; border: 1px solid #d8e1ed; border-radius: 8px; background: #fff; box-shadow: 0 14px 34px rgba(35,53,78,.16); }
.v2-mileage-options > header { display: flex; height: 34px; align-items: center; justify-content: space-between; border-bottom: 1px solid #edf1f5; padding: 0 11px; color: #77859a; font-size: 9px; }
.v2-mileage-options > header em { font-style: normal; }
.v2-mileage-options > button { display: grid; width: 100%; min-height: 42px; grid-template-columns: minmax(90px,.55fr) minmax(150px,1fr) auto; align-items: center; gap: 10px; border: 0; border-bottom: 1px solid #f0f3f7; background: #fff; padding: 7px 11px; color: var(--v2-text); cursor: pointer; text-align: left; }
.v2-mileage-options > button:hover { background: #f6f9fd; }
.v2-mileage-options > button:disabled { color: #8491a3; cursor: default; }
.v2-mileage-options > button strong { font-size: 11px; }
.v2-mileage-options > button span { overflow: hidden; color: #7d899a; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 9px; text-overflow: ellipsis; }
.v2-mileage-options > button em { color: var(--v2-blue); font-size: 9px; font-style: normal; }
.v2-mileage-options > p { margin: 0; padding: 16px; color: var(--v2-muted); text-align: center; font-size: 10px; }
.v2-mileage-source-strategy { position: relative; align-self: start; margin-top: 18px; }
.v2-mileage-source-trigger { display: flex; height: 40px; align-items: center; gap: 6px; border: 1px solid #d7e0ea; border-radius: 7px; background: #fff; padding: 0 11px; color: #56657a; cursor: pointer; font-size: 11px; font-weight: 600; white-space: nowrap; transition: border-color .16s ease, background .16s ease; }
.v2-mileage-source-trigger:hover, .v2-mileage-source-trigger[aria-expanded="true"] { border-color: #a8bad2; background: #f8fafc; }
.v2-mileage-source-trigger b { display: grid; min-width: 28px; height: 20px; place-items: center; border-radius: 10px; background: #eef4fd; color: #3167ad; font-size: 9px; }
.v2-mileage-source-popover { position: absolute; z-index: 45; top: calc(100% + 7px); right: 0; width: 430px; overflow: hidden; border: 1px solid #d8e1ed; border-radius: 10px; background: #fff; box-shadow: 0 18px 44px rgba(35,53,78,.18); }
.v2-mileage-source-popover > header { display: flex; min-height: 58px; align-items: center; justify-content: space-between; border-bottom: 1px solid #edf1f5; padding: 0 14px 0 16px; }
.v2-mileage-source-popover > header div { display: flex; flex-direction: column; gap: 3px; }
.v2-mileage-source-popover > header strong { color: #263448; font-size: 13px; }
.v2-mileage-source-popover > header span, .v2-mileage-source-popover > footer span { color: #8491a3; font-size: 9px; }
.v2-mileage-source-popover > header button { display: grid; width: 30px; height: 30px; place-items: center; border: 0; border-radius: 6px; background: transparent; color: #7d899a; cursor: pointer; }
.v2-mileage-source-list article { display: grid; min-height: 62px; grid-template-columns: auto minmax(130px,1fr) 72px auto; align-items: center; gap: 11px; border-bottom: 1px solid #edf1f5; padding: 8px 13px 8px 16px; }
.v2-mileage-source-list article.is-disabled { background: #fafbfc; }
.v2-mileage-source-switch { position: relative; width: 32px; height: 18px; border: 0; border-radius: 9px; background: #c9d1dc; padding: 0; cursor: pointer; transition: background .16s ease; }
.v2-mileage-source-switch i { position: absolute; top: 3px; left: 3px; width: 12px; height: 12px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(15,23,42,.2); transition: transform .16s ease; }
.v2-mileage-source-switch.is-on { background: var(--v2-blue); }
.v2-mileage-source-switch.is-on i { transform: translateX(14px); }
.v2-mileage-source-list article > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.v2-mileage-source-list article > div strong { color: #344257; font-size: 11px; }
.v2-mileage-source-list article > div small { display: flex; min-width: 0; align-items: center; gap: 7px; color: #8a96a7; font-size: 8px; }
.v2-mileage-source-list article > div small b { color: #55708f; font-size: 9px; font-weight: 650; }
.v2-mileage-source-list article > div code { overflow: hidden; font-family: ui-monospace,SFMono-Regular,Menlo,monospace; text-overflow: ellipsis; white-space: nowrap; }
.v2-mileage-source-list article > em { color: #5f7190; font-size: 9px; font-style: normal; text-align: right; white-space: nowrap; }
.v2-mileage-source-list article.is-disabled > div, .v2-mileage-source-list article.is-disabled > em { opacity: .58; }
.v2-mileage-source-list article > p { display: flex; gap: 3px; margin: 0; }
.v2-mileage-source-list article > p button { display: grid; width: 26px; height: 26px; place-items: center; border: 1px solid #dce4ee; border-radius: 5px; background: #fff; color: #65758b; cursor: pointer; }
.v2-mileage-source-list article > p button:disabled { opacity: .32; cursor: default; }
.v2-mileage-source-popover > footer { display: flex; min-height: 49px; align-items: center; justify-content: space-between; gap: 10px; padding: 0 13px 0 16px; }
.v2-mileage-source-popover > footer button { height: 30px; border: 0; border-radius: 6px; background: var(--v2-blue); padding: 0 13px; color: #fff; cursor: pointer; font-size: 10px; font-weight: 650; }
.v2-mileage-ranges { display: flex; align-items: center; align-self: end; gap: 2px; margin-bottom: 1px; }
.v2-mileage-ranges > span { display: none; }
.v2-mileage-ranges button { height: 38px; border: 0; border-radius: 6px; background: transparent; padding: 0 9px; color: #64748b; cursor: pointer; font-size: 11px; font-weight: 550; white-space: nowrap; transition: color .16s ease, background .16s ease; }
.v2-mileage-ranges button:hover { background: #f1f5fb; color: var(--v2-blue); }
.v2-mileage-summary { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); border-top: 1px solid #e5eaf1; background: #fbfcfe; }
.v2-mileage-summary article { position: relative; min-width: 0; padding: 15px 22px 16px; }
.v2-mileage-summary article + article::before { position: absolute; inset: 15px auto 15px 0; width: 1px; background: #dfe6ef; content: ""; }
.v2-mileage-summary small, .v2-mileage-summary span { display: block; overflow: hidden; color: #7c899b; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.v2-mileage-summary strong { display: block; margin: 6px 0 3px; overflow: hidden; color: #172033; font-size: clamp(18px,1.45vw,24px); font-variant-numeric: tabular-nums; letter-spacing: -.035em; text-overflow: ellipsis; white-space: nowrap; }
.v2-mileage-summary .is-primary strong { color: var(--v2-blue); }
.v2-mileage-results { display: flex; min-height: 430px; flex: 1; flex-direction: column; overflow: hidden; border: 1px solid #dce3ec; border-radius: 11px; background: #fff; box-shadow: 0 1px 2px rgba(15,23,42,.025); }
.v2-mileage-results > header, .v2-mileage-results > footer { display: flex; min-height: 50px; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 12px; padding: 0 18px; }
.v2-mileage-results > header { border-bottom: 1px solid #e4e9f0; }
.v2-mileage-results > header div { display: flex; align-items: baseline; gap: 10px; }
.v2-mileage-results > header strong { color: #1f2a3d; font-size: 13px; }
.v2-mileage-results > header span, .v2-mileage-results > header em, .v2-mileage-results > footer { color: #7c899b; font-size: 10px; font-style: normal; }
.v2-mileage-results > header .v2-mileage-result-actions { align-items: center; gap: 12px; }
.v2-mileage-result-actions > button { display: inline-flex; height: 32px; align-items: center; gap: 6px; border: 1px solid #cbd8e8; border-radius: 7px; background: #fff; padding: 0 11px; color: #315f9f; cursor: pointer; font-size: 10px; font-weight: 650; box-shadow: 0 1px 2px rgba(15,23,42,.04); transition: border-color .16s ease, background .16s ease, box-shadow .16s ease; }
.v2-mileage-result-actions > button:hover { border-color: #99b6dd; background: #f5f9ff; box-shadow: 0 2px 7px rgba(37,99,235,.08); }
.v2-mileage-result-actions > button:disabled { opacity: .5; cursor: not-allowed; box-shadow: none; }
.v2-mileage-table-wrap { min-height: 0; flex: 1; overflow: auto; }
.v2-mileage-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; }
.v2-mileage-table th { position: sticky; z-index: 2; top: 0; height: 42px; border-bottom: 1px solid #dfe6ef; background: #f8fafc; padding: 0 14px; color: #59677b; font-size: 11px; font-weight: 650; text-align: left; white-space: nowrap; }
.v2-mileage-table td { height: 52px; border-bottom: 1px solid #e8edf3; padding: 0 14px; color: #46556a; white-space: nowrap; }
.v2-mileage-table tbody tr:hover { background: #f8fbff; }
.v2-mileage-table td strong { color: #263448; }
.v2-mileage-table code { color: #69778b; font-family: ui-monospace,SFMono-Regular,Menlo,monospace; font-size: 10px; }
.v2-mileage-table .is-number { text-align: right; font-variant-numeric: tabular-nums; }
.v2-mileage-table .is-date { min-width: 96px; text-align: center; }
.v2-mileage-table .is-sticky { position: sticky; z-index: 3; background: #fff; }
.v2-mileage-table th.is-sticky { z-index: 5; background: #f7f9fc; }
.v2-mileage-table .is-plate { left: 0; width: 120px; min-width: 120px; }
.v2-mileage-table .is-vin { left: 120px; width: 182px; min-width: 182px; border-right: 1px solid #d8e0ea; box-shadow: 5px 0 10px rgba(15,23,42,.025); }
.v2-mileage-table .is-total { position: sticky; z-index: 3; right: 0; min-width: 128px; border-left: 1px solid #d8e0ea; background: #fbfdff; box-shadow: -5px 0 10px rgba(15,23,42,.025); }
.v2-mileage-table th.is-total { z-index: 5; background: #f1f6fe; color: #315f9f; }
.v2-mileage-table .is-daily { color: #28496f; font-size: 11px; font-weight: 650; }
.v2-mileage-table .is-empty { color: #b2bcc9; text-align: center; }
.v2-mileage-table .is-period { color: #263448; font-weight: 700; }
.v2-mileage-source { display: inline-flex; height: 22px; align-items: center; border: 1px solid #dbe4ef; border-radius: 4px; background: #f8fafc; padding: 0 7px; color: #66758a; font-size: 8px; }
.v2-mileage-results > footer { border-top: 1px solid var(--v2-border); background: #fbfcfe; }
.v2-mileage-results > footer div { display: flex; gap: 6px; }
.v2-mileage-results > footer button { height: 29px; border: 1px solid #d7e0eb; border-radius: 6px; background: #fff; padding: 0 12px; color: #50627a; cursor: pointer; font-size: 9px; font-weight: 600; }
.v2-mileage-results > footer button:hover:not(:disabled) { border-color: #a9bdd7; background: #f5f8fc; color: #315f9f; }
.v2-mileage-results > footer button:disabled { opacity: .45; cursor: not-allowed; }
.v2-mileage-mobile-list { display: none; }
.v2-mileage-empty { display: grid; min-height: 210px; flex: 1; place-items: center; color: #8290a3; font-size: 12px; }
.v2-mileage-evidence { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6px 16px; color: var(--v2-muted); font-size: 9px; }
@media (max-width: 1050px) {
.v2-stat-filter { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.v2-stat-search { grid-column: 1 / -1; }
.v2-stat-filter > .v2-primary-button { width: 100%; }
.v2-stat-kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.v2-stat-kpis article:nth-child(4)::before { display: none; }
.v2-stat-grid-layout { grid-template-columns: 1fr; }
.v2-stat-ranking > div { max-height: 400px; }
.v2-mileage-filter { grid-template-columns: repeat(2,minmax(0,1fr)) auto auto; }
.v2-mileage-vehicle-field { grid-column: 1 / -1; }
.v2-mileage-filter > .v2-primary-button { width: auto; margin-top: 18px; }
.v2-mileage-ranges { grid-column: 1 / -1; }
}
@media (max-width: 680px) {
@@ -899,6 +1060,9 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-search-field input, .v2-filterbar select { font-size: 16px; }
.v2-filterbar select, .v2-primary-button, .v2-secondary-button { height: 44px; }
.v2-primary-button, .v2-secondary-button { padding: 0 10px; font-size: 12px; }
.v2-monitor-mode { height: 44px; }
.v2-monitor-mode button { flex: 1; font-size: 12px; }
.v2-monitor-mobile-entry { height: 44px; font-size: 12px; }
.v2-kpis { display: flex; min-width: 0; overflow-x: auto; border-radius: 9px; scroll-snap-type: x proximity; scrollbar-width: none; }
.v2-kpis::-webkit-scrollbar { display: none; }
.v2-kpi { width: 108px; min-width: 108px; min-height: 70px; flex: 0 0 108px; padding: 11px 13px; scroll-snap-align: start; }
@@ -906,6 +1070,23 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-kpi small { font-size: 10px; }
.v2-kpi strong { margin-top: 7px; font-size: 20px; }
.v2-monitor-workspace, .v2-monitor-workspace.is-detail-open, .v2-monitor-workspace.is-detail-collapsed { min-height: calc(52dvh + 360px); flex: none; grid-template-columns: 1fr; grid-template-rows: minmax(360px, 52dvh) 360px; overflow: hidden; }
.v2-monitor-table-panel { min-height: 68dvh; flex: none; }
.v2-monitor-table-panel > header { align-items: flex-start; flex-direction: column; gap: 4px; padding: 10px 12px; }
.v2-monitor-table-scroll { display: none; }
.v2-monitor-mobile-cards { display: flex; min-height: 0; flex: 1; flex-direction: column; gap: 8px; overflow: auto; padding: 8px; background: #f5f7fa; }
.v2-monitor-mobile-cards > article { border: 1px solid #e0e6ee; border-radius: 10px; background: #fff; padding: 12px; box-shadow: 0 2px 8px rgba(21,32,51,.04); }
.v2-monitor-mobile-cards > article > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.v2-monitor-mobile-cards > article > header div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.v2-monitor-mobile-cards > article > header strong { font-size: 15px; }.v2-monitor-mobile-cards > article > header span { color: #7f8da1; font-size: 10px; }
.v2-monitor-mobile-cards > article > header > b { color: #1f4f8d; font-size: 18px; font-variant-numeric: tabular-nums; }.v2-monitor-mobile-cards > article > header > b small { margin-left: 3px; color: #8996a8; font-size: 8px; font-weight: 500; }
.v2-monitor-mobile-cards dl { display: grid; grid-template-columns: .72fr 1.28fr; gap: 10px; margin: 12px 0 0; border-top: 1px solid #edf1f6; padding-top: 11px; }
.v2-monitor-mobile-cards dl > div { min-width: 0; }.v2-monitor-mobile-cards dl > div.is-address { grid-column: 1 / -1; }
.v2-monitor-mobile-cards dt { color: #8a97a9; font-size: 9px; }.v2-monitor-mobile-cards dd { margin: 4px 0 0; color: #34445a; font-size: 11px; font-weight: 650; }
.v2-monitor-mobile-cards dd code { font-family: ui-monospace,SFMono-Regular,Menlo,monospace; font-size: 9px; font-weight: 500; }
.v2-monitor-mobile-cards .v2-monitor-address-result > span { white-space: normal; }
.v2-monitor-mobile-cards > article > footer { display: flex; align-items: center; justify-content: flex-end; gap: 16px; margin-top: 10px; border-top: 1px solid #edf1f6; padding-top: 10px; }
.v2-monitor-mobile-cards > article > footer button { display: flex; align-items: center; gap: 4px; border: 0; background: transparent; color: var(--v2-blue); font-size: 11px; font-weight: 700; }
.v2-monitor-table-panel > footer { position: sticky; bottom: calc(64px + env(safe-area-inset-bottom)); z-index: 4; background: #fff; }
.v2-vehicle-rail { grid-row: 2; border: 0; border-top: 1px solid var(--v2-border); }
.v2-fleet-map { grid-row: 1; }
.v2-vehicle-rail > header { height: 48px; }
@@ -1023,29 +1204,101 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-alert-rule-editor > footer { align-items: stretch; flex-direction: column; }
.v2-alert-notifications > footer { flex-direction: column; gap: 5px; }
.v2-ops-page { padding: 8px; }.v2-ops-heading { align-items: flex-start; flex-direction: column; gap: 8px; }.v2-ops-kpis { grid-template-columns: 1fr 1fr; }.v2-ops-kpis article + article::before { display: none; }.v2-ops-kpis article { border-bottom: 1px solid var(--v2-border); }
.v2-stat-page { gap: 9px; padding: 10px 8px 16px; }
.v2-stat-heading { align-items: flex-start; flex-direction: column; gap: 8px; }
.v2-stat-heading h2 { font-size: 18px; }
.v2-stat-heading p { line-height: 1.6; }
.v2-stat-heading > button { width: 100%; height: 40px; justify-content: center; }
.v2-stat-filter { grid-template-columns: 1fr; gap: 9px; padding: 10px; }
.v2-stat-search, .v2-stat-ranges { grid-column: auto; }
.v2-stat-filter input, .v2-stat-filter select, .v2-stat-search > div { height: 44px; font-size: 16px; }
.v2-stat-filter > .v2-primary-button { height: 44px; }
.v2-stat-ranges { display: grid; grid-template-columns: repeat(3, 1fr); }
.v2-stat-ranges button { height: 34px; border-radius: 7px; }
.v2-stat-kpis { display: flex; overflow-x: auto; scroll-snap-type: x proximity; scrollbar-width: none; }
.v2-stat-kpis article { width: 150px; min-width: 150px; flex: 0 0 150px; padding: 12px 14px; scroll-snap-align: start; }
.v2-stat-kpis article + article::before { display: block; }
.v2-stat-kpis strong { font-size: 19px; }
.v2-stat-grid-layout { min-height: 0; grid-template-columns: 1fr; }
.v2-stat-card > header { align-items: flex-start; flex-direction: column; }
.v2-stat-chart-wrap { overflow-x: auto; padding: 10px 4px 0; }
.v2-stat-chart-wrap svg { width: 680px; max-width: none; }
.v2-stat-daily-list { grid-template-columns: repeat(2, minmax(0,1fr)); margin: 0 9px 10px; }
.v2-stat-ranking > div { max-height: none; }
.v2-stat-ranking article { padding: 11px 10px; }
.v2-stat-evidence { flex-direction: column; line-height: 1.5; }
.v2-mileage-page { gap: 10px; padding: 12px 8px 18px; }
.v2-mileage-heading { align-items: flex-start; flex-direction: row; gap: 10px; }
.v2-mileage-heading h2 { font-size: 18px; }
.v2-mileage-heading p { line-height: 1.6; }
.v2-mileage-heading > button { width: auto; height: 36px; flex: 0 0 auto; justify-content: center; padding: 0 11px; }
.v2-mileage-query-panel { border-radius: 10px; }
.v2-mileage-filter { grid-template-columns: 1fr; gap: 12px; padding: 12px; }
.v2-mileage-vehicle-field { grid-column: auto; }
.v2-mileage-filter > label > input { height: 44px; font-size: 16px; }
.v2-mileage-multiselect { min-height: 44px; padding-top: 7px; padding-bottom: 7px; }
.v2-mileage-selection > input { height: 28px; font-size: 16px; }
.v2-mileage-filter > .v2-primary-button { height: 44px; margin-top: 0; }
.v2-mileage-source-strategy { margin-top: 0; }
.v2-mileage-source-trigger { width: 100%; height: 44px; justify-content: center; }
.v2-mileage-source-popover { position: fixed; top: 70px; right: 8px; bottom: auto; left: 8px; width: auto; max-height: calc(100dvh - 142px - env(safe-area-inset-bottom)); overflow-y: auto; overscroll-behavior: contain; }
.v2-mileage-source-popover > header { position: sticky; z-index: 2; top: 0; background: #fff; }
.v2-mileage-source-popover > footer { position: sticky; z-index: 2; bottom: 0; background: #fff; }
.v2-mileage-source-list article { grid-template-columns: auto minmax(0,1fr) auto; gap: 9px; padding-right: 11px; padding-left: 12px; }
.v2-mileage-source-list article > p { grid-column: 2 / -1; justify-content: flex-end; margin-top: -5px; }
.v2-mileage-ranges { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 4px; }
.v2-mileage-ranges button { height: 36px; background: #f6f8fb; }
.v2-mileage-options > button { grid-template-columns: minmax(80px,.7fr) minmax(0,1fr); }
.v2-mileage-options > button em { display: none; }
.v2-mileage-summary { grid-template-columns: repeat(2,minmax(0,1fr)); }
.v2-mileage-summary article { padding: 13px 14px 14px; }
.v2-mileage-summary article:nth-child(3)::before { display: none; }
.v2-mileage-summary article:nth-child(-n+2) { border-bottom: 1px solid #e5eaf1; }
.v2-mileage-summary strong { font-size: 18px; }
.v2-mileage-results { min-height: 420px; overflow: visible; }
.v2-mileage-results > header { align-items: flex-start; height: auto; flex-direction: column; gap: 4px; padding: 11px 12px; }
.v2-mileage-results > header div { align-items: flex-start; flex-direction: column; gap: 3px; }
.v2-mileage-results > header .v2-mileage-result-actions { width: 100%; align-items: center; justify-content: space-between; flex-direction: row; gap: 8px; }
.v2-mileage-result-actions > button { height: 34px; flex: 0 0 auto; }
.v2-mileage-table-wrap { display: none; }
.v2-mileage-mobile-list { display: flex; flex-direction: column; }
.v2-mileage-mobile-list article { border-bottom: 1px solid #e9eef5; padding: 14px 12px; }
.v2-mileage-mobile-list article > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.v2-mileage-mobile-list article > header div { min-width: 0; }
.v2-mileage-mobile-list article > header strong, .v2-mileage-mobile-list article > header span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.v2-mileage-mobile-list article > header strong { color: #253449; font-size: 13px; }
.v2-mileage-mobile-list article > header b { flex: 0 0 auto; color: var(--v2-blue); font-size: 14px; text-align: right; }
.v2-mileage-mobile-list article > header b small { display: block; margin-top: 3px; color: var(--v2-muted); font-size: 8px; font-weight: 500; }
.v2-mileage-mobile-list article > header span, .v2-mileage-mobile-list time { margin-top: 3px; color: var(--v2-muted); font-size: 9px; }
.v2-mileage-mobile-list dl { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px 14px; margin: 12px 0 0; }
.v2-mileage-mobile-list dl div { min-width: 0; }
.v2-mileage-mobile-list dt { color: var(--v2-muted); font-size: 9px; }
.v2-mileage-mobile-list dd { margin: 4px 0 0; overflow: hidden; color: #334155; font-size: 11px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
.v2-mileage-mobile-list dd.is-daily { color: var(--v2-blue); font-size: 14px; }
.v2-mileage-mobile-days { display: grid; grid-auto-columns: 84px; grid-auto-flow: column; gap: 6px; margin-top: 12px; overflow-x: auto; padding-bottom: 3px; }
.v2-mileage-mobile-days > div { border: 1px solid #e2e8f0; border-radius: 7px; background: #f8fafc; padding: 8px; }
.v2-mileage-mobile-days time, .v2-mileage-mobile-days strong { display: block; }
.v2-mileage-mobile-days time { color: var(--v2-muted); font-size: 8px; }
.v2-mileage-mobile-days strong { margin-top: 4px; color: #334155; font-size: 10px; }
.v2-mileage-results > footer { min-height: 58px; align-items: flex-start; flex-direction: column; gap: 7px; padding: 9px 10px; }
.v2-mileage-results > footer div { width: 100%; }
.v2-mileage-results > footer button { height: 34px; }
.v2-mileage-results > footer div button { flex: 1; }
.v2-mileage-evidence { flex-direction: column; line-height: 1.5; }
}
@media (max-width: 1280px) {
.v2-access-filter-v3 { grid-template-columns: minmax(210px, 1.3fr) repeat(3, minmax(120px, .75fr)) auto auto; }
.v2-access-filter-v3 > button { min-width: 76px; }
.v2-access-protocol-coverage b { display: none; }
.v2-access-workspace-v3.is-inspector-open { position: relative; grid-template-columns: minmax(0, 1fr); }
.v2-access-inspector-v3 { position: absolute; z-index: 6; top: 0; right: 0; bottom: 0; width: min(390px, calc(100% - 40px)); box-shadow: -12px 0 38px rgba(24, 38, 58, .16); }
}
@media (max-width: 760px) {
.v2-access-page-v3 { gap: 9px; padding: 10px 8px 18px; }
.v2-access-heading { align-items: flex-start; flex-direction: column; gap: 8px; }
.v2-access-heading h2 { font-size: 19px; }
.v2-access-heading p { font-size: 11px; }
.v2-access-heading > div:last-child { width: 100%; justify-content: space-between; }
.v2-access-filter-v3 { grid-template-columns: 1fr 1fr; padding: 11px; }
.v2-access-filter-v3 .is-search { grid-column: 1 / -1; }
.v2-access-filter-v3 label { font-size: 11px; }
.v2-access-filter-v3 input, .v2-access-filter-v3 select, .v2-access-filter-v3 label > div { height: 42px; font-size: 13px; }
.v2-access-filter-v3 > button { height: 42px; }
.v2-access-kpis-v3 { display: flex; min-height: 56px; overflow-x: auto; scroll-snap-type: x proximity; }
.v2-access-kpis-v3 button { min-width: 150px; flex: 0 0 150px; scroll-snap-align: start; }
.v2-access-kpis-v3 strong { font-size: 20px; }
.v2-access-workspace-v3 { min-height: 620px; flex-basis: 620px; }
.v2-access-table-v3 > header { align-items: flex-start; min-height: 62px; flex-direction: column; padding: 9px 10px; }
.v2-access-table-title { align-items: flex-start; flex-direction: column; gap: 3px; }
.v2-access-table-actions { width: 100%; justify-content: space-between; overflow: hidden; }
.v2-access-protocol-coverage { overflow-x: auto; }
.v2-access-protocol-coverage b { display: inline; }
.v2-access-table-v3 > footer { align-items: flex-start; min-height: 76px; flex-direction: column; padding: 9px 10px; }
.v2-access-table-scroll-v3 table { font-size: 12px; }
.v2-access-inspector-v3 { position: fixed; z-index: 30; top: 76px; right: 8px; bottom: 8px; width: calc(100% - 16px); border-radius: 12px; }
.v2-access-identity-queue-v3 summary { align-items: flex-start; flex-direction: column; justify-content: center; gap: 3px; padding: 8px 10px; }
.v2-access-identity-queue-v3 > div { grid-template-columns: 1fr; }
.v2-access-settings fieldset { align-items: stretch; flex-direction: column; }
.v2-access-settings input { width: 100%; box-sizing: border-box; }
}
@keyframes v2-mobile-sheet-enter {
@@ -1062,3 +1315,122 @@ button, a { -webkit-tap-highlight-color: transparent; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}
/* Track replay command center — map-first layout based on the G7 reference. */
.v2-track-page { display: grid; width: 100%; height: 100%; min-height: 0; grid-template-columns: 320px minmax(0, 1fr); gap: 0; overflow: hidden; padding: 0; background: #e9eef4; }
.v2-track-page.is-rail-collapsed { grid-template-columns: 0 minmax(0, 1fr); }
.v2-track-rail { position: relative; z-index: 12; display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: visible; border-right: 1px solid #d9e1ea; background: #fff; box-shadow: 8px 0 24px rgba(30, 45, 66, .07); transition: transform .18s ease, opacity .18s ease; }
.v2-track-page.is-rail-collapsed .v2-track-rail { opacity: 0; pointer-events: none; transform: translateX(-100%); }
.v2-track-query { flex: 0 0 auto; border-bottom: 1px solid #e2e8f0; padding: 14px 14px 13px; background: #fff; }
.v2-track-query > header { display: flex; height: 34px; align-items: flex-start; justify-content: space-between; margin-bottom: 11px; }
.v2-track-query > header div { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
.v2-track-query > header strong { color: #1e293b; font-size: 14px; letter-spacing: -.02em; }
.v2-track-query > header span { color: #8a97a9; font-size: 9px; }
.v2-track-query > header button { display: grid; width: 30px; height: 30px; place-items: center; border: 1px solid #dde5ef; border-radius: 7px; background: #fff; color: #66758a; cursor: pointer; }
.v2-track-query > label, .v2-track-date-grid label { position: relative; display: flex; min-width: 0; flex-direction: column; gap: 5px; color: #738197; font-size: 10px; }
.v2-track-vehicle-picker { position: relative; display: grid; height: 38px; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 7px; border: 1px solid #d8e1ec; border-radius: 7px; background: #fdfefe; padding: 0 9px; color: #8a97a8; }
.v2-track-vehicle-picker:focus-within { border-color: #8cb7fb; box-shadow: 0 0 0 3px rgba(37, 99, 235, .08); }
.v2-track-vehicle-picker input { min-width: 0; height: 34px; border: 0; background: transparent; color: #334155; outline: 0; font-family: inherit; font-size: 11px; }
.v2-track-vehicle-picker > button { display: grid; width: 24px; height: 24px; place-items: center; border: 0; border-radius: 5px; background: transparent; color: #8a97a8; cursor: pointer; }
.v2-track-vehicle-options { position: absolute; z-index: 50; top: calc(100% + 6px); right: -1px; left: -1px; overflow: hidden; border: 1px solid #d8e1ec; border-radius: 9px; background: #fff; box-shadow: 0 16px 36px rgba(29, 43, 63, .16); }
.v2-track-vehicle-options header { display: flex; height: 32px; align-items: center; justify-content: space-between; border-bottom: 1px solid #edf1f6; background: #f8fafc; padding: 0 10px; color: #617086; font-size: 9px; }
.v2-track-vehicle-options header em { color: #95a1b1; font-style: normal; }
.v2-track-vehicle-options > button { display: grid; width: 100%; min-height: 44px; grid-template-columns: minmax(0, .85fr) minmax(0, 1.4fr) auto; align-items: center; gap: 8px; border: 0; border-top: 1px solid #f0f3f7; background: #fff; padding: 6px 10px; text-align: left; cursor: pointer; }
.v2-track-vehicle-options > button:hover { background: #f3f7ff; }
.v2-track-vehicle-options > button strong, .v2-track-vehicle-options > button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.v2-track-vehicle-options > button strong { color: #334155; font-size: 10px; }.v2-track-vehicle-options > button span { color: #8390a2; font-size: 8px; }.v2-track-vehicle-options > button em { color: var(--v2-blue); font-size: 8px; font-style: normal; }
.v2-track-vehicle-options > p { display: flex; min-height: 46px; align-items: center; justify-content: center; gap: 7px; margin: 0; color: #8390a2; font-size: 9px; }
.v2-track-presets { display: flex; gap: 6px; margin: 8px 0; }
.v2-track-presets button { height: 25px; flex: 1; border: 1px solid #e0e7f0; border-radius: 5px; background: #f8fafc; color: #64748b; font-family: inherit; font-size: 9px; cursor: pointer; }
.v2-track-presets button:hover { border-color: #aac7f6; background: #eef5ff; color: var(--v2-blue); }
.v2-track-date-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin-bottom: 8px; }
.v2-track-date-grid input, .v2-track-query select { width: 100%; height: 35px; box-sizing: border-box; border: 1px solid #dbe3ed; border-radius: 6px; background: #fff; padding: 0 7px; color: #48566a; outline: 0; font-family: inherit; font-size: 8px; }
.v2-track-query select { margin-bottom: 9px; font-size: 10px; }
.v2-track-query-button { display: flex; width: 100%; height: 36px; align-items: center; justify-content: center; gap: 7px; border: 1px solid #2563eb; border-radius: 7px; background: #2563eb; color: #fff; box-shadow: 0 6px 14px rgba(37, 99, 235, .18); font-family: inherit; font-size: 11px; font-weight: 700; cursor: pointer; }
.v2-track-query-button:disabled { cursor: not-allowed; opacity: .48; }
.v2-track-rail-result { display: flex; min-height: 0; flex: 1; flex-direction: column; }
.v2-track-rail-vehicle { display: grid; min-height: 58px; grid-template-columns: 32px minmax(0, 1fr) auto; align-items: center; gap: 9px; border-bottom: 1px solid #e8edf3; padding: 8px 13px; }
.v2-track-rail-vehicle > span { display: grid; width: 30px; height: 30px; place-items: center; border-radius: 7px; background: #eaf2ff; color: #2563eb; }
.v2-track-rail-vehicle > div { min-width: 0; }.v2-track-rail-vehicle strong, .v2-track-rail-vehicle small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.v2-track-rail-vehicle strong { color: #253348; font-size: 12px; }.v2-track-rail-vehicle small { margin-top: 3px; color: #8b97a8; font-size: 8px; }
.v2-track-rail-vehicle em { color: #2563eb; font-size: 12px; font-style: normal; font-weight: 700; }
.v2-track-rail-result > nav { display: grid; height: 40px; flex: 0 0 40px; grid-template-columns: 1fr 1fr 1fr; border-bottom: 1px solid #dfe6ee; padding: 0 8px; }
.v2-track-rail-result > nav button { position: relative; border: 0; background: transparent; color: #66758a; font-family: inherit; font-size: 10px; cursor: pointer; }
.v2-track-rail-result > nav button.is-active { color: #2563eb; font-weight: 700; }.v2-track-rail-result > nav button.is-active::after { position: absolute; right: 12px; bottom: -1px; left: 12px; height: 2px; border-radius: 2px 2px 0 0; background: #2563eb; content: ''; }
.v2-track-rail-result > nav b { margin-left: 2px; font-size: 8px; font-weight: 600; }
.v2-track-rail-scroll { min-height: 0; flex: 1; overflow: auto; overscroll-behavior: contain; scrollbar-width: thin; }
.v2-track-rail-empty { display: flex; min-height: 280px; align-items: center; justify-content: center; flex-direction: column; padding: 24px; color: #9aa6b5; text-align: center; }
.v2-track-rail-empty > svg { font-size: 25px; }.v2-track-rail-empty strong { margin-top: 10px; color: #4c596d; font-size: 12px; }.v2-track-rail-empty p { margin: 6px 0 0; color: #8a97a8; font-size: 9px; line-height: 1.55; }
.v2-track-stop-list > button, .v2-track-event-list > button { display: grid; width: 100%; min-height: 48px; grid-template-columns: 23px minmax(0, 1fr) auto; align-items: center; gap: 8px; border: 0; border-bottom: 1px solid #edf1f5; background: #fff; padding: 6px 11px; text-align: left; cursor: pointer; }
.v2-track-stop-list > button:hover, .v2-track-event-list > button:hover { background: #f8fbff; }.v2-track-stop-list > button.is-active, .v2-track-event-list > button.is-active { background: #edf5ff; box-shadow: inset 3px 0 #2563eb; }
.v2-track-stop-list button > i, .v2-track-event-list button > i { display: grid; width: 21px; height: 21px; place-items: center; border-radius: 50%; background: #f3aa3c; color: #fff; font-size: 8px; font-style: normal; font-weight: 800; }
.v2-track-event-list button > i { background: #2563eb; }.v2-track-event-list button > i.is-start { background: #18a86b; }.v2-track-event-list button > i.is-end { background: #e74b4b; }.v2-track-event-list button > i.is-warning { background: #f1a238; }
.v2-track-stop-list button > span, .v2-track-event-list button > span { min-width: 0; }.v2-track-stop-list button strong, .v2-track-stop-list button small, .v2-track-event-list button strong, .v2-track-event-list button small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.v2-track-stop-list button strong, .v2-track-event-list button strong { color: #3d4b5f; font-size: 9px; }.v2-track-stop-list button small, .v2-track-event-list button small { margin-top: 3px; color: #8c98a9; font-size: 8px; }
.v2-track-stop-list button > em, .v2-track-event-list button > em { color: #6f7d90; font-size: 8px; font-style: normal; white-space: nowrap; }
.v2-track-list-empty { margin: 50px 20px; color: #8a97a8; font-size: 10px; text-align: center; }
.v2-track-overview-panel { padding-bottom: 14px; }.v2-track-overview-panel > section { border-bottom: 1px solid #edf1f5; padding: 11px 13px; }.v2-track-overview-panel section > header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }.v2-track-overview-panel header strong { color: #364357; font-size: 10px; }.v2-track-overview-panel header span { color: #8a97a8; font-size: 8px; }
.v2-track-overview-panel dl { margin: 0; }.v2-track-overview-panel dl div { display: grid; grid-template-columns: 86px minmax(0, 1fr); padding: 4px 0; font-size: 8px; }.v2-track-overview-panel dt { color: #8390a2; }.v2-track-overview-panel dd { margin: 0; color: #48566a; text-align: right; font-variant-numeric: tabular-nums; }
.v2-track-source-list { display: grid; gap: 5px; }.v2-track-source-list article { display: grid; grid-template-columns: minmax(0, 1fr) auto; border: 1px solid #e4e9f0; border-radius: 6px; background: #f8fafc; padding: 7px 8px; }.v2-track-source-list strong { color: #425066; font-size: 9px; }.v2-track-source-list span { color: #2563eb; font-size: 8px; }.v2-track-source-list small { grid-column: 1 / -1; margin-top: 3px; color: #8b97a8; font-size: 7px; }
.v2-track-quality-card p { margin: 0; color: #526176; font-size: 8px; line-height: 1.55; }.v2-track-quality-card small { display: block; margin-top: 6px; color: #8190a3; font-size: 7px; }.v2-track-quality-card.is-warning { background: #fffaf2; }
.v2-track-stage { position: relative; min-width: 0; min-height: 0; overflow: hidden; background: #e7edf3; }
.v2-track-map, .v2-track-map-canvas { position: absolute; inset: 0; }
.v2-track-stage-tools { position: absolute; z-index: 18; top: 14px; right: 14px; display: flex; gap: 7px; }
.v2-track-stage-tools button { display: flex; height: 34px; align-items: center; gap: 6px; border: 1px solid #d7e0eb; border-radius: 7px; background: rgba(255, 255, 255, .95); padding: 0 10px; color: #607086; box-shadow: 0 5px 15px rgba(26, 42, 63, .1); backdrop-filter: blur(8px); font-family: inherit; font-size: 9px; cursor: pointer; }
.v2-track-stage-tools button.is-active { border-color: #a8c5f4; background: #eef5ff; color: #2563eb; }.v2-track-stage-tools button:disabled { cursor: not-allowed; opacity: .45; }
.v2-track-coverage-float { position: absolute; z-index: 16; top: 14px; left: 14px; display: grid; width: min(510px, calc(100% - 450px)); min-height: 34px; grid-template-columns: 7px minmax(0, 1fr) auto; align-items: center; gap: 9px; border: 1px solid rgba(209, 221, 234, .94); border-radius: 7px; background: rgba(255, 255, 255, .94); padding: 5px 10px; color: #65748a; box-shadow: 0 5px 16px rgba(26, 42, 63, .09); backdrop-filter: blur(9px); font-size: 8px; }
.v2-track-coverage-float > i { width: 7px; height: 7px; border-radius: 50%; background: #18a86b; }.v2-track-coverage-float.is-warning > i { background: #f1a238; }.v2-track-coverage-float > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-track-coverage-float strong { margin-right: 7px; color: #354358; font-size: 9px; }.v2-track-coverage-float em { color: #758399; font-size: 8px; font-style: normal; white-space: nowrap; }
.v2-track-current-card { position: absolute; z-index: 16; top: 60px; left: 14px; width: 292px; overflow: hidden; border: 1px solid rgba(204, 216, 230, .95); border-radius: 9px; background: rgba(28, 39, 55, .91); color: #fff; box-shadow: 0 12px 30px rgba(15, 23, 42, .2); backdrop-filter: blur(12px); }
.v2-track-current-card > header { display: flex; align-items: center; justify-content: space-between; padding: 10px 11px 8px; }.v2-track-current-card header div { min-width: 0; }.v2-track-current-card header strong, .v2-track-current-card header span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-track-current-card header strong { font-size: 12px; }.v2-track-current-card header span { margin-top: 3px; color: #b8c4d2; font-size: 8px; }.v2-track-current-card header b { border-radius: 10px; background: rgba(37, 99, 235, .38); padding: 3px 7px; color: #dce9ff; font-size: 8px; }
.v2-track-current-card > div { display: grid; grid-template-columns: 1fr 1fr 1fr; border-top: 1px solid rgba(255, 255, 255, .1); border-bottom: 1px solid rgba(255, 255, 255, .1); }.v2-track-current-card > div > span { min-width: 0; padding: 8px 10px; }.v2-track-current-card > div > span + span { border-left: 1px solid rgba(255, 255, 255, .1); }.v2-track-current-card small, .v2-track-current-card strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-track-current-card small { color: #9eacbd; font-size: 7px; }.v2-track-current-card > div strong { margin-top: 4px; font-size: 10px; }.v2-track-current-card em { color: #aebac8; font-size: 7px; font-style: normal; font-weight: 500; }
.v2-track-current-card > p { margin: 0; overflow: hidden; padding: 8px 11px; color: #c4ced9; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
.v2-track-empty-state { position: absolute; z-index: 14; top: 50%; left: 50%; display: flex; width: min(330px, calc(100% - 36px)); align-items: center; flex-direction: column; transform: translate(-50%, calc(-50% - 56px)); border: 1px solid rgba(211, 221, 232, .95); border-radius: 12px; background: rgba(255, 255, 255, .93); padding: 24px; color: #8a97a8; box-shadow: 0 14px 36px rgba(27, 42, 61, .1); text-align: center; backdrop-filter: blur(10px); }.v2-track-empty-state > svg { color: #2563eb; font-size: 28px; }.v2-track-empty-state strong { margin-top: 10px; color: #344256; font-size: 13px; }.v2-track-empty-state p { margin: 6px 0 14px; font-size: 9px; }.v2-track-empty-state button { display: flex; height: 34px; align-items: center; gap: 6px; border: 0; border-radius: 6px; background: #2563eb; padding: 0 12px; color: #fff; font-family: inherit; font-size: 10px; font-weight: 700; cursor: pointer; }
.v2-track-loading { position: absolute; z-index: 25; top: 14px; left: 50%; display: flex; height: 34px; align-items: center; gap: 8px; transform: translateX(-50%); border: 1px solid #d9e2ec; border-radius: 7px; background: rgba(255, 255, 255, .96); padding: 0 12px; color: #59687c; box-shadow: 0 8px 20px rgba(30, 45, 66, .13); font-size: 9px; }
.v2-track-error { position: absolute; z-index: 28; top: 56px; right: 14px; width: min(420px, calc(100% - 28px)); }
.v2-track-marker { display: grid; width: 25px; height: 25px; place-items: center; border: 3px solid #fff; border-radius: 50%; background: #2563eb; color: #fff; box-shadow: 0 3px 10px rgba(29, 44, 65, .28); }.v2-track-marker span { font-size: 8px; font-weight: 800; line-height: 1; }.v2-track-marker.is-start { background: #18a86b; }.v2-track-marker.is-end { background: #e64c4c; }.v2-track-marker.is-stop { width: 22px; height: 22px; border-width: 2px; background: #f1a238; }
.v2-track-current-marker { position: relative; width: 42px; height: 42px; }.v2-track-current-marker > i { position: absolute; inset: 2px; border: 2px solid rgba(37, 99, 235, .45); border-radius: 50%; animation: v2-track-pulse 1.8s ease-out infinite; }.v2-track-current-marker > span { position: absolute; top: 13px; left: 13px; width: 16px; height: 16px; box-sizing: border-box; border: 4px solid #fff; border-radius: 50%; background: #2563eb; box-shadow: 0 3px 9px rgba(37, 99, 235, .5); }
@keyframes v2-track-pulse { 0% { opacity: .9; transform: scale(.45); } 80%, 100% { opacity: 0; transform: scale(1); } }
.v2-track-playback-dock { position: absolute; z-index: 20; right: 0; bottom: 0; left: 0; display: grid; min-height: 112px; grid-template-columns: 145px minmax(260px, 1fr) auto; align-items: center; gap: 18px; border-top: 1px solid rgba(202, 214, 228, .95); background: rgba(255, 255, 255, .96); padding: 12px 16px; box-shadow: 0 -8px 26px rgba(24, 38, 58, .11); backdrop-filter: blur(14px); }
.v2-track-dock-summary { min-width: 0; }.v2-track-dock-summary strong, .v2-track-dock-summary span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-track-dock-summary strong { color: #263448; font-size: 17px; letter-spacing: -.02em; }.v2-track-dock-summary span { margin-top: 5px; color: #7e8b9d; font-size: 8px; }
.v2-track-dock-progress { min-width: 0; }.v2-track-segment-rail { display: flex; width: 100%; height: 10px; overflow: hidden; border-radius: 5px; background: #e8edf3; }.v2-track-segment-rail button { min-width: 2px; border: 0; border-right: 1px solid rgba(255, 255, 255, .55); background: #b9e6d0; padding: 0; cursor: pointer; }.v2-track-segment-rail button.is-stopped { background: #f8d797; }.v2-track-segment-rail button.is-gap { background: #f2a7a7; }.v2-track-segment-rail button:hover { filter: brightness(.94); }.v2-track-segment-placeholder { height: 10px; border-radius: 5px; background: #e7ecf2; }
.v2-track-dock-progress > input { width: 100%; height: 4px; margin: 13px 0 7px; accent-color: #2563eb; cursor: pointer; }.v2-track-dock-progress > input:disabled { cursor: not-allowed; }.v2-track-dock-progress > div { display: flex; justify-content: space-between; color: #7c899a; font-size: 8px; }.v2-track-dock-progress time { color: #4b596d; font-variant-numeric: tabular-nums; }
.v2-track-dock-controls { display: flex; align-items: center; gap: 6px; }.v2-track-dock-controls > button { display: grid; width: 32px; height: 32px; place-items: center; border: 1px solid #d8e1eb; border-radius: 50%; background: #fff; color: #5c6b7f; cursor: pointer; }.v2-track-dock-controls > button.is-primary { width: 42px; height: 42px; border-color: #2563eb; background: #2563eb; color: #fff; box-shadow: 0 5px 14px rgba(37, 99, 235, .25); }.v2-track-dock-controls > button:disabled { cursor: not-allowed; opacity: .36; }
.v2-track-dock-controls label { display: flex; height: 32px; align-items: center; gap: 5px; border: 1px solid #d8e1eb; border-radius: 16px; padding: 0 8px 0 10px; color: #7c899a; font-size: 8px; }.v2-track-dock-controls select { border: 0; background: transparent; color: #3f4e63; outline: 0; font-family: inherit; font-size: 9px; font-weight: 700; }
.v2-track-dock-metrics { display: none; align-items: center; }.v2-track-dock-metrics > span { min-width: 92px; padding: 0 12px; }.v2-track-dock-metrics > span + span { border-left: 1px solid #e2e8f0; }.v2-track-dock-metrics small, .v2-track-dock-metrics strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-track-dock-metrics small { color: #8a97a8; font-size: 7px; }.v2-track-dock-metrics strong { margin-top: 5px; color: #405066; font-size: 9px; }
@media (min-width: 1680px) {
.v2-track-page { grid-template-columns: 340px minmax(0, 1fr); }
.v2-track-page.is-rail-collapsed { grid-template-columns: 0 minmax(0, 1fr); }
.v2-track-playback-dock { grid-template-columns: 150px minmax(310px, 1fr) auto auto; }
.v2-track-dock-metrics { display: flex; }
}
@media (max-width: 1100px) {
.v2-track-page { grid-template-columns: 286px minmax(0, 1fr); }
.v2-track-page.is-rail-collapsed { grid-template-columns: 0 minmax(0, 1fr); }
.v2-track-current-card { width: 260px; }
.v2-track-coverage-float { width: min(420px, calc(100% - 360px)); }
.v2-track-playback-dock { grid-template-columns: minmax(250px, 1fr) auto; gap: 12px; }
.v2-track-dock-summary, .v2-track-dock-metrics { display: none; }
}
@media (max-width: 700px) {
.v2-track-page, .v2-track-page.is-rail-collapsed { position: relative; display: block; height: 100%; min-height: 0; overflow: hidden; }
.v2-track-stage { width: 100%; height: 100%; }
.v2-track-rail { position: fixed; z-index: 72; right: 8px; bottom: calc(72px + env(safe-area-inset-bottom)); left: 8px; width: auto; height: min(76dvh, 680px); border: 1px solid #d7e0ea; border-radius: 16px; box-shadow: 0 -14px 46px rgba(22, 34, 50, .22); transform: translateY(0); }
.v2-track-rail::before { display: block; width: 40px; height: 4px; flex: 0 0 4px; margin: 8px auto 0; border-radius: 2px; background: #d5dee9; content: ''; }
.v2-track-page.is-rail-collapsed .v2-track-rail { opacity: 0; pointer-events: none; transform: translateY(calc(100% + 90px)); }
.v2-track-query { padding: 9px 12px 10px; }.v2-track-query > header { height: 30px; margin-bottom: 7px; }.v2-track-query > header strong { font-size: 14px; }.v2-track-query > header button { width: 34px; height: 34px; transform: rotate(-90deg); }
.v2-track-vehicle-picker { height: 42px; }.v2-track-vehicle-picker input, .v2-track-date-grid input, .v2-track-query select { font-size: 16px; }.v2-track-date-grid { grid-template-columns: 1fr; gap: 6px; }.v2-track-date-grid input, .v2-track-query select { height: 40px; }.v2-track-query select { margin-bottom: 7px; }.v2-track-presets { margin: 6px 0; }.v2-track-presets button { height: 29px; }.v2-track-query-button { height: 42px; font-size: 12px; }
.v2-track-rail-vehicle { min-height: 54px; }.v2-track-rail-result > nav { height: 42px; flex-basis: 42px; }.v2-track-stop-list > button, .v2-track-event-list > button { min-height: 54px; }.v2-track-stop-list button strong, .v2-track-event-list button strong { font-size: 11px; }.v2-track-stop-list button small, .v2-track-event-list button small { font-size: 9px; }
.v2-track-stage-tools { top: 8px; right: 8px; gap: 6px; }.v2-track-stage-tools button { width: 40px; height: 40px; justify-content: center; padding: 0; }.v2-track-stage-tools button > span:not(.semi-icon) { display: none; }.v2-track-stage-tools button > .semi-icon { display: inline-flex; font-size: 17px; }
.v2-track-coverage-float { top: 56px; right: 8px; left: 8px; width: auto; min-height: 36px; grid-template-columns: 7px minmax(0, 1fr); }.v2-track-coverage-float > span { white-space: nowrap; }.v2-track-coverage-float > span:not(strong) { font-size: 0; }.v2-track-coverage-float strong { font-size: 10px; }.v2-track-coverage-float em { display: none; }
.v2-track-current-card { top: auto; right: 8px; bottom: 126px; left: 8px; width: auto; border-radius: 9px; }.v2-track-current-card > header { padding: 8px 10px 6px; }.v2-track-current-card > div > span { padding: 6px 9px; }.v2-track-current-card > p { display: none; }
.v2-track-empty-state { width: min(300px, calc(100% - 28px)); transform: translate(-50%, calc(-50% - 42px)); padding: 20px 16px; }
.v2-track-loading { top: 103px; width: max-content; max-width: calc(100% - 20px); }
.v2-track-error { top: 103px; right: 8px; width: calc(100% - 16px); }
.v2-track-playback-dock { min-height: 118px; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; padding: 10px 9px; }.v2-track-dock-summary, .v2-track-dock-metrics { display: none; }.v2-track-dock-progress { align-self: center; }.v2-track-dock-controls { align-self: center; gap: 4px; }.v2-track-dock-controls > button { width: 34px; height: 34px; }.v2-track-dock-controls > button.is-primary { width: 42px; height: 42px; }.v2-track-dock-controls > button:first-child, .v2-track-dock-controls > button:nth-child(3), .v2-track-dock-controls > button:last-child { display: none; }.v2-track-dock-controls label { height: 34px; padding: 0 6px; }.v2-track-dock-controls label span { display: none; }
.v2-track-dock-progress > div span { display: none; }
}

View File

@@ -0,0 +1,66 @@
CREATE TABLE IF NOT EXISTS business_scope_sync_run (
run_id CHAR(32) NOT NULL PRIMARY KEY,
source_system VARCHAR(32) NOT NULL,
source_version VARCHAR(96) NOT NULL,
status VARCHAR(24) NOT NULL,
candidate_count INT UNSIGNED NOT NULL DEFAULT 0,
accepted_count INT UNSIGNED NOT NULL DEFAULT 0,
rejected_count INT UNSIGNED NOT NULL DEFAULT 0,
source_checksum CHAR(64) NOT NULL,
started_at DATETIME(3) NOT NULL,
finished_at DATETIME(3) NOT NULL,
error_message VARCHAR(512) NOT NULL DEFAULT '',
INDEX idx_business_scope_run_finished (finished_at, status),
INDEX idx_business_scope_run_version (source_version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS business_customer_vehicle_scope (
source_version VARCHAR(96) NOT NULL,
customer_id BIGINT NOT NULL,
vin VARCHAR(64) NOT NULL,
vehicle_id BIGINT NOT NULL,
contract_id BIGINT NULL,
contract_code VARCHAR(255) NOT NULL DEFAULT '',
plate_number VARCHAR(64) NOT NULL DEFAULT '',
project_name VARCHAR(255) NOT NULL DEFAULT '',
operation_status VARCHAR(32) NOT NULL DEFAULT '',
scope_start_at DATETIME(3) NOT NULL,
source_updated_at DATETIME(3) NULL,
published_at DATETIME(3) NOT NULL,
PRIMARY KEY (source_version, customer_id, vin),
UNIQUE KEY uk_business_scope_version_vehicle (source_version, vehicle_id),
INDEX idx_business_scope_customer (customer_id, source_version, vin),
INDEX idx_business_scope_vin (vin, source_version, customer_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS business_scope_rejection (
run_id CHAR(32) NOT NULL,
row_number INT UNSIGNED NOT NULL,
vehicle_id BIGINT NULL,
vin VARCHAR(64) NOT NULL DEFAULT '',
customer_id BIGINT NULL,
contract_id BIGINT NULL,
reason_code VARCHAR(64) NOT NULL,
created_at DATETIME(3) NOT NULL,
PRIMARY KEY (run_id, row_number),
INDEX idx_business_scope_rejection_reason (reason_code, created_at),
CONSTRAINT fk_business_scope_rejection_run
FOREIGN KEY (run_id) REFERENCES business_scope_sync_run(run_id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS business_scope_state (
id TINYINT UNSIGNED NOT NULL PRIMARY KEY,
active_version VARCHAR(96) NULL,
source_checksum CHAR(64) NULL,
candidate_count INT UNSIGNED NOT NULL DEFAULT 0,
accepted_count INT UNSIGNED NOT NULL DEFAULT 0,
rejected_count INT UNSIGNED NOT NULL DEFAULT 0,
generated_at DATETIME(3) NULL,
published_at DATETIME(3) NULL,
last_success_at DATETIME(3) NULL,
CONSTRAINT chk_business_scope_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO business_scope_state(id) VALUES (1)
ON DUPLICATE KEY UPDATE id = VALUES(id);

View File

@@ -0,0 +1,23 @@
[Unit]
Description=Lingniu Vehicle Platform OneOS Scope Sync
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=/opt/lingniu-vehicle-platform/current
EnvironmentFile=/opt/lingniu-go-native/env/base.env
EnvironmentFile=/opt/lingniu-vehicle-platform/env/platform.env
ExecStart=/opt/lingniu-vehicle-platform/current/oneos-scope-sync
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=6
TimeoutStartSec=90
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,13 @@
[Unit]
Description=Run OneOS customer vehicle scope sync every two minutes
[Timer]
OnBootSec=3min
OnUnitActiveSec=2min
RandomizedDelaySec=15s
AccuracySec=1s
Persistent=true
Unit=lingniu-vehicle-oneos-scope-sync.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,77 @@
# 轨迹回放 Design QA
- source visual truth path: `/Users/lingniu/.codex/attachments/70622989-d5e8-4010-9aba-11b1c6974c78/image-1.png`
- implementation route: `http://115.29.187.205:20300/tracks?keyword=LB9A32A23R0LS1529&dateFrom=2026-07-15T00%3A00&dateTo=2026-07-15T23%3A59`
- implementation screenshot path: `/tmp/tracks-production-desktop.png`
- current browser screenshot path: `/tmp/tracks-production-current.png`
- mobile screenshots: `/tmp/tracks-production-mobile.png`, `/tmp/tracks-production-mobile-open.png`
- viewport and state: desktop `2560 × 1285`, loaded vehicle `粤AGR9866`, first point, rail expanded, stop points visible, follow enabled, speed `1×`; current in-app viewport `815 × 874`; mobile `390 × 844`, both map-first collapsed state and query-sheet expanded state.
- source native size: `5120 × 2570`. Browser viewport override uses DPR 1, so the production capture used the exact same 1.992:1 logical aspect at `2560 × 1285`; the source was normalized to that size for comparison. A literal `5120 × 2570` CSS viewport would change the responsive composition instead of reproducing the source's logical viewport.
## Comparison Evidence
- full-view combined comparison: `/tmp/tracks-reference-vs-production.png`
- focused feature-rail comparison: `/tmp/tracks-reference-vs-production-rail.png`
- focused playback-dock comparison: `/tmp/tracks-reference-vs-production-dock.png`
- both the native source and the latest production desktop screenshot were opened with `view_image` in the same QA pass, followed by the combined full, rail, dock, and mobile comparisons.
## Fidelity Ledger
| Surface | Source evidence | Production evidence | Result |
| --- | --- | --- | --- |
| Information architecture | G7 uses a map-first canvas with a fixed query/detail rail and a persistent bottom playback strip. | Production uses the same three-region skeleton: query/detail rail, dominant map canvas, and persistent playback dock. | Matched. |
| Feature rail | G7 exposes vehicle/time query and stop/event detail lists in one continuous left rail. | Production keeps vehicle suggestions, time presets, source selection, stop/event/overview tabs and selectable evidence rows in one rail. | Matched; `概览` intentionally replaces G7's implementation-specific cache tab. |
| Map and route | G7 keeps the map visually dominant, with route, stop markers and a dark current-vehicle overlay. | Production uses the required AMap `whitesmoke` style, full route, passed route, start/end/stop/current markers, follow behavior and a dark telemetry/address card. | Matched interaction model; palette intentionally follows the platform's requested global map style. |
| Playback dock | G7 shows a full-width time strip and centered playback controls. | Production shows segment-colored activity rail, range seek, start/end times, previous/play/next, `0.5×4×`, reset, mileage/source/status metrics. | Matched and functionally expanded. |
| Typography | G7 uses compact utility typography and dense tabular labels. | Production uses the existing platform font stack with compact 814px utility hierarchy on the desktop rail and 16px mobile form controls. | Matched density while preserving product typography. |
| Color and surfaces | G7 has a dark global header, white rail and colorful default map. | Production preserves the existing light global shell, white rail, blue action system, dark telemetry card and `whitesmoke` map. | Intentional product-system deviation; feature hierarchy remains faithful. |
| Icons | G7 uses compact utility icons for map and playback actions. | Production reuses the platform's Semi icon family; mobile tools now retain visible icons and explicit accessible names. | Matched and accessible. |
| Responsive behavior | Source only provides desktop evidence. | Production was verified at `815 × 874` and `390 × 844`; mobile becomes a collapsible bottom sheet without horizontal overflow and preserves the map/dock task. | Passed; mobile is a product extension of the source. |
| Motion | Source implies moving vehicle playback and map following. | Production uses `requestAnimationFrame`, 0.5×/1×/2×/4× speeds, smooth map panning and manual-drag follow cancellation. | Matched and performance-safe. |
## Above-the-fold Copy Diff
The source's functional labels `轨迹回放`, vehicle/time query, `停留点`, `事件点`, distance, and playback controls are retained in product-appropriate Chinese. Intentional additions are `数据来源`, `概览`, `跟随车辆`, `停留点` visibility, `导出`, and bounded-coverage evidence because they expose source priority, playback state, and data completeness required by this platform. G7-specific brand/navigation strings, `缓存`, and its proprietary map tools were not copied.
## Primary Interactions Tested in Browser/IAB
- stop/event/overview tab switching and selecting an event to move the active point;
- playback at `4×`, verified progress advanced from point `0` to point `11`, then pause;
- follow vehicle / free browse toggle;
- stop-marker show/hide toggle;
- desktop and mobile query/detail rail collapse and expand;
- reset to start, speed reset to `1×`, and sample query loading with `1600` returned points;
- mobile toolbar accessible names: `展开查询与明细`, `关闭车辆跟随`, `隐藏停留点`, `导出轨迹 CSV`;
- console errors checked: none.
## Comparison History
### Pass 1 — blocked
- [P1] Mobile map toolbar icons were blank because a broad rule hid every nested `span`, including the Semi icon wrapper.
- Fix: hide only the text span and explicitly retain `.semi-icon` at 17px.
- Post-fix evidence: `/tmp/tracks-production-mobile.png` visibly shows list, follow, stop visibility and export icons.
- [P2] Two-column mobile `datetime-local` inputs clipped the minute value at 390px.
- Fix: stack start and end time fields into one column while keeping 16px touch-safe input text.
- Post-fix evidence: `/tmp/tracks-production-mobile-open.png` shows complete `2026/07/15 00:00` and `2026/07/15 23:59` values.
- [P2] Mobile icon-only controls lost meaningful accessible names when their visible text was hidden.
- Fix: add explicit dynamic `aria-label` values to expand, follow, stop and export controls.
- Post-fix evidence: Browser/IAB resolved each label exactly once.
### Pass 2 — passed
- Full desktop, current in-app viewport, mobile collapsed map and mobile expanded query sheet were recaptured from the final ECS release.
- No actionable P0/P1/P2 layout, typography, color-token, icon, image-quality, copy, interaction, accessibility or responsiveness findings remain.
## Intentional Deviations
- The platform's existing light top bar and vertical application navigation are preserved instead of cloning G7's dark global header.
- The required AMap `whitesmoke` map style is preserved instead of G7's colorful default map.
- A source/quality overview replaces G7's product-specific cache tab; data completeness is exposed as an on-map evidence strip.
- The route color is platform blue with state overlays rather than G7 red/green, keeping consistency with existing monitoring pages.
## Follow-up Polish
- P3: a future product iteration may add a paged sampled-point list if operations users confirm they need row-level point inspection in addition to the seek rail and event list.
final result: passed

View File

@@ -308,6 +308,8 @@ PUT /api/v2/access/thresholds
`summary` and `vehicles` consume the same filter shape, so KPI, protocol/OEM distribution, and rows share one scope. Besides keyword, protocol, OEM and status, both accept substring filters `model` and `provider`, plus paired `firstSeenFrom`/`firstSeenTo` and `latestSeenFrom`/`latestSeenTo` bounds. A supplied pair must be ordered; malformed or reversed timestamps return `ACCESS_TIME_INVALID` or `ACCESS_TIME_RANGE_INVALID`. `onlineState` values are `online`, `offline`, `never_reported`, and `unknown`. Online state is calculated on every request from latest receive/update freshness and the current global/protocol threshold version; the stored snapshot boolean is not reused as the answer.
The authoritative access-management population is the distinct VIN set in `vehicle_identity_binding`; realtime rows without a bound VIN stay in `unresolved-identities` and never inflate the main-vehicle count. Each main vehicle is returned once with the standard expected source slots `GB32960`, `JT808`, and `YUTONG_MQTT`. `expectedProtocols`, `actualProtocols`, `missingProtocols`, and `protocolStatuses` make the expected-versus-actual difference explicit. Every protocol status independently carries provider, first-seen evidence, latest event/receive time, report interval, freshness, delay, and online state. `connectionState` is one of `healthy`, `incomplete`, `degraded`, `offline`, or `not_connected`; the additional query value `attention` returns every non-healthy vehicle for the difference-first operations list.
`unresolved-identities` returns terminals that have real access evidence but no authoritative VIN binding. The current implementation reads JT808 registration evidence and excludes terminals already present in `vehicle_identity_binding`. It exposes only a stable hashed ID and masked terminal identifier, never the raw phone number. Resolve `missing_vin_jt808` by verifying registration evidence and maintaining an authoritative `phone -> VIN` binding; do not infer or fabricate a VIN.
Access rows expose both event and receive time, `dataDelaySec`, `freshnessSec`, the applied `thresholdSec`, latest realtime message semantics, event ID, source table, model/company master data, and evidence notes. The gateway snapshot upsert maintains `access_first_seen_at`, `access_previous_received_at`, `access_latest_received_at`, `access_report_interval_ms`, `access_sample_count`, and the latest received event ID independently from device event-time ordering. A strictly newer receipt with a different event ID advances the projection atomically; duplicates, equal timestamps, replay and older receipts do not change the interval or sample count. The API rounds milliseconds to `reportIntervalSec` and returns `reportSampleCount`, `firstSeenSource`, `firstSeenEvidence`, and `reportIntervalEvidence`. `live_writer` means first observation after the writer began maintaining the row. `snapshot_backfill` is only a deployment baseline reconstructed from the previous current-state row and must not be described as the vehicle's historical first-ever access.

View File

@@ -0,0 +1,194 @@
# 车辆数据中台客户 Scope API 覆盖矩阵
更新时间2026-07-14
状态:安全审计与实施设计;尚未开放客户主体。
## 1. 当前结论
车辆中台当前的 Bearer Token 只表达 `viewer/operator/admin` 功能角色,不表达客户或车辆数据范围。
因此,即使 OneOS 已能登录客户,也不能直接把客户 token 映射成 `viewer`:这会让客户读取全量车辆、轨迹、
告警、统计及其他人的导出文件。
2026-07-14 已只读核验 ECS 当前运行状态:
- `AUTH_MODE=enforce`
- 使用 `AUTH_TOKENS_JSON`,未使用单一 `AUTH_TOKEN`
- `/opt/lingniu-vehicle-platform/env/platform.env` 权限为 `0600 root:root`
- `lingniu-vehicle-platform` 服务已启用且正在运行。
该配置能阻止匿名访问普通平台 API但不能提供客户级隔离。
## 2. 独立发现:高德服务端接口绕过平台鉴权
`app.NewServer` 当前先把平台 API 包在 `withAPIAuth` 中,之后又在外层注册
`withAMapReverseGeocodeAPI`。所以 `/api/map/reverse-geocode` 在进入鉴权中间件前就会被处理;只要配置了
`AMAP_API_KEY`,匿名请求即可消耗服务端高德额度。
整改要求:
- 将逆地理编码路由放回鉴权之后;
- 对客户和内部主体分别限流,并限制经纬度格式和并发;
- 不记录完整 access token
- `app-config.js` 可继续公开,但只能暴露高德 Web JS 公钥和代理路径,不暴露服务端 API Key
- 高德安全代理因浏览器 SDK 需要可访问,应在边缘层配置域名来源、速率和响应缓存,不能把它视为客户身份接口。
## 3. 主体与权限模型
`Principal` 至少扩展为:
```text
name
role # 仅内部功能角色
subject_type # employee | service | customer
subject_id # OneOS sys_user.user_id
customer_id # 仅 customer 必填
tenant_id
identity_version
capabilities # vehicle:read, history:read, export:create ...
```
约束:
- 内部静态 token 只能生成 `employee/service` 主体;
- 客户主体只能来自 OneOS 签名、短时且 `aud=vehicle-data-platform` 的声明;
- 浏览器 Query、Body、Cookie 或普通转发头中的 `customerId` 均无效;
- 客户权限不能通过 `roleRank(viewer)` 复用内部 viewer 权限,需要独立 capability 判断;
- 未知 `subject_type`、缺失 customer ID、签名异常或身份版本失效全部拒绝。
## 4. QueryScope 机制
所有 Store 查询显式接收 `QueryScope`,避免新增方法时忘记从 `context.Context` 读取 Scope
```text
mode: unrestricted | customer_current | customer_period
customer_id
active_version
valid_from / valid_to # 历史持有期查询
```
`unrestricted` 只能由已验证的内部 employee/service 主体创建。空客户 Scope 表示“零车辆”,绝不能解释为不限制。
MySQL 当前数据查询应 JOIN
```text
business_scope_state(id=1)
business_customer_vehicle_scope(
source_version = active_version,
customer_id = principal.customer_id,
vin = 业务表.vin
)
```
同时要求:
- `last_success_at` 距当前时间不超过 10 分钟;
- active version 存在且快照计数完整;
- 快照缺失或过期返回 `503 BUSINESS_SCOPE_UNAVAILABLE`,而不是空列表或全量数据;
- 快照新鲜但该客户确实没有车辆时返回空列表;
- 单车越权统一返回 404避免泄露 VIN 是否存在。
TDengine 无法直接 JOIN MySQL Scope单车查询先在 MySQL 做授权判断;多车查询从同一 active version 读取授权
VIN 集合,设置数量上限后再生成参数化过滤。不能先从 TDengine 取全量再由前端过滤。
## 5. API 覆盖矩阵
### 5.1 客户首期允许,但必须按当前 Scope 过滤
| API 组 | 代表路由 | 必须实施的控制 |
| --- | --- | --- |
| 全局监控 | `/api/v2/monitor/summary``/map` | 车辆、点位、聚合、告警数和所有汇总均从同一客户 Scope 计算;禁止混用全局 DashboardSummary |
| 车辆列表与解析 | `/api/vehicles``/resolve``/detail` | VIN、车牌、终端号解析必须先限定 Scope越权关键字表现为不存在 |
| 最新状态 | `/api/realtime/vehicles``/locations` | 列表、total、分页和筛选均在 SQL 层过滤 |
| 车辆只读档案 | `GET /api/v2/vehicles/{vin}/profile` | 先校验 VIN只返回门户需要的非敏感字段 |
| 最新遥测 | `/api/v2/vehicles/{vin}/telemetry/latest` | 路径 VIN 校验 Scope原始协议帧字段按白名单输出 |
| 轨迹 | `/api/v2/tracks` | 车牌/VIN/终端解析、轨迹点、停留点和统计都使用同一授权 VIN |
| 历史查询 | `/api/history/locations``/api/v2/history/query``/series` | 首期只允许当前车辆且 eventAt 不早于 `scope_start_at`;不能读取交付前数据 |
| 里程 | `/api/mileage/summary``/daily``/api/v2/statistics/mileage` | 列表与汇总按 VIN 和授权时间过滤total 不能泄露全局数量 |
| 在线统计 | `/api/statistics/online-summary``/online-vehicles` | 分母、在线数、离线数、趋势和明细均限定客户车辆 |
| 告警只读 | `/api/v2/alerts/summary``/events``/events/{id}` | 列表 JOIN event.vin Scope详情按 ID+Scope 查询,越权返回 404 |
| 指标元数据 | `/api/v2/metrics``/api/v2/history/metrics` | 可返回全局字段定义,但过滤内部专用/敏感指标 |
| 逆地理编码 | `/api/map/reverse-geocode` | 必须鉴权和限流;不依赖客户参数,不返回其他车辆信息 |
### 5.2 客户首期明确禁止
| API 组 | 路由 | 原因 |
| --- | --- | --- |
| 平台总览 | `/api/dashboard/summary` | 包含全局车辆、帧量、Kafka/服务健康信息 |
| 接入诊断 | `/api/vehicles/coverage*``/api/vehicle-service*` | 包含协议覆盖、终端、原始帧和数据质量诊断 |
| 原始帧 | `/api/history/raw-frames*` | 包含协议证据和内部字段;首期使用白名单遥测/历史 API 代替 |
| 质量中心 | `/api/quality/*`、旧 `/api/alert-events/*` 别名 | 平台级质量问题,不是客户业务告警 |
| 运维 | `/api/ops/*` | 数据源、容量、Kafka、Redis 和服务健康仅内部可见 |
| 接入管理 | `/api/v2/access/*` | 身份绑定、阈值和未解析终端均为内部运维能力 |
| 车辆档案写入 | `PUT /api/v2/vehicles/{vin}/profile``/vehicle-profiles/sync` | 只允许内部管理员/同步服务 |
| 告警规则/处置 | `/api/v2/alerts/rules*``events/{id}/actions` | 首期客户只读,不能修改平台规则或全局事件状态 |
| 平台通知 | `/api/v2/alerts/notifications*` | 当前通知没有客户 owner读取与已读操作均可能跨客户 |
禁止应在服务端 capability 层返回 403不能只隐藏前端菜单。
### 5.3 导出必须单独重构后才能开放
当前导出实现有四个 P0 问题:
1. `CreateHistoryExport` 没有 `context`,创建时无法固化主体;
2. `HistoryExportJob` 没有 owner/customer/scope version
3. `ListHistoryExports` 返回全局任务;
4. `HistoryExportFile(id)` 只校验 ID 和完成状态,知道 ID 即可下载。
整改要求:
- `Create/List/Download` 全部接收 context 和 Principal
- Job 固化 `owner_subject_id``customer_id``scope_version`、已解析 VIN、时间范围和审计 request ID
- 创建任务时校验每个 VIN异步执行前再次校验
- 列表只返回当前 owner下载同时校验 owner 和当前授权,越权返回 404
- 客户停用、车辆授权撤销或 Scope 失效时停止未完成任务并拒绝下载;
- 文件名和 CSV 元数据不得包含其他客户信息;文件按 owner 隔离目录并设置 `0640`
- 定期清理过期文件与 jobs 记录。
在上述改造完成前,客户主体不授予 `export:create``export:download`
## 6. 当前 Scope 与历史 Scope 的边界
已实现的 `business_customer_vehicle_scope` 只保存当前有效车辆。它足以保护实时监控,但不能完整实现“客户还车后
仍可查看持有期间历史”。
首期安全策略:
- 只允许查询当前仍在 Scope 中的车辆;
- 历史起点不得早于当前记录的 `scope_start_at`
- 车辆还车并从当前 Scope 移除后,历史访问也随即关闭。
若产品确认需要持有期历史,再新增版本化区间投影:
```text
customer_id, vin, contract_id, valid_from, valid_to, source_version
```
历史查询必须满足 VIN 和 `valid_from <= event_at < valid_to`。不能仅凭“客户曾经拥有过这辆车”开放该 VIN 的全部历史。
## 7. 必须覆盖的越权测试
准备客户 A、客户 B、内部 viewer、内部 operator、内部 admin 和 service 六类主体,至少覆盖:
1. A 在列表、关键字解析、路径 VIN、POST body、分页游标中请求 B 的车辆;
2. A 直接访问 B 的告警事件 ID、导出 ID 和下载 URL
3. A 使用车牌或终端号绕过 VIN 校验;
4. A 的空 Scope 不返回全量Scope 缺失/过期返回 503
5. 快照切换期间所有查询只使用一个 active version不混合新旧结果
6. total、summary、地图聚合数量和在线率不泄露全局或 B 的数量;
7. A 只能读取交付后的历史点,不能读取交付前记录;
8. 车辆从 A 还车并交付给 B 后A 的实时访问立即 404B 只能读取自身授权时间段;
9. 客户主体访问运维、接入、质量、写档案、规则和处置接口均为 403
10. 匿名逆地理编码失败;认证请求达到限额后返回 429
11. 客户 token 的 customer ID 不能被 Query/Header/Body 覆盖;
12. 服务重启后导出 owner、Scope 和下载授权仍保持,不能退化成全局任务。
## 8. 推荐实施顺序
1. 扩展 Principal 和 OneOS JWT 校验,但暂不开放客户路由;
2. 引入强类型 QueryScope先改车辆解析与单车详情
3. 改所有列表、汇总、地图、统计和 TDengine 查询;
4. 改告警详情 IDOR、逆地理编码鉴权限流
5. 重构导出 owner 与异步 Scope
6. 完成双客户自动化测试和生产影子查询;
7. 最后为测试客户启用 capabilities默认保持客户入口关闭。

View File

@@ -13,6 +13,7 @@ GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../../dist/alert-evaluator ./cmd/alert-evaluator
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../../dist/alert-stream-evaluator ./cmd/alert-stream-evaluator
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../../dist/platform-migrate ./cmd/platform-migrate
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../../dist/oneos-scope-sync ./cmd/oneos-scope-sync
# Optional release/performance gate; this binary is run on demand, not installed as a service.
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../../dist/alert-benchmark ./cmd/alert-benchmark
```
@@ -44,6 +45,10 @@ EXPORT_DIR=/opt/lingniu-vehicle-platform/data/exports
AUTH_MODE=enforce
# JSON array with viewer/operator/admin principals. Keep this file mode 0600.
AUTH_TOKENS_JSON=[{"token":"<at-least-16-random-characters>","name":"ecs-admin","role":"admin"}]
ONEOS_MYSQL_DSN=vehicle_scope_reader:***@tcp(rm-bp179zbv481rnw3e2.mysql.rds.aliyuncs.com:3306)/ln_asset_management?parseTime=true&loc=Asia%2FShanghai
ONEOS_SCOPE_SYNC_TIMEOUT_SEC=60
ONEOS_SCOPE_MAX_REJECTED=100
ONEOS_SCOPE_MAX_REJECT_RATIO=0.10
REQUEST_TIMEOUT_MS=5000
AMAP_WEB_JS_KEY=***
AMAP_SECURITY_JS_CODE=***
@@ -91,6 +96,14 @@ Production smoke must create an export for an active VIN, wait for `completed`,
After editing the environment file, run `chmod 600 /opt/lingniu-vehicle-platform/env/platform.env`. Never put a real token in Git, static JavaScript, shell history or deployment logs.
`ONEOS_MYSQL_DSN` must use a dedicated account with direct table-level `SELECT` grants only. The sync binary runs `SHOW GRANTS FOR CURRENT_USER` before every read and accepts only global `USAGE` plus `SELECT` on the seven tables used by its query. It refuses database/global reads, `ALL PRIVILEGES`, DML, DDL, PROCESS, replication, roles, `SHOW VIEW`, or any other privilege. It then opens a repeatable-read, read-only transaction, applies a 10-second statement timeout, classifies invalid customer/contract relationships, and atomically publishes a content-addressed local snapshot. Never point it at the existing `ln-bi` account: production audit showed that account still has broad write and replication privileges.
Have an RDS administrator review and run `docs/oneos-scope-reader-provision.sql` separately. It restricts the login source to the verified ECS private address and grants `SELECT` on only the seven source tables used by the query. Do not include that DDL in application deployment or migration automation. Store `platform.env` as root-owned mode `0600`, and verify `SHOW GRANTS` before enabling the timer.
The active lifecycle comes from OneOS delivery and return task facts: the newest active delivery must have `delivery_status IN (2,3)`, and it must not have an active return task with `status IN (2,3,5)`. The sync intentionally does not use `vehicle_lease_order_record.last_return_time`, because OneOS currently writes that aggregate field while a return form is still a draft. The aggregate record remains a separate customer-ownership cross-check; missing, duplicate, or conflicting ownership fails closed.
The default two-minute timer reads one bounded current-scope result set (639 rows at the 2026-07-14 transactional-fact baseline), so it avoids per-request access to OneOS and adds negligible RDS load. A run is rejected before publication if it has no accepted rows, more than 100 rejected rows, or a rejected ratio above 10%. Current baseline is 613 accepted and 26 quarantined. An unchanged content hash records a successful run without duplicating the snapshot.
## Forward Migration
Before switching the API symlink, apply the idempotent access-threshold migration with a MySQL account allowed to create/alter platform-owned tables:
@@ -109,7 +122,8 @@ test -n "$MYSQL_DSN"
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/008_access_projection.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/009_alert_stream_checkpoint.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/010_alert_stream_metric_mapping.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/011_alert_stream_invalid_evidence.sql
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/011_alert_stream_invalid_evidence.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/012_business_scope_projection.sql
```
The API guards the access-threshold tables for compatibility, while alert APIs deliberately require the alert migrations to exist. Run every numbered migration explicitly before switching traffic so DDL permission and index creation failures are caught early. The migration journal records filename and SHA-256 and refuses a changed file; duplicate forward `ADD COLUMN` and `CREATE INDEX` statements are tolerated only when resuming partially executed MySQL DDL. Full-line SQL comments are removed before statement splitting, so punctuation in a comment cannot become executable SQL. Migration `008` adds forward-compatible access evidence columns and an index to the gateway-owned realtime snapshot table without changing its `(protocol, vin)` primary key. Migration `009` creates the per-group/topic/partition event-time checkpoint used to make MySQL effects authoritative before Kafka offsets are committed.
@@ -119,12 +133,16 @@ Install and start the evaluator as a separate unit only after API smoke checks a
```bash
sudo cp deploy/systemd/lingniu-vehicle-alert-evaluator.service /etc/systemd/system/
sudo cp deploy/systemd/lingniu-vehicle-alert-stream-evaluator.service /etc/systemd/system/
sudo cp deploy/systemd/lingniu-vehicle-oneos-scope-sync.service /etc/systemd/system/
sudo cp deploy/systemd/lingniu-vehicle-oneos-scope-sync.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now lingniu-vehicle-platform lingniu-vehicle-alert-evaluator lingniu-vehicle-alert-stream-evaluator
sudo systemctl enable --now lingniu-vehicle-platform lingniu-vehicle-alert-evaluator lingniu-vehicle-alert-stream-evaluator lingniu-vehicle-oneos-scope-sync.timer
sudo systemctl status --no-pager lingniu-vehicle-alert-evaluator
sudo systemctl status --no-pager lingniu-vehicle-alert-stream-evaluator
sudo journalctl -u lingniu-vehicle-alert-evaluator -n 100 --no-pager
sudo journalctl -u lingniu-vehicle-alert-stream-evaluator -n 100 --no-pager
sudo systemctl status --no-pager lingniu-vehicle-oneos-scope-sync.timer
sudo journalctl -u lingniu-vehicle-oneos-scope-sync.service -n 100 --no-pager
```
## Health
@@ -162,7 +180,7 @@ curl -fsS "http://127.0.0.1:20300$MAIN_ASSET" >/dev/null
unset PLATFORM_TOKEN AUTH_HEADER
```
The release gate must include both the root document and its hashed main asset. API-only smoke checks are insufficient because an incomplete archive can leave the service healthy while the browser returns 404. Before upload, verify the archive contains `web/index.html`, `platform-api`, both evaluator binaries, `platform-migrate`, every numbered migration and all three platform systemd units.
The release gate must include both the root document and its hashed main asset. API-only smoke checks are insufficient because an incomplete archive can leave the service healthy while the browser returns 404. Before upload, verify the archive contains `web/index.html`, `platform-api`, both evaluator binaries, `platform-migrate`, `oneos-scope-sync`, every numbered migration and all platform systemd units.
When `alertStream.lastInvalidCode` reports a recent `missing_vin_jt808`, query `/api/v2/access/unresolved-identities` as a viewer and hand the masked evidence to the identity owner. The response must not contain raw `phone` or unmasked identifier fields. Confirm the evidence against the GPS provider or vehicle owner, then maintain the authoritative `vehicle_identity_binding`; never derive VIN from the terminal number. The queue excludes a terminal after a valid binding exists. Retain the invalid counter as audit evidence and verify the recent warning clears after five minutes without another invalid frame.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -0,0 +1,97 @@
# OneOS 车辆业务影响矩阵
更新时间2026-07-14
## 1. 结论
车辆数据中台接入 OneOS 不能等同于“把所有里程和位置字段同步回资产库”。OneOS 同时存在实时遥测、人工单据、合同聚合和结算证据;它们含义不同,必须按数据域迁移。
- 实时位置、在线状态、SOC、速度、当日里程、最新总里程以车辆数据中台为权威来源。
- 交车里程、还车里程、异动起止里程和结算里程:保留 OneOS 单据事实,不允许遥测自动覆盖。
- 客户、合同、项目、当前租赁归属、车型和运营状态:以 OneOS 为权威来源。
- 车辆中台只消费最小业务投影,不复制合同金额、联系人、银行账号等敏感字段。
## 2. 当前遥测链路
| 链路 | 当前用途 | 已发现问题 | 建议 |
| --- | --- | --- | --- |
| `LocationUtil``47.99.166.38:20000` | 交车、还车、迷你交车按 VIN 取坐标和总里程 | 地址硬编码;每次新建 HTTP Client失败返回固定坐标而非“无数据”会把故障伪装成真实位置 | P0 禁止固定坐标降级;后续改为调用车辆中台单车快照 API |
| `TruckSyncService` → HASP | 每 10 分钟拉位置、在线、SOC、氢耗、里程 | 全车辆串行请求;依赖车牌/VIN 双接口;状态可能滞后;同时写 `vehicle_status.online_status` | 数据中台稳定后停用拉取任务,改为受控兼容投影或 OneOS 只读调用 |
| `tab_truck_remote_sync_realtime_info` | OneOS 地图、车辆列表位置和里程 | 老表字段多为字符串;总里程单位是米、日里程按 km坐标已转 GCJ-02客户名可能来自 HASP 而非 OneOS | 迁移期只作兼容缓存,不作为客户归属或历史权威来源 |
| `vehicle_realtime_location` | CRUD 型实时位置表 | 当前更像普通业务表,没有持续同步和客户范围 | 不新增依赖;确认无调用方后归档或明确用途 |
| `vehicle_status` | 运营/车辆/证照/保险/在线/里程混合状态 | 前四项是业务状态,后两项是遥测状态,来源混杂 | 保留业务状态;在线和遥测里程逐步改为中台只读展示,不反向覆盖业务状态 |
## 3. 业务域影响
| 业务域 | 主要模块/表 | 与客户车辆范围关系 | 中台接入策略 | 风险级别 |
| --- | --- | --- | --- | --- |
| 客户与账号 | `customer_info``sys_user` | 决定客户主体和启停 | OneOS 认证,向中台传递签名客户身份 | P0 |
| 合同 | `vehicle_lease_contract_info` | 主客户/丙方客户决定有效客户 | OneOS 输出有效客户,不由中台推断合同 | P0 |
| 当前租赁 | `vehicle_lease_order_record` | 决定当前可见车辆 | 修复聚合一致性后输出版本化 Scope | P0 |
| 交车 | `delivery_vehicle`、交车任务 | 形成 Scope 开始时间;保存签署里程和地点 | Scope 只使用已确认交车事实;人工里程不覆盖 | P0 |
| 还车 | `return_vehicle_task` | 形成 Scope 结束;从客户范围移除 | 完成还车后发布新 Scope异常时失败关闭 | P0 |
| 换车/转三方 | `VehicleReplaceRecordService`、合同变更 | 同时改变旧车、新车和有效客户 | 必须做原子或同版本范围切换 | P0 |
| 地图 | `MapTruckController`、旧 realtime 表 | 当前返回全量车辆 | 客户门户使用中台地图OneOS 内部地图后续再迁移 | P0 |
| 车辆列表/详情 | `VehicleInfoMapper` | 混入客户、合同、位置、总里程 | 业务档案继续 OneOS遥测展示改为批量调用禁止逐 VIN N+1 | P1 |
| 事故、违章 | `AccidentInfo``TrafficViolation` | 通过车辆或历史租赁记录关联客户 | 若未来开放客户查看,必须按“事件发生时的客户”而非当前客户授权 | P1 |
| 保险、年检、年审、证照 | 对应 vehicle 业务模块 | 当前以 vehicleId 关联 | 默认仍为内部业务;若开放客户须额外产品授权,不自动随实时车辆 Scope 开放 | P1 |
| 加氢台账 | `HydrogenFuelLedger` | 使用客户、车辆和里程字段 | 台账里程是业务记录;中台遥测只用于对账,不自动改账 | P1 |
| 结算 | `ReturnSettlement` | 使用交还车里程计算费用 | 中台可提供差异证据,不能成为自动覆盖结算的来源 | P0 |
| 异动、调拨、故障 | 多个车辆业务模块 | 保存计划/起止/故障时里程 | 保留业务单据值;按需要显示中台采集值作为旁证 | P1 |
| 运维和接入质量 | 车辆数据中台 | 不属于外部客户默认权限 | 仅内部运维/管理员开放 | P0 |
## 4. 时间语义
必须区分两类车辆权限:
### 当前车辆权限
用于 `/monitor`、当前车辆列表、最新遥测和当前告警。依据当前有效交付关系,车辆还车后立即移除。
### 历史事件权限
轨迹、历史数据和事故/违章存在两种产品口径,不能默认选择:
1. **持有期口径(推荐)**:客户只能查看 `scopeStartAt <= eventAt < scopeEndAt` 的数据。车辆还车后仍可查看租赁期间历史,但不能查看还车后的数据。
2. **仅当前口径**:还车后所有历史立即不可见,实现简单但不利于客户对账。
建议范围投影保留历史有效期,而不是只保存当前 VIN 集合:
```text
customer_id, vin, valid_from, valid_to, contract_id, scope_version
```
任何历史查询都同时校验 VIN 和事件时间。当前快照接口用于实时查询,历史范围接口或增量事件用于回填 `valid_to`
## 5. 坐标与单位
| 字段 | OneOS 当前情况 | 数据中台约定 | 展示规则 |
| --- | --- | --- | --- |
| 原始坐标 | 上游多为 WGS-84 | 中台保存 WGS-84 原始值并标注坐标系 | 高德前端展示前统一转 GCJ-02只转换一次 |
| 旧地图坐标 | `TruckSyncService` 已转 GCJ-02 | 不回写为“原始坐标” | 迁移时避免再次转换造成漂移 |
| `total_mileage` | HASP 老表单位为 m、类型为字符串 | 中台规范为 km、数值类型 | API 字段明确命名 `totalMileageKm` |
| `day_mileage` | OneOS 按 km 使用 | 中台按自然日、时区和来源生成 | 明确 `Asia/Shanghai` 日界线和统计版本 |
| 交还车里程 | 人工/单据 km | 不覆盖 | 可增加 `telemetryMileageKm` 作对照证据 |
## 6. 推荐迁移顺序
1. 修复并审计客户账号、当前租赁聚合和 Scope 接口。
2. 车辆中台完成客户身份、当前/历史 Scope 投影和全 API 隔离。
3. 外部客户门户先只读上线,功能限定为当前监控、持有期轨迹、里程和授权告警。
4. 为 OneOS 提供批量最新遥测 API影子对比 HASP 与旧接口,不改业务单据。
5. 对比位置、在线、SOC、总里程、日里程的覆盖率与延迟达到验收标准后切换 OneOS 地图读取源。
6. 最后停用 `TruckSyncService``LocationUtil` 旧链路;保留可回滚开关,不删除历史表。
## 7. 遥测切换验收
- 连续 7 天 VIN 覆盖率不低于旧链路,关键车辆无遗漏。
- 最新位置 P95 延迟满足业务约定,离线判断与数据上报周期匹配。
- WGS-84 → GCJ-02 只执行一次,抽样点位无系统性漂移。
- 总里程单位和来源明确,无米/公里千倍错误,无异常倒退未告警。
- OneOS 地图使用批量接口,不出现逐车 N+1 调用。
- 数据中台不可用时显示“实时数据暂不可用”,不得展示固定默认坐标或过期数据为实时值。
- 交车、还车、结算、加氢台账等人工事实在切换前后完全不变。
## 8. 本轮不建议改动的业务
在客户车辆范围和遥测切换稳定前,不建议同时改造保险、年检、结算、违章、事故、加氢台账等业务流程。它们可以消费 Scope 或遥测证据,但不应与第一批权限修复绑在一次发布中。

View File

@@ -0,0 +1,206 @@
# OneOS 客户车辆门户变更审批提案
更新时间2026-07-14
状态已停止。2026-07-14 用户明确要求不修改 `ln-asset-management` 任何逻辑此前本地试验改动已全部撤销OneOS 仓库保持干净。本文件仅保留为风险证据,不构成待执行审批项。
## 1. 审批目标
本提案只解决客户只读访问车辆数据所必需的三件事:
1. OneOS 能稳定识别客户账号并输出不可伪造的客户身份;
2. OneOS 的“当前客户车辆”事实不会因合同保存被破坏;
3. 车辆数据中台收到客户身份后,只能访问该客户的车辆 Scope。
车辆锁车、控车以及保险、结算、事故、违章等客户功能不在本批次范围内。
## 2. 当前源码证据
### 2.1 客户车辆 RPC 当前会泄露全量车辆
`ln-asset-management/src/main/java/com/ln/asset/api/impl/RemoteVehicleServiceImpl.java:83`
`listByCustomerId` 忽略 `customerId`,实际只按 `del_flag=0` 查询。其 VO 转换也没有回填
`customerId``contractCode`
该接口在修复前必须保持内部禁用,不能作为车辆数据中台的授权来源。
### 2.2 当前租赁 Mapper 会丢字段
`ln-asset-management/src/main/resources/mapper/VehicleLeaseOrderRecordMapper.xml:6`
`BaseResultMap``Base_Column_List` 缺少以下实体字段:
- `customer_id``project_name`
- `delivery_vehicle_id``last_delivery_name`
- `return_vehicle_task_id``last_return_name`
- `update_by`
使用这些自定义查询取得记录后,上述字段会变成空值。生产审计同时发现聚合记录与最新历史记录有
325 条关键字段差异,不能把空值当成“无业务关系”。
### 2.3 合同保存会清空交还车事实
`VehicleLeaseOrderRecordServiceImpl.java:262``deliveryVehicleId == null` 时,同时清空交车和还车的
单据 ID、时间、里程与操作人。合同提交/更新并不代表交车事实撤销,因此这里违反业务事实的单一写入原则。
交车字段只能由交车完成/撤销流程修改;还车字段只能由还车完成/撤销流程修改。合同更新只能更新客户、合同、
项目和业务组织字段。
### 2.4 客户账号当前不能安全登录
- `CustomerInfoServiceImpl.java:188` 先尝试创建 `sys_user`,之后才保存客户档案;远程调用失败被吞掉,两个库之间不存在事务一致性。
- `SysUserRegistrar.java:27` 给所有业务账号使用共享初始密码 `ln123456`
- `RemoteUserServiceImpl.java:222` 把业务账号开通绑定到公开注册开关 `sys.account.registerUser`
- `UserType.java:14` 只支持 `sys_user``app_user``customer` 会在 Sa-Token 权限解析时抛异常。
- 密码登录按 `tenantId + username` 查询,短信登录默认取同手机号的第一条记录,均不能可靠区分客户账号。
- 更新客户档案不会同步账号;删除客户时账号删除异常同样只记日志。
生产审计结果是 335 个有效客户中,当前客户账号数量为 0因此不能直接批量启用现有逻辑。
## 3. 推荐变更批次
### B1修复客户车辆业务事实`ln-asset-management`
预计修改文件:
- `src/main/resources/mapper/VehicleLeaseOrderRecordMapper.xml`
- `src/main/java/com/ln/asset/modules/contract/mapper/VehicleLeaseOrderRecordMapper.java`
- `src/main/java/com/ln/asset/modules/contract/service/impl/VehicleLeaseOrderRecordServiceImpl.java`
- `src/main/java/com/ln/asset/api/impl/RemoteVehicleServiceImpl.java`
- `ln-asset-api/src/main/java/com/ln/asset/api/RemoteVehicleService.java`
- `ln-asset-api/src/main/java/com/ln/asset/api/domain/vo/RemoteVehicleVo.java`
- 对应单元/集成测试文件
变更内容:
1. 补齐 Mapper 的所有实体字段映射与列清单;禁止“读出空值再全实体更新”覆盖已有事实。
2. 把合同字段更新与交车/还车生命周期更新拆开:合同保存不再修改任何交还车字段。
3. 增加显式的当前 Scope 查询,条件必须同时包含有效车辆、有效客户、有效合同、已交车未还车、
`record.customer_id = COALESCE(contract.other_customer_id, contract.customer_id)`
4. 异常关系返回隔离原因,不为其生成授权记录。
5. 修复 `listByCustomerId`,但将其标注为兼容接口;车辆中台继续使用已实现的最小权限 RDS 快照同步,
不依赖一次 RPC 返回无限列表。
6. 交车、还车聚合更新失败必须让业务事务失败或写入可重试 Outbox不能仅记录日志后继续成功。
明确不做:
- 不把遥测位置、在线状态或总里程回写为交还车业务事实;
- 不恢复 `vehicle_info.customer_id` 作为客户归属;
- 不修改锁车、控车业务。
验收测试:
- 客户 A 查询结果不含客户 B 车辆;空客户 ID 返回空集合;
- 丙方客户存在时只归属 `other_customer_id`
- 未交车、已还车、客户/合同不一致、缺 VIN 的车辆均不授权;
- 合同重复保存前后,交还车单 ID、时间、里程完全不变
- 同一车辆存在多个当前关系时整体隔离,而不是任选一条;
- 本段验收基线已被事务事实影子查询取代;当前只读中台口径为 639 个候选、613 个可发布、26 个隔离。
回滚:
- B1 不删除字段、不改变 Dubbo 方法签名;可按服务版本回滚;
- 新查询与旧接口并存一个版本,车辆中台只读同步可独立停用;
- 数据修复 SQL 与代码发布分开审批,禁止发布脚本自动改写异常关系。
### B2建立客户账号一致性`ln-cloud` + `ln-asset-management`
预计修改文件/模块:
- `ln-cloud/ruoyi-common/ruoyi-common-core/.../UserType.java`
- `ln-cloud/ruoyi-common/ruoyi-common-satoken/.../SaPermissionImpl.java`
- `ln-cloud/ruoyi-api/ruoyi-api-system/.../RemoteUserService.java`
- `ln-cloud/ruoyi-modules/ruoyi-system/.../RemoteUserServiceImpl.java`
- `ln-cloud/ruoyi-modules/ruoyi-system/.../ISysUserService.java`
- `ln-cloud/ruoyi-modules/ruoyi-system/.../SysUserServiceImpl.java`
- `ln-cloud/ruoyi-auth` 新增独立客户认证策略
- `ln-asset-management` 客户账号开通、更新、停用 Outbox/补偿任务
- 新增账号绑定/激活所需数据库迁移与测试
变更内容:
1. 增加明确的 `CUSTOMER("customer")` 类型,并为客户主体返回固定、最小的门户权限,不继承后台菜单权限。
2. 新增内部“业务账号开通”接口,不再复用公开自助注册开关;接口以
`(tenantId, userType, businessId)` 幂等,并校验用户名、手机号冲突。
3. 增加显式业务绑定,不能仅依赖 `sys_user.user_id == customer_info.id` 的偶然约定。
4. 客户档案事务写入 Outbox账号、角色、绑定创建失败可重试、可告警、可人工补偿。前端明确展示“账号待开通”
不再静默成功。
5. 删除共享默认密码。采用一次性随机激活凭证,短时有效、单次使用;激活后立即失效。
6. 新增独立 `customer_password` / `customer_sms` 登录策略。客户类型由受信客户端配置或策略固定,不能由浏览器
任意传入;后台密码登录仍只允许后台账号。
7. 客户停用、删除或合作终止时撤销其会话,并产生身份版本变更事件。
8. 存量补偿先输出冲突清单335 个客户不得按手机号自动合并,审计发现的 4 个手机号匹配必须人工确认。
验收测试:
- 同用户名/手机号存在不同主体类型时,客户登录只能命中 customer
- 客户账号不能使用后台登录策略,后台账号不能使用客户策略;
- 公开注册关闭时,内部业务账号开通仍可按授权调用;
- 重复 Outbox 消息不会创建重复账号、绑定或角色;
- 激活凭证不能复用,日志中不出现明文凭证或密码;
- 删除/停用客户后现有 token 立即失效;
- 客户权限解析不抛 `UserType not found`,且没有后台菜单权限。
回滚:
- 新客户认证使用独立 grant type 和独立客户端,关闭该客户端即可停止入口;
- 绑定表和 Outbox 只新增,不破坏现有后台账号;
- 回滚应用版本前先停止补偿消费者,不删除已经创建的客户账号,只统一停用,避免 ID 被复用。
### B3向车辆中台签发短时身份`ln-cloud/ruoyi-auth`
推荐新增内部/登录后交换接口:
```http
POST /inner/v1/vehicle-platform/token
Authorization: Bearer <oneos-customer-token>
```
服务端从 Sa-Token 会话和业务绑定取得 `userId/customerId/tenantId/userType`,签发最长 5 分钟、
`aud=vehicle-data-platform` 的短时 JWT。浏览器不能提交或覆盖 `customerId`
最低声明:
```json
{
"iss": "oneos-auth",
"aud": "vehicle-data-platform",
"sub": "<sys_user.user_id>",
"subjectType": "customer",
"customerId": "<customer_info.id>",
"tenantId": "<tenant_id>",
"scope": ["vehicle:read"],
"identityVersion": 1,
"iat": 0,
"exp": 0,
"jti": "<random>"
}
```
安全要求:
- 使用非对称签名,车辆中台只持有公钥;密钥 ID 支持轮换;
- 严格校验发行方、受众、算法、`exp/nbf/iat`、主体类型和客户 ID
- 网关删除外部传入的同名身份头;不信任 `X-Customer-Id`
- Scope 投影过期、身份绑定缺失或账号停用时,车辆中台失败关闭;
- 任何越权详情统一返回 404避免泄露 VIN 或告警 ID 是否存在。
回滚:停止 token 交换接口或撤销对应客户端;车辆中台客户入口关闭,内部管理员入口不受影响。
## 4. 数据库变更必须单独审批
在修复 1 组重复聚合记录并确认软删除语义前,不直接添加唯一约束。建议顺序:
1. 输出重复记录和 26 条隔离关系的业务核对清单;
2. 由合同/交还车业务负责人确认每条修复方式;
3. 在预发布库回放合同保存、交车、还车、再次交车和丙方客户用例;
4. 再评估 `vehicle_lease_order_record(vehicle_id)` 唯一约束及 Scope 查询索引;
5. DDL 使用在线方式并准备反向 DDL不与应用代码在同一步骤自动执行。
## 5. 推荐审批顺序
1. B1/B2 已停止,不修改 OneOS 业务逻辑;
2. 先完成车辆中台只读 Scope 投影与异常隔离;
3. 客户身份入口需另行确定可信身份来源并通过双客户越权测试;
4. 最后才进行外部客户灰度。
每个批次独立提交、独立发布、独立回滚。未经用户明确同意,不修改上述 OneOS 文件。

View File

@@ -0,0 +1,180 @@
# OneOS 客户车辆范围内部接口契约(草案)
更新时间2026-07-14
## 1. 目的与安全边界
该接口仅向车辆数据中台提供 OneOS 已确认的客户车辆授权范围,不提供密码,不允许浏览器直连,也不承担实时遥测查询。
- OneOS 是客户、合同、交还车事实和车辆档案的权威来源。
- 车辆数据中台只保存可重建的只读投影。
- 客户 ID 必须来自已验证的 OneOS 会话或内部同步任务,不能采用浏览器请求参数。
- 归属不确定、VIN 缺失、存在多客户冲突的车辆必须放入异常列表,不能进入授权列表。
- 内部请求需要服务身份认证、时间戳、防重放和请求 ID网关必须剥离外部同名身份头。
## 2. 推荐接口
### 查询客户车辆快照
```http
GET /inner/v1/customer-vehicle-scopes/{customerId}?cursor=&limit=500
Authorization: Service <service-token>
X-Request-Id: <uuid>
X-Request-Timestamp: <unix-seconds>
X-Request-Signature: <hmac-sha256>
```
响应:
```json
{
"code": 0,
"data": {
"customerId": "1001",
"scopeVersion": "2026-07-14T12:00:00.123Z/987654",
"generatedAt": "2026-07-14T12:00:00.123Z",
"complete": true,
"nextCursor": null,
"items": [
{
"vehicleId": "2001",
"vin": "LXXXXXXXXXXXXXXXX",
"plateNumber": "沪A00000",
"customerId": "1001",
"contractId": "3001",
"contractCode": "HT20260001",
"projectName": "示例项目",
"modelName": "示例车型",
"brandName": "示例品牌",
"operationStatus": "2",
"scopeStartAt": "2026-07-01T08:00:00+08:00",
"sourceUpdatedAt": "2026-07-14T11:59:58.456+08:00"
}
],
"rejected": [
{
"vehicleId": "2002",
"reason": "VIN_MISSING"
}
]
},
"requestId": "..."
}
```
约束:
- 雪花 ID 一律用 JSON 字符串传输,避免 JavaScript 数字精度丢失。
- `scopeVersion` 对同一业务快照稳定;分页中的每一页必须来自同一个快照。
- `complete=false` 表示快照不完整,车辆中台不得发布该版本。
- `items` 内 VIN 必须非空、标准化为大写并在当前快照内唯一。
- `rejected` 只返回机器可读原因和内部车辆 ID不返回敏感客户信息。
- 建议 `ETag=scopeVersion`,支持 `If-None-Match` 降低同步流量。
### 批量检查客户车辆归属
供登录后单车访问和安全审计使用,不代替快照同步:
```http
POST /inner/v1/customer-vehicle-scopes/check
Content-Type: application/json
{
"customerId": "1001",
"vins": ["LXXXXXXXXXXXXXXXX"]
}
```
响应必须对每个 VIN 返回 `allowed/denied/unknown``unknown` 与接口异常在外部访问场景中均按拒绝处理。
## 3. 当前车辆判定
候选记录至少满足:
```text
vehicle_info.del_flag = 0
vehicle_lease_order_record.del_flag = 0
最新有效 delivery_vehicle.delivery_status IN (2,3)
不存在该交车单对应的有效 return_vehicle_task.status IN (2,3,5)
vehicle_lease_order_record.customer_id = customerId仅作为独立归属交叉校验
VIN 非空且唯一
```
同时必须核验:
- 记录客户等于合同 `COALESCE(other_customer_id, customer_id)`
- 不使用 `vehicle_lease_order_record.last_return_time` 判定当前生命周期OneOS 还车草稿保存会提前写入该聚合字段;
- 聚合记录的合同、交车单和最近历史记录一致;
- 不存在同一 VIN/vehicleId 同时授权给多个客户;
- 合同保存没有覆盖已发生的交还车事实。
生产数据审计完成前,该判定仅为候选口径,不得直接开放外部客户。
## 4. 身份声明契约
客户请求由 OneOS Gateway 完成登录态验证后,可向车辆中台传递短时签名声明:
```json
{
"iss": "oneos-gateway",
"aud": "vehicle-data-platform",
"sub": "1001",
"subjectType": "customer",
"customerId": "1001",
"tenantId": "000000",
"roles": ["customer_vehicle_viewer"],
"iat": 1784001600,
"exp": 1784001900,
"jti": "..."
}
```
车辆中台必须校验发行方、受众、签名、有效期和 `jti`,并把 `customerId` 写入请求上下文。禁止从 Query、Body 或普通转发头覆盖它。
## 5. 车辆中台投影
建议表:
```text
business_customer
customer_id, status, source_version, synced_at
business_customer_vehicle_scope
customer_id, vin, vehicle_id, contract_id, scope_start_at,
source_version, source_updated_at, published_at
business_scope_sync_run
run_id, customer_id, source_version, status,
received_count, accepted_count, rejected_count, started_at, finished_at, error
business_scope_audit
request_id, principal_id, customer_id, action, vin, decision, reason, created_at
```
唯一约束至少包括 `(customer_id, vin)`;发布新版本时应在事务中整体切换,不能边分页边覆盖当前版本。
## 6. 失败策略
- 首次同步失败:客户门户不可用,不回退到全量车辆。
- 已有快照过期:达到配置的最大陈旧时间后拒绝访问,并告知“业务车辆范围同步异常”。
- 单车不在范围:统一返回 404避免泄露 VIN 是否存在。
- 多客户冲突或 VIN 缺失:隔离并告警,不授权。
- OneOS 账号停用:会话校验立即失败;车辆中台短时声明最长建议 5 分钟。
## 7. 验收用例
1. 客户 A 无法通过 VIN、车牌、导出、轨迹、告警 ID 或分页游标访问客户 B 的车辆。
2. 车辆还车后,新快照发布即从客户范围移除;旧 URL 也返回 404。
3. 车辆再次交付给另一客户后,仅新客户可见。
4. `other_customer_id` 存在时,只授权给有效丙方客户。
5. 缺 VIN、多客户冲突、聚合与合同不一致的车辆不进入任何外部范围。
6. OneOS 不可用、签名错误、Scope 过期或分页版本变化时全部失败关闭。
7. 1,000 台车辆的快照分页结果无重复、无遗漏,条件请求可返回 304。
8. 所有拒绝决策保留 request ID、主体、VIN 摘要和原因,且不记录访问令牌。
## 8. 兼容与回滚
- 保留现有 Dubbo `RemoteVehicleService`,但在修复前明确标记 `listByCustomerId` 不可用于外部授权。
- 新 HTTP 内部接口使用独立 `/inner/v1` 前缀,不改变现有页面接口。
- 先以影子模式计算快照并与人工样本对账,再允许车辆中台消费。
- 若上线后异常,关闭客户入口并回退到上一个已验证 Scope 版本;不得回退为“无 Scope 查询”。

View File

@@ -0,0 +1,301 @@
# OneOS 车辆业务与车辆数据中台集成分析
完整系统、模块、接口、安全、数据库和运维分析见 `docs/oneos-ln-asset-management-system-analysis.md`。本文件聚焦车辆数据中台集成边界。
更新时间2026-07-14
生产数据库聚合审计结果见 `oneos-production-audit-report.md`
涉及 OneOS 的精确修改文件、批次、测试和回滚边界见 `oneos-change-approval-proposal.md`;该提案尚未执行。
车辆数据中台全部 API 的客户 Scope、IDOR、导出和地图服务覆盖矩阵见 `customer-scope-api-enforcement-matrix.md`
## 1. 分析范围与约束
- OneOS 根目录:`/Users/lingniu/project/ai-coding/oneos`
- 核心业务服务:`ln-asset-management`,分支 `dev`
- 相关身份服务:`ln-cloud/ruoyi-auth``ln-cloud/ruoyi-system`
- 本轮仅进行只读分析,没有修改 OneOS 文件、数据库或运行配置。
- 车辆锁车、控车不在本方案范围内。
## 2. 系统职责边界
详细业务影响与遥测迁移边界见 `oneos-business-impact-matrix.md`
### OneOS 应作为权威来源
- 客户档案:`customer_info`
- 客户账号及启停:`ry-cloud.sys_user`
- 车辆基础信息:`vehicle_info``vehicle_model``vehicle_status`
- 合同客户:`vehicle_lease_contract_info`
- 当前车辆交付/归还状态:`vehicle_lease_order_record`,必要时结合交还车任务表复核
- 业务筛选维度:运营状态、车型、品牌、运营公司、业务部门、合同和项目
### 车辆数据中台应作为权威来源
- 多协议接入状态及最后上报时间
- 实时位置、车速、SOC 和车辆告警
- 历史轨迹和原始数据证据
- 按 VIN 归并后的每日里程、统计期里程和最新总里程
- 协议数据质量与异常诊断
OneOS 当前仍通过 HASP/旧车联网接口拉取位置和总里程,其中 `/vehicle-info/page?withTotalMileage=true` 会按 VIN 逐车调用外部 HTTP 接口,存在 N+1 请求。新集成不应让车辆中台反向依赖这条旧链路。
## 3. 已确认的数据关系
### 权威表与用途
| 数据域 | 表 | 主键/关联 | 集成用途 | 注意事项 |
| --- | --- | --- | --- | --- |
| 客户档案 | `customer_info` | `id` | 客户名称、编码、合作状态 | 含联系人、银行等敏感字段,范围接口只取最小必要字段 |
| 客户账号 | `ry-cloud.sys_user` | `user_id = customer_info.id` | 登录、启停、用户类型 | 账号可能缺失;当前 `user_type=customer` 未被权限枚举支持 |
| 账号角色 | `ry-cloud.sys_user_role``sys_role` | `user_id``role_id` | 客户门户功能权限 | 新客户当前不自动分配角色 |
| 车辆档案 | `vehicle_info` | `id``vin` | VIN、车牌、车型、运营组织 | 实体已不再保存客户归属VIN 必须核验空值和重复值 |
| 车辆状态 | `vehicle_status` | `vehicle_id` | 运营、车辆、证照、保险状态 | `online_status`、里程仍混有旧链路数据,不作为中台遥测权威值 |
| 合同 | `vehicle_lease_contract_info` | `id``order_id` | 主客户、丙方客户、合同和项目 | 有效客户为 `COALESCE(other_customer_id, customer_id)` |
| 当前租赁聚合 | `vehicle_lease_order_record` | 设计上一车一行 | 当前客户、合同、最近交还车事实 | Mapper 不完整、写入有清空风险,必须审计后使用 |
| 租赁历史 | `vehicle_lease_order_record_log` | `contract_id + vehicle_id` | 归属和交还车复核 | 是可追溯证据之一,但当前按组合更新,并非不可变事件日志 |
| 交车事实 | `delivery_vehicle` | `vehicle_id``order_detail_id` | 核验交车时间、里程和单据 | 完成状态枚举需与生产字典确认 |
| 还车事实 | `return_vehicle_task` | `vehicle_id``contract_id` | 核验还车时间、里程和完成状态 | 多条完成路径会更新聚合表,部分异常不中断主流程 |
车辆数据中台不应复制 `contact_mobile`、身份证/信用代码、银行账号、密码等字段。客户投影只需要客户 ID、展示名称、账号状态、业务版本和同步时间。
### 客户与账号
新增客户时,`customer_info.id` 使用雪花 ID并尝试以同一个 ID 创建 `sys_user.user_id`
```text
customer_info.id = sys_user.user_id
sys_user.user_type = customer
```
但账号创建失败只记录日志,不阻断客户保存,因此该关系不是天然完整的,必须进行覆盖率核验和补偿。
### 车辆与客户
2026-03-27 的 `9a8e2a5f` 提交将以下业务归属字段从 `VehicleInfo` 实体中移除:
- `customerId`
- `contractCode`
- `businessId`
- `businessDeptId`
- `contractType`
代码注释明确这些字段改由 `vehicle_lease_order_record` 提供。因此不能再把 `vehicle_info.customer_id` 当作唯一权威口径,即使旧数据库或旧文档仍保留该列。
合同存在甲方客户和丙方客户,现有业务代码的有效客户规则为:
```text
effectiveCustomerId = other_customer_id != null ? other_customer_id : customer_id
```
客户车辆范围还必须考虑交还车边界:只有已经交车且尚未还车的车辆,才能视为客户当前可见车辆。仅按 `vehicle_lease_order_record.customer_id` 查询会把已经归还的车辆继续授权给客户。
建议的当前客户车辆口径已调整为事务事实优先:
```text
vehicle_info.del_flag = 0
AND vehicle_lease_order_record.del_flag = 0
AND effective_customer_id = 当前客户
AND 最新有效 delivery_vehicle.delivery_status IN (2,3)
AND 不存在该交车单对应的 return_vehicle_task.status IN (2,3,5)
```
`vehicle_lease_order_record` 只用于客户归属交叉校验,不使用 `last_return_time` 判断生命周期,因为还车草稿会提前写入该字段。生产影子查询已验证该条件,仍需由业务抽样确认换车、合同变更、丙方客户、重复交还车和历史补录场景。
### 订单记录的可信度限制
`vehicle_lease_order_record` 的设计目标是“每辆车一条当前记录”,但当前实现还不能未经校验就作为唯一授权依据:
- `VehicleLeaseOrderRecordMapper.xml``BaseResultMap``Base_Column_List` 没有 `customer_id``project_name`、交还车单 ID、交还车人等实体字段。调用 `selectByVehicleId``selectByVehicleIds` 等自定义查询时,这些字段会丢失。
- `saveOrUpdateForContract()``deliveryVehicleId == null` 时会清空已有交车和还车字段。合同提交、重新提交、审批通过等多条路径都会调用该方法,若车辆此前已经交付,可能暂时或永久破坏当前租赁状态。
- 还车提交路径先写入还车时间,完成路径又可能清空当前合同和客户字段;不同查询不能仅以 `customer_id IS NOT NULL` 或仅以时间字段判断。
- 交还车更新失败只记录日志、不中断主流程,业务任务成功不代表聚合记录一定成功同步。
因此阶段 A 需要同时以当前聚合表、历史表、交车单、还车任务和合同表交叉核对。正式的范围快照接口应在一个受测试的查询服务中封装口径,并对异常记录失败关闭,不能让各调用方直接读取聚合表自行解释。
## 4. 已发现的高风险缺口
### P0现有客户车辆远程接口会返回全量车辆
`RemoteVehicleService.listByCustomerId(customerId)` 的客户过滤已被注释,当前实现实际查询全部未删除车辆;`RemoteVehicleVo.customerId``contractCode` 的回填也被注释。
该接口在修复前不得用于任何外部客户权限,否则存在严重跨客户数据泄露风险。
### P0客户账号体系尚不能直接用于车辆数据中台
- `customer_info` 创建成功不代表 `sys_user` 一定创建成功。
- 新客户账号没有分配角色或菜单权限。
- `sys_user.user_type=customer`,但 `UserType` 枚举只识别 `sys_user``app_user`
- Sa-Token 权限解析会调用 `UserType.getUserType()`,客户账号触发权限判断时可能抛出类型不存在异常。
- 车辆数据中台当前只支持静态 Bearer Token 和 `viewer/operator/admin`Principal 中没有 `customerId``tenantId` 或 VIN 范围。
- 客户账号创建复用了公开注册 RPC该 RPC受 `sys.account.registerUser` 开关控制。生产若关闭自助注册,客户档案仍会成功但账号创建必然失败。
- 所有业务账号使用相同初始密码 `ln123456`,没有发现首次登录强制改密或一次性激活机制。外部客户上线前必须废弃该做法。
- 通用密码登录按 `tenantId + username` 查询,不按 `user_type` 区分;客户编码必须在租户内与其他类型账号全局唯一,并需要防止账号类型混淆。
推荐新增“业务账号开通”内部能力,不再复用公开注册开关:
1.`(tenant_id, user_type, business_id)` 幂等开通客户账号;`business_id` 对客户即 `customer_info.id`
2. 同一事务或 Outbox 中完成账号、客户角色和业务绑定;失败可重试并可见,不再吞异常。
3. 使用一次性激活链接或随机临时口令,首次登录强制设置密码;禁止共享默认密码。
4. 登录成功后校验账号主体类型为 `customer`,并从服务端绑定关系取得 `customerId`
5. 客户停用、合作终止、账号删除时撤销会话并让车辆中台短时声明快速失效。
6. 存量补偿任务只处理缺失/异常账号,输出成功、冲突、跳过清单,不覆盖现有密码。
### P0OneOS 地图和车辆查询没有客户级数据隔离
- `MapTruckController` 的列表、搜索、详情和信息树均未按客户过滤。
- 地图 SQL 默认返回所有符合运营状态的车辆。
- `VehicleInfoMapper` 仅声明业务部门数据权限;业务专员/主管的默认过滤逻辑当前被整体注释。
- 客户账号既没有部门数据范围,也没有独立客户数据范围。
### P1代码、文档和映射存在漂移
- `VehicleInfo` 实体已移除客户和合同字段。
- `VehicleInfoMapper.xml` 的基础 ResultMap 和字段清单仍包含旧字段。
- `docs/VehicleInfo.md` 仍描述 `vehicle_info.customer_id` 并给出过时接口参数。
- 部分详情服务明确把客户和合同字段设置为 `null`
- `RemoteContractMatchService.matchByVehicleAtTime` 已处理交还车时间边界,但合同命中时直接返回 `contract.customerId`,没有采用丙方优先的有效客户规则。
- `VehicleLeaseOrderRecordMapper.xml` 未完整映射实体字段,自定义查询取得的 `customerId` 等值可能为 `null`
- 合同保存会重置交还车字段,而交还车聚合更新异常又不会阻断主流程,存在聚合状态与真实业务任务不一致的窗口。
### P0车辆中台现有授权是“功能角色”不是“数据范围”
当前 `Principal` 只有 `Name``Role`,鉴权中间件仅按 `viewer/operator/admin` 判断 HTTP 方法与功能权限。Store 接口和 SQL 构造器没有客户或 VIN Scope`PrincipalFromContext` 也只被用于写操作的审计 Actor。
这会产生以下具体越权面:
| API 组 | 当前行为 | 客户开放前要求 |
| --- | --- | --- |
| 监控、车辆、实时位置 | 全量查询,可按 VIN/车牌筛选 | 所有列表、汇总、点位、详情统一限定授权 VIN |
| 轨迹、历史、原始帧、里程 | 接受调用方传入 VIN/关键字 | 先校验 VIN Scope再访问 MySQL/TDengine空范围返回空不返回全量 |
| 告警 | 告警列表只按业务筛选;详情直接按事件 ID 查询 | 列表和详情都通过事件 VIN 校验,越权统一返回 404 |
| 导出 | 任务没有 owner/customerId列表展示全局任务知道 ID 即可下载 | 任务固化创建者、客户、VIN Scope 和快照版本;列表/下载校验所有权 |
| 运维质量、接入管理、规则配置 | 只按 viewer/operator/admin 控制 | 外部客户主体默认禁止,不能因拥有 viewer 角色而开放 |
| 车辆档案写入、同步 | 管理员可写 | 仅内部管理员或受信同步服务可用,客户主体永远禁止 |
尤其是导出任务当前保存在全局 `exports` Map 和 `jobs.json``ListHistoryExports()` 不区分创建者,`HistoryExportFile(id)` 也不校验主体;告警详情同样存在按 ID 直接读取的问题。这两处在客户入口上线前必须按 P0 修复。
## 5. 推荐集成架构
### 身份链路
外部客户登录仍由 OneOS 管理OneOS 负责账号启停和密码策略。车辆数据中台不复制客户密码。
推荐两种方案,优先级如下:
1. OneOS Gateway 验证 Sa-Token 后,向车辆数据中台转发由共享密钥签名的身份声明,包含 `userId``customerId``tenantId``role`、过期时间和请求 ID。
2. 车辆数据中台收到 OneOS Access Token 后,调用 OneOS 内部会话校验接口获得同样的身份声明。
禁止信任浏览器直接传入的 `customerId`、客户名或 VIN 列表。
### 车辆范围同步
OneOS 对外提供内部服务接口,返回客户当前授权车辆快照及版本:
```json
{
"customerId": 1001,
"version": "2026-07-14T12:00:00.000Z",
"vehicles": [
{
"vehicleId": 2001,
"vin": "...",
"plateNumber": "...",
"operationStatus": "...",
"modelName": "...",
"brandName": "...",
"contractId": 3001,
"contractCode": "...",
"projectName": "...",
"scopeStartAt": "..."
}
]
}
```
车辆中台保存只读投影,例如:
- `business_customer`
- `business_customer_vehicle_scope`
- `business_scope_sync_run`
- `business_scope_audit`
所有车辆 API 在 Store/SQL 层统一追加 VIN Scope不能逐个页面自行过滤也不能先查询全量后在前端过滤。
推荐把范围变成显式的 `QueryScope` 参数,而不是只放在 `context.Context`Store 接口若没有 Scope 参数就无法编译从机制上减少新增查询忘记加权限的概率。MySQL 查询优先 JOIN 本地范围投影表TDengine 单车查询先做 VIN 授权检查,多车查询使用已授权 VIN 集合并设置数量上限。空 Scope 对外部客户必须恒等于“无数据”,绝不能解释为“不限制”。
### 数据请求原则
- 身份声明决定客户 ID。
- 客户 ID 决定 VIN Scope。
- VIN Scope 再约束监控、车辆、轨迹、历史、里程、告警、导出和地图聚合查询。
- 内部管理员可显式选择客户;外部客户不能覆盖身份中的客户 ID。
- 当 Scope 服务不可用或同步数据过期时,外部访问应失败关闭,不得降级为全量数据。
## 6. 分阶段实施
### 阶段 A只读数据审计
- 执行 `oneos-readonly-audit.sql`
- 确认生产表结构是否仍有 `vehicle_info.customer_id`
- 统计客户账号覆盖率、启用率、角色覆盖率。
- 统计当前交付车辆的 VIN 完整率、客户完整率和客户档案匹配率。
- 抽样验证丙方客户、换车、已还车和多合同场景。
验收:形成数据质量基线,业务负责人确认“当前客户车辆”的唯一口径。
### 阶段 B修复 OneOS 权威接口和账号能力
该阶段已停止:用户明确要求不修改 `ln-asset-management` 任何逻辑。以下内容仅保留为已知风险清单,不执行。
- 修复 `listByCustomerId`,按有效客户和交还车边界查询。
- 增加批量客户车辆快照接口,返回 VIN 和业务版本。
- 统一有效客户规则,覆盖丙方客户。
- 修复订单记录 Mapper 字段遗漏,并禁止合同更新覆盖已经发生的交还车事实。
- 对范围异常记录给出隔离清单,不将不确定归属车辆授权给任何外部客户。
- 补充客户账号类型、角色、启停和存量账号补偿。
- 新增专用业务账号开通能力,移除共享默认密码和对公开注册开关的依赖。
- 增加双客户越权测试。
验收:客户 A、B 的车辆集合互斥;已还车辆立即退出范围;接口默认失败关闭。
### 阶段 C车辆数据中台身份和 Scope 投影
- 扩展 Principal加入主体类型、外部用户 ID、客户 ID 和租户 ID。
- 增加 OneOS 身份校验或可信代理声明校验。
- 建立客户与车辆范围投影、版本和审计表。
- 在底层查询构建器统一注入 VIN Scope。
- 覆盖监控、车辆、轨迹、历史、统计、告警、导出、地图聚合和反向地理编码相关入口。
验收:任何 URL 参数、VIN 搜索、批量接口和导出均不能越权Scope 过期时外部请求拒绝访问。
### 阶段 D客户门户和入口
- 外部子域名提供独立登录页。
- 内部 OneOS 菜单以新标签页进入车辆数据中台。
- 外部页面隐藏接入配置、运维质量和管理型功能。
- 客户页默认展示授权车辆数量、实时地图、轨迹和里程统计。
验收:内部入口统一,外部入口独立;账号停用后现有会话及时失效。
### 阶段 E历史里程补录
- 定义补录文件格式和来源平台。
- 以 VIN、自然日、来源和导入批次保证幂等。
- 导入约 20 天缺失里程并输出成功、冲突、缺 VIN 和异常跳变报告。
- 不伪造无法取得的历史轨迹,页面明确轨迹数据起始时间。
验收:补录前后总量可对账,重复导入不重复累计,每条补录记录可追溯。
## 7. 需要修改 OneOS 前必须同步的范围
预计至少涉及以下模块,尚未进行任何修改:
- `ln-asset-management/ln-asset-api`
- `ln-asset-management/src/main/java/com/ln/asset/api/impl/RemoteVehicleServiceImpl.java`
- `ln-asset-management` 的车辆/合同 Mapper 与 Service
- `ln-cloud/ruoyi-common-core` 的用户类型定义
- `ln-cloud/ruoyi-auth` 的客户登录策略
- `ln-cloud/ruoyi-system` 的客户账号角色和状态管理
- 可能新增 OneOS 内部 HTTP API、数据库索引和菜单脚本
修改前需要先同步具体文件、接口契约、SQL 变更、兼容方案、回滚方案和测试范围。

View File

@@ -0,0 +1,398 @@
# OneOS 与 ln-asset-management 系统分析
更新时间2026-07-14
分析方式:源码静态分析、生产数据库只读元数据与聚合核验
修改边界:本报告未修改 `/Users/lingniu/project/ai-coding/oneos`。用户已明确要求不修改 `ln-asset-management` 任何逻辑;所有建议均需另行同步和批准。
## 1. 结论先行
`ln-asset-management` 不是单一车辆档案服务,而是 OneOS 的资产和车辆业务单体,覆盖车辆主档、租赁合同、交车、还车、换车、应收、结算、保险、证照、异常异动、维修故障、加氢、备件、培训、消息与工作台等领域。车辆数据中台不能把它当作实时遥测服务,也不能直接把它的现有 REST/Dubbo 接口公开给客户。
推荐的系统职责是:
- OneOS车辆业务身份、客户、合同、交还车、业务状态的权威来源
- 车辆数据中台TDengine/Redis/Kafka 中遥测、轨迹、在线状态、告警和分析的权威来源;
- 两者之间:使用最小权限只读快照,不在客户请求链路上实时查询 OneOS不信任客户端传入的客户 ID
- 当前客户车辆生命周期:以完成的 `delivery_vehicle``return_vehicle_task` 事务事实判断,`vehicle_lease_order_record` 仅用于客户归属交叉校验。
当前不能安全公开 OneOS 接口,主要原因包括:
1. `RemoteVehicleService.listByCustomerId` 当前忽略客户 ID存在返回全量车辆风险
2. 网关能证明“已登录”,但 93 个业务 Controller 中只有 1 个检出细粒度权限注解,不能证明接口级或客户级授权完整;
3. 全局 API 日志拦截器会记录请求头、请求体和响应体,且未像注解切面一样脱敏 Authorization、Token 和 Cookie
4. 注解接口同时被全局拦截器与 `@ApiLog` 切面记录,存在重复日志;两个月日志表约占生产库数据体积 83%
5. 租赁聚合表会被合同保存和还车草稿等非最终业务动作影响,不能单独作为授权真值;
6. 核心交车、还车和合同类达到 2,7006,000 行,自动化测试没有覆盖核心生命周期;
7. 24 个定时任务未发现分布式锁,服务多副本部署存在重复执行风险。
## 2. 仓库与运行架构
`/Users/lingniu/project/ai-coding/oneos` 下是四个独立 Git 仓库,而不是一个单仓:
| 仓库 | 主要职责 | 与车辆数据中台关系 |
| --- | --- | --- |
| `ln-cloud` | RuoYi Cloud 网关、认证、用户、组织、字典、资源、工作流、Nacos 等 | 身份和组织来源;外部客户身份尚未建立 |
| `ln-asset-management` | 车辆和资产全生命周期业务 | 车辆业务主档和客户 Scope 来源 |
| `ln-energy-v2` | 能源及财务类远程能力 | OneOS 内部依赖,不作为遥测主源 |
| `ln-ocr` | OCR 能力及共享 API | 保险、证照等业务依赖 |
`ln-asset-management` 技术基线:
- Java 21Spring Boot 3.5.10Spring Cloud 2025.0.0
- MyBatis-Plus 3.5.15MySQL Connector/J 9.5
- Nacos 配置与注册、Dubbo 服务、Sa-Token、RabbitMQ、异步和定时任务
- 主服务端口 8701网关路由 `/asset/**` 后去除一层前缀;
- Dockerfile 默认 `test` profile生产实际配置依赖 Nacos 覆盖;
- Maven 依赖公司私有 SNAPSHOT 包,本机缺少仓库凭证时构建会在依赖解析阶段收到 401
- 仓库没有发现 Maven Wrapper、CI 工作流、docker-compose 或完整生产运行手册。
源码规模基线:
| 指标 | 数量 |
| --- | ---: |
| Java 代码 | 约 181,425 行 |
| 业务 Controller | 93 个(按源码注解统计;命名扫描为 95 个) |
| 显式 HTTP 方法 | 约 815 个 |
| Mapper | 约 161 个 |
| `@TableName` 实体映射 | 158 个 |
| Dubbo 服务实现 | 20 个 |
| 含 Dubbo 引用的文件 | 85 个 |
| 定时任务文件 | 24 个 |
| 测试文件 | 30 个、约 4,372 行 |
## 3. 业务领域清单
### 3.1 车辆主档与运营状态
模块路径存在历史拼写 `modules/vihicle`,但它是车辆核心模块,共 11 个 Controller、约 107 个 HTTP 方法。
主要能力:
- `VehicleInfoController`:车辆档案、查询、导入导出;
- `VehicleModelController`:车型;
- `VehicleRealtimeLocationController`OneOS 内部实时位置投影;
- `PrepareCarController`:备车;
- `VehicleLicenseController`、批量上牌:证照和上牌;
- `VehicleTransferController`:调拨;
- `AftermarketDeviceController`:后装设备;
- `AccidentInfoController`:事故;
- `VehicleCheckItemController``VehicleMaintenanceItemController`:检查和维保配置。
主要表:`vehicle_info``vehicle_model``vehicle_status``vehicle_realtime_location``prepare_car``vehicle_transfer``vehicle_license``aftermarket_device``accident_info``tab_data_attachment` 等 19 张显式映射表。
权威性判断:
- 车辆身份以 `vehicle_info.id` 和标准化 VIN 为主;
- 车牌会变化,不能作为跨系统唯一键;
- `vehicle_status` 是 OneOS 业务运营状态,不等于车联网在线状态;
- `vehicle_realtime_location` 和地图同步表是 OneOS 的展示投影,实时遥测仍应以车辆数据中台 Redis/TDengine 为准;
- `vehicle_info.customer_id` 不是可靠客户归属来源,客户关系需通过合同和交还车事实确定。
### 3.2 租赁合同、交车、还车和换车
`contract` 是最大领域21 个 Controller、约 237 个 HTTP 方法、46 张显式映射表。
主要链路:
```text
客户/合同
-> vehicle_lease_contract_info
-> vehicle_lease_order / vehicle_lease_order_detail
-> delivery_task_subject / delivery_vehicle
-> 正式交车完成
-> 客户当前持有车辆
-> return_vehicle_task 或 vehicle_replacement
-> 正式还车/换车完成
-> 应收、还车付款、还车结算、发票
```
关键事实表:
- 合同:`vehicle_lease_contract_info``vehicle_lease_contract_log`
- 合同车辆:`vehicle_lease_order``vehicle_lease_order_detail`
- 交车:`delivery_task_subject``delivery_vehicle``delivery_vehicle_check_item`
- 还车:`return_vehicle_task``return_vehicle_check_item`
- 换车:`vehicle_replacement`
- 当前聚合与历史:`vehicle_lease_order_record``vehicle_lease_order_record_log`
- 财务:`receivable_*``return_payment*``return_settlement*``rental_invoice`
当前客户车辆的安全口径:
```text
最新有效 delivery_vehicle.delivery_status IN (2, 3)
AND delivery_time IS NOT NULL
AND 不存在该交车单对应的有效 return_vehicle_task.status IN (2, 3, 5)
AND vehicle_info 有效且 VIN 非空
AND customer_info 有效
AND vehicle_lease_contract_info 有效
AND vehicle_lease_order_record.customer_id
= COALESCE(contract.other_customer_id, contract.customer_id)
AND 同一车辆和标准化 VIN 不存在多个当前关系
```
不能仅使用 `vehicle_lease_order_record.last_delivery_time/last_return_time`:还车草稿保存会提前写 `last_return_time`;合同保存也可能覆盖交还车聚合字段;部分聚合更新失败只记录日志。用户已要求不修改这些 OneOS 逻辑,因此车辆数据中台必须在读取侧绕开并失败关闭。
### 3.3 客户、司机与身份
- `customer_info`:业务客户档案;
- `driver_info`:司机档案、黑名单、手机号和证件;
- `ln-cloud/ry-cloud.sys_user`:登录账号;
- 业务客户 ID 与 `sys_user.user_id` 没有稳定映射,生产只读审计未发现可直接使用的客户账号体系;
- 手机号只能作为人工核对线索,不能自动建立授权关系;
- 合同有甲方和丙方客户,当前有效客户规则为 `COALESCE(other_customer_id, customer_id)`
结论:车辆数据中台内部管理员可以继续使用独立平台令牌;外部客户入口在可信身份映射建立前不能开放。
### 3.4 地图与外部定位
`map` 模块有 5 个 Controller字典、组织、站点、车辆和地理编码。
数据和依赖:
- `tab_truck_remote_sync_realtime_info`:车辆地图投影;
- `tab_outside_hydrogen_site`:站点投影;
- Hasp、远程实际车辆接口和高德 REST API
- `MapSyncTask` 定期同步车辆与站点。
结论OneOS 地图适合内部业务展示,不适合作为车辆数据中台遥测源。两套地图需要统一 WGS-84 到 GCJ-02 的显示转换,但原始存储坐标必须保留来源和坐标系标识。
### 3.5 资产后市场与合规
覆盖领域包括:
- 保险采购、保单、OCR14 张表、约 69 个 HTTP 方法;
- 车辆异常异动:`vehicle_abnormal_move*`
- 故障:`vehicle_fault_manage`
- 年检、年审、车辆证书、交通违章;
- 维修站、救援队、检查站;
- 备件、出入库和仓库;
- 培训材料与培训记录。
这些业务可以消费车辆中台的里程、位置、在线、告警证据,但车辆中台不能反向改写合同、保险、证照、结算或事故事实。涉及锁车、控车的能力不在本分析和实施范围。
### 3.6 能源与工作流
- 加氢站、加氢台账、加氢场站、充电站;
- 消息、通知规则、待办任务和工作台;
- 通过 `RemoteWorkflowService``RemoteUserService``RemoteDictService``RemoteFileService` 等 Dubbo 服务依赖 `ln-cloud`
- 定时生成账单、合同到期提醒、证照到期提醒、台账状态同步等。
## 4. 接口与安全边界
### 4.1 已有安全机制
- 网关 `AuthFilter` 对非白名单请求执行 `StpUtil.checkLogin()`
- 微服务 `SecurityConfiguration` 可通过 Same-Token 验证请求是否由网关转发;
- actuator 使用 Basic Auth
- 网关把 `/asset/**` 转发到 `ln-asset-management`
这些机制只能证明请求已登录或来自网关,不能自动证明用户有某个业务动作权限,更不能证明客户只能访问自己的车辆。
### 4.2 接口级授权缺口
静态扫描 93 个业务 Controller只有 1 个文件出现 `@SaCheckPermission` 等细粒度权限注解;另有 3 个 Controller/回调路径使用 `@SaIgnore`。部分数据权限由 Mapper/DataPermission 处理,但没有形成覆盖所有车辆、合同、地图和导出入口的统一客户 Scope。
因此:
- OneOS 现有 REST API 只能作为内部业务接口;
- 不能直接公网开放 8701
- 不能把 `customerId` 查询参数或 `X-Customer-Id` 请求头当作授权身份;
- 车辆数据中台应在自身底层查询统一注入 VIN Scope并对详情返回 404避免泄漏对象存在性。
### 4.3 Dubbo 接口评估
`ln-asset-api` 暴露 20 个远程服务,包括车辆、客户、合同匹配、充电/加氢站、异常异动、保险、结算和任务等。
车辆数据中台当前不应直接调用 `RemoteVehicleService.listByCustomerId`:实现忽略客户 ID并按有效车辆返回全量结果。可复用的 Dubbo 接口必须逐个做参数、范围、分页、超时和失败策略审计RPC 不等于安全边界。
## 5. 数据库现状
生产只读元数据基线:
| 指标 | 结果 |
| --- | ---: |
| 基础表 | 448 |
| 数据体积 | 约 2.63 GB |
| 索引体积 | 约 381 MB |
| 有效车辆 | 1,209 |
| 当前事务事实候选车辆 | 639 |
| 可安全发布 Scope | 613 |
| 隔离关系 | 26 |
448 张表明显多于 158 个实体映射因为数据库还包含历史表、BI 表、手工迁移表、备份表和动态月表。迁移、备份和运行表混放在同一业务库,增加容量、权限和误操作风险。
### 5.1 API 请求日志容量与隐私风险
`api_request_log_202606``api_request_log_202607` 合计约 2.19 GB约占全库数据体积 83%。源码证据:
- `ApiLogWebConfig` 把全局拦截器挂到几乎所有业务路径;
- `ApiLogConfig` 默认记录参数、请求体、响应体和请求头;
- `ApiLogInterceptor.getRequestHeaders()` 不脱敏任何请求头;
- `ApiLogAspect.getRequestHeaders()` 会脱敏 Authorization/Token/Cookie但仅用于 `@ApiLog` 切面;
-`@ApiLog` 的接口同时被拦截器和切面保存,可能生成两条日志;
- 清理接口只删除“当前时间减保留天数所在月份”一张表内的旧行,并不会遍历或删除所有更早月表;异常被吞掉;
- `/api-request-log/clean` 没有细粒度权限注解。
这是 P0 安全和运维风险:日志可能包含登录凭证、身份证、手机号、合同、签章、附件地址和完整业务响应,并持续快速增长。
### 5.2 关键索引和约束
已确认:
- `vehicle_info` 有车牌索引,但 VIN 没有索引或唯一约束;
- `vehicle_lease_order_record.vehicle_id``vehicle_status.vehicle_id` 只有普通索引,没有唯一约束;
- `vehicle_lease_order_record.customer_id` 缺少 Scope 查询索引;
- `delivery_vehicle.vehicle_id``return_vehicle_task.delivery_vehicle_id` 已有普通/组合索引;
- 生产数据存在 1 组“一车多聚合记录”和大量聚合/历史差异,不能直接添加唯一约束。
任何清理、唯一约束或索引 DDL 都必须单独审批,不属于当前只读分析。
## 6. 可靠性与可维护性
### 6.1 超大类热点
| 文件 | 行数(约) | 风险 |
| --- | ---: | --- |
| `VehicleLeaseContractFullServiceImpl` | 6,082 | 合同、工作流、附件和聚合职责耦合 |
| `ReturnTaskServiceImpl` | 4,015 | 草稿、完成、签章、结算与聚合边界混合 |
| `ReturnSettlementServiceImpl` | 3,902 | 财务聚合复杂、回滚边界难验证 |
| `InsuranceProcurement2ServiceImpl` | 3,626 | OCR、任务、保单、附件和状态混合 |
| `HydrogenFuelLedgerServiceImpl` | 3,322 | 导入、匹配、财务状态耦合 |
| `VehicleTransferServiceImpl` | 3,268 | 调拨、车辆状态、工作流耦合 |
| `DeliveryTaskServiceImpl` | 2,715 | 交车事实和下游副作用耦合 |
测试集中在加氢、保险辅助逻辑、证照和少量 Mapper没有覆盖合同保存、交车完成、还车草稿、还车完成、再次交车、丙方客户、换车等 Scope 核心用例。
### 6.2 事务和错误处理
106 个 `service/impl` 文件中约 63 个文件出现 `@Transactional`,但“存在注解”不代表跨 Dubbo、工作流、签章、文件和本地数据库具备原子性。核心链路存在捕获异常后只记日志、继续返回成功的模式导致任务状态与聚合表可能不一致。
### 6.3 定时任务
源码有 24 个 `@Scheduled` 文件,应用主类全局启用调度,未发现 ShedLock 或等价分布式锁。若生产运行多个实例,账单生成、到期提醒、地图同步、证照状态同步等任务可能重复执行。部署前必须确认生产实例数和任务幂等性。
### 6.4 配置和交付
- `application.yml` 是 Git 跟踪文件,包含邮件、短信和第三方服务的凭证型配置项;部分值看起来不是纯占位符,必须在不打印原值的前提下轮换并迁到密钥管理/Nacos
- README 只给出 Swagger 地址,没有依赖、构建、配置、迁移、部署、回滚、健康检查和故障处置说明;
- Dockerfile 默认测试环境,若生产变量遗漏可能连入错误命名空间;
- 私有 Maven 仓库凭证缺失会阻断可重复构建;
- 未发现 CI 门禁,无法证明每次发布都执行测试、依赖扫描和镜像校验。
### 6.5 可从源码推导的运维顺序
仓库没有 OneOS 专用生产运维手册,`ln-cloud/README.md` 基本是上游 RuoYi 框架说明Nacos README 只有“复制配置”的一句提示。以下只能作为源码推导,不能替代生产值班手册:
1. 先启动 MySQL、Redis、Nacos以及文件存储/资源服务所需基础设施;
2. 在对应 namespace/group 中准备 `application-common.yml``datasource.yml``ln-asset-management.yaml`
3. 启动 `ruoyi-system``ruoyi-resource``ruoyi-workflow` 等被 Dubbo 引用的服务;
4. 启动 `ruoyi-auth``ruoyi-gateway`
5. 最后启动 `ln-asset-management`,核验 Nacos 注册、Dubbo 引用、数据库、Redis、RabbitMQ、文件服务和 actuator
6. 确认只有一个实例执行非幂等 `@Scheduled`,或先证明所有定时任务具备分布式幂等;
7. 再开放 `/asset/**` 流量并检查错误率、Dubbo 超时、数据库连接池、API 日志增速和任务重复执行。
构建时应显式选择 profile并避免 Maven package 阶段意外连接 Docker daemon
```text
mvn -Pprod -Ddocker.skip=true clean package
```
该命令当前仍依赖公司私有 Maven 仓库凭证。Dockerfile 自带的 `SPRING_PROFILE_ACTIVE=test``NAMESPACE=test` 必须由生产环境显式覆盖;否则不能视为生产可部署制品。
生产运维说明仍缺少真实部署拓扑、实例数、镜像仓库流程、Nacos 配置版本、SQL 迁移账本、备份恢复、发布回滚、健康检查阈值、告警接收人、日志保留和密钥轮换。未补齐前,不建议自动化改动或独立部署 OneOS。
## 7. 风险矩阵
| 等级 | 风险 | 当前处置 |
| --- | --- | --- |
| P0 | API 日志可能保存未脱敏认证头和大量敏感请求/响应 | 不改 OneOS立即限制日志查询/清理接口访问,制定脱敏、保留和轮换方案后另行审批 |
| P0 | `listByCustomerId` 返回全量车辆 | 数据中台禁用该接口,使用只读事务事实快照 |
| P0 | 登录校验不等于业务/客户数据权限 | OneOS 不公开;数据中台统一执行 VIN Scope |
| P0 | 客户账号与业务客户没有可信映射 | 外部客户入口保持关闭 |
| P1 | 聚合生命周期被草稿/合同保存影响 | 读取交还车事务事实,聚合仅作交叉校验 |
| P1 | 关键业务异常被吞掉,状态可能不一致 | 快照失败关闭并隔离;上游修复需重新同步批准 |
| P1 | 多副本定时任务可能重复 | 上线前确认单实例或任务幂等/分布式锁 |
| P1 | VIN、聚合和状态缺少唯一约束 | 不直接 DDL先清理异常并做影子验证 |
| P1 | 核心生命周期测试缺失 | 将合同/交还车/换车作为未来修改的强制回归门禁 |
| P1 | 凭证型配置进入源码 | 安全轮换和密钥迁移需单独紧急流程 |
| P2 | 超大类、命名和 URL 漂移 | 分领域抽取应用服务,保持兼容接口 |
| P2 | 运维文档与 CI 缺失 | 补齐构建、发布、回滚、监控和演练说明 |
## 8. 对车辆数据中台的可复用与禁止清单
### 可只读复用
- `vehicle_info`VIN、车牌、车型和主档
- `vehicle_model`:车型能力和显示配置;
- `vehicle_status`OneOS 业务运营状态;
- `customer_info``vehicle_lease_contract_info`:客户和合同;
- `delivery_vehicle``return_vehicle_task`:当前持有生命周期;
- 停车场、站点、司机等低频主数据;
- 所有读取都通过专用表级 SELECT 账号和周期快照。
### 不作为权威来源
- OneOS 地图投影中的在线状态、位置和里程;
- `vehicle_info.customer_id`
- 单独的 `vehicle_lease_order_record.last_return_time`
- `vehicle_lease_order_record_log` 最大 ID
- 客户手机号与系统用户手机号的自动匹配;
- 客户请求参数中的客户 ID。
### 禁止回写
- 合同、交车、还车、换车;
- 锁车、控车;
- 保险、事故、违章、证照、结算和发票;
- OneOS 用户和客户归属;
- 任何生产数据修复或 DDL除非用户另行明确批准。
## 9. 分阶段建议
### 阶段 A只读基线已完成
- 仓库和模块清单;
- 生产表规模、车辆/客户/Scope 数据质量;
- 生命周期和权限风险;
- OneOS 保持零修改。
### 阶段 B车辆数据中台只读投影代码已准备尚未部署
- 专用只读账号只授予 7 张必要表 SELECT
- 两分钟一次读取交还车事务事实;
- 613 条安全关系发布26 条隔离;
- 内容寻址版本、事务整体切换、异常比例门禁;
- ECS 当前仍缺 `ONEOS_MYSQL_DSN`、平台迁移 012 和同步 timer。
### 阶段 C客户身份与 API Scope未完成
- 明确可信客户身份来源;
- 平台 Principal 增加主体类型和客户 ID
- 对监控、车辆、轨迹、历史、统计、告警、导出和地图统一注入 Scope
- 完成双客户越权测试后才允许灰度。
### 阶段 DOneOS 风险治理(未获授权)
如用户未来批准,应优先按独立批次处理:
1. API 日志脱敏、去重、保留策略和访问权限;
2. 客户车辆 RPC 失败关闭;
3. 聚合写入的事务与生命周期边界;
4. 定时任务幂等或分布式锁;
5. 凭证轮换、构建和运维门禁。
每个批次都必须先同步文件、行为变化、数据库影响、测试和回滚方案,不与数据修复或 DDL 混合发布。
## 10. 证据索引
- 系统集成与 Scope`docs/oneos-integration-analysis.md`
- 生产只读审计:`docs/oneos-production-audit-report.md`
- 业务影响:`docs/oneos-business-impact-matrix.md`
- 客户 API 覆盖:`docs/customer-scope-api-enforcement-matrix.md`
- Scope 契约:`docs/oneos-customer-vehicle-scope-contract.md`
- OneOS 网关认证:`ln-cloud/ruoyi-gateway/.../AuthFilter.java`
- 微服务 Same-Token`ln-cloud/ruoyi-common/ruoyi-common-security/.../SecurityConfiguration.java`
- 全局 API 日志:`ln-asset-management/src/main/java/com/ln/asset/common/log/`
- 租赁生命周期:`ln-asset-management/src/main/java/com/ln/asset/modules/contract/`
- 车辆主档:`ln-asset-management/src/main/java/com/ln/asset/modules/vihicle/`
- 远程接口:`ln-asset-management/ln-asset-api/src/main/java/com/ln/asset/api/`

View File

@@ -0,0 +1,135 @@
# OneOS 生产数据库只读审计报告
审计时间2026-07-14
## 1. 执行边界
- 连接路径:本机 → SSH 加密隧道 → 当前车辆中台 ECS → 阿里云 RDS 内网地址。
- 所有查询会话均执行 `SET SESSION TRANSACTION READ ONLY`
- 单条查询设置 `MAX_EXECUTION_TIME=10000`10 秒)。
- 仅执行元数据和聚合查询,未读取密码、客户姓名、手机号、银行账号等明细。
- 未执行任何 INSERT、UPDATE、DELETE、DDL 或权限变更。
## 2. 账号权限核验
当前从 `ln-bi` 部署配置取得的 RDS 账号并不是账号级只读:
-`ln_asset_management``ry-cloud` 等多个业务库拥有 `ALL PRIVILEGES`
- 还拥有 `PROCESS``REPLICATION SLAVE``REPLICATION CLIENT``XA_RECOVER_ADMIN` 等全局权限。
本次审计依靠会话只读保护,没有使用这些写权限。但该账号不适合作为车辆数据中台的生产同步账号,也不应继续以“只读账号”管理。
建议另建独立账号,只授予必要表的 `SELECT`;如需最小到列级,优先建立脱敏只读视图并仅授权视图。权限调整属于数据库写操作,需另行审批。
## 3. 数据质量基线
### 车辆档案
| 指标 | 结果 |
| --- | ---: |
| 有效车辆 | 1,209 |
| VIN 缺失 | 0 |
| VIN 重复组 | 0 |
| 车牌缺失 | 0 |
车辆身份基础质量良好,当前 1,209 台有效车辆均有 VIN 和车牌,标准化 VIN 后无重复。
生产库尚未给 `vehicle_info.vin` 建立索引或唯一约束。当前数据满足增加唯一索引的前置条件,但正式 DDL 前仍要确认历史/逻辑删除记录及业务兼容性。
### 客户账号
| 指标 | 结果 |
| --- | ---: |
| 有效客户 | 335 |
| `customer_info.id = sys_user.user_id` 的账号 | 0 |
| `user_type=customer` 的账号 | 0 |
| 客户编码与系统用户名匹配 | 0 |
| 联系手机号与系统账号手机号匹配 | 4 |
| 客户编码缺失 | 7 |
结论客户账号体系尚未形成不能通过“补角色”直接开放。4 个手机号匹配仅能作为待人工核验线索,不能自动认定为客户账号或覆盖现有用户。
两个库的字符排序规则不同:
- `customer_info.customer_code/contact_mobile``utf8mb4_unicode_ci`
- `sys_user.user_name/phonenumber``utf8mb4_0900_ai_ci`
跨库字符串直接等值 JOIN 会报 collation 冲突。正式关联应优先使用稳定 ID补偿审计若按编码/手机号匹配,需要显式统一 collation。
### 当前客户车辆候选范围(事务事实口径)
候选条件:每车最新有效交车单已完成(`delivery_status IN (2,3)`),且该交车单不存在正式完成的还车任务(`status IN (2,3,5)`)。聚合表仅用于客户归属交叉校验,不使用可能被还车草稿提前写入的 `last_return_time` 判断生命周期。
| 指标 | 结果 |
| --- | ---: |
| 候选记录 | 639 |
| 候选车辆 | 639 |
| 可安全发布记录 | 613 |
| 应隔离记录 | 26 |
隔离原因(可能重叠):
| 原因 | 数量 |
| --- | ---: |
| 聚合记录或客户 ID 缺失 | 17 |
| 聚合客户与合同有效客户不一致 | 9 |
| 车辆档案不可用 | 0 |
| VIN 缺失 | 0 |
因此第一版客户车辆 Scope 最多只能发布 613 条已校验关系,其余 26 条必须隔离,不能为了数量完整而放宽权限。
与旧聚合时间口径相比,事务事实口径排除 16 台12 台已有正式还车事实、3 台最新交车尚未完成、1 台没有交车事实;另有 8 台进入事务候选但不在旧聚合候选,其中 4 台缺聚合记录并会继续 fail-closed 隔离。该差异证明 `last_delivery_time/last_return_time` 不适合作为外部授权生命周期真值。
### 租赁聚合与历史一致性
| 指标 | 结果 |
| --- | ---: |
| 有效当前聚合记录 | 831 |
| 无任何交还车时间 | 13 |
| 已交车、无还车时间 | 617 |
| 还车后再次交车 | 30 |
| 已还车 | 171 |
| 聚合表与最新历史关键字段不一致 | 325 |
| 违反“一车一条聚合记录”的车辆组 | 1 |
| 当前候选范围内重复车辆 | 0 |
325 条差异说明 `vehicle_lease_order_record_log` 不能简单取最大 ID 当作当前真值1 组重复聚合记录也意味着现有 `selectOne(vehicle_id)` 存在运行时异常风险。正式修复前需要对异常组做明细复核,但明细不写入本报告。
## 4. 索引结论
生产库已有车牌、vehicleId 和部分状态索引,但缺少:
- `vehicle_info.vin` 索引/唯一约束;
- `vehicle_lease_order_record.customer_id` 的客户范围查询索引;
- `vehicle_lease_order_record.vehicle_id` 唯一约束;
- `vehicle_status.vehicle_id` 唯一约束;
- 合同有效客户查询所需索引。
当前数据量不大。2026-07-14 事务事实影子查询耗时约 93 ms主交车表预估扫描 2,320 行,其余关联均使用 `eq_ref/ref`;两分钟一次的单次批量查询压力可控。后续数据增长后仍需监控执行时间,索引 DDL 必须单独审批并用 `EXPLAIN`、影子查询和回滚方案验证。
## 5. 当前可采用的权限口径
在业务确认和异常修复前,外部客户范围必须同时满足:
```text
车辆有效且 VIN 非空
聚合记录有效
最新有效交车单已完成
该交车单不存在正式完成的还车任务
客户档案有效
合同有效
聚合客户 ID 非空
合同有效客户非空
聚合客户 = COALESCE(合同丙方客户, 合同主客户)
同一车辆不存在多客户冲突
```
不满足任一条件的记录进入隔离清单,外部查询失败关闭。
## 6. 下一步
1. 创建真正的最小权限 RDS 只读账号,替换当前高权限账号。
2. 由业务人员复核 26 条隔离关系和 1 组重复聚合记录。
3. 不修改 `ln-asset-management`;将还车草稿提前写聚合字段记录为上游风险,数据中台只读取正式交还车任务状态。
4. 以 613 条可发布关系运行影子 Scope与合同和交还车样本对账。
5. 完成客户身份入口与所有客户 API 的 Scope 强制校验后,再审批上线外部客户入口。

View File

@@ -0,0 +1,252 @@
-- OneOS 客户账号与当前车辆范围只读审计
-- 仅允许在只读连接执行;不包含任何 INSERT/UPDATE/DELETE/DDL。
SET SESSION TRANSACTION READ ONLY;
SET SESSION MAX_EXECUTION_TIME = 10000;
-- 0. 核验实际授权。只读账号不应出现 ALL PRIVILEGES、PROCESS、REPLICATION 或 DDL/DML 权限。
SHOW GRANTS FOR CURRENT_USER;
-- 1. 核对关键表字段,识别代码、文档与生产结构漂移。
SELECT table_schema, table_name, column_name, data_type, is_nullable, column_key
FROM information_schema.columns
WHERE table_schema IN ('ln_asset_management', 'ry-cloud')
AND table_name IN (
'vehicle_info', 'vehicle_lease_order_record', 'vehicle_lease_order_record_log',
'vehicle_lease_contract_info', 'vehicle_lease_order_detail', 'delivery_vehicle',
'return_vehicle_task', 'customer_info', 'sys_user', 'sys_user_role'
)
AND column_name IN (
'id', 'user_id', 'vehicle_id', 'plate_number', 'vin', 'customer_id',
'other_customer_id', 'contract_id', 'contract_code', 'last_delivery_time',
'last_return_time', 'delivery_time', 'arrival_time', 'user_type', 'status', 'del_flag'
)
ORDER BY table_schema, table_name, ordinal_position;
-- 2. 车辆基础数据质量。
SELECT
COUNT(*) AS active_vehicles,
SUM(vin IS NULL OR TRIM(vin) = '') AS missing_vin,
COUNT(DISTINCT NULLIF(TRIM(vin), '')) AS distinct_vin,
COUNT(DISTINCT NULLIF(TRIM(plate_number), '')) AS distinct_plate
FROM ln_asset_management.vehicle_info
WHERE del_flag = '0';
-- 3. 客户档案与客户账号覆盖率,不输出手机号、密码等敏感字段。
SELECT
COUNT(*) AS active_customers,
SUM(u.user_id IS NOT NULL) AS customers_with_account,
SUM(u.status = '0' AND u.del_flag = '0') AS customers_with_enabled_account,
SUM(u.user_id IS NULL) AS customers_without_account
FROM ln_asset_management.customer_info c
LEFT JOIN `ry-cloud`.sys_user u
ON u.user_id = c.id
AND u.user_type = 'customer'
WHERE c.del_flag = '0';
-- 4. 客户账号角色覆盖率。
SELECT
COUNT(DISTINCT u.user_id) AS customer_accounts,
COUNT(DISTINCT ur.user_id) AS customer_accounts_with_role
FROM `ry-cloud`.sys_user u
LEFT JOIN `ry-cloud`.sys_user_role ur ON ur.user_id = u.user_id
WHERE u.user_type = 'customer'
AND u.del_flag = '0';
-- 5. 旧聚合时间口径,仅用于与事务事实口径对比,不能作为授权真值。
SELECT
COUNT(*) AS scope_rows,
COUNT(DISTINCT r.vehicle_id) AS scope_vehicles,
COUNT(DISTINCT r.customer_id) AS scope_customers,
SUM(r.customer_id IS NULL) AS missing_customer,
SUM(v.vin IS NULL OR TRIM(v.vin) = '') AS missing_vin,
SUM(c.id IS NULL AND r.customer_id IS NOT NULL) AS missing_customer_profile
FROM ln_asset_management.vehicle_lease_order_record r
LEFT JOIN ln_asset_management.vehicle_info v
ON v.id = r.vehicle_id AND v.del_flag = '0'
LEFT JOIN ln_asset_management.customer_info c
ON c.id = r.customer_id AND c.del_flag = '0'
WHERE r.del_flag = '0'
AND r.last_delivery_time IS NOT NULL
AND (r.last_return_time IS NULL OR r.last_return_time < r.last_delivery_time);
-- 5A. 当前交车/还车事务事实候选及归属质量。
SELECT
COUNT(*) AS candidate_rows,
COUNT(DISTINCT d.vehicle_id) AS candidate_vehicles,
SUM(v.id IS NULL OR v.vin IS NULL OR TRIM(v.vin) = '') AS invalid_vehicle,
SUM(r.id IS NULL OR r.customer_id IS NULL) AS missing_aggregate_customer,
SUM(ci.id IS NULL AND r.customer_id IS NOT NULL) AS invalid_customer_profile,
SUM(co.id IS NULL) AS invalid_contract,
SUM(co.id IS NOT NULL AND COALESCE(co.other_customer_id, co.customer_id) IS NULL) AS missing_contract_customer,
SUM(
r.customer_id IS NOT NULL
AND COALESCE(co.other_customer_id, co.customer_id) IS NOT NULL
AND r.customer_id <> COALESCE(co.other_customer_id, co.customer_id)
) AS customer_mismatch
FROM ln_asset_management.delivery_vehicle d
LEFT JOIN ln_asset_management.vehicle_info v
ON v.id = d.vehicle_id AND v.del_flag = '0'
LEFT JOIN ln_asset_management.vehicle_lease_order_record r
ON r.vehicle_id = d.vehicle_id AND r.del_flag = '0'
LEFT JOIN ln_asset_management.customer_info ci
ON ci.id = r.customer_id AND ci.del_flag = '0'
LEFT JOIN ln_asset_management.vehicle_lease_contract_info co
ON co.id = d.contract_id AND co.del_flag = '0'
WHERE d.del_flag = '0'
AND d.delivery_status IN (2, 3)
AND d.vehicle_id IS NOT NULL
AND d.delivery_time IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM ln_asset_management.delivery_vehicle newer
WHERE newer.del_flag = '0'
AND newer.vehicle_id = d.vehicle_id
AND (
COALESCE(newer.delivery_time, '1000-01-01') > COALESCE(d.delivery_time, '1000-01-01')
OR (
COALESCE(newer.delivery_time, '1000-01-01') = COALESCE(d.delivery_time, '1000-01-01')
AND newer.id > d.id
)
)
)
AND NOT EXISTS (
SELECT 1
FROM ln_asset_management.return_vehicle_task rt
WHERE rt.delivery_vehicle_id = d.id
AND rt.del_flag = '0'
AND rt.status IN (2, 3, 5)
);
-- 6. 检查同一车辆是否出现多个当前客户范围记录。
SELECT r.vehicle_id, COUNT(*) AS active_scope_rows, COUNT(DISTINCT r.customer_id) AS customer_count
FROM ln_asset_management.vehicle_lease_order_record r
WHERE r.del_flag = '0'
AND r.last_delivery_time IS NOT NULL
AND (r.last_return_time IS NULL OR r.last_return_time < r.last_delivery_time)
GROUP BY r.vehicle_id
HAVING COUNT(*) > 1 OR COUNT(DISTINCT r.customer_id) > 1
ORDER BY active_scope_rows DESC
LIMIT 100;
-- 7. 检查客户 ID 与客户档案不一致的记录。
SELECT COUNT(*) AS unmatched_customer_records
FROM ln_asset_management.vehicle_lease_order_record r
LEFT JOIN ln_asset_management.customer_info c
ON c.id = r.customer_id AND c.del_flag = '0'
WHERE r.del_flag = '0'
AND r.customer_id IS NOT NULL
AND c.id IS NULL;
-- 8. 聚合表客户与合同有效客户是否一致。
SELECT
COUNT(*) AS records_with_contract,
SUM(c.id IS NULL) AS missing_contract,
SUM(c.id IS NOT NULL AND r.customer_id IS NULL) AS missing_record_customer,
SUM(
c.id IS NOT NULL
AND r.customer_id IS NOT NULL
AND r.customer_id <> COALESCE(c.other_customer_id, c.customer_id)
) AS effective_customer_mismatch
FROM ln_asset_management.vehicle_lease_order_record r
LEFT JOIN ln_asset_management.vehicle_lease_contract_info c
ON c.id = r.contract_id AND c.del_flag = '0'
WHERE r.del_flag = '0'
AND r.contract_id IS NOT NULL;
-- 9. 已交车但聚合表被清空或缺少客户/合同的可疑记录。
-- delivery_vehicle 的正式完成状态字段需先通过第 1 项结构核对,再按生产枚举追加状态条件。
SELECT
COUNT(*) AS delivered_rows,
SUM(r.id IS NULL) AS missing_aggregate_record,
SUM(r.id IS NOT NULL AND r.customer_id IS NULL) AS aggregate_missing_customer,
SUM(r.id IS NOT NULL AND r.contract_id IS NULL) AS aggregate_missing_contract,
SUM(r.id IS NOT NULL AND r.last_delivery_time IS NULL) AS aggregate_missing_delivery_time
FROM ln_asset_management.delivery_vehicle d
LEFT JOIN ln_asset_management.vehicle_lease_order_record r
ON r.vehicle_id = d.vehicle_id AND r.del_flag = '0'
WHERE d.del_flag = '0'
AND d.delivery_time IS NOT NULL;
-- 10. 当前聚合表与最新历史记录的关键字段差异。
SELECT COUNT(*) AS aggregate_log_mismatch
FROM ln_asset_management.vehicle_lease_order_record r
JOIN ln_asset_management.vehicle_lease_order_record_log l
ON l.vehicle_id = r.vehicle_id
AND l.del_flag = '0'
AND l.id = (
SELECT MAX(l2.id)
FROM ln_asset_management.vehicle_lease_order_record_log l2
WHERE l2.vehicle_id = r.vehicle_id AND l2.del_flag = '0'
)
WHERE r.del_flag = '0'
AND NOT (
r.customer_id <=> l.customer_id
AND r.contract_id <=> l.contract_id
AND r.last_delivery_time <=> l.last_delivery_time
AND r.last_return_time <=> l.last_return_time
);
-- 11. 检查支撑客户车辆范围查询的索引。这里只读索引元数据,不执行 DDL。
SELECT table_name, index_name, non_unique,
GROUP_CONCAT(column_name ORDER BY seq_in_index) AS indexed_columns
FROM information_schema.statistics
WHERE table_schema = 'ln_asset_management'
AND table_name IN (
'vehicle_info', 'vehicle_lease_order_record', 'vehicle_lease_order_record_log',
'vehicle_lease_contract_info', 'delivery_vehicle', 'return_vehicle_task'
)
GROUP BY table_name, index_name, non_unique
ORDER BY table_name, index_name;
-- 12. 客户编码与系统用户名的冲突、错类型和错租户情况。
-- tenant_id 的目标值应由生产配置确认,这里先按客户 ID 关联并报告实际分布。
SELECT
COUNT(*) AS customer_profiles,
SUM(u.user_id IS NULL) AS no_same_id_account,
SUM(u.user_id IS NOT NULL AND u.user_type <> 'customer') AS same_id_wrong_type,
SUM(
u.user_id IS NOT NULL
AND (CONVERT(u.user_name USING utf8mb4) COLLATE utf8mb4_unicode_ci)
<> (CONVERT(c.customer_code USING utf8mb4) COLLATE utf8mb4_unicode_ci)
) AS username_code_mismatch,
COUNT(DISTINCT CASE WHEN u.user_id IS NOT NULL THEN u.tenant_id END) AS account_tenant_count
FROM ln_asset_management.customer_info c
LEFT JOIN `ry-cloud`.sys_user u ON u.user_id = c.id AND u.del_flag = '0'
WHERE c.del_flag = '0';
-- 13. 同一租户、用户名是否存在多账号或跨 user_type 冲突。
SELECT tenant_id, user_name,
COUNT(*) AS account_count,
COUNT(DISTINCT user_type) AS user_type_count
FROM `ry-cloud`.sys_user
WHERE del_flag = '0'
AND user_name IS NOT NULL
AND TRIM(user_name) <> ''
GROUP BY tenant_id, user_name
HAVING COUNT(*) > 1 OR COUNT(DISTINCT user_type) > 1
ORDER BY account_count DESC
LIMIT 100;
-- 14. 业务库整体体积与最大表。table_rows 为 InnoDB 估算值,不用于财务或业务计数。
SELECT
COUNT(*) AS base_tables,
SUM(data_length) AS data_bytes,
SUM(index_length) AS index_bytes
FROM information_schema.tables
WHERE table_schema = 'ln_asset_management'
AND table_type = 'BASE TABLE';
SELECT table_name, table_rows, data_length, index_length
FROM information_schema.tables
WHERE table_schema = 'ln_asset_management'
AND table_type = 'BASE TABLE'
ORDER BY data_length + index_length DESC
LIMIT 20;
-- 15. API 月日志体积,用于核对保留策略;不读取任何日志内容。
SELECT table_name, table_rows, data_length, index_length
FROM information_schema.tables
WHERE table_schema = 'ln_asset_management'
AND table_name LIKE 'api_request_log\\_%'
ORDER BY table_name;

View File

@@ -0,0 +1,37 @@
-- OneOS Scope 同步专用账号开通模板(由 RDS 管理员审核后执行)
--
-- 安全边界:
-- 1. 本文件包含 CREATE USER / GRANT不能使用现有业务账号执行自动部署。
-- 2. <GENERATE_AND_STORE_A_STRONG_PASSWORD> 必须替换为独立随机密码,并写入受限的 platform.env。
-- 3. 172.17.111.55 是 2026-07-14 核验到的当前 ECS 私网源地址;迁移 ECS 后必须同步调整。
-- 4. 不授予 *.*、库级 SELECT、PROCESS、REPLICATION、DML、DDL 或角色。
CREATE USER 'vehicle_scope_reader'@'172.17.111.55'
IDENTIFIED BY '<GENERATE_AND_STORE_A_STRONG_PASSWORD>';
GRANT SELECT ON ln_asset_management.vehicle_lease_order_record
TO 'vehicle_scope_reader'@'172.17.111.55';
GRANT SELECT ON ln_asset_management.delivery_vehicle
TO 'vehicle_scope_reader'@'172.17.111.55';
GRANT SELECT ON ln_asset_management.return_vehicle_task
TO 'vehicle_scope_reader'@'172.17.111.55';
GRANT SELECT ON ln_asset_management.vehicle_info
TO 'vehicle_scope_reader'@'172.17.111.55';
GRANT SELECT ON ln_asset_management.customer_info
TO 'vehicle_scope_reader'@'172.17.111.55';
GRANT SELECT ON ln_asset_management.vehicle_lease_contract_info
TO 'vehicle_scope_reader'@'172.17.111.55';
GRANT SELECT ON ln_asset_management.vehicle_status
TO 'vehicle_scope_reader'@'172.17.111.55';
SHOW GRANTS FOR 'vehicle_scope_reader'@'172.17.111.55';
-- 管理员验收:输出只能包含 USAGE 和以上 7 个表的 SELECT。
-- 回收(仅在确认同步服务已停用后由管理员执行):
-- DROP USER 'vehicle_scope_reader'@'172.17.111.55';

View File

@@ -5,6 +5,6 @@
"web:build": "pnpm --dir apps/web run build",
"web:test": "pnpm --dir apps/web run test",
"api:test": "cd apps/api && go test ./...",
"build": "pnpm run web:build && cd apps/api && go build -o ../../dist/platform-api ./cmd/platform-api"
"build": "pnpm run web:build && cd apps/api && go build -o ../../dist/platform-api ./cmd/platform-api && go build -o ../../dist/oneos-scope-sync ./cmd/oneos-scope-sync"
}
}