diff --git a/vehicle-data-platform/apps/api/internal/platform/handler_test.go b/vehicle-data-platform/apps/api/internal/platform/handler_test.go index 40a8a075..15c95eb1 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -36,7 +36,7 @@ func TestHandlerVehicles(t *testing.T) { func TestHandlerVehicleCoverage(t *testing.T) { handler := NewHandler(NewService(NewMockStore())) rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/vehicles/coverage?limit=10", nil) + req := httptest.NewRequest(http.MethodGet, "/api/vehicles/coverage?limit=10&coverage=multi&online=online&bindingStatus=bound", nil) handler.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) diff --git a/vehicle-data-platform/apps/api/internal/platform/mock_store.go b/vehicle-data-platform/apps/api/internal/platform/mock_store.go index f7bd42ca..785bbfe3 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -87,7 +87,9 @@ func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[V } items := make([]VehicleCoverageRow, 0, len(byVIN)) for _, row := range byVIN { - items = append(items, *row) + if keepCoverageRow(*row, query) { + items = append(items, *row) + } } sortVehicleCoverage(items) return page(items, query), nil @@ -237,6 +239,37 @@ func sortVehicleCoverage(rows []VehicleCoverageRow) { }) } +func keepCoverageRow(row VehicleCoverageRow, query url.Values) bool { + switch strings.TrimSpace(query.Get("coverage")) { + case "single": + if row.SourceCount != 1 { + return false + } + case "multi": + if row.SourceCount <= 1 { + return false + } + } + switch strings.TrimSpace(query.Get("online")) { + case "online": + if !row.Online { + return false + } + case "offline": + if row.Online { + return false + } + } + switch strings.TrimSpace(query.Get("bindingStatus")) { + case "bound": + return row.BindingStatus == "bound" + case "unbound": + return row.BindingStatus == "unbound" + default: + return true + } +} + func parsePositive(raw string, fallback int) int { value, err := strconv.Atoi(raw) if err != nil || value < 0 { diff --git a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go index da18c38d..27a8d0f9 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go +++ b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go @@ -45,6 +45,7 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery { offset := parsePositive(query.Get("offset"), 0) args := []any{} where := []string{"s.vin IS NOT NULL", "s.vin <> ''"} + having := []string{} if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" { where = append(where, "(s.vin LIKE ? OR s.plate LIKE ? OR b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)") like := "%" + keyword + "%" @@ -54,21 +55,44 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery { where = append(where, "s.protocol = ?") args = append(args, protocol) } + switch strings.TrimSpace(query.Get("coverage")) { + case "single": + having = append(having, "COUNT(DISTINCT s.protocol) = 1") + case "multi": + having = append(having, "COUNT(DISTINCT s.protocol) > 1") + } + switch strings.TrimSpace(query.Get("online")) { + case "online": + having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) > 0") + case "offline": + having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) = 0") + } + switch strings.TrimSpace(query.Get("bindingStatus")) { + case "bound": + having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1") + case "unbound": + having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 0") + } args = append(args, limit, offset) + havingSQL := "" + if len(having) > 0 { + havingSQL = ` HAVING ` + strings.Join(having, " AND ") + ` ` + } return SQLQuery{ Text: `SELECT s.vin, ` + `COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), b.plate, '') AS plate, ` + `COALESCE(b.phone, '') AS phone, COALESCE(b.oem, '') AS oem, ` + `COALESCE(GROUP_CONCAT(DISTINCT s.protocol ORDER BY s.protocol SEPARATOR ','), '') AS protocols, ` + `COUNT(DISTINCT s.protocol) AS source_count, ` + - `SUM(CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN 1 ELSE 0 END) AS online_source_count, ` + - `CASE WHEN SUM(CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN 1 ELSE 0 END) > 0 THEN 1 ELSE 0 END AS online, ` + + `COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) AS online_source_count, ` + + `CASE WHEN COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) > 0 THEN 1 ELSE 0 END AS online, ` + `COALESCE(DATE_FORMAT(MAX(s.updated_at), '%Y-%m-%d %H:%i:%s'), '') AS last_seen, ` + `CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 'bound' ELSE 'unbound' END AS binding_status ` + `FROM vehicle_realtime_snapshot s ` + `LEFT JOIN vehicle_identity_binding b ON b.vin = s.vin ` + `WHERE ` + strings.Join(where, " AND ") + ` ` + `GROUP BY s.vin, b.plate, b.phone, b.oem, b.vin ` + + havingSQL + `ORDER BY MAX(s.updated_at) DESC LIMIT ? OFFSET ?`, Args: args, } diff --git a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go index 3de78ef1..241b4439 100644 --- a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go @@ -20,13 +20,16 @@ func TestBuildVehicleListSQL(t *testing.T) { } func TestBuildVehicleCoverageSQL(t *testing.T) { - query := url.Values{"keyword": {"粤A"}, "protocol": {"GB32960"}, "limit": {"8"}, "offset": {"16"}} + query := url.Values{"keyword": {"粤A"}, "protocol": {"GB32960"}, "coverage": {"multi"}, "online": {"online"}, "bindingStatus": {"bound"}, "limit": {"8"}, "offset": {"16"}} built := buildVehicleCoverageSQL(query) - for _, want := range []string{"GROUP BY s.vin", "GROUP_CONCAT(DISTINCT s.protocol", "source_count", "online_source_count"} { + for _, want := range []string{"GROUP BY s.vin", "HAVING", "GROUP_CONCAT(DISTINCT s.protocol", "source_count", "online_source_count"} { if !strings.Contains(built.Text, want) { t.Fatalf("SQL missing %q: %s", want, built.Text) } } + if strings.Contains(built.Text, "1ORDER BY") || strings.Contains(built.Text, "0ORDER BY") { + t.Fatalf("SQL should keep whitespace before ORDER BY: %s", built.Text) + } if len(built.Args) != 9 || built.Args[0] != "%粤A%" || built.Args[6] != "GB32960" || built.Args[7] != 8 || built.Args[8] != 16 { t.Fatalf("args = %#v", built.Args) } diff --git a/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx b/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx index d07bfcfa..d24ce625 100644 --- a/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx @@ -1,4 +1,4 @@ -import { Button, Card, Col, Row, Space, Spin, Table, Tag, Toast, Typography } from '@douyinfe/semi-ui'; +import { Button, Card, Col, Form, Row, Select, Space, Spin, Table, Tag, Toast, Typography } from '@douyinfe/semi-ui'; import { useEffect, useState } from 'react'; import { api } from '../api/client'; import type { DashboardSummary, LinkHealth, ProtocolStat, RealtimeLocationRow, VehicleCoverageRow, VehicleRow } from '../api/types'; @@ -17,6 +17,20 @@ export function Dashboard({ onOpenVehicle }: { onOpenVehicle: (vin: string) => v const [coverage, setCoverage] = useState([]); const [locations, setLocations] = useState([]); const [loading, setLoading] = useState(true); + const [coverageLoading, setCoverageLoading] = useState(false); + + const loadCoverage = (values?: Record) => { + setCoverageLoading(true); + const params = new URLSearchParams({ limit: '8' }); + if (values?.keyword) params.set('keyword', values.keyword); + if (values?.coverage) params.set('coverage', values.coverage); + if (values?.online) params.set('online', values.online); + if (values?.bindingStatus) params.set('bindingStatus', values.bindingStatus); + api.vehicleCoverage(params) + .then((page) => setCoverage(page.items)) + .catch((error: Error) => Toast.error(error.message)) + .finally(() => setCoverageLoading(false)); + }; useEffect(() => { Promise.all([ @@ -93,7 +107,27 @@ export function Dashboard({ onOpenVehicle }: { onOpenVehicle: (vin: string) => v Kafka 当前消费积压:{summary?.kafkaLag ?? 0} +
loadCoverage(values as Record)} style={{ marginBottom: 12 }}> + + + 单源 + 多源 + + + 在线 + 离线 + + + 已绑定 + 未绑定 + + + + + +