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 15c95eb1..697e148c 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -56,7 +56,7 @@ func TestHandlerVehicleDetail(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) } - for _, want := range []string{"identity", "realtime", "history", "raw", "mileage", "sources", "sourceStatus"} { + for _, want := range []string{"identity", "realtime", "history", "raw", "mileage", "quality", "sources", "sourceStatus", "VIN_MISSING"} { if !strings.Contains(rec.Body.String(), want) { t.Fatalf("response missing %q: %s", want, 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 785bbfe3..55c3ef51 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -163,9 +163,15 @@ func (m *MockStore) DailyMileage(_ context.Context, query url.Values) (Page[Dail func (m *MockStore) QualityIssues(_ context.Context, query url.Values) (Page[QualityIssueRow], error) { rows := []QualityIssueRow{ - {VIN: "LB9A32A24P0LS1230", Plate: "粤AFF7936", Protocol: "JT808", IssueType: "VIN_MISSING", Severity: "warning", LastSeen: "2026-07-03 19:58:00", Detail: "phone 未命中 binding 表"}, + {VIN: "LB9A32A24R0LS1426", Plate: "粤AG18312", Protocol: "JT808", IssueType: "VIN_MISSING", Severity: "warning", LastSeen: "2026-07-03 19:58:00", Detail: "phone 曾未命中 binding 表,已通过手机号关联 VIN"}, {VIN: "LNXNEGRR7SR318212", Plate: "川AHTWO1", Protocol: "GB32960", IssueType: "LINK_GAP", Severity: "error", LastSeen: "2026-07-03 18:20:00", Detail: "Hyundai 平台近 60 分钟无转发"}, } + if vin := strings.TrimSpace(query.Get("vin")); vin != "" { + rows = keep(rows, func(row QualityIssueRow) bool { return row.VIN == vin }) + } + if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" { + rows = keep(rows, func(row QualityIssueRow) bool { return row.Protocol == protocol }) + } return page(rows, query), nil } diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index a7592ff0..db8d49d1 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -63,6 +63,7 @@ type VehicleDetail struct { History Page[HistoryLocationRow] `json:"history"` Raw Page[RawFrameRow] `json:"raw"` Mileage Page[DailyMileageRow] `json:"mileage"` + Quality Page[QualityIssueRow] `json:"quality"` } type VehicleSourceStatus struct { diff --git a/vehicle-data-platform/apps/api/internal/platform/production_store.go b/vehicle-data-platform/apps/api/internal/platform/production_store.go index f6cfdb2d..285a40ec 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -273,19 +273,38 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P func (s *ProductionStore) QualityIssues(ctx context.Context, query url.Values) (Page[QualityIssueRow], error) { limit, offset := buildLimitOffset(query) - rows, err := s.db.QueryContext(ctx, `SELECT phone, COALESCE(source_endpoint, ''), COALESCE(DATE_FORMAT(latest_seen_at, '%Y-%m-%d %H:%i:%s'), '') FROM jt808_registration WHERE vin = '' OR vin = 'unknown' OR vin IS NULL ORDER BY latest_seen_at DESC LIMIT ? OFFSET ?`, limit, offset) + where := []string{"(r.vin = '' OR r.vin = 'unknown' OR r.vin IS NULL)"} + args := []any{} + if vin := strings.TrimSpace(query.Get("vin")); vin != "" { + where = append(where, "(r.vin = ? OR b.vin = ?)") + args = append(args, vin, vin) + } + if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" { + where = append(where, "? = 'JT808'") + args = append(args, protocol) + } + if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" { + where = append(where, "(r.phone LIKE ? OR COALESCE(r.source_endpoint, '') LIKE ? OR COALESCE(b.plate, '') LIKE ?)") + like := "%" + keyword + "%" + args = append(args, like, like, like) + } + args = append(args, limit, offset) + rows, err := s.db.QueryContext(ctx, `SELECT COALESCE(b.vin, 'unknown') AS vin, COALESCE(b.plate, '') AS plate, r.phone, COALESCE(r.source_endpoint, ''), COALESCE(DATE_FORMAT(r.latest_seen_at, '%Y-%m-%d %H:%i:%s'), '') `+ + `FROM jt808_registration r LEFT JOIN vehicle_identity_binding b ON b.phone = r.phone `+ + `WHERE `+strings.Join(where, " AND ")+` ORDER BY r.latest_seen_at DESC LIMIT ? OFFSET ?`, args...) if err != nil { return Page[QualityIssueRow]{}, nil } defer rows.Close() items := make([]QualityIssueRow, 0) for rows.Next() { - var phone, source, lastSeen string - if err := rows.Scan(&phone, &source, &lastSeen); err != nil { + var vin, plate, phone, source, lastSeen string + if err := rows.Scan(&vin, &plate, &phone, &source, &lastSeen); err != nil { return Page[QualityIssueRow]{}, err } items = append(items, QualityIssueRow{ - VIN: "unknown", + VIN: vin, + Plate: plate, Protocol: "JT808", IssueType: "VIN_MISSING", Severity: "warning", diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index 7e0d770b..2908ac00 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -69,10 +69,12 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string historyQuery := url.Values{"vin": {resolvedVIN}, "limit": {"20"}} rawQuery := RawFrameQuery{VIN: resolvedVIN, IncludeFields: true, Limit: 10} mileageQuery := url.Values{"vin": {resolvedVIN}, "limit": {"20"}} + qualityQuery := url.Values{"vin": {resolvedVIN}, "limit": {"20"}} if protocol != "" { realtimeQuery.Set("protocol", protocol) historyQuery.Set("protocol", protocol) rawQuery.Protocol = protocol + qualityQuery.Set("protocol", protocol) } realtime, err := s.store.RealtimeLocations(ctx, realtimeQuery) if err != nil { @@ -90,6 +92,10 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string if err != nil { return VehicleDetail{}, err } + quality, err := s.store.QualityIssues(ctx, qualityQuery) + if err != nil { + return VehicleDetail{}, err + } sourceStatus := vehicleSourceStatus(vehicles.Items, realtime.Items, history.Items, raw.Items, mileage.Items) sourceStatus = s.enrichVehicleSourceStatus(ctx, resolvedVIN, sourceStatus) return VehicleDetail{ @@ -101,6 +107,7 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string History: history, Raw: raw, Mileage: mileage, + Quality: quality, }, nil } diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index ab44c70c..a9ae4ba6 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -60,6 +60,7 @@ export interface VehicleDetail { history: Page; raw: Page; mileage: Page; + quality: Page; } export interface VehicleSourceStatus { diff --git a/vehicle-data-platform/apps/web/src/pages/Quality.tsx b/vehicle-data-platform/apps/web/src/pages/Quality.tsx index 6c5ea8e4..42859c62 100644 --- a/vehicle-data-platform/apps/web/src/pages/Quality.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Quality.tsx @@ -1,4 +1,4 @@ -import { Card, Col, Row, Table, Tag, Toast } from '@douyinfe/semi-ui'; +import { Button, Card, Col, Form, Row, Select, Space, Table, Tag, Toast } from '@douyinfe/semi-ui'; import { useEffect, useState } from 'react'; import { api } from '../api/client'; import type { OpsHealth, QualityIssueRow } from '../api/types'; @@ -8,10 +8,17 @@ export function Quality() { const [issues, setIssues] = useState([]); const [health, setHealth] = useState(null); - useEffect(() => { - api.qualityIssues(new URLSearchParams({ limit: '20' })) + const loadIssues = (values?: Record) => { + const params = new URLSearchParams({ limit: '20' }); + if (values?.keyword) params.set('keyword', values.keyword); + if (values?.protocol) params.set('protocol', values.protocol); + api.qualityIssues(params) .then((page) => setIssues(page.items)) .catch((error: Error) => Toast.error(error.message)); + }; + + useEffect(() => { + loadIssues(); api.opsHealth() .then(setHealth) .catch((error: Error) => Toast.error(error.message)); @@ -26,6 +33,16 @@ export function Quality() { {health?.tdengineWritable && health.mysqlWritable ? '正常' : '异常'} +
loadIssues(values as Record)} style={{ marginBottom: 12 }}> + + + JT808 + + + + + + detail?.sources ?? [], [detail?.sources]); const latestRaw = detail?.raw.items[0]; + const qualityCount = detail?.quality.items.length ?? 0; return (
@@ -78,7 +79,8 @@ export function VehicleDetail({ vin }: { vin: string }) { { key: '在线', value: }, { key: '来源协议', value: protocols.length > 0 ? {protocols.map((item) => {item})} : '-' }, { key: '最后位置时间', value: latest?.lastSeen ?? '-' }, - { key: '最新 RAW 时间', value: latestRaw?.serverTime ?? '-' } + { key: '最新 RAW 时间', value: latestRaw?.serverTime ?? '-' }, + { key: '质量风险', value: 0 ? 'orange' : 'green'}>{qualityCount > 0 ? `${qualityCount} 项` : '无'} } ]} />
@@ -170,6 +172,21 @@ export function VehicleDetail({ vin }: { vin: string }) { ]} /> + +
`${row?.protocol ?? ''}-${row?.issueType ?? ''}-${row?.lastSeen ?? ''}`} + dataSource={detail?.quality.items ?? []} + columns={[ + { title: '来源', dataIndex: 'protocol', width: 130 }, + { title: '问题', dataIndex: 'issueType', width: 150 }, + { title: '级别', width: 110, render: (_: unknown, row: QualityIssueRow) => {row.severity} }, + { title: '最后时间', dataIndex: 'lastSeen', width: 190 }, + { title: '说明', dataIndex: 'detail' } + ]} + /> +