feat(platform): add vehicle quality evidence actions
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user