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} />,
|
dashboard: <Dashboard onOpenVehicle={openVehicle} onOpenQuality={openQuality} onOpenRealtime={openRealtime} onOpenVehicles={openVehicles} onOpenHistory={openHistoryWithFilters} onOpenMileage={openMileageWithFilters} />,
|
||||||
vehicles: <Vehicles onOpenVehicle={openVehicle} onOpenQuality={openQuality} onFiltersChange={updateVehicleFilters} initialFilters={vehicleFilters} />,
|
vehicles: <Vehicles onOpenVehicle={openVehicle} onOpenQuality={openQuality} onFiltersChange={updateVehicleFilters} initialFilters={vehicleFilters} />,
|
||||||
realtime: <Realtime onOpenVehicle={openVehicle} onOpenHistory={openHistoryForVehicle} onOpenQuality={openQuality} onFiltersChange={updateRealtimeFilters} initialFilters={realtimeFilters} />,
|
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} />,
|
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} />,
|
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} />
|
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 { VehicleMap, type VehicleMapPoint } from '../components/VehicleMap';
|
||||||
import { buildAppHash } from '../domain/appRoute';
|
import { buildAppHash } from '../domain/appRoute';
|
||||||
import { buildCsv, downloadCsv, type CsvColumn } from '../domain/csvExport';
|
import { buildCsv, downloadCsv, type CsvColumn } from '../domain/csvExport';
|
||||||
|
import { qualityIssueLabel } from '../domain/qualityIssue';
|
||||||
import { isLikelyVIN } from '../domain/vehicleLookup';
|
import { isLikelyVIN } from '../domain/vehicleLookup';
|
||||||
import { summarizeVehicleService } from '../domain/vehicleService';
|
import { summarizeVehicleService } from '../domain/vehicleService';
|
||||||
|
|
||||||
@@ -95,6 +96,28 @@ function vehicleReportFileName(vin: string, protocol?: string) {
|
|||||||
return `vehicle-service-${vin || 'unknown'}-${protocol || 'all-source'}.csv`;
|
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) {
|
async function copyText(value: string, label: string) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(value);
|
await navigator.clipboard.writeText(value);
|
||||||
@@ -113,6 +136,8 @@ export function VehicleDetail({
|
|||||||
onOpenMileage,
|
onOpenMileage,
|
||||||
onOpenVehicles,
|
onOpenVehicles,
|
||||||
onOpenQuality,
|
onOpenQuality,
|
||||||
|
onOpenHistoryEvidence,
|
||||||
|
onOpenRawEvidence,
|
||||||
onQueryChange
|
onQueryChange
|
||||||
}: {
|
}: {
|
||||||
vin: string;
|
vin: string;
|
||||||
@@ -123,6 +148,8 @@ export function VehicleDetail({
|
|||||||
onOpenMileage: (vin: string, protocol?: string) => void;
|
onOpenMileage: (vin: string, protocol?: string) => void;
|
||||||
onOpenVehicles?: (filters?: Record<string, string>) => void;
|
onOpenVehicles?: (filters?: Record<string, string>) => void;
|
||||||
onOpenQuality?: (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;
|
onQueryChange?: (keyword: string, protocol?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [query, setQuery] = useState<VehicleQuery>({ keyword: vin, protocol });
|
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 protocols = useMemo(() => resolution?.protocols?.length ? resolution.protocols : detail?.sources ?? [], [detail?.sources, resolution?.protocols]);
|
||||||
const latestRaw = detail?.raw?.items?.[0];
|
const latestRaw = detail?.raw?.items?.[0];
|
||||||
const qualityCount = detail?.quality?.total ?? 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 online = resolution?.online || summary?.online || identity?.online || false;
|
||||||
const lastSeen = resolution?.lastSeen || summary?.lastSeen || latest?.lastSeen || '-';
|
const lastSeen = resolution?.lastSeen || summary?.lastSeen || latest?.lastSeen || '-';
|
||||||
const serviceStatus = detail?.serviceStatus;
|
const serviceStatus = detail?.serviceStatus;
|
||||||
@@ -283,6 +318,31 @@ export function VehicleDetail({
|
|||||||
keyword: resolvedVIN,
|
keyword: resolvedVIN,
|
||||||
...(activeProtocol ? { protocol: activeProtocol } : {})
|
...(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 vehicleServiceURL = (hash: string) => `${window.location.origin}${window.location.pathname}${hash}`;
|
||||||
const vehicleServiceHash = (page: 'detail' | 'realtime' | 'history' | 'mileage', filters?: Record<string, string>) => buildAppHash({
|
const vehicleServiceHash = (page: 'detail' | 'realtime' | 'history' | 'mileage', filters?: Record<string, string>) => buildAppHash({
|
||||||
page,
|
page,
|
||||||
@@ -593,6 +653,55 @@ export function VehicleDetail({
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</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 }}>
|
<Card bordered title="车辆档案" style={{ marginTop: 16 }}>
|
||||||
<div className="vp-vehicle-summary">
|
<div className="vp-vehicle-summary">
|
||||||
<Descriptions
|
<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');
|
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 () => {
|
test('shows canonical vehicle archive on detail', async () => {
|
||||||
window.history.replaceState(null, '', '/#/detail?keyword=VIN-ARCHIVE-001');
|
window.history.replaceState(null, '', '/#/detail?keyword=VIN-ARCHIVE-001');
|
||||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user