feat(monitor): retain authorized vehicles without locations

This commit is contained in:
lingniu
2026-07-16 16:04:23 +08:00
parent 4688abadef
commit c224907fad
12 changed files with 352 additions and 100 deletions

View File

@@ -869,6 +869,55 @@ func TestHandlerVehicleRealtimeFiltersServiceStatus(t *testing.T) {
}
}
func TestHandlerVehicleRealtimeKeepsBoundVehiclesWithoutLocation(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/realtime/vehicles?status=no_location&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) == 0 {
t.Fatalf("no-location filter should retain bound vehicles: %s", rec.Body.String())
}
for _, item := range body.Data.Items {
if item.LocationAvailable {
t.Fatalf("no-location result must not contain a located vehicle: %+v", item)
}
}
}
func TestHandlerVehicleRealtimeOfflineFilterExcludesNeverReportedVehicles(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/realtime/vehicles?online=offline&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())
}
for _, item := range body.Data.Items {
if item.SourceCount == 0 {
t.Fatalf("offline means a known source stopped reporting; never-reported vehicles belong to no-location: %+v", item)
}
}
}
func TestHandlerVehicleRealtimeAcceptsKeyword(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
rec := httptest.NewRecorder()

View File

@@ -485,30 +485,48 @@ func buildArchiveMissingFieldStats(missingPlate, missingPhone, missingOEM int) [
func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[VehicleRealtimeRow], error) {
rows := m.locations
if keywords := vehicleSearchKeywords(query); len(keywords) > 0 {
keywords := vehicleSearchKeywords(query)
matchesIdentity := func(vin, plate, phone, oem string) bool {
if len(keywords) == 0 {
return true
}
batch := strings.TrimSpace(query.Get("keywords")) != ""
rows = keep(rows, func(row RealtimeLocationRow) bool {
vehicle := m.vehicleByVIN(row.VIN)
if batch {
for _, keyword := range keywords {
if strings.EqualFold(row.VIN, keyword) || strings.EqualFold(row.Plate, keyword) || strings.EqualFold(vehicle.Plate, keyword) {
return true
}
}
return false
}
haystack := strings.ToLower(row.VIN + row.Plate + vehicle.Plate + vehicle.Phone + vehicle.OEM)
if batch {
for _, keyword := range keywords {
if strings.Contains(haystack, strings.ToLower(keyword)) {
if strings.EqualFold(vin, keyword) || strings.EqualFold(plate, keyword) {
return true
}
}
return false
}
haystack := strings.ToLower(vin + plate + phone + oem)
for _, keyword := range keywords {
if strings.Contains(haystack, strings.ToLower(keyword)) {
return true
}
}
return false
}
if len(keywords) > 0 {
rows = keep(rows, func(row RealtimeLocationRow) bool {
vehicle := m.vehicleByVIN(row.VIN)
return matchesIdentity(row.VIN, firstNonEmpty(row.Plate, vehicle.Plate), vehicle.Phone, vehicle.OEM)
})
}
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
rows = keep(rows, func(row RealtimeLocationRow) bool { return row.Protocol == protocol })
}
scopeVINs := splitCSV(query.Get("scopeVins"))
scope := make(map[string]struct{}, len(scopeVINs))
for _, vin := range scopeVINs {
scope[vin] = struct{}{}
}
if len(scope) > 0 {
rows = keep(rows, func(row RealtimeLocationRow) bool {
_, allowed := scope[row.VIN]
return allowed
})
}
byVIN := map[string]*VehicleRealtimeRow{}
for _, location := range rows {
current := byVIN[location.VIN]
@@ -533,10 +551,14 @@ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[V
}
if location.LastSeen >= current.LastSeen {
current.PrimaryProtocol = location.Protocol
current.LocationAvailable = validRealtimeCoordinate(location.Longitude, location.Latitude)
current.Longitude = location.Longitude
current.Latitude = location.Latitude
current.SpeedAvailable = true
current.SpeedKmh = location.SpeedKmh
current.SOCAvailable = location.Protocol == "GB32960" || location.Protocol == "YUTONG_MQTT"
current.SOCPercent = location.SOCPercent
current.MileageAvailable = true
current.TotalMileageKm = location.TotalMileageKm
current.LastSeen = location.LastSeen
}
@@ -545,6 +567,28 @@ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[V
current.OnlineSourceCount++
}
}
if strings.TrimSpace(query.Get("protocol")) == "" {
for _, vehicle := range m.vehicles {
if _, exists := byVIN[vehicle.VIN]; exists {
continue
}
if len(scope) > 0 {
if _, allowed := scope[vehicle.VIN]; !allowed {
continue
}
}
if !matchesIdentity(vehicle.VIN, vehicle.Plate, vehicle.Phone, vehicle.OEM) {
continue
}
byVIN[vehicle.VIN] = &VehicleRealtimeRow{
VIN: vehicle.VIN,
Plate: vehicle.Plate,
Phone: vehicle.Phone,
OEM: vehicle.OEM,
BindingStatus: "bound",
}
}
}
items := make([]VehicleRealtimeRow, 0, len(byVIN))
for _, row := range byVIN {
sort.Strings(row.Protocols)
@@ -562,12 +606,14 @@ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[V
continue
}
case "offline":
if row.Online {
if row.Online || row.SourceCount == 0 {
continue
}
}
if status := strings.TrimSpace(query.Get("status")); status != "" && !matchesMonitorStatus(*row, status) {
continue
if status != "no_location" || row.LocationAvailable {
continue
}
}
if !keepServiceStatus(row.ServiceStatus, query.Get("serviceStatus")) {
continue
@@ -649,10 +695,14 @@ func (m *MockStore) realtimeSummaryForVIN(vin string, protocol string) *VehicleR
}
if location.LastSeen >= summary.LastSeen {
summary.PrimaryProtocol = location.Protocol
summary.LocationAvailable = validRealtimeCoordinate(location.Longitude, location.Latitude)
summary.Longitude = location.Longitude
summary.Latitude = location.Latitude
summary.SpeedAvailable = true
summary.SpeedKmh = location.SpeedKmh
summary.SOCAvailable = location.Protocol == "GB32960" || location.Protocol == "YUTONG_MQTT"
summary.SOCPercent = location.SOCPercent
summary.MileageAvailable = true
summary.TotalMileageKm = location.TotalMileageKm
summary.LastSeen = location.LastSeen
}

View File

@@ -9,6 +9,8 @@ type Page[T any] struct {
type MonitorSummary struct {
TotalVehicles int `json:"totalVehicles"`
LocationVehicles int `json:"locationVehicles"`
NoLocationVehicles int `json:"noLocationVehicles"`
OnlineVehicles int `json:"onlineVehicles"`
OfflineVehicles int `json:"offlineVehicles"`
DrivingVehicles int `json:"drivingVehicles"`
@@ -1009,28 +1011,34 @@ type RealtimeLocationRow struct {
}
type VehicleRealtimeRow struct {
VIN string `json:"vin"`
Plate string `json:"plate"`
Phone string `json:"phone"`
OEM string `json:"oem"`
Protocols []string `json:"protocols"`
SourceStatus []VehicleSourceStatus `json:"sourceStatus"`
SourceCount int `json:"sourceCount"`
OnlineSourceCount int `json:"onlineSourceCount"`
Online bool `json:"online"`
BindingStatus string `json:"bindingStatus"`
ServiceStatus *VehicleServiceStatus `json:"serviceStatus,omitempty"`
PrimaryProtocol string `json:"primaryProtocol"`
LocationSource string `json:"locationSource"`
LocationConflict bool `json:"locationConflict"`
ConflictDistanceM *float64 `json:"conflictDistanceM,omitempty"`
Longitude float64 `json:"longitude"`
Latitude float64 `json:"latitude"`
SpeedKmh float64 `json:"speedKmh"`
SOCPercent float64 `json:"socPercent"`
TotalMileageKm float64 `json:"totalMileageKm"`
LastSeen string `json:"lastSeen"`
ReportIntervalMs *int64 `json:"reportIntervalMs,omitempty"`
VIN string `json:"vin"`
Plate string `json:"plate"`
Phone string `json:"phone"`
OEM string `json:"oem"`
Protocols []string `json:"protocols"`
SourceStatus []VehicleSourceStatus `json:"sourceStatus"`
SourceCount int `json:"sourceCount"`
OnlineSourceCount int `json:"onlineSourceCount"`
Online bool `json:"online"`
BindingStatus string `json:"bindingStatus"`
ServiceStatus *VehicleServiceStatus `json:"serviceStatus,omitempty"`
PrimaryProtocol string `json:"primaryProtocol"`
LocationSource string `json:"locationSource"`
LocationConflict bool `json:"locationConflict"`
ConflictDistanceM *float64 `json:"conflictDistanceM,omitempty"`
LocationAvailable bool `json:"locationAvailable"`
Longitude float64 `json:"longitude"`
Latitude float64 `json:"latitude"`
SpeedAvailable bool `json:"speedAvailable"`
SpeedKmh float64 `json:"speedKmh"`
SOCAvailable bool `json:"socAvailable"`
SOCPercent float64 `json:"socPercent"`
MileageAvailable bool `json:"mileageAvailable"`
TotalMileageKm float64 `json:"totalMileageKm"`
TodayMileageAvailable bool `json:"todayMileageAvailable"`
TodayMileageKm float64 `json:"todayMileageKm"`
LastSeen string `json:"lastSeen"`
ReportIntervalMs *int64 `json:"reportIntervalMs,omitempty"`
}
type HistoryLocationRow struct {

View File

@@ -328,8 +328,8 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
offset := parsePositive(query.Get("offset"), 0)
canonicalSourceCount := strconv.Itoa(len(canonicalVehicleProtocols))
args := []any{}
where := []string{"l.vin IS NOT NULL", "l.vin <> ''"}
where, args = appendVINListFilter(where, args, "l.vin", query.Get("scopeVins"))
where := []string{"v.vin IS NOT NULL", "v.vin <> ''"}
where, args = appendVINListFilter(where, args, "v.vin", query.Get("scopeVins"))
having := []string{}
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
where = append(where, "l.protocol = ?")
@@ -338,7 +338,7 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
if keywords := vehicleSearchKeywords(query); len(keywords) > 0 {
if strings.TrimSpace(query.Get("keywords")) != "" {
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(keywords)), ",")
where = append(where, "(l.vin IN ("+placeholders+") OR l.plate IN ("+placeholders+") OR b.plate IN ("+placeholders+"))")
where = append(where, "(v.vin IN ("+placeholders+") OR l.plate IN ("+placeholders+") OR v.plate IN ("+placeholders+"))")
for range 3 {
for _, keyword := range keywords {
args = append(args, keyword)
@@ -347,7 +347,7 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
} else {
keyword := keywords[0]
like := "%" + keyword + "%"
where = append(where, "(l.vin LIKE ? OR l.plate LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
where = append(where, "(v.vin LIKE ? OR l.plate LIKE ? OR v.plate LIKE ? OR v.phone LIKE ? OR v.oem LIKE ?)")
args = append(args, like, like, like, like, like)
}
}
@@ -355,9 +355,19 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
case "online":
having = append(having, "COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) > 0")
case "offline":
having = append(having, "COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) = 0")
having = append(having,
"COUNT(DISTINCT l.protocol) > 0",
"COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) = 0",
)
}
primarySpeed := "CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.speed_kmh AS CHAR) ORDER BY l.updated_at DESC, l.protocol ASC), ',', 1) AS DECIMAL(18,6))"
orderExpr := `CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 10 MINUTE) THEN 0 ELSE 1 END ASC, ` +
`CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 10 MINUTE) THEN CASE l.protocol ` +
`WHEN 'GB32960' THEN 10 WHEN 'YUTONG_MQTT' THEN 20 WHEN 'JT808' THEN 30 ELSE 100 END ELSE 100 END ASC, ` +
`l.updated_at DESC, l.protocol ASC`
primarySpeed := "CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.speed_kmh AS CHAR) ORDER BY " + orderExpr + "), ',', 1) AS DECIMAL(18,6))"
primaryLocationAvailable := `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(` +
`CASE WHEN l.vin IS NOT NULL AND l.longitude BETWEEN -180 AND 180 AND l.latitude BETWEEN -90 AND 90 ` +
`AND NOT (l.longitude = 0 AND l.latitude = 0) THEN 1 ELSE 0 END ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0)`
switch strings.TrimSpace(query.Get("status")) {
case "driving":
having = append(having,
@@ -369,29 +379,31 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
"COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) > 0",
primarySpeed+" <= 3",
)
case "no_location":
having = append(having, primaryLocationAvailable+" = 0")
}
switch strings.TrimSpace(query.Get("serviceStatus")) {
case "healthy":
having = append(having,
"MAX(CASE WHEN b.vin IS NOT NULL THEN 1 ELSE 0 END) = 1",
"MAX(CASE WHEN v.binding_status = 'bound' THEN 1 ELSE 0 END) = 1",
"COUNT(DISTINCT l.protocol) = "+canonicalSourceCount,
"COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) = COUNT(DISTINCT l.protocol)",
)
case "degraded":
having = append(having,
"MAX(CASE WHEN b.vin IS NOT NULL THEN 1 ELSE 0 END) = 1",
"MAX(CASE WHEN v.binding_status = 'bound' THEN 1 ELSE 0 END) = 1",
"COUNT(DISTINCT l.protocol) > 0",
"COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) > 0",
"(COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) < COUNT(DISTINCT l.protocol) OR COUNT(DISTINCT l.protocol) < "+canonicalSourceCount+")",
)
case "offline":
having = append(having,
"MAX(CASE WHEN b.vin IS NOT NULL THEN 1 ELSE 0 END) = 1",
"MAX(CASE WHEN v.binding_status = 'bound' THEN 1 ELSE 0 END) = 1",
"COUNT(DISTINCT l.protocol) > 0",
"COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) = 0",
)
case "identity_required":
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL THEN 1 ELSE 0 END) = 0")
having = append(having, "MAX(CASE WHEN v.binding_status = 'bound' THEN 1 ELSE 0 END) = 0")
}
countArgs := append([]any(nil), args...)
args = append(args, limit, offset)
@@ -399,45 +411,62 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
if len(having) > 0 {
havingSQL = ` HAVING ` + strings.Join(having, " AND ") + ` `
}
groupSQL := `FROM vehicle_realtime_location l ` +
`LEFT JOIN vehicle_identity_binding b ON b.vin = l.vin ` +
`LEFT JOIN vehicle_realtime_snapshot s ON s.vin = l.vin AND s.protocol = l.protocol ` +
`WHERE ` + strings.Join(where, " AND ") + ` ` +
`GROUP BY l.vin, b.plate, b.phone, b.oem ` +
populationSQL := `(` +
`SELECT b0.vin, COALESCE(MAX(NULLIF(b0.plate, '')), '') AS plate, ` +
`COALESCE(MAX(NULLIF(b0.phone, '')), '') AS phone, COALESCE(MAX(NULLIF(b0.oem, '')), '') AS oem, 'bound' AS binding_status ` +
`FROM vehicle_identity_binding b0 WHERE b0.vin IS NOT NULL AND b0.vin <> '' GROUP BY b0.vin ` +
`UNION ALL ` +
`SELECT l0.vin, COALESCE(MAX(NULLIF(l0.plate, '')), '') AS plate, '' AS phone, '' AS oem, 'unbound' AS binding_status ` +
`FROM vehicle_realtime_location l0 LEFT JOIN vehicle_identity_binding b1 ON b1.vin = l0.vin ` +
`WHERE l0.vin IS NOT NULL AND l0.vin <> '' AND b1.vin IS NULL GROUP BY l0.vin` +
`) v`
baseGroupSQL := `FROM ` + populationSQL + ` ` +
`LEFT JOIN vehicle_realtime_location l ON l.vin = v.vin ` +
`LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin AND s.protocol = l.protocol `
groupSuffixSQL := `WHERE ` + strings.Join(where, " AND ") + ` ` +
`GROUP BY v.vin, v.plate, v.phone, v.oem, v.binding_status ` +
havingSQL
groupSQL := baseGroupSQL +
`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 = v.vin AND m.protocol = l.protocol ` +
groupSuffixSQL
// Keep one authoritative coordinate source while all protocols are healthy.
// Ordering only by updated_at makes the selected point flap whenever protocols
// report at different cadences. When every source is stale, recency remains the
// safest fallback so an old high-priority source cannot mask newer evidence.
orderExpr := `CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 10 MINUTE) THEN 0 ELSE 1 END ASC, ` +
`CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 10 MINUTE) THEN CASE l.protocol ` +
`WHEN 'GB32960' THEN 10 WHEN 'YUTONG_MQTT' THEN 20 WHEN 'JT808' THEN 30 ELSE 100 END ELSE 100 END ASC, ` +
`l.updated_at DESC, l.protocol ASC`
return SQLQuery{
Text: `SELECT l.vin, ` +
`COALESCE(NULLIF(MAX(NULLIF(l.plate, '')), ''), b.plate, '') AS plate, ` +
`COALESCE(b.phone, '') AS phone, COALESCE(b.oem, '') AS oem, ` +
Text: `SELECT v.vin, ` +
`COALESCE(NULLIF(MAX(NULLIF(l.plate, '')), ''), v.plate, '') AS plate, ` +
`COALESCE(v.phone, '') AS phone, COALESCE(v.oem, '') AS oem, ` +
`COALESCE(GROUP_CONCAT(DISTINCT l.protocol ORDER BY l.protocol SEPARATOR ','), '') AS protocols, ` +
`COALESCE(GROUP_CONCAT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END ORDER BY l.protocol SEPARATOR ','), '') AS online_protocols, ` +
`COUNT(DISTINCT l.protocol) AS source_count, ` +
`COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) AS online_source_count, ` +
`CASE WHEN COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) > 0 THEN 1 ELSE 0 END AS online, ` +
`CASE WHEN MAX(CASE WHEN b.vin IS NOT NULL THEN 1 ELSE 0 END) = 1 THEN 'bound' ELSE 'unbound' END AS binding_status, ` +
`v.binding_status, ` +
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(l.protocol ORDER BY ` + orderExpr + `), ',', 1), '') AS primary_protocol, ` +
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(NULLIF(l.source_key, ''), CONCAT(l.protocol, ':canonical')) ORDER BY ` + orderExpr + `), ',', 1), '') AS location_source, ` +
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(l.location_conflict, 0) ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS location_conflict, ` +
`SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(CAST(l.location_conflict_distance_m AS CHAR), '-1') ORDER BY ` + orderExpr + `), ',', 1) AS conflict_distance_m, ` +
primaryLocationAvailable + ` AS location_available, ` +
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.longitude AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS longitude, ` +
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.latitude AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS latitude, ` +
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN l.speed_kmh IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS speed_available, ` +
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.speed_kmh AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS speed_kmh, ` +
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN l.protocol IN ('GB32960','YUTONG_MQTT') AND l.soc_percent IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS soc_available, ` +
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.soc_percent AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS soc_percent, ` +
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN l.total_mileage_km IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS mileage_available, ` +
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.total_mileage_km AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS total_mileage_km, ` +
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN m.daily_mileage_km IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS today_mileage_available, ` +
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(m.daily_mileage_km AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS today_mileage_km, ` +
`COALESCE(DATE_FORMAT(MAX(l.updated_at), '%Y-%m-%d %H:%i:%s'), '') AS last_seen, ` +
`CAST(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(s.access_report_interval_ms, -1) ORDER BY ` + orderExpr + `), ',', 1) AS SIGNED) AS report_interval_ms ` +
groupSQL +
`ORDER BY MAX(l.updated_at) DESC, l.vin ASC LIMIT ? OFFSET ?`,
`ORDER BY MAX(l.updated_at) IS NULL ASC, MAX(l.updated_at) DESC, v.vin ASC LIMIT ? OFFSET ?`,
Args: args,
CountText: `SELECT COUNT(*) FROM (SELECT l.vin ` + groupSQL + `) vehicle_realtime_count`,
CountText: `SELECT COUNT(*) FROM (SELECT v.vin ` + baseGroupSQL + groupSuffixSQL + `) vehicle_realtime_count`,
CountArgs: countArgs,
}
}

View File

@@ -403,14 +403,25 @@ func (s *ProductionStore) VehicleRealtime(ctx context.Context, query url.Values)
var longitude, latitude, speed, soc, mileage string
var conflictDistance sql.NullString
var locationConflict int
var locationAvailable int
var speedAvailable int
var socAvailable int
var mileageAvailable int
var todayMileageAvailable int
var reportIntervalMs int64
if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &protocols, &onlineProtocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.BindingStatus, &row.PrimaryProtocol, &row.LocationSource, &locationConflict, &conflictDistance, &longitude, &latitude, &speed, &soc, &mileage, &row.LastSeen, &reportIntervalMs); err != nil {
var todayMileage string
if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &protocols, &onlineProtocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.BindingStatus, &row.PrimaryProtocol, &row.LocationSource, &locationConflict, &conflictDistance, &locationAvailable, &longitude, &latitude, &speedAvailable, &speed, &socAvailable, &soc, &mileageAvailable, &mileage, &todayMileageAvailable, &todayMileage, &row.LastSeen, &reportIntervalMs); err != nil {
return Page[VehicleRealtimeRow]{}, err
}
row.Protocols = splitCSV(protocols)
row.SourceStatus = buildVehicleCoverageSourceStatus(row.Protocols, splitCSV(onlineProtocols), row.LastSeen)
row.Online = online == 1
row.LocationConflict = locationConflict == 1
row.LocationAvailable = locationAvailable == 1
row.SpeedAvailable = speedAvailable == 1
row.SOCAvailable = socAvailable == 1
row.MileageAvailable = mileageAvailable == 1
row.TodayMileageAvailable = todayMileageAvailable == 1
if conflictDistance.Valid {
value := parseFloatString(conflictDistance.String)
if value >= 0 {
@@ -423,6 +434,7 @@ func (s *ProductionStore) VehicleRealtime(ctx context.Context, query url.Values)
row.SpeedKmh = parseFloatString(speed)
row.SOCPercent = parseFloatString(soc)
row.TotalMileageKm = parseFloatString(mileage)
row.TodayMileageKm = parseFloatString(todayMileage)
if reportIntervalMs > 0 {
row.ReportIntervalMs = &reportIntervalMs
}

View File

@@ -200,12 +200,12 @@ func TestBuildRealtimeLocationSQL(t *testing.T) {
func TestBuildVehicleRealtimeSQL(t *testing.T) {
query := url.Values{"vin": {"粤A"}, "protocol": {"JT808"}, "online": {"online"}, "limit": {"10"}, "offset": {"20"}}
built := buildVehicleRealtimeSQL(query)
for _, want := range []string{"vehicle_realtime_location", "vehicle_identity_binding", "vehicle_realtime_snapshot", "access_report_interval_ms", "GROUP BY l.vin", "GROUP_CONCAT(DISTINCT l.protocol", "online_source_count", "vehicle_realtime_count"} {
for _, want := range []string{"vehicle_realtime_location", "vehicle_identity_binding", "vehicle_realtime_snapshot", "vehicle_daily_mileage", "access_report_interval_ms", "GROUP BY v.vin", "GROUP_CONCAT(DISTINCT l.protocol", "online_source_count", "location_available", "soc_available", "today_mileage_available", "vehicle_realtime_count"} {
if !strings.Contains(built.Text+built.CountText, want) {
t.Fatalf("SQL missing %q: %s / %s", want, built.Text, built.CountText)
}
}
if !strings.Contains(built.Text, "ORDER BY MAX(l.updated_at) DESC, l.vin ASC") {
if !strings.Contains(built.Text, "ORDER BY MAX(l.updated_at) IS NULL ASC, MAX(l.updated_at) DESC, v.vin ASC") {
t.Fatalf("SQL should keep vehicle-level stable pagination order: %s", built.Text)
}
for _, want := range []string{
@@ -233,7 +233,7 @@ func TestBuildVehicleRealtimeSQL(t *testing.T) {
func TestBuildVehicleRealtimeSQLFiltersMultipleVehicleKeywords(t *testing.T) {
built := buildVehicleRealtimeSQL(url.Values{"keywords": {"粤AG18312, 川AHTWO1, 粤AG18312"}, "limit": {"10"}})
for _, text := range []string{built.Text, built.CountText} {
if !strings.Contains(text, "l.vin IN (?,?)") || !strings.Contains(text, "l.plate IN (?,?)") || !strings.Contains(text, "b.plate IN (?,?)") {
if !strings.Contains(text, "v.vin IN (?,?)") || !strings.Contains(text, "l.plate IN (?,?)") || !strings.Contains(text, "v.plate IN (?,?)") {
t.Fatalf("batch vehicle search must use exact identity sets in rows and count: %s", text)
}
if strings.Contains(text, "LIKE") {
@@ -292,6 +292,20 @@ func TestBuildVehicleRealtimeSQLFiltersMotionStatusBeforePagination(t *testing.T
}
}
func TestBuildVehicleRealtimeSQLFiltersVehiclesWithoutRealtimeLocation(t *testing.T) {
built := buildVehicleRealtimeSQL(url.Values{"status": {"no_location"}, "limit": {"200"}})
for _, text := range []string{built.Text, built.CountText} {
for _, want := range []string{"vehicle_identity_binding b0", "LEFT JOIN vehicle_realtime_location l ON l.vin = v.vin", "= 0"} {
if !strings.Contains(text, want) {
t.Fatalf("no-location filter must retain identity population and filter after the left join, missing %q: %s", want, text)
}
}
}
if !strings.Contains(built.Text, "AS location_available") {
t.Fatalf("realtime row must expose explicit location availability: %s", built.Text)
}
}
func TestBuildDailyMileageSQL(t *testing.T) {
query := url.Values{"vin": {"VIN001"}, "protocol": {"JT808"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-03"}}
built := buildDailyMileageSQL(query)

View File

@@ -411,6 +411,10 @@ func (bounds monitorBounds) contains(longitude, latitude float64) bool {
return longitude >= bounds.minLongitude && longitude <= bounds.maxLongitude && latitude >= bounds.minLatitude && latitude <= bounds.maxLatitude
}
func validRealtimeCoordinate(longitude, latitude float64) bool {
return longitude >= -180 && longitude <= 180 && latitude >= -90 && latitude <= 90 && (longitude != 0 || latitude != 0)
}
func monitorVehicleStatus(row VehicleRealtimeRow) string {
if strings.TrimSpace(row.LastSeen) == "" {
return "unknown"
@@ -480,6 +484,9 @@ func vehicleSearchKeywords(query url.Values) []string {
func matchesMonitorStatus(row VehicleRealtimeRow, requested string) bool {
requested = strings.TrimSpace(requested)
if requested == "no_location" {
return !row.LocationAvailable
}
return requested == "" || monitorVehicleStatus(row) == requested || requested == "online" && row.Online
}
@@ -522,6 +529,11 @@ func (s *Service) buildMonitorSummary(ctx context.Context, query url.Values, veh
continue
}
result.TotalVehicles++
if vehicle.LocationAvailable {
result.LocationVehicles++
} else {
result.NoLocationVehicles++
}
if _, active := activeAlertVINs[vehicle.VIN]; active {
result.AlertVehicles++
}
@@ -607,10 +619,10 @@ func buildMonitorMapResponse(vehicles Page[VehicleRealtimeRow], query url.Values
if !matchesMonitorStatus(vehicle, query.Get("status")) {
continue
}
result.Total++
if vehicle.Longitude < 73 || vehicle.Longitude > 135 || vehicle.Latitude < 18 || vehicle.Latitude > 54 {
if !vehicle.LocationAvailable || vehicle.Longitude < 73 || vehicle.Longitude > 135 || vehicle.Latitude < 18 || vehicle.Latitude > 54 {
continue
}
result.Total++
if hasBounds && !bounds.contains(vehicle.Longitude, vehicle.Latitude) {
continue
}

View File

@@ -433,9 +433,9 @@ func TestMonitorWorkspaceSharesOneRealtimeSnapshot(t *testing.T) {
func TestMonitorMapUsesMeaningfulClustersAndReleasesPointsAtDetailZoom(t *testing.T) {
reportIntervalMs := int64(30000)
vehicles := Page[VehicleRealtimeRow]{Items: []VehicleRealtimeRow{
{VIN: "NEAR-1", Plate: "粤A00001", Online: true, Longitude: 113.260, Latitude: 23.130, ReportIntervalMs: &reportIntervalMs},
{VIN: "NEAR-2", Plate: "粤A00002", Online: true, Longitude: 113.265, Latitude: 23.135},
{VIN: "SINGLE", Plate: "粤A00003", Online: true, Longitude: 113.600, Latitude: 23.500},
{VIN: "NEAR-1", Plate: "粤A00001", Online: true, LocationAvailable: true, Longitude: 113.260, Latitude: 23.130, ReportIntervalMs: &reportIntervalMs},
{VIN: "NEAR-2", Plate: "粤A00002", Online: true, LocationAvailable: true, Longitude: 113.265, Latitude: 23.135},
{VIN: "SINGLE", Plate: "粤A00003", Online: true, LocationAvailable: true, Longitude: 113.600, Latitude: 23.500},
}, Total: 3, Limit: 3}
mixed, err := buildMonitorMapResponse(vehicles, url.Values{"zoom": {"10"}})
@@ -475,7 +475,7 @@ func syntheticMonitorVehicles(count int) Page[VehicleRealtimeRow] {
for index := range items {
items[index] = VehicleRealtimeRow{
VIN: "SYNTH" + strconv.Itoa(index), Online: true, SpeedKmh: float64(index % 90),
Longitude: 73 + float64((index/100)%200)*0.3, Latitude: 18 + float64(index%100)*0.3,
LocationAvailable: true, Longitude: 73 + float64((index/100)%200)*0.3, Latitude: 18 + float64(index%100)*0.3,
LastSeen: "2026-07-14T08:00:00+08:00",
}
}

View File

@@ -60,6 +60,8 @@ export interface CustomerUserInput {
export interface MonitorSummary {
totalVehicles: number;
locationVehicles?: number;
noLocationVehicles?: number;
onlineVehicles: number;
offlineVehicles: number;
drivingVehicles: number;
@@ -593,11 +595,17 @@ export interface VehicleRealtimeRow {
locationSource?: string;
locationConflict?: boolean;
conflictDistanceM?: number;
locationAvailable?: boolean;
longitude: number;
latitude: number;
speedAvailable?: boolean;
speedKmh: number;
socAvailable?: boolean;
socPercent: number;
mileageAvailable?: boolean;
totalMileageKm: number;
todayMileageAvailable?: boolean;
todayMileageKm?: number;
lastSeen: string;
reportIntervalMs?: number;
}

View File

@@ -9,11 +9,13 @@ import { ROUTER_FUTURE } from '../routing/routerConfig';
const vehicles = [{
vin: 'LTEST000000000001', plate: '粤A12345', protocols: ['JT808'], primaryProtocol: 'JT808',
longitude: 113.26, latitude: 23.13, speedKmh: 42, socPercent: 80, totalMileageKm: 1234,
locationAvailable: true, longitude: 113.26, latitude: 23.13, speedAvailable: true, speedKmh: 42,
socAvailable: false, socPercent: 0, mileageAvailable: true, totalMileageKm: 1234, todayMileageAvailable: true, todayMileageKm: 18.6,
lastSeen: '2026-07-14T01:00:00Z', online: true, sourceCount: 1, onlineSourceCount: 1
}, {
vin: 'LTEST000000000002', plate: '粤B67890', protocols: ['JT808'], primaryProtocol: 'JT808',
longitude: 113.28, latitude: 23.15, speedKmh: 0, socPercent: 72, totalMileageKm: 2234,
locationAvailable: true, longitude: 113.28, latitude: 23.15, speedAvailable: true, speedKmh: 0,
socAvailable: false, socPercent: 0, mileageAvailable: true, totalMileageKm: 2234, todayMileageAvailable: false, todayMileageKm: 0,
lastSeen: '2026-07-14T01:00:00Z', online: true, sourceCount: 1, onlineSourceCount: 1
}] as VehicleRealtimeRow[];
const monitorMap = { clusters: [], points: [], total: 2 };
@@ -58,7 +60,7 @@ vi.mock('../hooks/useMonitorData', () => ({
useMonitorData: (...args: unknown[]) => {
monitorDataArgsSpy(...args);
return {
summary: { data: { totalVehicles: 2, onlineVehicles: 2, offlineVehicles: 0, drivingVehicles: 1, idleVehicles: 1, frameToday: 10 } },
summary: { data: { totalVehicles: 2, locationVehicles: 2, noLocationVehicles: 0, onlineVehicles: 2, offlineVehicles: 0, drivingVehicles: 1, idleVehicles: 1, frameToday: 10 } },
vehicles: { data: { items: vehicles, total: 2 }, isError: false, ...monitorQueryFlags },
map: { data: monitorMap, isPlaceholderData: monitorQueryFlags.isPlaceholderData },
selectedVehicle: { data: { items: [] } }
@@ -95,7 +97,8 @@ test('starts without a selection and supports expand, collapse, reselection, and
expect(firstVehicle).not.toHaveClass('is-selected');
expect(screen.queryByRole('button', { name: '取消选择车辆' })).not.toBeInTheDocument();
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', '');
expect(screen.getByText('有实时位置')).toBeInTheDocument();
expect(screen.getByText('车辆总数')).toBeInTheDocument();
expect(screen.getAllByText('无实时位置').length).toBeGreaterThanOrEqual(2);
expect(screen.getByRole('status', { name: '搜索和筛选条件修改后自动生效' })).toHaveTextContent('实时筛选');
expect(screen.queryByRole('button', { name: '筛选' })).not.toBeInTheDocument();
expect(screen.queryByText('接入车辆')).not.toBeInTheDocument();
@@ -106,6 +109,7 @@ test('starts without a selection and supports expand, collapse, reselection, and
expect(screen.getByRole('button', { name: '取消选择车辆' })).toBeInTheDocument();
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', 'LTEST000000000001');
expect(vehicleCardArgsSpy).toHaveBeenLastCalledWith('LTEST000000000001', vehicles[0], true);
expect(screen.getByText('SOC').parentElement).toHaveTextContent('SOC—');
const mapRendersAfterSelection = fleetMapRenderSpy.mock.calls.length;
const cardCallsAfterSelection = vehicleCardArgsSpy.mock.calls.length;
@@ -140,15 +144,17 @@ test('switches to a lightweight realtime list and resolves addresses only on dem
const view = render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
fireEvent.click(screen.getByRole('button', { name: /列表/ }));
expect(await screen.findByText('车辆实时列表')).toBeInTheDocument();
expect(screen.getByText('速度、里程和坐标实时刷新,文字地址按需解析')).toBeInTheDocument();
expect(screen.getByText('覆盖全部授权车辆;实时字段缺失时显示“—”,文字地址按需解析')).toBeInTheDocument();
expect(await screen.findAllByText('粤A12345')).toHaveLength(1);
expect(screen.getAllByText('42')).toHaveLength(1);
expect(screen.getAllByText(/1,234/)).toHaveLength(1);
expect(screen.getAllByText(/18\.6/)).toHaveLength(1);
expect(screen.getAllByText(/113\.260000/)).toHaveLength(1);
expect(reverseGeocode).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '解析粤A12345位置' }));
expect(await screen.findByText('广东省广州市天河区测试路')).toBeInTheDocument();
expect(screen.getByText(/解析于 \d{2}:\d{2}/)).toBeInTheDocument();
expect(reverseGeocode).toHaveBeenCalledTimes(1);
expect(queryClient.getQueryCache().findAll({ queryKey: ['monitor', 'list-address'] })).toHaveLength(1);
expect(screen.queryByText('综合状态')).not.toBeInTheDocument();
@@ -160,6 +166,41 @@ test('switches to a lightweight realtime list and resolves addresses only on dem
view.unmount();
});
test('keeps authorized vehicles without realtime coordinates in the list without geocoding them', async () => {
const row = {
...vehicles[0],
vin: 'LNOLOCATION0000001',
plate: '粤A无定位',
protocols: [],
primaryProtocol: '',
locationAvailable: false,
longitude: 0,
latitude: 0,
speedAvailable: false,
speedKmh: 0,
mileageAvailable: false,
totalMileageKm: 0,
todayMileageAvailable: false,
todayMileageKm: 0,
lastSeen: '',
online: false,
sourceCount: 0,
onlineSourceCount: 0
} satisfies VehicleRealtimeRow;
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [row], total: 1, limit: 50, offset: 0 });
const reverseGeocode = vi.spyOn(api, 'reverseGeocode');
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
fireEvent.click(screen.getByRole('button', { name: /列表/ }));
expect(await screen.findByText('粤A无定位')).toBeInTheDocument();
expect(screen.getByText('暂无实时位置')).toBeInTheDocument();
expect(screen.getByText('从未上报')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /解析粤A无定位位置/ })).not.toBeInTheDocument();
expect(reverseGeocode).not.toHaveBeenCalled();
});
test('pauses selected-vehicle polling in list mode and resumes it when returning to the map', () => {
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: vehicles, total: 2, limit: 50, offset: 0 });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });

View File

@@ -11,7 +11,7 @@ import { MAX_MONITOR_SEARCH_TERMS, MONITOR_REFRESH, monitorFilterScope, monitorQ
import { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
const protocols = ['', 'GB32960', 'JT808', 'YUTONG_MQTT'];
const statuses = ['', 'online', 'offline', 'driving', 'idle'];
const statuses = ['', 'online', 'offline', 'driving', 'idle', 'no_location'];
const EMPTY_VEHICLES: VehicleRealtimeRow[] = [];
const MOBILE_MONITOR_QUERY = '(max-width: 760px)';
@@ -49,8 +49,27 @@ function useMobileMonitorLayout() {
type AddressCoordinate = { longitude: number; latitude: number; key: string };
function hasRealtimeLocation(vehicle: VehicleRealtimeRow) {
if (vehicle.locationAvailable != null) return vehicle.locationAvailable;
return Number.isFinite(vehicle.longitude) && Number.isFinite(vehicle.latitude)
&& Math.abs(vehicle.longitude) <= 180 && Math.abs(vehicle.latitude) <= 90
&& (vehicle.longitude !== 0 || vehicle.latitude !== 0);
}
function hasRealtimeSpeed(vehicle: VehicleRealtimeRow) {
return vehicle.speedAvailable ?? hasRealtimeLocation(vehicle);
}
function hasRealtimeMileage(vehicle: VehicleRealtimeRow) {
return vehicle.mileageAvailable ?? hasRealtimeLocation(vehicle);
}
function hasRealtimeSOC(vehicle: VehicleRealtimeRow) {
return vehicle.socAvailable ?? (vehicle.primaryProtocol === 'GB32960' || vehicle.primaryProtocol === 'YUTONG_MQTT');
}
function addressCoordinate(vehicle: VehicleRealtimeRow): AddressCoordinate | undefined {
if (!Number.isFinite(vehicle.longitude) || !Number.isFinite(vehicle.latitude)
if (!hasRealtimeLocation(vehicle) || !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));
@@ -75,29 +94,31 @@ const MonitorAddressCell = memo(function MonitorAddressCell({ vehicle }: { vehic
});
const moved = Boolean(current && requested && current.key !== requested.key);
if (!current) return <span className="v2-monitor-address-empty"></span>;
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>;
return <div className="v2-monitor-address-result"><span title={addressQuery.data?.formattedAddress}>{addressQuery.data?.formattedAddress || '暂无地址'}</span><small>{addressQuery.dataUpdatedAt ? `解析于 ${new Date(addressQuery.dataUpdatedAt).toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' })}` : ''}</small>{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 }) {
const mobile = useMobileMonitorLayout();
return <section className="v2-monitor-table-panel">
<header><div><strong></strong><span></span></div><b>{total.toLocaleString('zh-CN')} </b></header>
{!mobile ? <div className="v2-monitor-table-scroll"><table><thead><tr><th></th><th></th><th></th><th></th><th></th></tr></thead>
<header><div><strong></strong><span></span></div><b>{total.toLocaleString('zh-CN')} </b></header>
{!mobile ? <div className="v2-monitor-table-scroll"><table><thead><tr><th></th><th></th><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><span className="v2-monitor-source-badge">{row.primaryProtocol || '—'}</span></td>
<td><strong className="v2-monitor-live-value">{hasRealtimeSpeed(row) ? formatNumber(row.speedKmh, 1) : '—'}</strong>{hasRealtimeSpeed(row) ? <small className="v2-monitor-live-unit">km/h</small> : null}</td>
<td><div className="v2-monitor-mileage"><span><small></small><strong>{hasRealtimeMileage(row) ? `${formatNumber(row.totalMileageKm, 1)} km` : '—'}</strong></span><span><small></small><strong>{row.todayMileageAvailable ? `${formatNumber(row.todayMileageKm ?? 0, 1)} km` : '—'}</strong></span></div></td>
<td>{hasRealtimeLocation(row) ? <code className="v2-monitor-coordinate">{row.longitude.toFixed(6)}<br />{row.latitude.toFixed(6)}</code> : <span className="v2-monitor-unavailable"></span>}</td>
<td><MonitorAddressCell vehicle={row} /></td>
<td><div className="v2-monitor-last-seen"><strong>{row.lastSeen ? relativeFreshness(row.lastSeen) : '从未上报'}</strong><span>{row.lastSeen || '—'}</span></div></td>
</tr>)}</tbody></table>{loading ? <div className="v2-monitor-table-loading"><i className="v2-spinner" /></div> : null}{!loading && !rows.length ? <EmptyState /> : null}</div> : null}
{mobile ? <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>
<header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin} · {row.primaryProtocol || '暂无来源'}</span></div><b>{hasRealtimeSpeed(row) ? formatNumber(row.speedKmh, 1) : '—'}{hasRealtimeSpeed(row) ? <small>km/h</small> : null}</b></header>
<dl><div><dt></dt><dd>{hasRealtimeMileage(row) ? `${formatNumber(row.totalMileageKm, 1)} km` : '—'}</dd></div><div><dt></dt><dd>{row.todayMileageAvailable ? `${formatNumber(row.todayMileageKm ?? 0, 1)} km` : '—'}</dd></div><div><dt></dt><dd>{hasRealtimeLocation(row) ? <code>{row.longitude.toFixed(6)}, {row.latitude.toFixed(6)}</code> : '—'}</dd></div><div><dt></dt><dd>{row.lastSeen ? relativeFreshness(row.lastSeen) : '从未上报'}</dd></div><div className="is-address"><dt></dt><dd><MonitorAddressCell vehicle={row} /></dd></div></dl>
<footer><button type="button" onClick={() => onSelect(row.vin)}><IconMapPin />{hasRealtimeLocation(row) ? '地图定位' : '查看车辆'}</button></footer>
</article>)}{loading ? <div className="v2-monitor-table-loading"><i className="v2-spinner" /></div> : null}{!loading && !rows.length ? <EmptyState /> : null}</div> : null}
<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>;
@@ -199,9 +220,9 @@ function VehicleDetailCard({
<section>
<h3></h3>
<div className="v2-metric-grid">
<div><small></small><strong>{formatNumber(vehicle.speedKmh, 1)}<em>km/h</em></strong></div>
<div><small>SOC</small><strong>{formatNumber(vehicle.socPercent, 1)}<em>%</em></strong></div>
<div><small></small><strong>{formatNumber(vehicle.totalMileageKm, 1)}<em>km</em></strong></div>
<div><small></small><strong>{hasRealtimeSpeed(vehicle) ? formatNumber(vehicle.speedKmh, 1) : '—'}<em>{hasRealtimeSpeed(vehicle) ? 'km/h' : ''}</em></strong></div>
<div><small>SOC</small><strong>{hasRealtimeSOC(vehicle) ? formatNumber(vehicle.socPercent, 1) : '—'}<em>{hasRealtimeSOC(vehicle) ? '%' : ''}</em></strong></div>
<div><small></small><strong>{hasRealtimeMileage(vehicle) ? formatNumber(vehicle.totalMileageKm, 1) : '—'}<em>{hasRealtimeMileage(vehicle) ? 'km' : ''}</em></strong></div>
<div><small></small><strong>{dailyMileage == null ? '—' : formatNumber(dailyMileage, 1)}<em>{dailyMileage == null ? '' : 'km'}</em></strong></div>
<div><small></small><strong>{statusLabel(status)}</strong></div>
<div><small></small><strong>{formatNumber(activeAlerts?.total ?? 0)}<em></em></strong></div>
@@ -212,8 +233,8 @@ function VehicleDetailCard({
<dl className="v2-detail-list">
<div><dt></dt><dd>{vehicle.lastSeen || '暂无'}</dd></div>
<div><dt></dt><dd>{relativeFreshness(vehicle.lastSeen)}</dd></div>
<div><dt></dt><dd>{vehicle.longitude.toFixed(6)}, {vehicle.latitude.toFixed(6)}</dd></div>
<div><dt></dt><dd>{address?.formattedAddress || '位置解析中'}</dd></div>
<div><dt></dt><dd>{hasRealtimeLocation(vehicle) ? `${vehicle.longitude.toFixed(6)}, ${vehicle.latitude.toFixed(6)}` : '—'}</dd></div>
<div><dt></dt><dd>{hasRealtimeLocation(vehicle) ? address?.formattedAddress || '位置解析中' : '暂无实时位置'}</dd></div>
<div><dt></dt><dd>{latestAlert ? `${latestAlert.ruleName} · ${latestAlert.severity}` : '无当前业务告警'}</dd></div>
</dl>
</section>
@@ -328,7 +349,7 @@ export default function MonitorPage() {
{protocols.map((item) => <option key={item} value={item}>{item || '全部协议'}</option>)}
</select>
<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>)}
{statuses.map((item) => <option key={item} value={item}>{item === 'no_location' ? '无实时位置' : item ? statusLabel(item as never) : '全部状态'}</option>)}
</select>
<button type="button" className="v2-secondary-button" onClick={() => { setKeyword(''); setProtocol(''); setStatus(''); setListOffset(0); }}><IconRefresh /></button>
<span className="v2-filter-live-status" role="status" title="搜索和筛选条件修改后自动生效"><IconFilter /></span>
@@ -339,7 +360,8 @@ export default function MonitorPage() {
<section className="v2-kpis" aria-label="车辆整体统计">
{[
['有实时位置', formatNumber(summary.data?.totalVehicles ?? vehicles.data?.total ?? rows.length), 'fleet'],
['车辆总数', formatNumber(summary.data?.totalVehicles ?? vehicles.data?.total ?? rows.length), 'fleet'],
['无实时位置', formatNumber(summary.data?.noLocationVehicles ?? 0), 'missing'],
['当前在线', formatNumber(summary.data?.onlineVehicles ?? rows.length - offline), 'online'],
['当前离线', formatNumber(summary.data?.offlineVehicles ?? offline), 'offline'],
['行驶车辆', formatNumber(summary.data?.drivingVehicles ?? driving), 'driving'],

View File

@@ -123,7 +123,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
.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-kpis { display: grid; grid-template-columns: repeat(8, minmax(86px, 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; }
.v2-kpi + .v2-kpi::before { position: absolute; inset: 12px auto 12px 0; width: 1px; background: var(--v2-border); content: ""; }
.v2-kpi small { display: block; color: var(--v2-muted); font-size: 11px; }
@@ -131,7 +131,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-kpi.is-online strong, .v2-kpi.is-idle strong { color: var(--v2-green); }
.v2-kpi.is-driving strong, .v2-kpi.is-today strong { color: var(--v2-blue); }
.v2-kpi.is-alert strong { color: var(--v2-red); }
.v2-kpi.is-offline strong { color: #7b8798; }
.v2-kpi.is-offline strong, .v2-kpi.is-missing strong { color: #7b8798; }
.v2-monitor-workspace { display: grid; min-height: 0; flex: 1; grid-template-columns: clamp(244px, 14vw, 300px) minmax(480px, 1fr); grid-template-rows: minmax(0, 1fr); overflow: hidden; border: 1px solid #dce4ee; border-radius: 8px; background: #fff; box-shadow: 0 4px 16px rgba(21,32,51,.04); }
.v2-monitor-workspace.is-detail-open { grid-template-columns: clamp(244px, 14vw, 300px) minmax(480px, 1fr) clamp(300px, 17vw, 360px); }
@@ -166,9 +166,9 @@ button, a { -webkit-tap-highlight-color: transparent; }
.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 table { width: 100%; min-width: 1080px; 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 th:nth-child(1) { width: 180px; }.v2-monitor-table-scroll th:nth-child(2) { width: 116px; }.v2-monitor-table-scroll th:nth-child(3) { width: 92px; }.v2-monitor-table-scroll th:nth-child(4) { width: 154px; }.v2-monitor-table-scroll th:nth-child(5) { width: 150px; }.v2-monitor-table-scroll th:nth-child(6) { width: auto; }.v2-monitor-table-scroll th:nth-child(7) { width: 148px; }
.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; }
@@ -178,11 +178,18 @@ button, a { -webkit-tap-highlight-color: transparent; }
.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-source-badge { display: inline-flex !important; max-width: 100%; align-items: center; overflow: hidden; border: 1px solid #dbe5f1; border-radius: 999px; background: #f7faff; padding: 4px 8px; color: #42648f; font-size: 9px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
.v2-monitor-mileage, .v2-monitor-last-seen { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.v2-monitor-mileage > span { display: grid; min-width: 0; grid-template-columns: 26px minmax(0,1fr); align-items: center; gap: 4px; }
.v2-monitor-mileage small { color: #8996a8; font-size: 8px; }.v2-monitor-mileage strong { overflow: hidden; color: #34445a; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
.v2-monitor-last-seen strong { color: #46566c; font-size: 10px; }.v2-monitor-last-seen span { overflow: hidden; color: #8a97a9; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
.v2-monitor-unavailable { color: #a2acb9; }
.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 > small { color: #98a3b2; font-size: 8px; }
.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; }