feat(platform): add vehicle quality evidence actions

This commit is contained in:
lingniu
2026-07-04 15:12:26 +08:00
parent c96144e92a
commit 46024151a0
3 changed files with 224 additions and 1 deletions

View File

@@ -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} />

View File

@@ -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

View File

@@ -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) => {