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

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