Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/shared/VehicleSourceEvidencePanel.tsx
2026-07-18 18:07:15 +08:00

137 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useQuery } from '@tanstack/react-query';
import { Button, Card, Empty, Input, Spin, Tag } from '@douyinfe/semi-ui';
import { useMemo, useState } from 'react';
import { api } from '../../api/client';
import type { VehicleLocationSourceEvidence, VehicleMileageSourceEvidence } from '../../api/types';
import { protocolDisplayLabel, protocolSourceLabel } from '../domain/protocols';
import { QUERY_MEMORY } from '../queryPolicy';
function today() {
const now = new Date();
const offset = now.getTimezoneOffset() * 60_000;
return new Date(now.getTime() - offset).toISOString().slice(0, 10);
}
function number(value?: number, digits = 1) {
return value == null || !Number.isFinite(value) ? '—' : value.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
function coordinate(source: VehicleLocationSourceEvidence) {
if (source.longitude == null || source.latitude == null) return '—';
return `${source.longitude.toFixed(6)}, ${source.latitude.toFixed(6)}`;
}
function evidenceTone(source: Pick<VehicleLocationSourceEvidence, 'recommended' | 'enabled' | 'online' | 'qualityStatus'>) {
if (!source.enabled) return 'disabled';
if (source.qualityStatus !== 'OK') return 'warning';
if (source.recommended) return 'recommended';
return source.online ? 'ready' : 'offline';
}
function sourceBadges(source: Pick<VehicleLocationSourceEvidence, 'recommended' | 'selectedWithinProtocol' | 'enabled' | 'online' | 'qualityStatus'>) {
return <>
{source.recommended ? <Tag className="is-recommended" color="blue" type="light" size="small"></Tag> : null}
{!source.recommended && source.selectedWithinProtocol ? <Tag color="cyan" type="light" size="small"></Tag> : null}
{!source.enabled ? <Tag className="is-disabled" color="grey" type="light" size="small"></Tag> : null}
{source.enabled && !source.online ? <Tag className="is-offline" color="grey" type="light" size="small">线</Tag> : null}
{source.qualityStatus !== 'OK' ? <Tag className="is-warning" color="orange" type="light" size="small">{source.qualityStatus}</Tag> : null}
</>;
}
function evidenceSourceLabel(sourceLabel: string | undefined, protocol: string) {
const normalized = sourceLabel?.trim();
if (!normalized || normalized === protocol) return protocolDisplayLabel(protocol);
return protocolSourceLabel(normalized);
}
function LocationSourceCard({ source }: { source: VehicleLocationSourceEvidence }) {
return <Card className={`v2-source-evidence-card is-${evidenceTone(source)}`} bodyStyle={{ padding: 0 }}>
<header><div><strong>{evidenceSourceLabel(source.sourceLabel, source.protocol)}</strong><span>{protocolDisplayLabel(source.protocol)}{source.terminalLabel ? ` · ${source.terminalLabel}` : ''}</span></div><aside>{sourceBadges(source)}</aside></header>
<dl>
<div><dt></dt><dd>{coordinate(source)}</dd></div>
<div><dt></dt><dd>{source.speedKmh == null ? '—' : `${number(source.speedKmh)} km/h`}</dd></div>
<div><dt></dt><dd>{source.totalMileageKm == null ? '—' : `${number(source.totalMileageKm)} km`}</dd></div>
<div><dt>SOC</dt><dd>{source.socPercent == null ? '—' : `${number(source.socPercent)}%`}</dd></div>
<div><dt></dt><dd>{source.eventTime || '—'}</dd></div>
<div><dt></dt><dd>{source.receivedAt || '—'}</dd></div>
</dl>
{source.qualityReason ? <p>{source.qualityReason}</p> : null}
</Card>;
}
function MileageSourceCard({ source }: { source: VehicleMileageSourceEvidence }) {
const comparable = { ...source, online: true };
return <Card className={`v2-source-evidence-card is-${evidenceTone(comparable)}`} bodyStyle={{ padding: 0 }}>
<header><div><strong>{evidenceSourceLabel(source.sourceLabel, source.protocol)}</strong><span>{protocolDisplayLabel(source.protocol)}{source.terminalLabel ? ` · ${source.terminalLabel}` : ''}</span></div><aside>{sourceBadges(comparable)}</aside></header>
<dl>
<div><dt></dt><dd>{source.dailyMileageKm == null ? '—' : `${number(source.dailyMileageKm)} km`}</dd></div>
<div><dt></dt><dd>{source.latestTotalMileageKm == null ? '—' : `${number(source.latestTotalMileageKm)} km`}</dd></div>
<div><dt></dt><dd>{source.firstTotalMileageKm == null ? '—' : `${number(source.firstTotalMileageKm)} km`}</dd></div>
<div><dt></dt><dd>{source.sampleCount.toLocaleString('zh-CN')}</dd></div>
<div><dt></dt><dd>{source.firstEventTime || '—'}</dd></div>
<div><dt></dt><dd>{source.latestEventTime || '—'}</dd></div>
</dl>
{source.qualityReason ? <p>{source.qualityReason}</p> : null}
</Card>;
}
export function VehicleSourceEvidencePanel({
vin,
compact = false,
open,
onOpenChange
}: {
vin: string;
compact?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const [internalOpen, setInternalOpen] = useState(false);
const expanded = open ?? internalOpen;
const setExpanded = (value: boolean) => {
if (open == null) setInternalOpen(value);
onOpenChange?.(value);
};
const [date, setDate] = useState(today);
const query = useQuery({
queryKey: ['vehicle-source-evidence', vin, date],
queryFn: ({ signal }) => api.vehicleSourceEvidence(vin, date, signal),
enabled: expanded && Boolean(vin),
staleTime: 15_000,
gcTime: QUERY_MEMORY.summaryGcTime,
refetchOnWindowFocus: false
});
const sourceCount = (query.data?.locationSources.length ?? 0) + (query.data?.mileageSources.length ?? 0);
const description = useMemo(() => {
if (!query.data) return '按需读取,不影响实时刷新';
if (sourceCount <= 2) return '当前车辆来源较少,仅展示实际存在的证据';
return `已读取 ${query.data.locationSources.length} 个位置来源、${query.data.mileageSources.length} 个里程来源`;
}, [query.data, sourceCount]);
return <Card className={`v2-source-evidence${compact ? ' is-compact' : ''}${expanded ? ' is-open' : ''}`} bodyStyle={{ padding: 0 }}>
<header className="v2-source-evidence-trigger">
<div><strong></strong><span>{description}</span></div>
<Button theme="light" aria-expanded={expanded} onClick={() => setExpanded(!expanded)}>{expanded ? '收起来源' : '查看全部来源'}</Button>
</header>
{expanded ? <div className="v2-source-evidence-body">
<div className="v2-source-evidence-toolbar">
<label><span></span><Input aria-label="里程日期" type="date" value={date} max={today()} onChange={setDate} /></label>
<small></small>
</div>
{query.isPending ? <div className="v2-source-evidence-state" role="status"><Spin size="small" /></div> : null}
{query.isError ? <div className="v2-source-evidence-state is-error"><span>{query.error instanceof Error ? query.error.message : '来源证据读取失败'}</span><Button theme="borderless" type="danger" size="small" onClick={() => void query.refetch()}></Button></div> : null}
{query.data ? <>
<div className="v2-source-evidence-summary">
<div><small></small><strong>{evidenceSourceLabel(query.data.recommendedLocationLabel, query.data.recommendedLocationProtocol)}</strong></div>
<div><small></small><strong>{number(query.data.comparison.locationMaxDistanceM)}<em>m</em></strong></div>
<div><small></small><strong>{number(query.data.comparison.totalMileageDeltaKm)}<em>km</em></strong></div>
<div><small></small><strong>{number(query.data.comparison.reportTimeDeltaSeconds, 0)}<em>s</em></strong></div>
</div>
{query.data.locationSources.length ? <section className="v2-source-evidence-group"><header><strong></strong><span>{query.data.locationConflict ? `后台检测到位置冲突${query.data.conflictDistanceM == null ? '' : ` · ${number(query.data.conflictDistanceM)} m`}` : '推荐来源与备用来源并列展示'}</span></header><div>{query.data.locationSources.map((source, index) => <LocationSourceCard key={`${source.protocol}-${source.sourceLabel}-${source.terminalLabel}-${index}`} source={source} />)}</div></section> : null}
{query.data.mileageSources.length ? <section className="v2-source-evidence-group"><header><strong>{query.data.mileageDate} </strong><span> {number(query.data.comparison.dailyMileageDeltaKm)} km</span></header><div>{query.data.mileageSources.map((source, index) => <MileageSourceCard key={`${source.protocol}-${source.sourceLabel}-${source.terminalLabel}-${index}`} source={source} />)}</div></section> : null}
{!query.data.locationSources.length && !query.data.mileageSources.length ? <Empty className="v2-source-evidence-empty" title="暂无来源证据" description="该车辆当前没有可展示的位置或里程来源。" /> : null}
</> : null}
</div> : null}
</Card>;
}