feat(platform): harden telemetry pipeline and unify Semi UI workspaces
This commit is contained in:
@@ -1,24 +1,29 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
IconAlarm, IconArrowRight, IconBox, IconCalendar, IconClock, IconCopy,
|
||||
IconMapPin, IconSearch, IconTickCircle
|
||||
IconChevronRight, IconMapPin, IconSearch, IconTickCircle
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { FormEvent, useMemo, useState } from 'react';
|
||||
import { Button, Card, Descriptions, Empty, Input, List, Select, Table, Tag, Typography } from '@douyinfe/semi-ui';
|
||||
import { FormEvent, lazy, Suspense, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { LatestTelemetryResponse, QualityIssueRow, VehicleDetail, VehicleProfileSyncItem, VehicleProfileSyncResult, VehicleRealtimeRow } from '../../api/types';
|
||||
import type { LatestTelemetryResponse, LatestTelemetryValue, QualityIssueRow, VehicleDetail, VehicleRealtimeRow } from '../../api/types';
|
||||
import { usePlatformSession } from '../auth/AuthGate';
|
||||
import { canAdminister, hasMenu } from '../auth/session';
|
||||
import { LIVE_QUERY_POLICY, QUERY_MEMORY } from '../queryPolicy';
|
||||
import { formatTelemetryTime, formatTelemetryValue, telemetryQualityLabel } from '../domain/telemetry';
|
||||
import { formatZhNumber } from '../domain/formatters';
|
||||
import { parseVehicleProfileSyncCSV, vehicleProfileSyncCSVHeader } from '../domain/profileSync';
|
||||
import { isValidAMapCoordinate } from '../../integrations/amap';
|
||||
import { FleetMap } from '../map/FleetMap';
|
||||
import { InlineError, PageLoading } from '../shared/AsyncState';
|
||||
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
|
||||
import { SegmentedTabs } from '../shared/SegmentedTabs';
|
||||
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
|
||||
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
|
||||
import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel';
|
||||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||
import { monitorReturnFromParams, withMonitorReturn } from '../routing/monitorContext';
|
||||
import { useMobileLayout } from '../hooks/useMobileLayout';
|
||||
|
||||
function fmt(value?: string) { return value?.trim() || '—'; }
|
||||
function metric(value: number | undefined, fallback = '—') { return typeof value === 'number' && Number.isFinite(value) ? formatZhNumber(value, 1) : fallback; }
|
||||
@@ -28,108 +33,157 @@ function durationHours(seconds?: number | null) { return seconds == null ? '—'
|
||||
function localDateTime(value?: string) { return value ? value.slice(0, 16) : ''; }
|
||||
const operationStatusLabels = { unknown: '待维护', active: '运营中', inactive: '停运', maintenance: '维保中', retired: '已退役' } as const;
|
||||
const SINGLE_VEHICLE_REFRESH_MS = 10_000;
|
||||
const VehicleProfileSyncPanel = lazy(() => import('./VehicleProfileSyncPanel'));
|
||||
|
||||
function ProfileSyncPanel({ onClose }: { onClose: () => void }) {
|
||||
const [sourceSystem, setSourceSystem] = useState('');
|
||||
const [sourceVersion, setSourceVersion] = useState('');
|
||||
const [conflictPolicy, setConflictPolicy] = useState<'preserve' | 'overwrite'>('preserve');
|
||||
const [items, setItems] = useState<VehicleProfileSyncItem[]>([]);
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [parseError, setParseError] = useState('');
|
||||
const sync = useMutation<VehicleProfileSyncResult, Error, boolean>({
|
||||
mutationFn: (dryRun) => api.syncVehicleProfiles({ sourceSystem: sourceSystem.trim(), sourceVersion: sourceVersion.trim(), conflictPolicy, dryRun, items })
|
||||
});
|
||||
const readFile = async (file?: File) => {
|
||||
sync.reset(); setItems([]); setFileName(file?.name ?? ''); setParseError('');
|
||||
if (!file) return;
|
||||
try { setItems(parseVehicleProfileSyncCSV(await file.text())); } catch (error) { setParseError(error instanceof Error ? error.message : 'CSV 解析失败'); }
|
||||
};
|
||||
const ready = sourceSystem.trim() !== '' && sourceVersion.trim() !== '' && items.length > 0 && !sync.isPending;
|
||||
const issues = sync.data?.items.filter((item) => item.status.startsWith('conflict_') || item.status === 'missing_vehicle').slice(0, 20) ?? [];
|
||||
const applied = sync.data && !sync.data.dryRun;
|
||||
return <section className="v2-profile-sync-panel" aria-label="车辆主档批量同步">
|
||||
<header><div><strong>批量同步车辆主档</strong><p>CSV 最多 500 辆;先预演,再写入。默认保留人工档案和其他来源。</p></div><button type="button" onClick={onClose}>关闭</button></header>
|
||||
<div className="v2-profile-sync-fields">
|
||||
<label><span>来源系统标识</span><input value={sourceSystem} onChange={(event) => { setSourceSystem(event.target.value); sync.reset(); }} placeholder="例如 oem-tsp" maxLength={64} /></label>
|
||||
<label><span>来源版本</span><input value={sourceVersion} onChange={(event) => { setSourceVersion(event.target.value); sync.reset(); }} placeholder="例如 snapshot-20260714-01" maxLength={128} /></label>
|
||||
<label><span>冲突策略</span><select value={conflictPolicy} onChange={(event) => { setConflictPolicy(event.target.value as 'preserve' | 'overwrite'); sync.reset(); }}><option value="preserve">保护现有来源</option><option value="overwrite">显式覆盖现有来源</option></select></label>
|
||||
<label className="is-file"><span>CSV 文件</span><input type="file" accept=".csv,text/csv" onChange={(event) => { void readFile(event.target.files?.[0]); }} /></label>
|
||||
</div>
|
||||
<p className="v2-profile-sync-format">表头:<code>{vehicleProfileSyncCSVHeader}</code></p>
|
||||
{fileName ? <p className="v2-profile-sync-file">{fileName} · 已读取 {items.length} 辆</p> : null}
|
||||
{parseError ? <p className="v2-profile-sync-error">{parseError}</p> : null}
|
||||
{sync.isError ? <p className="v2-profile-sync-error">{sync.error.message}</p> : null}
|
||||
{sync.data ? <div className="v2-profile-sync-result">
|
||||
<div><span>收到<strong>{sync.data.received}</strong></span><span>新增<strong>{sync.data.created}</strong></span><span>更新<strong>{sync.data.updated}</strong></span><span>未变化<strong>{sync.data.unchanged}</strong></span><span>冲突<strong>{sync.data.conflicted}</strong></span><span>身份缺失<strong>{sync.data.missing}</strong></span></div>
|
||||
{issues.length ? <ul>{issues.map((item) => <li key={item.vin}><b>{item.vin}</b><span>{item.status === 'missing_vehicle' ? '网关身份不存在' : item.status === 'conflict_source_version' ? '同来源版本内容不一致' : `现有来源 ${item.previousSource || '未知'} 已保护`}</span></li>)}</ul> : <p>未发现来源冲突或身份缺失。</p>}
|
||||
</div> : null}
|
||||
{conflictPolicy === 'overwrite' ? <p className="v2-profile-sync-warning">覆盖模式会接管人工或其他系统维护的补充主档,请先确认预演结果。</p> : null}
|
||||
<footer><button type="button" onClick={() => sync.mutate(true)} disabled={!ready}>{sync.isPending ? '处理中…' : '预演同步'}</button><button className="is-primary" type="button" onClick={() => sync.mutate(false)} disabled={!ready || !sync.data?.dryRun}>{applied ? '已完成写入' : '确认写入'}</button></footer>
|
||||
</section>;
|
||||
function resetWorkspaceScroll() {
|
||||
const content = document.querySelector<HTMLElement>('.v2-content');
|
||||
if (!content) return;
|
||||
content.scrollTop = 0;
|
||||
content.scrollLeft = 0;
|
||||
content.scrollTo?.({ top: 0, left: 0, behavior: 'auto' });
|
||||
}
|
||||
|
||||
function VehicleSearch() {
|
||||
const navigate = useNavigate();
|
||||
const { session } = usePlatformSession();
|
||||
const mobileLayout = useMobileLayout();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [syncOpen, setSyncOpen] = useState(false);
|
||||
const [candidatesOpen, setCandidatesOpen] = useState(false);
|
||||
const closeTimerRef = useRef<number>();
|
||||
const deferredKeyword = useDeferredValue(keyword.trim());
|
||||
const candidateParams = useMemo(() => {
|
||||
const params = new URLSearchParams({ limit: '8', offset: '0' });
|
||||
if (deferredKeyword) params.set('keyword', deferredKeyword);
|
||||
return params;
|
||||
}, [deferredKeyword]);
|
||||
const candidates = useQuery({
|
||||
queryKey: ['vehicle-search-options', candidateParams.toString()],
|
||||
queryFn: ({ signal }) => api.vehicles(candidateParams, signal),
|
||||
enabled: candidatesOpen,
|
||||
staleTime: 30_000,
|
||||
gcTime: QUERY_MEMORY.optionGcTime
|
||||
});
|
||||
const options = useMemo(() => mergeVehicleCandidates(candidates.data?.items ?? []), [candidates.data?.items]);
|
||||
useEffect(() => () => window.clearTimeout(closeTimerRef.current), []);
|
||||
const openCandidates = () => {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
setCandidatesOpen(true);
|
||||
};
|
||||
const closeCandidates = () => {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = window.setTimeout(() => setCandidatesOpen(false), 140);
|
||||
};
|
||||
const openVehicle = (value: string) => {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) return;
|
||||
setCandidatesOpen(false);
|
||||
resetWorkspaceScroll();
|
||||
navigate(`/vehicles/${encodeURIComponent(normalized)}`);
|
||||
window.queueMicrotask(resetWorkspaceScroll);
|
||||
window.requestAnimationFrame(resetWorkspaceScroll);
|
||||
window.setTimeout(resetWorkspaceScroll, 120);
|
||||
};
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const value = keyword.trim();
|
||||
if (value) navigate(`/vehicles/${encodeURIComponent(value)}`);
|
||||
openVehicle(keyword);
|
||||
};
|
||||
return <section className={`v2-vehicle-search-page ${syncOpen ? 'has-sync-panel' : ''}`}>
|
||||
<div className="v2-vehicle-search-card">
|
||||
<span className="v2-search-hero-icon"><IconBox size="extra-large" /></span>
|
||||
<h2>查询单车数字档案</h2>
|
||||
<p>通过车牌、VIN 或终端手机号定位车辆,并查看统一身份、实时状态和来源证据。</p>
|
||||
<form onSubmit={submit}>
|
||||
<IconSearch /><input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="输入车牌 / VIN / 终端手机号" autoFocus />
|
||||
<button type="submit">查询车辆 <IconArrowRight /></button>
|
||||
return <section className={`v2-vehicle-search-page${syncOpen ? ' has-sync-panel' : ''}${candidatesOpen ? ' has-candidates' : ''}`}>
|
||||
<Card className="v2-vehicle-search-card">
|
||||
<div className="v2-vehicle-search-intro">
|
||||
<span className="v2-search-hero-icon"><IconBox size="extra-large" /></span>
|
||||
<div>
|
||||
<Typography.Title heading={2}>查询单车数字档案</Typography.Title>
|
||||
<Typography.Text type="secondary">通过车牌、VIN 或终端手机号定位车辆,并查看统一身份、实时状态和来源证据。</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
<form className="v2-vehicle-search-form" onSubmit={submit}>
|
||||
<div className={`v2-vehicle-search-picker${candidatesOpen ? ' is-open' : ''}`}>
|
||||
<Input
|
||||
aria-label="搜索车辆"
|
||||
aria-controls="v2-vehicle-search-options"
|
||||
aria-expanded={candidatesOpen}
|
||||
prefix={<IconSearch />}
|
||||
value={keyword}
|
||||
onChange={(value) => { setKeyword(value); setCandidatesOpen(true); }}
|
||||
onFocus={openCandidates}
|
||||
onBlur={closeCandidates}
|
||||
placeholder="输入车牌 / VIN / 终端手机号"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<Button theme="solid" htmlType="submit" icon={<IconArrowRight />} iconPosition="right">查询车辆</Button>
|
||||
{candidatesOpen ? <VehicleCandidateList
|
||||
id="v2-vehicle-search-options"
|
||||
className="v2-vehicle-search-options"
|
||||
items={options}
|
||||
loading={candidates.isFetching}
|
||||
loadingText="正在搜索授权车辆"
|
||||
error={candidates.isError ? (candidates.error instanceof Error ? candidates.error.message : '车辆候选加载失败') : undefined}
|
||||
onRetry={() => candidates.refetch()}
|
||||
emptyText="没有匹配的授权车辆"
|
||||
header="车辆候选"
|
||||
meta="车牌优先 · VIN 辅助"
|
||||
showProtocols
|
||||
layout={mobileLayout ? 'list' : 'grid'}
|
||||
onSelect={(vehicle) => openVehicle(vehicle.vin)}
|
||||
/> : null}
|
||||
</form>
|
||||
{canAdminister(session) ? <button className="v2-profile-sync-open" type="button" onClick={() => setSyncOpen((value) => !value)}>{syncOpen ? '收起批量同步' : '批量同步主档'}</button> : null}
|
||||
</div>
|
||||
{syncOpen ? <ProfileSyncPanel onClose={() => setSyncOpen(false)} /> : null}
|
||||
<div className="v2-vehicle-search-capabilities" aria-label="可查询的数据范围">
|
||||
<span><IconBox /><b>统一身份</b><small>车牌、VIN 与车辆主档</small></span>
|
||||
<span><IconClock /><b>实时状态</b><small>位置、里程与最新上报</small></span>
|
||||
<span><IconTickCircle /><b>来源证据</b><small>协议质量与字段明细</small></span>
|
||||
</div>
|
||||
{canAdminister(session) ? <Button className="v2-profile-sync-open" theme="borderless" onClick={() => setSyncOpen((value) => !value)}>{syncOpen ? '收起批量同步' : '批量同步主档'}</Button> : null}
|
||||
</Card>
|
||||
{syncOpen ? <Suspense fallback={<Card className="v2-profile-sync-panel v2-profile-sync-loading" bodyStyle={{ padding: 0 }}><span role="status"><span className="v2-spinner" />正在加载主档同步工具…</span></Card>}><VehicleProfileSyncPanel onClose={() => setSyncOpen(false)} /></Suspense> : null}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function Archive({ detail, editable, onUpdated }: { detail: VehicleDetail; editable: boolean; onUpdated: () => void }) {
|
||||
const profile = detail.profile;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState({ modelName: '', vehicleType: '', companyName: '', operationStatus: 'unknown', accessProvider: '', firstAccessAt: '', runtimeHours: '' });
|
||||
const [draft, setDraft] = useState({ brandName: '', modelName: '', vehicleType: '', companyName: '', operationStatus: 'unknown', accessProvider: '', firstAccessAt: '', runtimeHours: '' });
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.updateVehicleProfile(detail.vin, {
|
||||
modelName: draft.modelName.trim(), vehicleType: draft.vehicleType.trim(), companyName: draft.companyName.trim(),
|
||||
brandName: draft.brandName.trim(), modelName: draft.modelName.trim(), vehicleType: draft.vehicleType.trim(), companyName: draft.companyName.trim(),
|
||||
operationStatus: draft.operationStatus as NonNullable<typeof profile>['operationStatus'], accessProvider: draft.accessProvider.trim(), firstAccessAt: draft.firstAccessAt,
|
||||
runtimeSeconds: draft.runtimeHours.trim() === '' ? null : Math.round(Number(draft.runtimeHours) * 3600), version: profile?.version ?? 0
|
||||
}),
|
||||
onSuccess: () => { setEditing(false); onUpdated(); }
|
||||
});
|
||||
const startEditing = () => {
|
||||
setDraft({ modelName: profile?.modelName ?? '', vehicleType: profile?.vehicleType ?? '', companyName: profile?.companyName ?? '', operationStatus: profile?.operationStatus ?? 'unknown', accessProvider: profile?.accessProvider ?? '', firstAccessAt: localDateTime(profile?.firstAccessAt), runtimeHours: profile?.runtimeSeconds == null ? '' : String(profile.runtimeSeconds / 3600) });
|
||||
setDraft({ brandName: profile?.brandName ?? '', modelName: profile?.modelName ?? '', vehicleType: profile?.vehicleType ?? '', companyName: profile?.companyName ?? '', operationStatus: profile?.operationStatus ?? 'unknown', accessProvider: profile?.accessProvider ?? '', firstAccessAt: localDateTime(profile?.firstAccessAt), runtimeHours: profile?.runtimeSeconds == null ? '' : String(profile.runtimeSeconds / 3600) });
|
||||
save.reset(); setEditing(true);
|
||||
};
|
||||
const submit = (event: FormEvent) => { event.preventDefault(); if (draft.runtimeHours === '' || Number.isFinite(Number(draft.runtimeHours))) save.mutate(); };
|
||||
return <section className="v2-record-card v2-archive-card">
|
||||
<header><strong>车辆主档</strong><span className="v2-profile-heading">完整度 {profile?.completeness ?? 0}%{editable && !editing ? <button type="button" onClick={startEditing}>维护档案</button> : null}</span></header>
|
||||
return <Card className="v2-record-card v2-archive-card" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
title="车辆主档"
|
||||
description="身份、车型与运营属性"
|
||||
meta={`完整度 ${profile?.completeness ?? 0}%`}
|
||||
actions={editable && !editing ? <Button theme="borderless" size="small" onClick={startEditing}>维护档案</Button> : null}
|
||||
/>
|
||||
{editing ? <form className="v2-profile-form" onSubmit={submit}>
|
||||
<label><span>车型</span><input maxLength={128} value={draft.modelName} onChange={(event) => setDraft({ ...draft, modelName: event.target.value })} /></label>
|
||||
<label><span>车辆类型</span><input maxLength={64} value={draft.vehicleType} onChange={(event) => setDraft({ ...draft, vehicleType: event.target.value })} /></label>
|
||||
<label><span>所属企业</span><input maxLength={128} value={draft.companyName} onChange={(event) => setDraft({ ...draft, companyName: event.target.value })} /></label>
|
||||
<label><span>运营状态</span><select value={draft.operationStatus} onChange={(event) => setDraft({ ...draft, operationStatus: event.target.value })}>{Object.entries(operationStatusLabels).map(([value, label]) => <option value={value} key={value}>{label}</option>)}</select></label>
|
||||
<label><span>接入服务商</span><input maxLength={128} value={draft.accessProvider} onChange={(event) => setDraft({ ...draft, accessProvider: event.target.value })} /></label>
|
||||
<label><span>首次接入</span><input type="datetime-local" value={draft.firstAccessAt} onChange={(event) => setDraft({ ...draft, firstAccessAt: event.target.value })} /></label>
|
||||
<label><span>累计运行(小时)</span><input type="number" min="0" step="0.1" value={draft.runtimeHours} onChange={(event) => setDraft({ ...draft, runtimeHours: event.target.value })} /></label>
|
||||
{save.isError ? <p>{save.error.message}</p> : null}<footer><button type="button" onClick={() => setEditing(false)}>取消</button><button className="is-primary" type="submit" disabled={save.isPending}>{save.isPending ? '保存中' : '保存档案'}</button></footer>
|
||||
</form> : <><dl className="v2-record-list">
|
||||
<div><dt>车型 / 类型</dt><dd>{[profile?.modelName, profile?.vehicleType].filter(Boolean).join(' / ') || '—'}</dd></div>
|
||||
<div><dt>所属企业</dt><dd>{fmt(profile?.companyName)}</dd></div>
|
||||
<div><dt>运营状态</dt><dd>{operationStatusLabels[profile?.operationStatus ?? 'unknown']}</dd></div>
|
||||
<div><dt>接入服务商</dt><dd>{fmt(profile?.accessProvider)}</dd></div>
|
||||
<div><dt>首次接入</dt><dd>{fmt(profile?.firstAccessAt)}</dd></div>
|
||||
<div><dt>累计运行</dt><dd>{durationHours(profile?.runtimeSeconds)}</dd></div>
|
||||
</dl><p className="v2-record-note">身份字段来自网关;补充主档来源 {profile?.sourceSystem || '未配置'}{profile?.updatedAt ? ` · v${profile.version} · ${profile.updatedBy} 更新` : ''}</p></>}
|
||||
</section>;
|
||||
<label><span>车辆品牌</span><Input maxLength={128} value={draft.brandName} onChange={(value) => setDraft({ ...draft, brandName: value })} /></label>
|
||||
<label><span>车型</span><Input maxLength={128} value={draft.modelName} onChange={(value) => setDraft({ ...draft, modelName: value })} /></label>
|
||||
<label><span>车辆类型</span><Input maxLength={64} value={draft.vehicleType} onChange={(value) => setDraft({ ...draft, vehicleType: value })} /></label>
|
||||
<label><span>所属企业</span><Input maxLength={128} value={draft.companyName} onChange={(value) => setDraft({ ...draft, companyName: value })} /></label>
|
||||
<label><span>运营状态</span><Select value={draft.operationStatus} onChange={(value) => setDraft({ ...draft, operationStatus: String(value) })} optionList={Object.entries(operationStatusLabels).map(([value, label]) => ({ value, label }))} /></label>
|
||||
<label><span>接入服务商</span><Input maxLength={128} value={draft.accessProvider} onChange={(value) => setDraft({ ...draft, accessProvider: value })} /></label>
|
||||
<label><span>首次接入</span><Input aria-label="首次接入" type="datetime-local" value={draft.firstAccessAt} onChange={(value) => setDraft({ ...draft, firstAccessAt: value })} /></label>
|
||||
<label><span>累计运行(小时)</span><Input type="number" min="0" step="0.1" value={draft.runtimeHours} onChange={(value) => setDraft({ ...draft, runtimeHours: value })} /></label>
|
||||
{save.isError ? <p>{save.error.message}</p> : null}<footer><Button theme="light" onClick={() => setEditing(false)}>取消</Button><Button theme="solid" htmlType="submit" loading={save.isPending}>保存档案</Button></footer>
|
||||
</form> : <><Descriptions className="v2-record-descriptions" align="left" size="small" data={[
|
||||
{ key: '车辆品牌', value: fmt(profile?.brandName) },
|
||||
{ key: '车型 / 类型', value: [profile?.modelName, profile?.vehicleType].filter(Boolean).join(' / ') || '—' },
|
||||
{ key: '所属企业', value: fmt(profile?.companyName) },
|
||||
{ key: '运营状态', value: <Tag color={profile?.operationStatus === 'active' ? 'green' : profile?.operationStatus === 'maintenance' ? 'orange' : 'grey'} type="light" size="small">{operationStatusLabels[profile?.operationStatus ?? 'unknown']}</Tag> },
|
||||
{ key: '接入服务商', value: fmt(profile?.accessProvider) },
|
||||
{ key: '首次接入', value: fmt(profile?.firstAccessAt) },
|
||||
{ key: '累计运行', value: durationHours(profile?.runtimeSeconds) }
|
||||
]} /><p className="v2-record-note">身份字段来自网关;补充主档来源 {profile?.sourceSystem || '未配置'}{profile?.updatedAt ? ` · v${profile.version} · ${profile.updatedBy} 更新` : ''}</p></>}
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function Events({ detail }: { detail: VehicleDetail }) {
|
||||
@@ -137,42 +191,119 @@ function Events({ detail }: { detail: VehicleDetail }) {
|
||||
...detail.quality.items.slice(0, 3).map((item) => ({ tone: issueTone(item), title: item.severity === 'error' ? '质量异常' : '质量提醒', detail: item.detail, time: item.lastSeen })),
|
||||
...detail.sourceStatus.slice(0, 3).map((item) => ({ tone: item.online ? 'success' : 'muted', title: item.online ? '数据上报' : '来源离线', detail: `${item.protocol} · ${item.online ? '当前在线' : '暂无在线数据'}`, time: item.lastSeen }))
|
||||
].slice(0, 5);
|
||||
return <section className="v2-record-card v2-events-card">
|
||||
<header><strong>最近事件</strong><Link to={`/alerts?vin=${encodeURIComponent(detail.vin)}`}>查看全部</Link></header>
|
||||
<div className="v2-event-list">{events.length ? events.map((event, index) => <div className={`v2-event-row is-${event.tone}`} key={`${event.title}-${event.time}-${index}`}>
|
||||
<span className="v2-event-icon">{event.tone === 'success' ? <IconTickCircle /> : <IconAlarm />}</span>
|
||||
<div><strong>{event.title}</strong><p>{event.detail}</p></div><time>{fmt(event.time)}</time>
|
||||
</div>) : <div className="v2-empty-compact">暂无可用事件证据</div>}</div>
|
||||
</section>;
|
||||
return <Card className="v2-record-card v2-events-card" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
title="最近事件"
|
||||
description="质量提醒与协议来源状态"
|
||||
meta={`${events.length} 条`}
|
||||
actions={<Link to={`/alerts?vin=${encodeURIComponent(detail.vin)}`}><Button theme="borderless" type="tertiary" size="small">查看全部</Button></Link>}
|
||||
/>
|
||||
<List
|
||||
className="v2-event-list"
|
||||
dataSource={events}
|
||||
split={false}
|
||||
emptyContent={<Empty className="v2-event-empty" title="暂无可用事件证据" description="车辆产生质量提醒或来源状态变化后会显示在这里。" />}
|
||||
renderItem={(event, index) => <List.Item className={`v2-event-row is-${event.tone}`} key={`${event.title}-${event.time}-${index}`}>
|
||||
<span className="v2-event-icon">{event.tone === 'success' ? <IconTickCircle /> : <IconAlarm />}</span>
|
||||
<div><strong>{event.title}</strong><p>{event.detail}</p></div><time>{fmt(event.time)}</time>
|
||||
</List.Item>}
|
||||
/>
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function TelemetryFieldCell({ item }: { item: LatestTelemetryValue }) {
|
||||
return <div className="v2-telemetry-field"><strong title={item.description}>{item.label}</strong><small title={item.sourceField}>{item.sourceField}</small></div>;
|
||||
}
|
||||
|
||||
function TelemetryValueCell({ item }: { item: LatestTelemetryValue }) {
|
||||
return <div className="v2-telemetry-value"><strong>{formatTelemetryValue(item.value, item.displayValue)}</strong>{item.unit ? <span>{item.unit}</span> : null}</div>;
|
||||
}
|
||||
|
||||
function TelemetryQualityCell({ item }: { item: LatestTelemetryValue }) {
|
||||
const color = item.quality === 'good' ? 'green' : item.quality === 'stale' ? 'orange' : 'red';
|
||||
return <div className="v2-telemetry-quality"><Tag color={color} type="light" size="small">{telemetryQualityLabel(item.quality)}</Tag><small title={item.qualityReason}>{item.qualityReason || '未提供质量说明'} · {formatZhNumber(item.freshnessSeconds, 0)}s</small></div>;
|
||||
}
|
||||
|
||||
function TelemetryTimeCell({ item }: { item: LatestTelemetryValue }) {
|
||||
return <div className="v2-telemetry-time"><span><small>设备</small>{formatTelemetryTime(item.deviceTime)}</span><span><small>接收</small>{formatTelemetryTime(item.serverTime)}</span></div>;
|
||||
}
|
||||
|
||||
function TelemetrySourceCell({ item }: { item: LatestTelemetryValue }) {
|
||||
return <div className="v2-telemetry-source"><Tag color="blue" type="light" size="small">{item.protocol}</Tag><small title={item.sourceEndpoint}>{item.sourceEndpoint || '协议默认来源'}</small></div>;
|
||||
}
|
||||
|
||||
function telemetryRowKey(item?: LatestTelemetryValue) {
|
||||
return `${item?.protocol ?? ''}-${item?.category ?? ''}-${item?.sourceField ?? ''}-${item?.frameId ?? ''}`;
|
||||
}
|
||||
|
||||
function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryResponse; pending: boolean; error?: string }) {
|
||||
const mobileLayout = useMobileLayout();
|
||||
const [selectedProtocol, setSelectedProtocol] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState('vehicle');
|
||||
const indexed = useMemo(() => {
|
||||
const valuesByCategory = new Map<string, LatestTelemetryResponse['values']>();
|
||||
const valuesByProtocolCategory = new Map<string, LatestTelemetryResponse['values']>();
|
||||
const sources = new Map<string, { protocol: string; endpoint?: string }>();
|
||||
const protocols: string[] = [];
|
||||
for (const value of data?.values ?? []) {
|
||||
const values = valuesByCategory.get(value.category);
|
||||
if (values) values.push(value); else valuesByCategory.set(value.category, [value]);
|
||||
if (!protocols.includes(value.protocol)) protocols.push(value.protocol);
|
||||
const categoryKey = `${value.protocol}\u0000${value.category}`;
|
||||
const values = valuesByProtocolCategory.get(categoryKey);
|
||||
if (values) values.push(value); else valuesByProtocolCategory.set(categoryKey, [value]);
|
||||
const sourceKey = `${value.protocol}\u0000${value.sourceEndpoint ?? ''}`;
|
||||
if (!sources.has(sourceKey)) sources.set(sourceKey, { protocol: value.protocol, endpoint: value.sourceEndpoint });
|
||||
}
|
||||
return { valuesByCategory, sources: [...sources.values()] };
|
||||
const order = ['GB32960', 'JT808', 'YUTONG_MQTT'];
|
||||
protocols.sort((left, right) => order.indexOf(left) - order.indexOf(right));
|
||||
return { valuesByProtocolCategory, protocols, sources: [...sources.values()] };
|
||||
}, [data]);
|
||||
const categories = data?.categories ?? [];
|
||||
const activeCategory = indexed.valuesByCategory.has(selectedCategory) ? selectedCategory : categories[0]?.key ?? '';
|
||||
const visibleMetrics = indexed.valuesByCategory.get(activeCategory) ?? [];
|
||||
return <section className="v2-record-card v2-telemetry-card">
|
||||
<nav>{categories.map((item) => <button className={activeCategory === item.key ? 'is-active' : ''} onClick={() => setSelectedCategory(item.key)} type="button" key={item.key}>{item.label}<span>{item.count}</span></button>)}</nav>
|
||||
<div className="v2-telemetry-list">
|
||||
{pending ? <div className="v2-empty-compact">正在读取最新遥测…</div> : error ? <div className="v2-empty-compact is-error">最新遥测不可用:{error}</div> : visibleMetrics.length ? visibleMetrics.map((item) => <div key={item.key}>
|
||||
<span>{item.label}<small title={item.sourceField}>{item.sourceField} · {item.protocol}{item.sourceEndpoint ? ` · ${item.sourceEndpoint}` : ''}</small></span>
|
||||
<strong>{formatTelemetryValue(item.value)} <em>{item.unit}</em></strong>
|
||||
<time title={`设备时间 ${item.deviceTime || '缺失'};接收时间 ${item.serverTime || '缺失'};${item.qualityReason};帧 ${item.frameId}`}><i className={`is-${item.quality}`}>{telemetryQualityLabel(item.quality)}</i>{formatTelemetryTime(item.deviceTime || item.serverTime)}</time>
|
||||
</div>) : <div className="v2-empty-compact">最近 {data?.scannedFrames ?? 0} 帧没有可展示的标量遥测</div>}
|
||||
const activeProtocol = indexed.protocols.includes(selectedProtocol) ? selectedProtocol : indexed.protocols[0] ?? '';
|
||||
const categoryLabels = new Map((data?.categories ?? []).map((category) => [category.key, category.label]));
|
||||
const categories = [...new Set((data?.values ?? []).filter((value) => value.protocol === activeProtocol).map((value) => value.category))]
|
||||
.map((key) => ({ key, label: categoryLabels.get(key) ?? key, count: indexed.valuesByProtocolCategory.get(`${activeProtocol}\u0000${key}`)?.length ?? 0 }));
|
||||
const activeCategory = indexed.valuesByProtocolCategory.has(`${activeProtocol}\u0000${selectedCategory}`) ? selectedCategory : categories[0]?.key ?? '';
|
||||
const visibleMetrics = indexed.valuesByProtocolCategory.get(`${activeProtocol}\u0000${activeCategory}`) ?? [];
|
||||
const protocolLabel = (protocol: string) => protocol === 'GB32960' ? 'GB/T 32960' : protocol === 'JT808' ? 'JT/T 808' : protocol === 'YUTONG_MQTT' ? '宇通 MQTT' : protocol;
|
||||
const columns = [
|
||||
{ title: '字段 / 协议映射', dataIndex: 'label', width: 260, render: (_: unknown, item: LatestTelemetryValue) => <TelemetryFieldCell item={item} /> },
|
||||
{ title: '当前值', dataIndex: 'value', width: 150, render: (_: unknown, item: LatestTelemetryValue) => <TelemetryValueCell item={item} /> },
|
||||
{ title: '质量 / 新鲜度', dataIndex: 'quality', width: 170, render: (_: unknown, item: LatestTelemetryValue) => <TelemetryQualityCell item={item} /> },
|
||||
{ title: '设备 / 接收时间', dataIndex: 'deviceTime', width: 230, render: (_: unknown, item: LatestTelemetryValue) => <TelemetryTimeCell item={item} /> },
|
||||
{ title: '数据来源', dataIndex: 'protocol', width: 180, render: (_: unknown, item: LatestTelemetryValue) => <TelemetrySourceCell item={item} /> }
|
||||
];
|
||||
const state = pending
|
||||
? <div className="v2-telemetry-state" role="status"><strong>正在读取最新遥测</strong><span>按协议整理最新标量证据…</span></div>
|
||||
: error
|
||||
? <div className="v2-telemetry-state is-error" role="alert"><strong>最新遥测不可用</strong><span>{error}</span></div>
|
||||
: visibleMetrics.length === 0
|
||||
? <Empty className="v2-telemetry-empty" title="暂无可展示字段" description={`该协议最近 ${data?.scannedFrames ?? 0} 帧没有可展示的标量遥测。`} />
|
||||
: mobileLayout
|
||||
? <List className="v2-telemetry-mobile-list" dataSource={visibleMetrics} split={false} renderItem={(item) => <List.Item className="v2-telemetry-mobile-item" key={telemetryRowKey(item)}>
|
||||
<header><TelemetryFieldCell item={item} /><TelemetryValueCell item={item} /></header>
|
||||
<div><TelemetryQualityCell item={item} /><TelemetryTimeCell item={item} /></div>
|
||||
<TelemetrySourceCell item={item} />
|
||||
</List.Item>} />
|
||||
: <div className="v2-telemetry-table-wrap"><Table
|
||||
className="v2-telemetry-table"
|
||||
columns={columns}
|
||||
dataSource={visibleMetrics}
|
||||
rowKey={telemetryRowKey}
|
||||
pagination={false}
|
||||
scroll={{ x: 990 }}
|
||||
empty={null}
|
||||
/></div>;
|
||||
return <Card className="v2-record-card v2-telemetry-card" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
title="实时遥测"
|
||||
description="按协议与字段分类查看当前值、质量和来源"
|
||||
meta={`${data?.values.length ?? 0} 项`}
|
||||
/>
|
||||
<div className="v2-telemetry-tabs">
|
||||
<SegmentedTabs className="v2-telemetry-protocols" variant="filled" ariaLabel="数据协议" value={activeProtocol} onChange={(protocol) => { setSelectedProtocol(protocol); setSelectedCategory('vehicle'); }} items={indexed.protocols.map((protocol) => ({ key: protocol, label: protocolLabel(protocol), count: (data?.values ?? []).filter((value) => value.protocol === protocol).length }))} />
|
||||
<SegmentedTabs className="v2-telemetry-categories" ariaLabel="字段分类" value={activeCategory} onChange={setSelectedCategory} items={categories} />
|
||||
</div>
|
||||
{state}
|
||||
<footer><b>来源</b>{indexed.sources.map((source) => <span key={`${source.protocol}-${source.endpoint ?? ''}`} title={source.endpoint}>{source.protocol}</span>)}<small>扫描 {data?.scannedFrames ?? 0} 帧 · 截至 {formatTelemetryTime(data?.asOf)}</small></footer>
|
||||
</section>;
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function CurrentVehicleAddress({ vehicle, fallback }: { vehicle?: VehicleRealtimeRow; fallback?: string }) {
|
||||
@@ -201,58 +332,107 @@ function CurrentVehicleAddress({ vehicle, fallback }: { vehicle?: VehicleRealtim
|
||||
<span className="v2-current-address"><small>地理位置</small>{!coordinate
|
||||
? <strong>暂无有效实时坐标</strong>
|
||||
: !requestedKey
|
||||
? <button type="button" onClick={() => setRequestedKey(coordinateKey)}>解析当前位置</button>
|
||||
? <Button className="v2-current-address-action" theme="light" type="primary" size="small" aria-label="解析当前位置" icon={<IconMapPin />} onClick={() => setRequestedKey(coordinateKey)}>解析当前位置</Button>
|
||||
: address.isFetching && !address.data
|
||||
? <strong>地址解析中…</strong>
|
||||
: address.isError
|
||||
? <button type="button" className="is-error" onClick={() => void address.refetch()}>解析失败,重试</button>
|
||||
? <Button className="v2-current-address-action is-error" theme="light" type="danger" size="small" aria-label="地址解析失败,重试" onClick={() => void address.refetch()}>解析失败,重试</Button>
|
||||
: <strong title={address.data?.formattedAddress}>{address.data?.formattedAddress || fallback || '暂无地址结果'}</strong>}
|
||||
{moved ? <button type="button" onClick={() => setRequestedKey(coordinateKey)}>车辆已移动 · 更新地址</button> : null}
|
||||
{moved ? <Button className="v2-current-address-action" theme="borderless" type="primary" size="small" aria-label="车辆已移动,更新地址" icon={<IconMapPin />} onClick={() => setRequestedKey(coordinateKey)}>车辆已移动 · 更新地址</Button> : null}
|
||||
</span>
|
||||
</div>;
|
||||
}
|
||||
|
||||
const vehicleSectionIDs = {
|
||||
location: 'vehicle-location-panel',
|
||||
events: 'vehicle-events-panel',
|
||||
telemetry: 'vehicle-telemetry-panel',
|
||||
archive: 'vehicle-archive-panel'
|
||||
} as const;
|
||||
|
||||
type VehicleSection = keyof typeof vehicleSectionIDs;
|
||||
|
||||
function VehicleRecordNavigation() {
|
||||
const jumpToSection = (section: VehicleSection) => {
|
||||
const target = document.getElementById(vehicleSectionIDs[section]);
|
||||
if (!target) return;
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
target.focus({ preventScroll: true });
|
||||
};
|
||||
return <Card className="v2-record-card v2-vehicle-record-nav" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
variant="compact"
|
||||
title="详情导航"
|
||||
description="快速定位当前车辆的数据区域"
|
||||
actions={<nav className="v2-vehicle-section-nav" aria-label="单车详情导航">
|
||||
<Button size="small" theme="borderless" type="tertiary" icon={<IconMapPin />} aria-label="跳转到实时位置" onClick={() => jumpToSection('location')}>实时位置</Button>
|
||||
<Button size="small" theme="borderless" type="tertiary" icon={<IconAlarm />} aria-label="跳转到最近事件" onClick={() => jumpToSection('events')}>最近事件</Button>
|
||||
<Button size="small" theme="borderless" type="tertiary" icon={<IconClock />} aria-label="跳转到实时遥测" onClick={() => jumpToSection('telemetry')}>实时遥测</Button>
|
||||
<Button size="small" theme="borderless" type="tertiary" icon={<IconBox />} aria-label="跳转到车辆主档" onClick={() => jumpToSection('archive')}>车辆主档</Button>
|
||||
</nav>}
|
||||
/>
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, telemetryError, monitorReturn, onUpdated }: { detail: VehicleDetail; liveRealtime?: VehicleRealtimeRow; telemetry?: LatestTelemetryResponse; telemetryPending: boolean; telemetryError?: string; monitorReturn: string; onUpdated: () => void }) {
|
||||
const { session } = usePlatformSession();
|
||||
const navigate = useNavigate();
|
||||
const [sourceEvidenceOpen, setSourceEvidenceOpen] = useState(false);
|
||||
const realtime = liveRealtime ?? detail.realtimeSummary;
|
||||
const identity = detail.identity;
|
||||
const hasLocation = Boolean(realtime && realtime.locationAvailable !== false && isValidAMapCoordinate(realtime.longitude, realtime.latitude));
|
||||
const mapVehicles = hasLocation && realtime ? [realtime] : [];
|
||||
const lastMileage = detail.mileage.items[0];
|
||||
const actions = [
|
||||
{ key: 'switch', label: '切换车辆', icon: <IconSearch />, to: '/vehicles', type: 'primary' as const },
|
||||
...(hasMenu(session, 'tracks') ? [{ key: 'tracks', label: '轨迹回放', icon: <IconMapPin />, to: withMonitorReturn(`/tracks?vin=${encodeURIComponent(detail.vin)}`, monitorReturn), type: 'tertiary' as const }] : []),
|
||||
...(hasMenu(session, 'history') ? [{ key: 'history', label: '历史数据', icon: <IconCalendar />, to: withMonitorReturn(`/history?vin=${encodeURIComponent(detail.vin)}`, monitorReturn), type: 'tertiary' as const }] : []),
|
||||
...(hasMenu(session, 'statistics') ? [{ key: 'statistics', label: '里程查询', icon: <IconClock />, to: withMonitorReturn(`/statistics?vins=${encodeURIComponent(detail.vin)}`, monitorReturn), type: 'tertiary' as const }] : []),
|
||||
...(hasMenu(session, 'alerts') ? [{ key: 'alerts', label: '告警事件', icon: <IconAlarm />, to: `/alerts?vin=${encodeURIComponent(detail.vin)}`, type: 'tertiary' as const }] : [])
|
||||
];
|
||||
return <div className="v2-vehicle-record-page">
|
||||
<MonitorReturnBar />
|
||||
<section className="v2-identity-band">
|
||||
<div className="v2-identity-primary"><span className="v2-plate"><IconBox />{fmt(identity?.plate || realtime?.plate)}</span><span className={`v2-online-label ${realtime?.online ? 'is-online' : ''}`}><i />{realtime?.online ? '在线' : '离线'}</span><small>VIN</small><b>{detail.vin}</b><button type="button" title="复制 VIN" onClick={() => navigator.clipboard?.writeText(detail.vin)}><IconCopy /></button></div>
|
||||
<div className="v2-identity-meta"><div><small>统一车辆身份</small><strong>{fmt(identity?.oem || realtime?.oem)}</strong></div><div><small>可用数据来源</small><p>{detail.sources.length ? detail.sources.map((source) => <span key={source}>{source}</span>) : <span>暂无来源</span>}</p></div></div>
|
||||
<div className="v2-identity-actions">
|
||||
{hasMenu(session, 'tracks') ? <Link to={withMonitorReturn(`/tracks?vin=${encodeURIComponent(detail.vin)}`, monitorReturn)}><IconMapPin />轨迹回放</Link> : null}
|
||||
{hasMenu(session, 'history') ? <Link to={withMonitorReturn(`/history?vin=${encodeURIComponent(detail.vin)}`, monitorReturn)}><IconCalendar />历史数据</Link> : null}
|
||||
{hasMenu(session, 'statistics') ? <Link to={withMonitorReturn(`/statistics?vins=${encodeURIComponent(detail.vin)}`, monitorReturn)}><IconClock />里程查询</Link> : null}
|
||||
{hasMenu(session, 'alerts') ? <Link to={`/alerts?vin=${encodeURIComponent(detail.vin)}`}><IconAlarm />告警事件</Link> : null}
|
||||
<Card className="v2-identity-band" bodyStyle={{ padding: 0 }}>
|
||||
<div className="v2-identity-primary"><span className="v2-plate"><IconBox />{fmt(identity?.plate || realtime?.plate)}</span><Tag className={`v2-online-label ${realtime?.online ? 'is-online' : ''}`} color={realtime?.online ? 'green' : 'grey'} size="small">{realtime?.online ? '在线' : '离线'}</Tag><small>VIN</small><b>{detail.vin}</b><Button theme="borderless" aria-label="复制 VIN" title="复制 VIN" icon={<IconCopy />} onClick={() => navigator.clipboard?.writeText(detail.vin)} /></div>
|
||||
<div className="v2-identity-meta"><div><small>车辆品牌 / 车型</small><strong>{[detail.profile?.brandName, detail.profile?.modelName].filter(Boolean).join(' / ') || fmt(identity?.oem || realtime?.oem)}</strong></div><div><small>可用数据来源</small><p>{detail.sources.length ? detail.sources.map((source) => <span key={source}>{source}</span>) : <span>暂无来源</span>}</p></div></div>
|
||||
<div className="v2-identity-actions" role="group" aria-label="车辆快捷操作">
|
||||
{actions.map((action) => <Button key={action.key} theme="light" type={action.type} icon={action.icon} aria-label={action.label} onClick={() => navigate(action.to)}>{action.label}</Button>)}
|
||||
</div>
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
<section className="v2-record-card v2-live-card v2-live-overview">
|
||||
<header><strong>最新上报</strong><span><i className={realtime?.online ? 'is-online' : ''} />{realtime?.online ? '实时在线' : '当前离线'} · <IconClock />{fmt(realtime?.lastSeen || identity?.lastSeen)}</span></header>
|
||||
<Card className="v2-record-card v2-live-card v2-live-overview" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
title="最新上报"
|
||||
meta={<span className="v2-live-report-meta"><i className={realtime?.online ? 'is-online' : ''} />{realtime?.online ? '实时在线' : '当前离线'} · <IconClock />{fmt(realtime?.lastSeen || identity?.lastSeen)}</span>}
|
||||
/>
|
||||
<div className="v2-live-grid">
|
||||
<div><small>速度</small><strong>{availableMetric(realtime?.speedKmh, realtime?.speedAvailable)}<em>km/h</em></strong></div>
|
||||
<div><small>SOC</small><strong>{availableMetric(realtime?.socPercent, realtime?.socAvailable)}<em>%</em></strong></div>
|
||||
<div><small>总里程</small><button type="button" className="v2-live-source-link" title="展开全部里程来源" onClick={() => setSourceEvidenceOpen(true)}>{availableMetric(realtime?.totalMileageKm, realtime?.mileageAvailable)}<em>km</em></button></div>
|
||||
<div><small>当日里程</small><button type="button" className="v2-live-source-link" title="展开全部里程来源" onClick={() => setSourceEvidenceOpen(true)}>{availableMetric(realtime?.todayMileageKm ?? lastMileage?.dailyMileageKm, realtime?.todayMileageAvailable)}<em>km</em></button></div>
|
||||
<div><small>总里程</small><Button theme="borderless" type="tertiary" className="v2-live-source-link" title="展开全部里程来源" aria-label="查看总里程全部来源" onClick={() => setSourceEvidenceOpen(true)}><span>{availableMetric(realtime?.totalMileageKm, realtime?.mileageAvailable)}<em>km</em></span><IconChevronRight /></Button></div>
|
||||
<div><small>当日里程</small><Button theme="borderless" type="tertiary" className="v2-live-source-link" title="展开全部里程来源" aria-label="查看当日里程全部来源" onClick={() => setSourceEvidenceOpen(true)}><span>{availableMetric(realtime?.todayMileageKm, realtime?.todayMileageAvailable)}<em>km</em></span><IconChevronRight /></Button></div>
|
||||
<div><small>推荐来源</small><strong className="is-text">{fmt(realtime?.primaryProtocol)}<em>{realtime?.locationSource ? ` · ${realtime.locationSource}` : ''}</em></strong></div>
|
||||
<div><small>来源状态</small><strong>{realtime?.onlineSourceCount ?? 0}<em> 在线 / {detail.sourceStatus.length} 个</em></strong></div>
|
||||
</div>
|
||||
<CurrentVehicleAddress vehicle={realtime} fallback={identity?.locationText} />
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
<VehicleSourceEvidencePanel vin={detail.vin} open={sourceEvidenceOpen} onOpenChange={setSourceEvidenceOpen} />
|
||||
<VehicleRecordNavigation />
|
||||
|
||||
<div className="v2-record-grid">
|
||||
<section className="v2-single-map-card"><FleetMap vehicles={mapVehicles} selectedVin={hasLocation ? detail.vin : undefined} onSelect={() => undefined} /><footer><button type="button" className="v2-map-source-link" title="展开全部位置来源" onClick={() => setSourceEvidenceOpen(true)}><IconMapPin />{hasLocation && realtime ? `实时坐标 ${realtime.longitude.toFixed(6)}, ${realtime.latitude.toFixed(6)}` : '暂无有效实时坐标'}{identity?.locationText ? ` · 档案区域 ${identity.locationText}` : ''}</button><time>更新时间:{fmt(realtime?.lastSeen)}</time></footer></section>
|
||||
<Events detail={detail} />
|
||||
<TelemetryPanel data={telemetry} pending={telemetryPending} error={telemetryError} />
|
||||
<Archive detail={detail} editable={canAdminister(session)} onUpdated={onUpdated} />
|
||||
<div id={vehicleSectionIDs.location} tabIndex={-1} className="v2-record-section-anchor v2-record-location-anchor">
|
||||
<Card className="v2-single-map-card" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
title="实时位置"
|
||||
description="定位点按车辆上报周期平滑移动"
|
||||
meta={realtime?.online ? '实时跟随' : '保留最后位置'}
|
||||
/>
|
||||
<FleetMap vehicles={mapVehicles} selectedVin={hasLocation ? detail.vin : undefined} onSelect={() => undefined} />
|
||||
<footer><Button theme="borderless" type="tertiary" className="v2-map-source-link" title="展开全部位置来源" aria-label="查看全部位置来源" icon={<IconMapPin />} onClick={() => setSourceEvidenceOpen(true)}>{hasLocation && realtime ? `实时坐标 ${realtime.longitude.toFixed(6)}, ${realtime.latitude.toFixed(6)}` : '暂无有效实时坐标'}{identity?.locationText ? ` · 档案区域 ${identity.locationText}` : ''}</Button><time>更新时间:{fmt(realtime?.lastSeen)}</time></footer>
|
||||
</Card>
|
||||
</div>
|
||||
<div id={vehicleSectionIDs.events} tabIndex={-1} className="v2-record-section-anchor"><Events detail={detail} /></div>
|
||||
<div id={vehicleSectionIDs.telemetry} tabIndex={-1} className="v2-record-section-anchor"><TelemetryPanel data={telemetry} pending={telemetryPending} error={telemetryError} /></div>
|
||||
<div id={vehicleSectionIDs.archive} tabIndex={-1} className="v2-record-section-anchor"><Archive detail={detail} editable={canAdminister(session)} onUpdated={onUpdated} /></div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -273,9 +453,25 @@ export default function VehiclePage() {
|
||||
...LIVE_QUERY_POLICY
|
||||
});
|
||||
const telemetry = useQuery({ queryKey: ['vehicle-latest-telemetry', vin], enabled: Boolean(vin), queryFn: ({ signal }) => api.latestTelemetry(vin!, signal), staleTime: 10_000, gcTime: QUERY_MEMORY.summaryGcTime, refetchInterval: 20_000, ...LIVE_QUERY_POLICY });
|
||||
useEffect(() => {
|
||||
resetWorkspaceScroll();
|
||||
let trailingFrame = 0;
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
resetWorkspaceScroll();
|
||||
trailingFrame = window.requestAnimationFrame(resetWorkspaceScroll);
|
||||
});
|
||||
const shortTimer = window.setTimeout(resetWorkspaceScroll, 120);
|
||||
const settledTimer = window.setTimeout(resetWorkspaceScroll, 360);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
window.cancelAnimationFrame(trailingFrame);
|
||||
window.clearTimeout(shortTimer);
|
||||
window.clearTimeout(settledTimer);
|
||||
};
|
||||
}, [vin, resolvedVin]);
|
||||
if (!vin) return <VehicleSearch />;
|
||||
if (query.isPending) return <PageLoading />;
|
||||
if (query.isError) return <div className="v2-page-error"><InlineError message={query.error instanceof Error ? query.error.message : '车辆档案加载失败'} onRetry={() => query.refetch()} /></div>;
|
||||
if (!query.data.lookupResolved) return <section className="v2-not-found"><IconSearch size="extra-large" /><h2>未找到车辆</h2><p>没有匹配“{vin}”的车牌、VIN 或终端记录。</p><Link to="/vehicles">重新查询</Link></section>;
|
||||
if (!query.data.lookupResolved) return <Card className="v2-not-found" bodyStyle={{ padding: 0 }}><Empty image={<IconSearch size="extra-large" />} title="未找到车辆" description={`没有匹配“${vin}”的车牌、VIN 或终端记录。`}><Link to="/vehicles"><Button theme="solid" icon={<IconSearch />}>重新查询</Button></Link></Empty></Card>;
|
||||
return <VehicleRecord detail={query.data} liveRealtime={realtime.data?.items[0]} telemetry={telemetry.data} telemetryPending={telemetry.isPending} telemetryError={telemetry.isError ? (telemetry.error instanceof Error ? telemetry.error.message : '请求失败') : undefined} monitorReturn={monitorReturn} onUpdated={() => { void query.refetch(); void realtime.refetch(); void telemetry.refetch(); }} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user