feat(operations): support canonical source providers
This commit is contained in:
@@ -173,3 +173,57 @@ test('fuzzy searches a vehicle and renders all source diagnosis evidence', async
|
||||
}));
|
||||
await waitFor(() => expect(mocks.vehicleSourceDiagnostic).toHaveBeenCalledWith('VIN001', expect.any(AbortSignal)));
|
||||
});
|
||||
|
||||
test('allows provider maintenance but keeps canonical source policy read only', async () => {
|
||||
seedSession();
|
||||
mocks.opsHealth.mockResolvedValue({
|
||||
linkHealth: [], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5,
|
||||
tdengineWritable: true, mysqlWritable: true,
|
||||
runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000, amapSecurityProxyEnabled: true, amapSecurityCodeExposed: false }
|
||||
});
|
||||
mocks.sourceReadiness.mockResolvedValue({ totalVehicles: 1, boundVehicles: 1, identityRequiredVehicles: 0, onlineVehicles: 1, sources: [] });
|
||||
mocks.vehicleCoverage.mockResolvedValue({ items: [{ vin: 'VIN-CANONICAL', plate: '沪A00001', protocols: ['JT808'], missingProtocols: [], sourceStatus: [], sourceCount: 1, onlineSourceCount: 1, online: true, lastSeen: '', bindingStatus: 'bound' }], total: 1, limit: 20, offset: 0 });
|
||||
const diagnostic = {
|
||||
evidence: {
|
||||
vin: 'VIN-CANONICAL', plate: '沪A00001', mileageDate: '', recommendedLocationProtocol: 'JT808',
|
||||
recommendedLocationLabel: 'JT808', locationConflict: false,
|
||||
locationSources: [{
|
||||
protocol: 'JT808', sourceLabel: 'JT808', providerOverride: '', terminalLabel: '', sourceKind: 'CANONICAL', sourceRef: 'b'.repeat(64),
|
||||
selectedWithinProtocol: true, recommended: true, enabled: true, priority: 100, policyRemark: '', online: true, qualityStatus: 'OK', qualityReason: '',
|
||||
longitude: 113.1, latitude: 23.1, eventTime: '2026-07-16 10:00:00', receivedAt: '2026-07-16 10:00:01',
|
||||
selectionReason: '协议融合快照只能维护提供方'
|
||||
}],
|
||||
mileageSources: [], comparison: { locationMaxDistanceM: 0, totalMileageDeltaKm: 0, dailyMileageDeltaKm: 0, reportTimeDeltaSeconds: 0 }, asOf: ''
|
||||
},
|
||||
policy: { vin: 'VIN-CANONICAL', version: 1, updatedBy: 'system', updatedAt: '', audit: [] },
|
||||
recommendationReason: '当前推荐 JT808 的协议融合结果', refreshHint: '等待下一次上报'
|
||||
};
|
||||
mocks.vehicleSourceDiagnostic.mockResolvedValue(diagnostic);
|
||||
mocks.updateVehicleSourcePolicy.mockResolvedValue(diagnostic);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><OperationsPage /></QueryClientProvider>);
|
||||
fireEvent.change(screen.getByLabelText('按车牌或 VIN 搜索诊断车辆'), { target: { value: '沪A' } });
|
||||
const candidateButton = await waitFor(() => {
|
||||
const button = document.querySelector<HTMLButtonElement>('.v2-source-candidates button');
|
||||
expect(button).toBeTruthy();
|
||||
return button!;
|
||||
});
|
||||
fireEvent.click(candidateButton);
|
||||
|
||||
expect(await screen.findByLabelText('JT808 优先级')).toBeDisabled();
|
||||
expect(screen.getByLabelText('JT808 策略备注')).toBeDisabled();
|
||||
expect(screen.getByRole('checkbox', { name: '启用' })).toBeDisabled();
|
||||
expect(screen.getByLabelText('JT808 提供方')).toBeEnabled();
|
||||
fireEvent.change(screen.getByLabelText('JT808 提供方'), { target: { value: '东方北斗' } });
|
||||
fireEvent.change(screen.getByLabelText('JT808 提供方核验依据'), { target: { value: 'GPS 运维终端清单 2026-07-16' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存提供方' }));
|
||||
await waitFor(() => expect(mocks.updateVehicleSourcePolicy).toHaveBeenCalledWith('VIN-CANONICAL', {
|
||||
version: 1,
|
||||
sourceRef: 'b'.repeat(64),
|
||||
providerName: '东方北斗',
|
||||
providerEvidence: 'GPS 运维终端清单 2026-07-16',
|
||||
enabled: true,
|
||||
priority: 100,
|
||||
remark: ''
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -53,8 +53,9 @@ function SourcePolicyRow({ vin, source, diagnostic, editable, onSaved }: {
|
||||
onSuccess: onSaved
|
||||
});
|
||||
const providerChanged = providerName.trim() !== (source.providerOverride || '');
|
||||
const policyEditable = source.sourceKind !== 'CANONICAL';
|
||||
const policyChanged = enabled !== source.enabled || priority !== source.priority || remark.trim() !== (source.policyRemark || '');
|
||||
const changed = policyChanged || providerChanged;
|
||||
const changed = (policyEditable && policyChanged) || providerChanged;
|
||||
return <tr className={source.recommended ? 'is-recommended' : ''}>
|
||||
<td><strong>{source.sourceLabel}</strong><span>{source.terminalLabel || source.sourceKind || '未维护终端'}</span></td>
|
||||
<td><b>{source.protocol}</b><span>{source.selectedWithinProtocol ? '协议内已选' : '协议内候选'}</span></td>
|
||||
@@ -64,12 +65,12 @@ function SourcePolicyRow({ vin, source, diagnostic, editable, onSaved }: {
|
||||
<td><strong>{source.longitude == null || source.latitude == null ? '—' : `${source.longitude.toFixed(6)}, ${source.latitude.toFixed(6)}`}</strong><span>{number(source.speedKmh)} km/h · {number(source.totalMileageKm)} km</span></td>
|
||||
<td className="v2-source-reason"><strong>{source.recommended ? '当前推荐' : source.selectedWithinProtocol ? '协议首选' : '备用来源'}</strong><span>{source.selectionReason || '等待选举说明'}</span></td>
|
||||
<td className="v2-source-policy-cell">
|
||||
<label><input type="checkbox" checked={enabled} disabled={!editable || save.isPending} onChange={(event) => setEnabled(event.target.checked)} />启用</label>
|
||||
<input aria-label={`${source.sourceLabel} 优先级`} type="number" min="1" max="1000" value={priority} disabled={!editable || save.isPending} onChange={(event) => setPriority(Number(event.target.value))} />
|
||||
<label><input type="checkbox" checked={enabled} disabled={!editable || !policyEditable || save.isPending} onChange={(event) => setEnabled(event.target.checked)} />启用</label>
|
||||
<input aria-label={`${source.sourceLabel} 优先级`} type="number" min="1" max="1000" value={priority} disabled={!editable || !policyEditable || save.isPending} onChange={(event) => setPriority(Number(event.target.value))} />
|
||||
<input className="v2-source-provider-input" aria-label={`${source.sourceLabel} 提供方`} value={providerName} maxLength={128} disabled={!editable || save.isPending} onChange={(event) => setProviderName(event.target.value)} placeholder="提供方,如 G7s" />
|
||||
<input className="v2-source-provider-evidence-input" aria-label={`${source.sourceLabel} 提供方核验依据`} value={providerEvidence} maxLength={255} disabled={!editable || save.isPending || !providerChanged} onChange={(event) => setProviderEvidence(event.target.value)} placeholder={providerChanged ? '权威终端清单、厂商确认记录等(必填)' : '修改提供方后填写核验依据'} />
|
||||
<input className="v2-source-policy-remark-input" aria-label={`${source.sourceLabel} 策略备注`} value={remark} maxLength={200} disabled={!editable || save.isPending} onChange={(event) => setRemark(event.target.value)} placeholder="启停或优先级调整原因(可选)" />
|
||||
<button type="button" disabled={!editable || !changed || save.isPending || !source.sourceRef || priority < 1 || priority > 1000 || (providerChanged && !providerEvidence.trim())} onClick={() => save.mutate()}>{save.isPending ? '保存中' : '保存策略'}</button>
|
||||
<input className="v2-source-policy-remark-input" aria-label={`${source.sourceLabel} 策略备注`} value={remark} maxLength={200} disabled={!editable || !policyEditable || save.isPending} onChange={(event) => setRemark(event.target.value)} placeholder={policyEditable ? '启停或优先级调整原因(可选)' : '协议融合快照不可调整策略'} />
|
||||
<button type="button" disabled={!editable || !changed || save.isPending || !source.sourceRef || priority < 1 || priority > 1000 || (providerChanged && !providerEvidence.trim())} onClick={() => save.mutate()}>{save.isPending ? '保存中' : policyEditable ? '保存策略' : '保存提供方'}</button>
|
||||
{save.isError ? <em role="alert">{save.error instanceof Error ? save.error.message : '保存失败'}</em> : null}
|
||||
</td>
|
||||
</tr>;
|
||||
|
||||
Reference in New Issue
Block a user