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 77cd93a9..6efb00c3 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -647,6 +647,40 @@ func TestBuildQualityIssueWhereUsesMatchingArgs(t *testing.T) { } } +func TestBuildQualityIssueWhereFiltersIssueType(t *testing.T) { + fromSQL, args := buildQualityIssueWhere(url.Values{"issueType": {"NO_SOURCE"}}) + if !strings.Contains(fromSQL, "q.issue_type = ?") { + t.Fatalf("quality issue SQL should filter issue type: %s", fromSQL) + } + if len(args) != 1 || args[0] != "NO_SOURCE" { + t.Fatalf("args = %#v", args) + } +} + +func TestHandlerQualityIssuesFiltersIssueType(t *testing.T) { + handler := NewHandler(NewService(NewMockStore())) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/quality/issues?issueType=NO_SOURCE&limit=20", 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 Page[QualityIssueRow] `json:"data"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("response JSON should decode: %v body=%s", err, rec.Body.String()) + } + if body.Data.Total == 0 { + t.Fatalf("expected at least one NO_SOURCE issue") + } + for _, item := range body.Data.Items { + if item.IssueType != "NO_SOURCE" { + t.Fatalf("expected only NO_SOURCE issues, got %+v", body.Data.Items) + } + } +} + func TestHandlerOpsHealthIncludesVehicleServiceRuntime(t *testing.T) { handler := NewHandler(NewServiceWithRuntime(NewMockStore(), RuntimeInfo{RequestTimeoutMs: 1500})) rec := httptest.NewRecorder() 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 eb22b9a2..a7ef66cf 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -527,6 +527,9 @@ func (m *MockStore) QualityIssues(_ context.Context, query url.Values) (Page[Qua if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" { rows = keep(rows, func(row QualityIssueRow) bool { return row.Protocol == protocol }) } + if issueType := strings.TrimSpace(query.Get("issueType")); issueType != "" { + rows = keep(rows, func(row QualityIssueRow) bool { return row.IssueType == issueType }) + } return page(rows, query), nil } 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 fc04a17a..21a11e23 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -713,6 +713,10 @@ func buildQualityIssueWhere(query url.Values) (string, []any) { where = append(where, "q.protocol = ?") args = append(args, protocol) } + if issueType := strings.TrimSpace(query.Get("issueType")); issueType != "" { + where = append(where, "q.issue_type = ?") + args = append(args, issueType) + } if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" { where = append(where, "(q.vin LIKE ? OR q.plate LIKE ? OR q.phone LIKE ? OR q.source_endpoint LIKE ?)") like := "%" + keyword + "%" diff --git a/vehicle-data-platform/apps/web/src/domain/qualityIssue.test.ts b/vehicle-data-platform/apps/web/src/domain/qualityIssue.test.ts index 73683476..618c3057 100644 --- a/vehicle-data-platform/apps/web/src/domain/qualityIssue.test.ts +++ b/vehicle-data-platform/apps/web/src/domain/qualityIssue.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest'; -import { qualityIssueLabel, qualityProtocolLabel, qualityProtocolOptions } from './qualityIssue'; +import { qualityIssueLabel, qualityIssueOptions, qualityProtocolLabel, qualityProtocolOptions } from './qualityIssue'; describe('qualityIssueLabel', () => { test('maps no-source issues to a vehicle-service label', () => { @@ -27,3 +27,10 @@ describe('qualityProtocolOptions', () => { ]); }); }); + +describe('qualityIssueOptions', () => { + test('includes issue types used by governance filters', () => { + expect(qualityIssueOptions.map((item) => item.value)).toContain('NO_SOURCE'); + expect(qualityIssueOptions.map((item) => item.value)).toContain('VIN_MISSING'); + }); +}); diff --git a/vehicle-data-platform/apps/web/src/domain/qualityIssue.ts b/vehicle-data-platform/apps/web/src/domain/qualityIssue.ts index 3ca106c7..8aabb061 100644 --- a/vehicle-data-platform/apps/web/src/domain/qualityIssue.ts +++ b/vehicle-data-platform/apps/web/src/domain/qualityIssue.ts @@ -19,6 +19,13 @@ export const qualityProtocolOptions = [ { value: 'YUTONG_MQTT', label: protocolLabels.YUTONG_MQTT } ]; +export const qualityIssueOptions = [ + { value: 'NO_SOURCE', label: issueLabels.NO_SOURCE }, + { value: 'VIN_MISSING', label: issueLabels.VIN_MISSING }, + { value: 'LINK_GAP', label: issueLabels.LINK_GAP }, + { value: 'FIELD_MISSING', label: issueLabels.FIELD_MISSING } +]; + export function qualityIssueLabel(issueType: string) { return issueLabels[issueType] ?? issueType; } diff --git a/vehicle-data-platform/apps/web/src/pages/Quality.tsx b/vehicle-data-platform/apps/web/src/pages/Quality.tsx index c1814f65..19aafee8 100644 --- a/vehicle-data-platform/apps/web/src/pages/Quality.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Quality.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'; import { api } from '../api/client'; import type { OpsHealth, QualitySummary, QualityIssueRow } from '../api/types'; import { PageHeader } from '../components/PageHeader'; -import { qualityIssueLabel, qualityProtocolLabel, qualityProtocolOptions } from '../domain/qualityIssue'; +import { qualityIssueLabel, qualityIssueOptions, qualityProtocolLabel, qualityProtocolOptions } from '../domain/qualityIssue'; import { qualityIssueVehicleLookup } from '../domain/vehicleLookup'; const statusColor: Record = { @@ -39,6 +39,7 @@ function qualityParams(values: Record) { const params = new URLSearchParams(); if (values?.keyword) params.set('keyword', values.keyword); if (values?.protocol) params.set('protocol', values.protocol); + if (values?.issueType) params.set('issueType', values.issueType); return params; } @@ -71,6 +72,7 @@ export function Quality({ const [loadingHealth, setLoadingHealth] = useState(true); const [filters, setFilters] = useState>({}); const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 }); + const primaryIssueType = summary.issueTypes[0]?.name; const loadIssues = (values: Record = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => { setLoadingIssues(true); @@ -111,6 +113,19 @@ export function Quality({ loadHealth(); }, []); + const applyFilters = (nextFilters: Record) => { + setFilters(nextFilters); + loadSummary(nextFilters); + loadIssues(nextFilters, 1, pagination.pageSize); + }; + + const drillPrimaryIssue = () => { + if (!primaryIssueType) { + return; + } + applyFilters({ ...filters, issueType: primaryIssueType }); + }; + return (
@@ -121,12 +136,27 @@ export function Quality({ { label: '错误 / 警告', value: `${summary.errorCount}/${summary.warningCount}` }, { label: '主要问题', - value: summary.issueTypes.length > 0 ? `${qualityIssueLabel(summary.issueTypes[0].name)} ${summary.issueTypes[0].count}` : '-' + value: primaryIssueType ? `${qualityIssueLabel(primaryIssueType)} ${summary.issueTypes[0].count}` : '-', + onClick: primaryIssueType ? drillPrimaryIssue : undefined } ].map((item) => ( -
{item.value}
-
{item.label}
+ {item.onClick ? ( + + ) : ( + <> +
{item.value}
+
{item.label}
+ + )}
))}
@@ -141,11 +171,9 @@ export function Quality({ -
{ + { const nextFilters = values as Record; - setFilters(nextFilters); - loadSummary(nextFilters); - loadIssues(nextFilters, 1, pagination.pageSize); + applyFilters(nextFilters); }} style={{ marginBottom: 12 }}> @@ -153,12 +181,15 @@ export function Quality({ {item.label} ))} + + {qualityIssueOptions.map((item) => ( + {item.label} + ))} + diff --git a/vehicle-data-platform/apps/web/src/test/App.test.tsx b/vehicle-data-platform/apps/web/src/test/App.test.tsx index ba7fcef1..2b555a15 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -729,6 +729,72 @@ test('renders quality issues as vehicle-service governance labels', async () => expect(screen.getAllByText('车辆服务').length).toBeGreaterThanOrEqual(1); }); +test('drills into quality issues by issue type', async () => { + window.history.replaceState(null, '', '/#/quality'); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const path = String(input); + if (path.includes('/api/ops/health')) { + return { + ok: true, + json: async () => ({ + data: { linkHealth: [], kafkaLag: 0, redisOnlineKeys: 0, tdengineWritable: true, mysqlWritable: true, runtime: { requestTimeoutMs: 5000 } }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/quality/summary')) { + return { + ok: true, + json: async () => ({ + data: { + issueVehicleCount: 1, + issueRecordCount: 1, + errorCount: 0, + warningCount: 1, + protocols: [{ name: 'VEHICLE_SERVICE', count: 1 }], + issueTypes: [{ name: 'NO_SOURCE', count: 1 }] + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/quality/issues')) { + return { + ok: true, + json: async () => ({ + data: { + items: [], + total: 1, + limit: 20, + offset: 0 + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + return { + ok: true, + json: async () => ({ + data: { items: [], total: 0, limit: 20, offset: 0 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + }); + + render(); + + fireEvent.click(await screen.findByRole('button', { name: /主要问题 暂无数据来源 1/ })); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/quality/issues?issueType=NO_SOURCE&limit=20&offset=0'), undefined); + }); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/quality/summary?issueType=NO_SOURCE'), undefined); +}); + test('opens vehicle detail from shareable hash', async () => { window.history.replaceState(null, '', '/#/detail?keyword=%E7%B2%A4AG18312'); vi.spyOn(globalThis, 'fetch').mockResolvedValue({