feat(platform): add vehicle quality evidence actions
This commit is contained in:
@@ -401,7 +401,7 @@ export default function App() {
|
||||
dashboard: <Dashboard onOpenVehicle={openVehicle} onOpenQuality={openQuality} onOpenRealtime={openRealtime} onOpenVehicles={openVehicles} onOpenHistory={openHistoryWithFilters} onOpenMileage={openMileageWithFilters} />,
|
||||
vehicles: <Vehicles onOpenVehicle={openVehicle} onOpenQuality={openQuality} onFiltersChange={updateVehicleFilters} initialFilters={vehicleFilters} />,
|
||||
realtime: <Realtime onOpenVehicle={openVehicle} onOpenHistory={openHistoryForVehicle} onOpenQuality={openQuality} onFiltersChange={updateRealtimeFilters} initialFilters={realtimeFilters} />,
|
||||
detail: <VehicleDetail vin={activeVin} protocol={activeProtocol} onOpenRealtime={openRealtimeForVehicle} onOpenHistory={openHistoryForVehicle} onOpenRaw={openRawForVehicle} onOpenMileage={openMileageForVehicle} onOpenVehicles={openVehicles} onOpenQuality={openQuality} onQueryChange={updateVehicleDetailQuery} />,
|
||||
detail: <VehicleDetail vin={activeVin} protocol={activeProtocol} onOpenRealtime={openRealtimeForVehicle} onOpenHistory={openHistoryForVehicle} onOpenRaw={openRawForVehicle} onOpenMileage={openMileageForVehicle} onOpenVehicles={openVehicles} onOpenQuality={openQuality} onOpenHistoryEvidence={openHistoryWithFilters} onOpenRawEvidence={openRawWithFilters} onQueryChange={updateVehicleDetailQuery} />,
|
||||
history: <History initialVin={analysisVin} initialProtocol={activeProtocol} initialTab={historyTab} initialFilters={historyFilters} onFiltersChange={updateHistoryFilters} onOpenVehicle={openVehicle} onOpenMileage={openMileageWithFilters} onOpenRaw={openRawWithFilters} />,
|
||||
mileage: <Mileage initialVin={analysisVin} initialProtocol={activeProtocol} initialFilters={mileageFilters} onFiltersChange={updateMileageFilters} onOpenVehicle={openVehicle} onOpenHistory={openHistoryWithFilters} onOpenRaw={openRawWithFilters} />,
|
||||
quality: <Quality onOpenVehicle={openVehicle} onOpenRealtime={openRealtime} onOpenHistory={openHistoryWithFilters} onOpenRaw={openRawWithFilters} onOpenMileage={openMileageWithFilters} onHealthLoaded={(health) => setLinkIssueCount(health.linkHealth.filter((item) => item.status !== 'ok').length)} onFiltersChange={updateQualityFilters} initialFilters={qualityFilters} />
|
||||
|
||||
@@ -9,6 +9,7 @@ import { StatusTag } from '../components/StatusTag';
|
||||
import { VehicleMap, type VehicleMapPoint } from '../components/VehicleMap';
|
||||
import { buildAppHash } from '../domain/appRoute';
|
||||
import { buildCsv, downloadCsv, type CsvColumn } from '../domain/csvExport';
|
||||
import { qualityIssueLabel } from '../domain/qualityIssue';
|
||||
import { isLikelyVIN } from '../domain/vehicleLookup';
|
||||
import { summarizeVehicleService } from '../domain/vehicleService';
|
||||
|
||||
@@ -95,6 +96,28 @@ function vehicleReportFileName(vin: string, protocol?: string) {
|
||||
return `vehicle-service-${vin || 'unknown'}-${protocol || 'all-source'}.csv`;
|
||||
}
|
||||
|
||||
function qualityIssuePriority(row: QualityIssueRow) {
|
||||
if (row.severity === 'error') return 0;
|
||||
if (row.severity === 'warning') return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function issueEvidenceDate(value?: string) {
|
||||
const match = value?.match(/\d{4}-\d{2}-\d{2}/);
|
||||
return match?.[0];
|
||||
}
|
||||
|
||||
function nextDate(value: string) {
|
||||
const [year, month, day] = value.split('-').map(Number);
|
||||
const date = new Date(Date.UTC(year, month - 1, day + 1));
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function qualityIssueVehicleLabel(row: QualityIssueRow, fallbackVIN: string) {
|
||||
const vin = row.vin?.trim() || fallbackVIN;
|
||||
return [row.plate?.trim(), vin].filter(Boolean).join(' / ') || row.phone || row.sourceEndpoint || '-';
|
||||
}
|
||||
|
||||
async function copyText(value: string, label: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
@@ -113,6 +136,8 @@ export function VehicleDetail({
|
||||
onOpenMileage,
|
||||
onOpenVehicles,
|
||||
onOpenQuality,
|
||||
onOpenHistoryEvidence,
|
||||
onOpenRawEvidence,
|
||||
onQueryChange
|
||||
}: {
|
||||
vin: string;
|
||||
@@ -123,6 +148,8 @@ export function VehicleDetail({
|
||||
onOpenMileage: (vin: string, protocol?: string) => void;
|
||||
onOpenVehicles?: (filters?: Record<string, string>) => void;
|
||||
onOpenQuality?: (filters?: Record<string, string>) => void;
|
||||
onOpenHistoryEvidence?: (filters?: Record<string, string>) => void;
|
||||
onOpenRawEvidence?: (filters?: Record<string, string>) => void;
|
||||
onQueryChange?: (keyword: string, protocol?: string) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState<VehicleQuery>({ keyword: vin, protocol });
|
||||
@@ -179,6 +206,14 @@ export function VehicleDetail({
|
||||
const protocols = useMemo(() => resolution?.protocols?.length ? resolution.protocols : detail?.sources ?? [], [detail?.sources, resolution?.protocols]);
|
||||
const latestRaw = detail?.raw?.items?.[0];
|
||||
const qualityCount = detail?.quality?.total ?? 0;
|
||||
const priorityQualityIssue = useMemo(() => {
|
||||
const items = detail?.quality?.items ?? [];
|
||||
return [...items].sort((a, b) => {
|
||||
const severityDelta = qualityIssuePriority(a) - qualityIssuePriority(b);
|
||||
if (severityDelta !== 0) return severityDelta;
|
||||
return (Date.parse(b.lastSeen || '') || 0) - (Date.parse(a.lastSeen || '') || 0);
|
||||
})[0];
|
||||
}, [detail?.quality?.items]);
|
||||
const online = resolution?.online || summary?.online || identity?.online || false;
|
||||
const lastSeen = resolution?.lastSeen || summary?.lastSeen || latest?.lastSeen || '-';
|
||||
const serviceStatus = detail?.serviceStatus;
|
||||
@@ -283,6 +318,31 @@ export function VehicleDetail({
|
||||
keyword: resolvedVIN,
|
||||
...(activeProtocol ? { protocol: activeProtocol } : {})
|
||||
});
|
||||
const qualityIssueEvidenceFilters = (row: QualityIssueRow, tab?: 'raw') => {
|
||||
const dateFrom = issueEvidenceDate(row.lastSeen);
|
||||
return {
|
||||
keyword: row.vin?.trim() || resolvedVIN,
|
||||
protocol: row.protocol || activeProtocol,
|
||||
...(dateFrom ? { dateFrom, dateTo: nextDate(dateFrom) } : {}),
|
||||
...(tab === 'raw' ? { tab: 'raw', includeFields: 'true' } : {})
|
||||
};
|
||||
};
|
||||
const openQualityIssueHistoryEvidence = (row: QualityIssueRow) => {
|
||||
const filters = qualityIssueEvidenceFilters(row);
|
||||
if (onOpenHistoryEvidence) {
|
||||
onOpenHistoryEvidence(filters);
|
||||
return;
|
||||
}
|
||||
onOpenHistory(filters.keyword, filters.protocol);
|
||||
};
|
||||
const openQualityIssueRawEvidence = (row: QualityIssueRow) => {
|
||||
const filters = qualityIssueEvidenceFilters(row, 'raw');
|
||||
if (onOpenRawEvidence) {
|
||||
onOpenRawEvidence(filters);
|
||||
return;
|
||||
}
|
||||
onOpenRaw(filters.keyword, filters.protocol);
|
||||
};
|
||||
const vehicleServiceURL = (hash: string) => `${window.location.origin}${window.location.pathname}${hash}`;
|
||||
const vehicleServiceHash = (page: 'detail' | 'realtime' | 'history' | 'mileage', filters?: Record<string, string>) => buildAppHash({
|
||||
page,
|
||||
@@ -593,6 +653,55 @@ export function VehicleDetail({
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{priorityQualityIssue ? (
|
||||
<Card bordered title="单车处置建议" style={{ marginTop: 16 }}>
|
||||
<div className="vp-action-item">
|
||||
<div>
|
||||
<Space wrap>
|
||||
<Tag color={priorityQualityIssue.severity === 'error' ? 'red' : 'orange'}>
|
||||
{qualityIssueLabel(priorityQualityIssue.issueType)}
|
||||
</Tag>
|
||||
<Tag color="blue">{priorityQualityIssue.protocol || activeProtocol || '全部来源'}</Tag>
|
||||
<Tag color="grey">{priorityQualityIssue.lastSeen || '无时间'}</Tag>
|
||||
</Space>
|
||||
<Typography.Text strong style={{ display: 'block', marginTop: 8 }}>
|
||||
{qualityIssueVehicleLabel(priorityQualityIssue, resolvedVIN)}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 6 }}>
|
||||
{priorityQualityIssue.detail || '该车存在质量问题,需要结合轨迹和 RAW 证据确认影响范围。'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Button size="small" theme="light" type="warning" onClick={() => onOpenQuality?.({
|
||||
keyword: priorityQualityIssue.vin || resolvedVIN,
|
||||
protocol: priorityQualityIssue.protocol || activeProtocol,
|
||||
issueType: priorityQualityIssue.issueType
|
||||
})}>
|
||||
告警列表
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="单车告警轨迹证据"
|
||||
size="small"
|
||||
theme="light"
|
||||
type="primary"
|
||||
onClick={() => openQualityIssueHistoryEvidence(priorityQualityIssue)}
|
||||
>
|
||||
轨迹证据
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="单车告警RAW证据"
|
||||
size="small"
|
||||
theme="light"
|
||||
type="tertiary"
|
||||
onClick={() => openQualityIssueRawEvidence(priorityQualityIssue)}
|
||||
>
|
||||
RAW 证据
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card bordered title="车辆档案" style={{ marginTop: 16 }}>
|
||||
<div className="vp-vehicle-summary">
|
||||
<Descriptions
|
||||
|
||||
@@ -10038,6 +10038,120 @@ test('shows actionable vehicle service recommendations on detail', async () => {
|
||||
expect(window.location.hash).toBe('#/vehicles?serviceStatus=degraded&missingProtocol=YUTONG_MQTT');
|
||||
});
|
||||
|
||||
test('opens single vehicle quality evidence from vehicle detail', async () => {
|
||||
window.history.replaceState(null, '', '/#/detail?keyword=VIN-DETAIL-QUALITY&protocol=JT808');
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||
const path = String(input);
|
||||
if (path.includes('/api/vehicle-service')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
vin: 'VIN-DETAIL-QUALITY',
|
||||
lookupKey: 'VIN-DETAIL-QUALITY',
|
||||
lookupResolved: true,
|
||||
resolution: {
|
||||
lookupKey: 'VIN-DETAIL-QUALITY',
|
||||
resolved: true,
|
||||
vin: 'VIN-DETAIL-QUALITY',
|
||||
plate: '粤A详情告警',
|
||||
phone: '13307795425',
|
||||
oem: 'G7s',
|
||||
protocols: ['JT808'],
|
||||
online: true,
|
||||
lastSeen: '2026-07-03T20:12:10+08:00'
|
||||
},
|
||||
realtimeSummary: {
|
||||
vin: 'VIN-DETAIL-QUALITY',
|
||||
plate: '粤A详情告警',
|
||||
phone: '13307795425',
|
||||
oem: 'G7s',
|
||||
protocols: ['JT808'],
|
||||
sourceCount: 1,
|
||||
onlineSourceCount: 1,
|
||||
online: true,
|
||||
primaryProtocol: 'JT808',
|
||||
longitude: 113.2,
|
||||
latitude: 23.1,
|
||||
speedKmh: 32,
|
||||
socPercent: 0,
|
||||
totalMileageKm: 1000.5,
|
||||
lastSeen: '2026-07-03T20:12:10+08:00'
|
||||
},
|
||||
serviceOverview: {
|
||||
vin: 'VIN-DETAIL-QUALITY',
|
||||
plate: '粤A详情告警',
|
||||
phone: '13307795425',
|
||||
oem: 'G7s',
|
||||
protocols: ['JT808'],
|
||||
primaryProtocol: 'JT808',
|
||||
coverageStatus: 'online',
|
||||
sourceCount: 1,
|
||||
onlineSourceCount: 1,
|
||||
lastSeen: '2026-07-03T20:12:10+08:00',
|
||||
realtimeCount: 1,
|
||||
historyCount: 8,
|
||||
rawCount: 16,
|
||||
mileageCount: 2,
|
||||
qualityIssueCount: 1
|
||||
},
|
||||
sources: ['JT808'],
|
||||
sourceStatus: [
|
||||
{ protocol: 'JT808', online: true, lastSeen: '2026-07-03T20:12:10+08:00', hasRealtime: true, hasHistory: true, hasRaw: true, hasMileage: true }
|
||||
],
|
||||
realtime: [],
|
||||
history: { items: [], total: 8, limit: 20, offset: 0 },
|
||||
raw: { items: [], total: 16, limit: 10, offset: 0 },
|
||||
mileage: { items: [], total: 2, limit: 20, offset: 0 },
|
||||
quality: {
|
||||
items: [
|
||||
{
|
||||
vin: 'VIN-DETAIL-QUALITY',
|
||||
plate: '粤A详情告警',
|
||||
phone: '13307795425',
|
||||
protocol: 'JT808',
|
||||
issueType: 'VIN_MISSING',
|
||||
severity: 'error',
|
||||
lastSeen: '2026-07-03 20:12:10',
|
||||
detail: '详情页质量问题需要处理',
|
||||
sourceEndpoint: '115.231.168.135:43625'
|
||||
}
|
||||
],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0
|
||||
}
|
||||
},
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: path.includes('/api/ops/health')
|
||||
? { linkHealth: [], kafkaLag: 0, redisOnlineKeys: 0, tdengineWritable: true, mysqlWritable: true, runtime: { requestTimeoutMs: 5000 } }
|
||||
: { items: [], total: 0, limit: 10, offset: 0 },
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText('单车处置建议')).toBeInTheDocument();
|
||||
expect(screen.getByText('VIN 缺失')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('详情页质量问题需要处理').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('粤A详情告警 / VIN-DETAIL-QUALITY').length).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '单车告警轨迹证据' }));
|
||||
await waitFor(() => {
|
||||
expect(window.location.hash).toBe('#/history?keyword=VIN-DETAIL-QUALITY&protocol=JT808&dateFrom=2026-07-03&dateTo=2026-07-04');
|
||||
});
|
||||
});
|
||||
|
||||
test('shows canonical vehicle archive on detail', async () => {
|
||||
window.history.replaceState(null, '', '/#/detail?keyword=VIN-ARCHIVE-001');
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||
|
||||
Reference in New Issue
Block a user