feat(platform): align quality issues with vehicle service
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { qualityIssueLabel, qualityProtocolLabel, qualityProtocolOptions } from './qualityIssue';
|
||||||
|
|
||||||
|
describe('qualityIssueLabel', () => {
|
||||||
|
test('maps no-source issues to a vehicle-service label', () => {
|
||||||
|
expect(qualityIssueLabel('NO_SOURCE')).toBe('暂无数据来源');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps unknown issue types visible', () => {
|
||||||
|
expect(qualityIssueLabel('CUSTOM_GAP')).toBe('CUSTOM_GAP');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('qualityProtocolLabel', () => {
|
||||||
|
test('labels vehicle-level quality issues as vehicle service', () => {
|
||||||
|
expect(qualityProtocolLabel('VEHICLE_SERVICE')).toBe('车辆服务');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('qualityProtocolOptions', () => {
|
||||||
|
test('includes vehicle service and all ingestion sources', () => {
|
||||||
|
expect(qualityProtocolOptions.map((item) => item.value)).toEqual([
|
||||||
|
'VEHICLE_SERVICE',
|
||||||
|
'GB32960',
|
||||||
|
'JT808',
|
||||||
|
'YUTONG_MQTT'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
28
vehicle-data-platform/apps/web/src/domain/qualityIssue.ts
Normal file
28
vehicle-data-platform/apps/web/src/domain/qualityIssue.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
const issueLabels: Record<string, string> = {
|
||||||
|
NO_SOURCE: '暂无数据来源',
|
||||||
|
VIN_MISSING: 'VIN 缺失',
|
||||||
|
LINK_GAP: '链路间断',
|
||||||
|
FIELD_MISSING: '字段缺失'
|
||||||
|
};
|
||||||
|
|
||||||
|
const protocolLabels: Record<string, string> = {
|
||||||
|
VEHICLE_SERVICE: '车辆服务',
|
||||||
|
GB32960: 'GB32960',
|
||||||
|
JT808: 'JT808',
|
||||||
|
YUTONG_MQTT: '宇通 MQTT'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const qualityProtocolOptions = [
|
||||||
|
{ value: 'VEHICLE_SERVICE', label: protocolLabels.VEHICLE_SERVICE },
|
||||||
|
{ value: 'GB32960', label: protocolLabels.GB32960 },
|
||||||
|
{ value: 'JT808', label: protocolLabels.JT808 },
|
||||||
|
{ value: 'YUTONG_MQTT', label: protocolLabels.YUTONG_MQTT }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function qualityIssueLabel(issueType: string) {
|
||||||
|
return issueLabels[issueType] ?? issueType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function qualityProtocolLabel(protocol: string) {
|
||||||
|
return protocolLabels[protocol] ?? protocol;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { OpsHealth, QualitySummary, QualityIssueRow } from '../api/types';
|
import type { OpsHealth, QualitySummary, QualityIssueRow } from '../api/types';
|
||||||
import { PageHeader } from '../components/PageHeader';
|
import { PageHeader } from '../components/PageHeader';
|
||||||
|
import { qualityIssueLabel, qualityProtocolLabel, qualityProtocolOptions } from '../domain/qualityIssue';
|
||||||
import { qualityIssueVehicleLookup } from '../domain/vehicleLookup';
|
import { qualityIssueVehicleLookup } from '../domain/vehicleLookup';
|
||||||
|
|
||||||
const statusColor: Record<string, 'green' | 'orange' | 'red' | 'grey'> = {
|
const statusColor: Record<string, 'green' | 'orange' | 'red' | 'grey'> = {
|
||||||
@@ -120,7 +121,7 @@ export function Quality({
|
|||||||
{ label: '错误 / 警告', value: `${summary.errorCount}/${summary.warningCount}` },
|
{ label: '错误 / 警告', value: `${summary.errorCount}/${summary.warningCount}` },
|
||||||
{
|
{
|
||||||
label: '主要问题',
|
label: '主要问题',
|
||||||
value: summary.issueTypes.length > 0 ? `${summary.issueTypes[0].name} ${summary.issueTypes[0].count}` : '-'
|
value: summary.issueTypes.length > 0 ? `${qualityIssueLabel(summary.issueTypes[0].name)} ${summary.issueTypes[0].count}` : '-'
|
||||||
}
|
}
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<Card key={item.label} bordered loading={loadingSummary}>
|
<Card key={item.label} bordered loading={loadingSummary}>
|
||||||
@@ -146,9 +147,11 @@ export function Quality({
|
|||||||
loadSummary(nextFilters);
|
loadSummary(nextFilters);
|
||||||
loadIssues(nextFilters, 1, pagination.pageSize);
|
loadIssues(nextFilters, 1, pagination.pageSize);
|
||||||
}} style={{ marginBottom: 12 }}>
|
}} style={{ marginBottom: 12 }}>
|
||||||
<Form.Input field="keyword" label="关键词" placeholder="手机号 / 来源地址 / 车牌" style={{ width: 260 }} />
|
<Form.Input field="keyword" label="关键词" placeholder="VIN / 车牌 / 手机号 / 来源地址" style={{ width: 260 }} />
|
||||||
<Form.Select field="protocol" label="数据来源" placeholder="全部来源" style={{ width: 160 }}>
|
<Form.Select field="protocol" label="数据来源" placeholder="全部来源" style={{ width: 160 }}>
|
||||||
<Select.Option value="JT808">JT808</Select.Option>
|
{qualityProtocolOptions.map((item) => (
|
||||||
|
<Select.Option key={item.value} value={item.value}>{item.label}</Select.Option>
|
||||||
|
))}
|
||||||
</Form.Select>
|
</Form.Select>
|
||||||
<Space>
|
<Space>
|
||||||
<Button htmlType="submit" theme="solid" type="primary">筛选</Button>
|
<Button htmlType="submit" theme="solid" type="primary">筛选</Button>
|
||||||
@@ -176,8 +179,8 @@ export function Quality({
|
|||||||
{ title: '车牌', dataIndex: 'plate' },
|
{ title: '车牌', dataIndex: 'plate' },
|
||||||
{ title: '手机号', dataIndex: 'phone' },
|
{ title: '手机号', dataIndex: 'phone' },
|
||||||
{ title: '来源地址', dataIndex: 'sourceEndpoint' },
|
{ title: '来源地址', dataIndex: 'sourceEndpoint' },
|
||||||
{ title: '数据来源', dataIndex: 'protocol' },
|
{ title: '数据来源', render: (_: unknown, row: QualityIssueRow) => qualityProtocolLabel(row.protocol) },
|
||||||
{ title: '问题', dataIndex: 'issueType' },
|
{ title: '问题', render: (_: unknown, row: QualityIssueRow) => qualityIssueLabel(row.issueType) },
|
||||||
{ title: '级别', render: (_: unknown, row: QualityIssueRow) => <Tag color={row.severity === 'error' ? 'red' : 'orange'}>{row.severity}</Tag> },
|
{ title: '级别', render: (_: unknown, row: QualityIssueRow) => <Tag color={row.severity === 'error' ? 'red' : 'orange'}>{row.severity}</Tag> },
|
||||||
{ title: '最后时间', dataIndex: 'lastSeen' },
|
{ title: '最后时间', dataIndex: 'lastSeen' },
|
||||||
{ title: '说明', dataIndex: 'detail' },
|
{ title: '说明', dataIndex: 'detail' },
|
||||||
|
|||||||
@@ -656,6 +656,79 @@ test('opens dashboard coverage from service status distribution', async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('renders quality issues as vehicle-service governance labels', async () => {
|
||||||
|
window.history.replaceState(null, '', '/#/quality');
|
||||||
|
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: [{
|
||||||
|
vin: 'VIN-NO-SOURCE-001',
|
||||||
|
plate: '粤A无源1',
|
||||||
|
phone: '13300000002',
|
||||||
|
sourceEndpoint: 'vehicle_identity_binding',
|
||||||
|
protocol: 'VEHICLE_SERVICE',
|
||||||
|
issueType: 'NO_SOURCE',
|
||||||
|
severity: 'warning',
|
||||||
|
lastSeen: '',
|
||||||
|
detail: '车辆已绑定,但暂无 32960、808 或 MQTT 数据来源'
|
||||||
|
}],
|
||||||
|
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(<App />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('VIN-NO-SOURCE-001')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('暂无数据来源').length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getAllByText('车辆服务').length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('opens vehicle detail from shareable hash', async () => {
|
test('opens vehicle detail from shareable hash', async () => {
|
||||||
window.history.replaceState(null, '', '/#/detail?keyword=%E7%B2%A4AG18312');
|
window.history.replaceState(null, '', '/#/detail?keyword=%E7%B2%A4AG18312');
|
||||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
|
|||||||
Reference in New Issue
Block a user