feat(platform): consolidate production vehicle data workflows
This commit is contained in:
@@ -1,117 +1,126 @@
|
||||
import { IconClose, IconDownload, IconRefresh, IconSave, IconSearch } from '@douyinfe/semi-icons';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { IconDownload, IconRefresh, IconSave, IconSearch, IconSetting } from '@douyinfe/semi-icons';
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { AccessQuery, AccessSummary, AccessThresholdConfig, AccessThresholdUpdate, AccessUnresolvedIdentity, AccessVehicleRow } from '../../api/types';
|
||||
import { accessRowsToCSV, accessStateLabels, formatAccessTime, formatSeconds, updateProtocolThreshold } from '../domain/access';
|
||||
import type { AccessProtocolStatus, AccessQuery, AccessSummary, AccessThresholdConfig, AccessThresholdUpdate, AccessUnresolvedIdentity, AccessVehicleRow } from '../../api/types';
|
||||
import { accessRowsToCSV, formatAccessTime, formatSeconds, updateProtocolThreshold } from '../domain/access';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
import { usePlatformSession } from '../auth/AuthGate';
|
||||
import { canAdminister } from '../auth/session';
|
||||
|
||||
const EMPTY_FILTERS = { keyword: '', protocol: '', oem: '', model: '', provider: '', firstSeenFrom: '', firstSeenTo: '', latestSeenFrom: '', latestSeenTo: '', onlineState: '', delayState: '' };
|
||||
const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'];
|
||||
const protocolColors = ['#1685c5', '#6f2da8', '#15a46d', '#7c8fd6', '#9aa4b2'];
|
||||
|
||||
const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'] as const;
|
||||
const EMPTY_FILTERS = { keyword: '', protocol: '', oem: '', connectionState: '', onlineState: '', model: '', provider: '', firstSeenFrom: '', firstSeenTo: '', latestSeenFrom: '', latestSeenTo: '', delayState: '' };
|
||||
type Filters = typeof EMPTY_FILTERS;
|
||||
|
||||
function StatusLabel({ state }: { state: AccessVehicleRow['onlineState'] }) {
|
||||
return <span className={`v2-access-status is-${state}`}><i />{accessStateLabels[state]}</span>;
|
||||
const connectionLabels: Record<AccessVehicleRow['connectionState'], string> = {
|
||||
healthy: '三协议正常', degraded: '协议离线', incomplete: '接入不完整', offline: '全部离线', not_connected: '尚未接入'
|
||||
};
|
||||
|
||||
function statusByProtocol(row: AccessVehicleRow, protocol: string) {
|
||||
return row.protocolStatuses.find((item) => item.protocol === protocol);
|
||||
}
|
||||
|
||||
function ProtocolDistribution({ summary }: { summary?: AccessSummary }) {
|
||||
const rows = summary?.protocols ?? [];
|
||||
const total = Math.max(1, rows.reduce((sum, item) => sum + item.total, 0));
|
||||
return <section className="v2-access-protocols"><header><strong>协议分布</strong><span>同一筛选口径 · 在线率按车辆计算</span></header>
|
||||
<div className="v2-access-segments">{rows.map((item, index) => <i key={item.name} style={{ width: `${item.total / total * 100}%`, background: protocolColors[index % protocolColors.length] }} title={`${item.name} ${item.total} 台`} />)}</div>
|
||||
<div className="v2-access-legends">{rows.map((item, index) => <span key={item.name}><i style={{ background: protocolColors[index % protocolColors.length] }} /><b>{item.name}</b>{item.total.toLocaleString('zh-CN')} 台<em>{item.onlineRate.toFixed(1)}% 在线</em></span>)}{!rows.length ? <span>暂无协议分布</span> : null}</div>
|
||||
</section>;
|
||||
function compactTime(value: string) {
|
||||
if (!value) return '—';
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return '—';
|
||||
return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(parsed).replace(/\//g, '-');
|
||||
}
|
||||
|
||||
function IdentityQueue({ items, total, loading }: { items: AccessUnresolvedIdentity[]; total: number; loading: boolean }) {
|
||||
const [copied, setCopied] = useState('');
|
||||
if (!loading && total === 0) return null;
|
||||
const copyEvidence = async (item: AccessUnresolvedIdentity) => {
|
||||
const text = [`身份待绑定:${item.identifierMasked}`, `协议:${item.protocol}`, `车牌:${item.plate || '待核对'}`, `厂家:${item.manufacturer || '待核对'}`, `来源:${item.sourceEndpoint || '未知'}`, `最近上报:${formatAccessTime(item.latestSeenAt)}`, `问题:${item.issueCode}`, `建议动作:${item.recommendedAction}`].join('\n');
|
||||
await navigator.clipboard?.writeText(text);
|
||||
setCopied(item.id);
|
||||
};
|
||||
return <details id="access-identity-queue" className="v2-access-identity-queue" open={total > 0}><summary><span><b>身份待绑定</b><strong>{loading ? '…' : total.toLocaleString('zh-CN')}</strong></span><em>不会参与车辆告警 · 需核对后维护权威 VIN</em></summary>
|
||||
<div>{items.slice(0, 5).map((item) => <article key={item.id}><span className="v2-access-identity-code">{item.identifierMasked}</span><dl><div><dt>证据</dt><dd>{[item.plate, item.manufacturer, item.sourceEndpoint].filter(Boolean).join(' · ') || '仅有终端上报'}</dd></div><div><dt>最近上报</dt><dd>{formatAccessTime(item.latestSeenAt)} · {formatSeconds(item.freshnessSec)}</dd></div></dl><button type="button" onClick={() => void copyEvidence(item)}>{copied === item.id ? '已复制' : '复制处置证据'}</button></article>)}</div>
|
||||
</details>;
|
||||
function ProtocolState({ status, detailed = false }: { status?: AccessProtocolStatus; detailed?: boolean }) {
|
||||
const state = !status?.connected ? 'missing' : status.onlineState;
|
||||
const label = state === 'missing' ? '未接入' : state === 'online' ? '在线' : state === 'offline' ? '离线' : state === 'unknown' ? '未知' : '从未上报';
|
||||
if (detailed) {
|
||||
return <article className={`v2-access-protocol-detail is-${state}`}>
|
||||
<header><strong>{status?.protocol}</strong><span><i />{label}</span></header>
|
||||
<dl>
|
||||
<div><dt>接入厂家</dt><dd>{status?.provider || '—'}</dd></div>
|
||||
<div><dt>首次接入</dt><dd>{formatAccessTime(status?.firstSeenAt || '')}</dd></div>
|
||||
<div><dt>最新上报</dt><dd>{formatAccessTime(status?.latestReceivedAt || '')}</dd></div>
|
||||
<div><dt>当前离线</dt><dd>{formatSeconds(status?.freshnessSec)}</dd></div>
|
||||
<div><dt>上报间隔</dt><dd>{formatSeconds(status?.reportIntervalSec)}</dd></div>
|
||||
<div><dt>数据延迟</dt><dd className={status?.delayAbnormal ? 'is-danger' : ''}>{formatSeconds(status?.dataDelaySec)}</dd></div>
|
||||
</dl>
|
||||
<p>{status?.firstSeenEvidence || '应接协议尚未形成实时快照'}</p>
|
||||
</article>;
|
||||
}
|
||||
return <div className={`v2-access-protocol-cell is-${state}`} title={status?.latestReceivedAt ? `最新上报:${formatAccessTime(status.latestReceivedAt)}` : '应接协议尚未形成实时快照'}>
|
||||
<span><i />{label}</span>
|
||||
<strong>{status?.connected ? compactTime(status.latestReceivedAt) : '等待接入'}</strong>
|
||||
<small>{status?.provider || (status?.connected ? '接入方未维护' : '无实时快照')}</small>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function AccessInspector({ row }: { row?: AccessVehicleRow }) {
|
||||
if (!row) return <section className="v2-access-inspector"><header><strong>选中车辆</strong></header><div className="v2-access-side-empty">选择一行查看接入证据和时间口径。</div></section>;
|
||||
return <section className="v2-access-inspector"><header><strong>选中车辆</strong><StatusLabel state={row.onlineState} /></header>
|
||||
<dl className="v2-access-identity"><div><dt>车牌</dt><dd>{row.plate || '—'}</dd></div><div><dt>VIN</dt><dd>{row.vin}</dd></div><div><dt>车型 / 企业</dt><dd>{[row.model, row.company].filter(Boolean).join(' / ') || '—'}</dd></div><div><dt>协议</dt><dd>{row.protocol || '—'}</dd></div><div><dt>厂家 / 接入方</dt><dd>{[row.oem, row.provider].filter(Boolean).join(' / ') || '—'}</dd></div></dl>
|
||||
<Link className="v2-access-vehicle-link" to={`/vehicles/${encodeURIComponent(row.vin)}`}>查看车辆详情</Link>
|
||||
<section><h3>事件与接收对比</h3><dl><div><dt>最新事件时间</dt><dd>{formatAccessTime(row.latestEventAt)}</dd></div><div><dt>最新接收时间</dt><dd>{formatAccessTime(row.latestReceivedAt)}</dd></div><div><dt>数据延迟</dt><dd className={row.delayAbnormal ? 'is-danger' : 'is-good'}>{formatSeconds(row.dataDelaySec)}</dd></div><div><dt>上报间隔</dt><dd>{formatSeconds(row.reportIntervalSec)}</dd></div><div><dt>当前新鲜度</dt><dd>{formatSeconds(row.freshnessSec)}</dd></div><div><dt>动态阈值</dt><dd>{formatSeconds(row.thresholdSec)}</dd></div></dl></section>
|
||||
<section><h3>最新消息与错误</h3><dl><div><dt>消息类型</dt><dd>{row.latestMessageType || '—'}</dd></div><div><dt>事件 ID</dt><dd>{row.latestEventId || '—'}</dd></div><div><dt>最近错误</dt><dd className={row.latestError ? 'is-danger' : ''}>{row.latestError || '无已知错误'}</dd></div><div><dt>数据来源</dt><dd>{row.source || '—'}</dd></div></dl></section>
|
||||
<section className="v2-access-proof"><h3>证据完整性</h3><p><b>首次接入</b>{row.firstSeenAt ? `${formatAccessTime(row.firstSeenAt)} · ${row.firstSeenEvidence}` : row.firstSeenEvidence}</p><p><b>上报间隔</b>{row.reportIntervalEvidence || (row.reportIntervalSec !== null ? `由 ${row.reportSampleCount} 个持久样本计算` : '等待连续样本')}</p></section>
|
||||
</section>;
|
||||
function ConnectionState({ row }: { row: AccessVehicleRow }) {
|
||||
return <div className={`v2-access-connection is-${row.connectionState}`}><strong>{connectionLabels[row.connectionState]}</strong><span>{row.actualProtocols.length} / {row.expectedProtocols.length} 已接入</span></div>;
|
||||
}
|
||||
|
||||
function ThresholdPanel({ config, draft, saving, error, editable, onChange, onSave }: { config?: AccessThresholdConfig; draft?: AccessThresholdUpdate; saving: boolean; error?: string; editable: boolean; onChange: (next: AccessThresholdUpdate) => void; onSave: () => void }) {
|
||||
return <section className="v2-access-threshold"><header><strong>在线阈值</strong><span>v{config?.version ?? '—'}</span></header>
|
||||
{draft ? <fieldset className="v2-threshold-form" disabled={!editable}><label><span>全局默认</span><select value={draft.defaultThresholdSec} onChange={(event) => onChange({ ...draft, defaultThresholdSec: Number(event.target.value) })}><option value="60">1 分钟</option><option value="300">5 分钟</option><option value="600">10 分钟</option><option value="1800">30 分钟</option></select></label><label><span>延迟异常</span><input type="number" min="1" max="3600" value={draft.delayThresholdSec} onChange={(event) => onChange({ ...draft, delayThresholdSec: Number(event.target.value) })} /><em>秒</em></label><label><span>长离线</span><select value={draft.longOfflineSec} onChange={(event) => onChange({ ...draft, longOfflineSec: Number(event.target.value) })}><option value="1800">30 分钟</option><option value="3600">1 小时</option><option value="21600">6 小时</option><option value="86400">24 小时</option></select></label>
|
||||
{PROTOCOLS.map((protocol) => <label key={protocol}><span>{protocol}</span><input type="number" min="30" max="86400" value={draft.protocols.find((item) => item.protocol === protocol)?.thresholdSec ?? draft.defaultThresholdSec} onChange={(event) => onChange({ ...draft, protocols: updateProtocolThreshold(draft.protocols, protocol, Number(event.target.value)) })} /><em>秒</em></label>)}
|
||||
{error ? <p className="v2-threshold-error">{error}</p> : null}{editable ? <button type="button" disabled={saving} onClick={onSave}><IconSave />{saving ? '保存中' : '保存并重算'}</button> : <p className="v2-role-notice">只读角色不可修改阈值</p>}</fieldset> : <div className="v2-access-side-empty">正在读取阈值版本…</div>}
|
||||
{config?.audit[0] ? <footer><span>最近变更</span><b>{config.audit[0].actor} · {formatAccessTime(config.audit[0].changedAt)}</b></footer> : <footer><span>配置来源</span><b>MySQL 版本化配置</b></footer>}
|
||||
</section>;
|
||||
function ProtocolCoverage({ summary }: { summary?: AccessSummary }) {
|
||||
return <div className="v2-access-protocol-coverage" aria-label="三协议接入概览">
|
||||
{PROTOCOLS.map((protocol) => {
|
||||
const actual = summary?.protocols.find((item) => item.name === protocol);
|
||||
const total = summary?.totalVehicles ?? 0;
|
||||
return <span key={protocol}><b>{protocol}</b><em>{(actual?.total ?? 0).toLocaleString('zh-CN')} / {total.toLocaleString('zh-CN')}</em></span>;
|
||||
})}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function VehicleInspector({ row, onClose }: { row: AccessVehicleRow; onClose: () => void }) {
|
||||
return <aside className="v2-access-inspector-v3">
|
||||
<header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div><button type="button" onClick={onClose} aria-label="关闭车辆接入详情"><IconClose /></button></header>
|
||||
<section className="v2-access-inspector-summary"><div><span>品牌 / 车型</span><strong>{[row.oem, row.model].filter(Boolean).join(' / ') || '未维护'}</strong></div><div><span>应接协议</span><strong>{row.expectedProtocols.join(' / ')}</strong></div><div><span>实际接入</span><strong>{row.actualProtocols.length ? row.actualProtocols.join(' / ') : '尚未接入'}</strong></div><div><span>综合状态</span><ConnectionState row={row} /></div></section>
|
||||
<div className="v2-access-protocol-details">{PROTOCOLS.map((protocol) => <ProtocolState key={protocol} status={statusByProtocol(row, protocol)} detailed />)}</div>
|
||||
<footer><span>{row.expectationEvidence}</span><Link to={`/vehicles/${encodeURIComponent(row.vin)}`}>查看车辆详情</Link></footer>
|
||||
</aside>;
|
||||
}
|
||||
|
||||
function IdentityQueue({ items, total }: { items: AccessUnresolvedIdentity[]; total: number }) {
|
||||
if (!total) return null;
|
||||
return <details id="access-identity-queue" className="v2-access-identity-queue-v3"><summary><strong>另有 {total.toLocaleString('zh-CN')} 条来源身份待绑定</strong><span>这些来源不计入主车辆,绑定权威 VIN 后再归档</span></summary><div>{items.slice(0, 6).map((item) => <article key={item.id}><b>{item.identifierMasked}</b><span>{item.protocol} · {item.plate || '车牌待核对'} · {formatAccessTime(item.latestSeenAt)}</span><small>{item.recommendedAction}</small></article>)}</div></details>;
|
||||
}
|
||||
|
||||
function ThresholdSettings({ config, draft, editable, saving, error, onChange, onSave }: { config?: AccessThresholdConfig; draft?: AccessThresholdUpdate; editable: boolean; saving: boolean; error?: string; onChange: (next: AccessThresholdUpdate) => void; onSave: () => void }) {
|
||||
if (!draft) return null;
|
||||
return <details className="v2-access-settings"><summary>在线判定阈值 · v{config?.version ?? '—'}</summary><fieldset disabled={!editable}><label><span>全局默认</span><input type="number" value={draft.defaultThresholdSec} onChange={(event) => onChange({ ...draft, defaultThresholdSec: Number(event.target.value) })} /></label><label><span>长离线</span><input type="number" value={draft.longOfflineSec} onChange={(event) => onChange({ ...draft, longOfflineSec: Number(event.target.value) })} /></label>{PROTOCOLS.map((protocol) => <label key={protocol}><span>{protocol}</span><input type="number" value={draft.protocols.find((item) => item.protocol === protocol)?.thresholdSec ?? draft.defaultThresholdSec} onChange={(event) => onChange({ ...draft, protocols: updateProtocolThreshold(draft.protocols, protocol, Number(event.target.value)) })} /></label>)}{error ? <p>{error}</p> : null}{editable ? <button type="button" onClick={onSave} disabled={saving}><IconSave />{saving ? '保存中' : '保存阈值'}</button> : <small>当前账户为只读角色</small>}</fieldset></details>;
|
||||
}
|
||||
|
||||
function downloadRows(rows: AccessVehicleRow[]) {
|
||||
const blob = new Blob([accessRowsToCSV(rows)], { type: 'text/csv;charset=utf-8' });
|
||||
const href = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = href; anchor.download = `vehicle-access-${new Date().toISOString().slice(0, 10)}.csv`; anchor.click();
|
||||
URL.revokeObjectURL(href);
|
||||
const href = URL.createObjectURL(blob); const anchor = document.createElement('a');
|
||||
anchor.href = href; anchor.download = `vehicle-access-${new Date().toISOString().slice(0, 10)}.csv`; anchor.click(); URL.revokeObjectURL(href);
|
||||
}
|
||||
|
||||
export default function AccessPage() {
|
||||
const { session } = usePlatformSession(); const thresholdEditable = canAdminister(session);
|
||||
const { session } = usePlatformSession(); const editable = canAdminister(session);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const initial: Filters = Object.fromEntries(Object.keys(EMPTY_FILTERS).map((key) => [key, searchParams.get(key) ?? ''])) as Filters;
|
||||
const [draft, setDraft] = useState(initial);
|
||||
const [criteria, setCriteria] = useState(initial);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [limit, setLimit] = useState(50);
|
||||
const [selectedVIN, setSelectedVIN] = useState('');
|
||||
const [thresholdDraft, setThresholdDraft] = useState<AccessThresholdUpdate>();
|
||||
const queryClient = useQueryClient();
|
||||
const baseQuery: AccessQuery = useMemo(() => Object.fromEntries(Object.entries(criteria).filter(([, value]) => value)) as AccessQuery, [criteria]);
|
||||
const summaryQuery = useQuery({ queryKey: ['access-summary', baseQuery], queryFn: () => api.accessSummary(baseQuery), staleTime: 10_000 });
|
||||
const initial = Object.fromEntries(Object.keys(EMPTY_FILTERS).map((key) => [key, searchParams.get(key) ?? ''])) as Filters;
|
||||
const [draft, setDraft] = useState(initial); const [criteria, setCriteria] = useState(initial);
|
||||
const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(50); const [selectedVIN, setSelectedVIN] = useState('');
|
||||
const [thresholdDraft, setThresholdDraft] = useState<AccessThresholdUpdate>(); const queryClient = useQueryClient();
|
||||
const baseQuery = useMemo(() => Object.fromEntries(Object.entries(criteria).filter(([, value]) => value)) as AccessQuery, [criteria]);
|
||||
const summaryQuery = useQuery({ queryKey: ['access-summary'], queryFn: () => api.accessSummary({}), staleTime: 15_000 });
|
||||
const vehiclesQuery = useQuery({ queryKey: ['access-vehicles', baseQuery, limit, offset], queryFn: () => api.accessVehicles({ ...baseQuery, limit, offset }), placeholderData: (previous) => previous });
|
||||
const unresolvedQuery = useQuery({ queryKey: ['access-unresolved-identities', criteria.keyword, criteria.protocol], queryFn: () => api.accessUnresolvedIdentities({ keyword: criteria.keyword || undefined, protocol: criteria.protocol || undefined, limit: 20, offset: 0 }), staleTime: 10_000 });
|
||||
const unresolvedQuery = useQuery({ queryKey: ['access-unresolved-identities', criteria.keyword, criteria.protocol], queryFn: () => api.accessUnresolvedIdentities({ keyword: criteria.keyword || undefined, protocol: criteria.protocol || undefined, limit: 20, offset: 0 }), staleTime: 15_000 });
|
||||
const thresholdQuery = useQuery({ queryKey: ['access-thresholds'], queryFn: api.accessThresholds, staleTime: 60_000 });
|
||||
const updateThreshold = useMutation({ mutationFn: api.updateAccessThresholds, onSuccess: async (config) => { queryClient.setQueryData(['access-thresholds'], config); setThresholdDraft({ version: config.version, defaultThresholdSec: config.defaultThresholdSec, delayThresholdSec: config.delayThresholdSec, longOfflineSec: config.longOfflineSec, protocols: config.protocols }); await Promise.all([queryClient.invalidateQueries({ queryKey: ['access-summary'] }), queryClient.invalidateQueries({ queryKey: ['access-vehicles'] })]); } });
|
||||
const rows = vehiclesQuery.data?.items ?? [];
|
||||
const selected = rows.find((row) => row.vin === selectedVIN) ?? rows[0];
|
||||
|
||||
useEffect(() => { if (rows.length && !rows.some((row) => row.vin === selectedVIN)) setSelectedVIN(rows[0].vin); }, [rows, selectedVIN]);
|
||||
useEffect(() => { const config = thresholdQuery.data; if (config && !thresholdDraft) setThresholdDraft({ version: config.version, defaultThresholdSec: config.defaultThresholdSec, delayThresholdSec: config.delayThresholdSec, longOfflineSec: config.longOfflineSec, protocols: config.protocols }); }, [thresholdDraft, thresholdQuery.data]);
|
||||
|
||||
useEffect(() => { if (thresholdQuery.data && !thresholdDraft) setThresholdDraft({ version: thresholdQuery.data.version, defaultThresholdSec: thresholdQuery.data.defaultThresholdSec, delayThresholdSec: thresholdQuery.data.delayThresholdSec, longOfflineSec: thresholdQuery.data.longOfflineSec, protocols: thresholdQuery.data.protocols }); }, [thresholdDraft, thresholdQuery.data]);
|
||||
const updateThreshold = useMutation({ mutationFn: api.updateAccessThresholds, onSuccess: async (config) => { setThresholdDraft({ version: config.version, defaultThresholdSec: config.defaultThresholdSec, delayThresholdSec: config.delayThresholdSec, longOfflineSec: config.longOfflineSec, protocols: config.protocols }); await Promise.all([queryClient.invalidateQueries({ queryKey: ['access-summary'] }), queryClient.invalidateQueries({ queryKey: ['access-vehicles'] })]); } });
|
||||
const rows = vehiclesQuery.data?.items ?? []; const selected = rows.find((row) => row.vin === selectedVIN);
|
||||
const syncURL = (filters: Filters) => { const next = new URLSearchParams(); Object.entries(filters).forEach(([key, value]) => { if (value) next.set(key, value); }); setSearchParams(next, { replace: true }); };
|
||||
const submit = (event: FormEvent) => { event.preventDefault(); setCriteria(draft); setOffset(0); syncURL(draft); };
|
||||
const reset = () => { setDraft(EMPTY_FILTERS); setCriteria(EMPTY_FILTERS); setOffset(0); setSearchParams({}, { replace: true }); };
|
||||
const applyState = (onlineState: string, delayState = '') => { const next = { ...criteria, onlineState, delayState }; setDraft(next); setCriteria(next); setOffset(0); syncURL(next); };
|
||||
const showIdentityQueue = () => document.getElementById('access-identity-queue')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
const page = Math.floor(offset / limit) + 1;
|
||||
const totalPages = Math.max(1, Math.ceil((vehiclesQuery.data?.total ?? 0) / limit));
|
||||
const summary = summaryQuery.data;
|
||||
const apply = (next: Filters) => { setDraft(next); setCriteria(next); setOffset(0); setSelectedVIN(''); syncURL(next); };
|
||||
const submit = (event: FormEvent) => { event.preventDefault(); apply(draft); };
|
||||
const page = Math.floor(offset / limit) + 1; const totalPages = Math.max(1, Math.ceil((vehiclesQuery.data?.total ?? 0) / limit)); const summary = summaryQuery.data;
|
||||
|
||||
return <div className="v2-access-page">
|
||||
<form className="v2-access-filter" onSubmit={submit}><label><span>关键词</span><div><IconSearch /><input value={draft.keyword} onChange={(event) => setDraft((current) => ({ ...current, keyword: event.target.value }))} placeholder="车牌 / VIN" /></div></label><label><span>接入协议</span><select value={draft.protocol} onChange={(event) => setDraft((current) => ({ ...current, protocol: event.target.value }))}><option value="">全部协议</option>{PROTOCOLS.map((item) => <option key={item}>{item}</option>)}</select></label><label><span>车辆厂家</span><select value={draft.oem} onChange={(event) => setDraft((current) => ({ ...current, oem: event.target.value }))}><option value="">全部厂家</option>{summary?.oems.filter((item) => item.name !== '未维护').map((item) => <option key={item.name}>{item.name}</option>)}</select></label><label><span>在线状态</span><select value={draft.onlineState} onChange={(event) => setDraft((current) => ({ ...current, onlineState: event.target.value }))}><option value="">全部状态</option><option value="online">在线</option><option value="offline">离线</option><option value="never_reported">从未上报</option><option value="unknown">未知</option></select></label><label><span>延迟状态</span><select value={draft.delayState} onChange={(event) => setDraft((current) => ({ ...current, delayState: event.target.value }))}><option value="">全部状态</option><option value="normal">正常</option><option value="abnormal">延迟异常</option></select></label><button className="v2-primary-button" type="submit">查询</button><button className="v2-secondary-button" type="button" onClick={reset}>重置</button><details className="v2-access-advanced"><summary>更多筛选 · 车型 / 接入厂家 / 接入与上报时间</summary><div><label><span>车辆型号</span><input value={draft.model} onChange={(event) => setDraft((current) => ({ ...current, model: event.target.value }))} placeholder="输入车型关键词" /></label><label><span>接入厂家</span><input value={draft.provider} onChange={(event) => setDraft((current) => ({ ...current, provider: event.target.value }))} placeholder="输入平台名称" /></label><label><span>首次接入起</span><input type="datetime-local" value={draft.firstSeenFrom} onChange={(event) => setDraft((current) => ({ ...current, firstSeenFrom: event.target.value }))} /></label><label><span>首次接入止</span><input type="datetime-local" value={draft.firstSeenTo} onChange={(event) => setDraft((current) => ({ ...current, firstSeenTo: event.target.value }))} /></label><label><span>最新上报起</span><input type="datetime-local" value={draft.latestSeenFrom} onChange={(event) => setDraft((current) => ({ ...current, latestSeenFrom: event.target.value }))} /></label><label><span>最新上报止</span><input type="datetime-local" value={draft.latestSeenTo} onChange={(event) => setDraft((current) => ({ ...current, latestSeenTo: event.target.value }))} /></label></div></details></form>
|
||||
return <div className="v2-access-page v2-access-page-v3">
|
||||
<header className="v2-access-heading"><div><h2>车辆接入管理</h2><p>以主车辆为对象,对照应接协议、实际接入和各协议最新上报时间</p></div><div><span>数据时间 {summary?.asOf ? formatAccessTime(summary.asOf) : '—'}</span><button type="button" onClick={() => void Promise.all([summaryQuery.refetch(), vehiclesQuery.refetch()])}><IconRefresh />刷新</button></div></header>
|
||||
<form className="v2-access-filter-v3" onSubmit={submit}><label className="is-search"><span>车辆</span><div><IconSearch /><input aria-label="车辆" value={draft.keyword} onChange={(event) => setDraft({ ...draft, keyword: event.target.value })} placeholder="车牌 / VIN" /></div></label><label><span>接入状态</span><select aria-label="接入状态" value={draft.connectionState} onChange={(event) => setDraft({ ...draft, connectionState: event.target.value })}><option value="">全部状态</option><option value="attention">有接入差异</option><option value="healthy">三协议正常</option><option value="incomplete">接入不完整</option><option value="degraded">协议离线</option><option value="offline">全部离线</option><option value="not_connected">尚未接入</option></select></label><label><span>关注协议</span><select aria-label="关注协议" value={draft.protocol} onChange={(event) => setDraft({ ...draft, protocol: event.target.value })}><option value="">全部协议</option>{PROTOCOLS.map((item) => <option key={item}>{item}</option>)}</select></label><label><span>车辆品牌</span><select aria-label="车辆品牌" value={draft.oem} onChange={(event) => setDraft({ ...draft, oem: event.target.value })}><option value="">全部品牌</option>{summary?.oems.filter((item) => item.name !== '未维护').map((item) => <option key={item.name}>{item.name}</option>)}</select></label><button className="v2-primary-button" type="submit">查询</button><button className="v2-secondary-button" type="button" onClick={() => apply(EMPTY_FILTERS)}>重置</button></form>
|
||||
{summaryQuery.isError ? <InlineError message={summaryQuery.error instanceof Error ? summaryQuery.error.message : '接入汇总读取失败'} onRetry={() => summaryQuery.refetch()} /> : null}
|
||||
<section className="v2-access-kpis">{[
|
||||
['接入车辆', summary?.totalVehicles ?? 0, '', () => applyState('')], ['在线', summary?.onlineVehicles ?? 0, 'online', () => applyState('online')], ['长离线', summary?.longOfflineVehicles ?? 0, 'offline', () => applyState('offline')], ['从未上报', summary?.neverReported ?? 0, 'never', () => applyState('never_reported')], ['延迟异常', summary?.delayAbnormal ?? 0, 'delay', () => applyState('', 'abnormal')], ['身份待绑定', unresolvedQuery.data?.total ?? 0, 'identity', showIdentityQueue], ['今日上报', summary?.reportedToday ?? 0, 'today', () => applyState('')]
|
||||
].map(([label, value, tone, action]) => <button key={String(label)} type="button" className={`is-${tone}`} onClick={action as () => void}><small>{label as string}</small><strong>{Number(value).toLocaleString('zh-CN')}</strong>{label === '在线' ? <em>{(summary?.onlineRate ?? 0).toFixed(1)}%</em> : null}</button>)}</section>
|
||||
<ProtocolDistribution summary={summary} />
|
||||
{unresolvedQuery.isError ? <InlineError message={unresolvedQuery.error instanceof Error ? unresolvedQuery.error.message : '身份待绑定队列读取失败'} onRetry={() => unresolvedQuery.refetch()} /> : null}
|
||||
<IdentityQueue items={unresolvedQuery.data?.items ?? []} total={unresolvedQuery.data?.total ?? 0} loading={unresolvedQuery.isLoading} />
|
||||
<section className="v2-access-kpis-v3">{[
|
||||
['主车辆', summary?.totalVehicles ?? 0, 'all', ''], ['有接入差异', Math.max(0, (summary?.totalVehicles ?? 0) - (summary?.healthyVehicles ?? 0)), 'attention', 'attention'], ['全部离线', summary?.offlineVehicles ?? 0, 'offline', 'offline'], ['尚未接入', summary?.neverReported ?? 0, 'never', 'not_connected']
|
||||
].map(([label, value, tone, connectionState]) => <button key={String(label)} className={`is-${tone}`} type="button" onClick={() => apply({ ...criteria, connectionState: String(connectionState) })}><small>{label}</small><strong>{Number(value).toLocaleString('zh-CN')}</strong>{label === '主车辆' ? <em>车辆主档</em> : null}</button>)}</section>
|
||||
{vehiclesQuery.isError ? <InlineError message={vehiclesQuery.error instanceof Error ? vehiclesQuery.error.message : '接入车辆读取失败'} onRetry={() => vehiclesQuery.refetch()} /> : null}
|
||||
<div className="v2-access-workspace"><section className="v2-access-table-card"><header><strong>车辆接入状态</strong><div><span>阈值版本 v{summary?.thresholdVersion ?? '—'}</span><button type="button" onClick={() => vehiclesQuery.refetch()}><IconRefresh />刷新</button><button type="button" onClick={() => downloadRows(rows)} disabled={!rows.length}><IconDownload />导出当前页</button><button type="button"><IconSetting />列说明</button></div></header><div className="v2-access-table-scroll"><table><thead><tr><th /><th>在线状态</th><th>车牌</th><th>VIN</th><th>厂家</th><th>协议</th><th>首次接入</th><th>最新事件时间</th><th>最新接收时间</th><th>上报间隔</th><th>数据延迟</th><th>动态阈值</th><th>最新消息类型</th><th>最近错误</th><th>操作</th></tr></thead><tbody>{rows.map((row) => <tr key={row.vin} className={selected?.vin === row.vin ? 'is-selected' : ''}><td><input type="radio" name="access-row" checked={selected?.vin === row.vin} onChange={() => setSelectedVIN(row.vin)} aria-label={`选择 ${row.plate || row.vin}`} /></td><td><StatusLabel state={row.onlineState} /></td><td>{row.plate || '—'}</td><td title={row.vin}>{row.vin}</td><td>{row.oem || '—'}</td><td>{row.protocol || '—'}</td><td title={row.firstSeenEvidence}>{formatAccessTime(row.firstSeenAt)}</td><td>{formatAccessTime(row.latestEventAt)}</td><td>{formatAccessTime(row.latestReceivedAt)}</td><td title={row.reportIntervalEvidence}>{formatSeconds(row.reportIntervalSec)}</td><td className={row.delayAbnormal ? 'is-danger' : 'is-good'}>{formatSeconds(row.dataDelaySec)}</td><td>{formatSeconds(row.thresholdSec)}</td><td>{row.latestMessageType || '—'}</td><td className={row.latestError ? 'is-danger' : ''} title={row.latestError}>{row.latestError || '—'}</td><td><button type="button" onClick={() => setSelectedVIN(row.vin)}>查看证据</button></td></tr>)}</tbody></table>{vehiclesQuery.isFetching ? <div className="v2-access-loading"><i />正在更新接入状态…</div> : null}{!vehiclesQuery.isFetching && !rows.length ? <div className="v2-access-empty">当前筛选条件没有车辆接入记录</div> : null}</div><footer><span>第 {page} / {totalPages} 页,共 {(vehiclesQuery.data?.total ?? 0).toLocaleString('zh-CN')} 条</span><div><button type="button" disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}>上一页</button><button type="button" disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}>下一页</button><select value={limit} onChange={(event) => { setLimit(Number(event.target.value)); setOffset(0); }}><option value="20">20 条/页</option><option value="50">50 条/页</option><option value="100">100 条/页</option></select></div></footer></section>
|
||||
<aside className="v2-access-side"><AccessInspector row={selected} /><ThresholdPanel config={thresholdQuery.data} draft={thresholdDraft} saving={updateThreshold.isPending} error={updateThreshold.error instanceof Error ? updateThreshold.error.message : undefined} editable={thresholdEditable} onChange={setThresholdDraft} onSave={() => thresholdDraft && updateThreshold.mutate(thresholdDraft)} /></aside></div>
|
||||
<div className={`v2-access-workspace-v3 ${selected ? 'is-inspector-open' : ''}`}><section className="v2-access-table-v3"><header><div className="v2-access-table-title"><strong>车辆协议接入差异</strong><span>优先展示应接与实接差异;时间为各协议最后接收时间</span></div><div className="v2-access-table-actions"><ProtocolCoverage summary={summary} /><button type="button" onClick={() => downloadRows(rows)} disabled={!rows.length}><IconDownload />导出当前页</button></div></header><div className="v2-access-table-scroll-v3"><table><thead><tr><th>车辆</th><th>品牌 / 车型</th><th>应接协议</th>{PROTOCOLS.map((item) => <th key={item}>{item}</th>)}<th>综合状态</th></tr></thead><tbody>{rows.map((row) => <tr key={row.vin} data-testid={`access-row-${row.vin}`} tabIndex={0} className={selected?.vin === row.vin ? 'is-selected' : ''} onClick={() => setSelectedVIN(row.vin)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') setSelectedVIN(row.vin); }}><td><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></td><td><strong>{row.oem || '品牌未维护'}</strong><span>{row.model || row.company || '车型未维护'}</span></td><td><b>{row.expectedProtocols.length} 项</b><span>{row.expectedProtocols.join(' / ')}</span></td>{PROTOCOLS.map((protocol) => <td key={protocol}><ProtocolState status={statusByProtocol(row, protocol)} /></td>)}<td><ConnectionState row={row} /></td></tr>)}</tbody></table>{vehiclesQuery.isFetching ? <div className="v2-access-loading"><i />正在更新车辆接入状态…</div> : null}{!vehiclesQuery.isFetching && !rows.length ? <div className="v2-access-empty">当前筛选条件没有车辆</div> : null}</div><footer><span>第 {page} / {totalPages} 页,共 {(vehiclesQuery.data?.total ?? 0).toLocaleString('zh-CN')} 辆主车辆</span><div><button type="button" disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}>上一页</button><button type="button" disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}>下一页</button><select aria-label="每页数量" value={limit} onChange={(event) => { setLimit(Number(event.target.value)); setOffset(0); }}><option value="20">20 辆/页</option><option value="50">50 辆/页</option><option value="100">100 辆/页</option></select></div></footer></section>{selected ? <VehicleInspector row={selected} onClose={() => setSelectedVIN('')} /> : null}</div>
|
||||
<IdentityQueue items={unresolvedQuery.data?.items ?? []} total={unresolvedQuery.data?.total ?? 0} />
|
||||
<ThresholdSettings config={thresholdQuery.data} draft={thresholdDraft} editable={editable} saving={updateThreshold.isPending} error={updateThreshold.error instanceof Error ? updateThreshold.error.message : undefined} onChange={setThresholdDraft} onSave={() => thresholdDraft && updateThreshold.mutate(thresholdDraft)} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import type { VehicleRealtimeRow } from '../../api/types';
|
||||
import { api } from '../../api/client';
|
||||
import MonitorPage from './MonitorPage';
|
||||
|
||||
const vehicles = [{
|
||||
@@ -26,7 +28,18 @@ vi.mock('../map/FleetMap', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useMonitorData', () => ({
|
||||
MAX_MONITOR_SEARCH_TERMS: 100,
|
||||
MONITOR_REFRESH: { selected: 10_000, fleet: 15_000, summary: 30_000 },
|
||||
parseMonitorSearchTerms: (value: string) => Array.from(new Set(value.split(/[\s,,、;;]+/).map((item) => item.trim().toLocaleUpperCase()).filter(Boolean))).slice(0, 100),
|
||||
monitorQueryParams: (filters: { keyword: string; protocol: string; status: string }, limit: number) => {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
const terms = Array.from(new Set(filters.keyword.split(/[\s,,、;;]+/).map((item) => item.trim().toLocaleUpperCase()).filter(Boolean))).slice(0, 100);
|
||||
if (terms.length === 1) params.set('keyword', terms[0]);
|
||||
if (terms.length > 1) params.set('keywords', terms.join(','));
|
||||
if (filters.protocol) params.set('protocol', filters.protocol);
|
||||
if (filters.status) params.set('status', filters.status);
|
||||
return params;
|
||||
},
|
||||
useMonitorData: () => ({
|
||||
summary: { data: { totalVehicles: 2, onlineVehicles: 2, offlineVehicles: 0, drivingVehicles: 1, idleVehicles: 1, frameToday: 10 } },
|
||||
vehicles: { data: { items: vehicles, total: 2 }, isError: false, isLoading: false, isFetching: false },
|
||||
@@ -39,7 +52,8 @@ vi.mock('../hooks/useMonitorData', () => ({
|
||||
afterEach(cleanup);
|
||||
|
||||
test('starts without a selection and supports expand, collapse, reselection, and clear', () => {
|
||||
const view = render(<MemoryRouter><MonitorPage /></MemoryRouter>);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(<QueryClientProvider client={queryClient}><MemoryRouter><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
const workspace = view.container.querySelector('.v2-monitor-workspace')!;
|
||||
const firstVehicle = screen.getByRole('button', { name: /粤A12345 LTEST000000000001/ });
|
||||
const secondVehicle = screen.getByRole('button', { name: /粤B67890 LTEST000000000002/ });
|
||||
@@ -78,3 +92,45 @@ test('starts without a selection and supports expand, collapse, reselection, and
|
||||
expect(workspace).toHaveClass('is-detail-open');
|
||||
expect(secondVehicle).toHaveClass('is-selected');
|
||||
});
|
||||
|
||||
test('switches to a lightweight realtime list and resolves addresses only on demand', async () => {
|
||||
const row = vehicles[0];
|
||||
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [row], total: 1, limit: 50, offset: 0 });
|
||||
const reverseGeocode = vi.spyOn(api, 'reverseGeocode').mockResolvedValue({ provider: 'AMap', longitude: 113.26, latitude: 23.13, formattedAddress: '广东省广州市天河区测试路' });
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={queryClient}><MemoryRouter><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
fireEvent.click(screen.getByRole('button', { name: /列表/ }));
|
||||
expect(await screen.findByText('车辆实时列表')).toBeInTheDocument();
|
||||
expect(screen.getByText('速度、里程和坐标实时刷新,文字地址按需解析')).toBeInTheDocument();
|
||||
expect(await screen.findAllByText('粤A12345')).toHaveLength(2);
|
||||
expect(screen.getAllByText('42')).toHaveLength(2);
|
||||
expect(screen.getAllByText(/1,234/)).toHaveLength(2);
|
||||
expect(screen.getAllByText(/113\.260000/)).toHaveLength(2);
|
||||
expect(reverseGeocode).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: '解析粤A12345位置' })[0]);
|
||||
expect(await screen.findByText('广东省广州市天河区测试路')).toBeInTheDocument();
|
||||
expect(reverseGeocode).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByText('综合状态')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('三路正常')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('fleet-map')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('pastes, deduplicates, and submits multiple plates as one batch search', async () => {
|
||||
const vehicleRealtime = vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: vehicles, total: 2, limit: 50, offset: 0 });
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={queryClient}><MemoryRouter><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
const input = screen.getByRole('textbox', { name: '搜索车辆' });
|
||||
fireEvent.paste(input, { clipboardData: { getData: () => '粤a12345\n粤B67890,粤A12345' } });
|
||||
expect(input).toHaveValue('粤A12345,粤B67890');
|
||||
expect(screen.getByText('已识别 2 辆')).toBeInTheDocument();
|
||||
expect(screen.getByText('正在批量筛选 2 辆')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /列表/ }));
|
||||
await waitFor(() => {
|
||||
const params = vehicleRealtime.mock.calls[vehicleRealtime.mock.calls.length - 1]?.[0];
|
||||
expect(params?.get('keywords')).toBe('粤A12345,粤B67890');
|
||||
expect(params?.has('keyword')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,80 @@
|
||||
import { IconChevronLeft, IconChevronRight, IconClose, IconFilter, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||||
import { memo, useCallback, useDeferredValue, useMemo, useState } from 'react';
|
||||
import { IconChevronLeft, IconChevronRight, IconClose, IconFilter, IconList, IconMapPin, IconQrCode, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import QRCode from 'qrcode';
|
||||
import { memo, useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { AlertEvent, MapReverseGeocode, VehicleDetail as VehicleDetailData, VehicleRealtimeRow } from '../../api/types';
|
||||
import { FleetMap } from '../map/FleetMap';
|
||||
import { EmptyState, InlineError } from '../shared/AsyncState';
|
||||
import { formatNumber, relativeFreshness, statusLabel, vehicleStatus } from '../domain/monitor';
|
||||
import { MONITOR_REFRESH, useMonitorData, useMonitorVehicleCard, type MonitorViewport } from '../hooks/useMonitorData';
|
||||
import { MAX_MONITOR_SEARCH_TERMS, MONITOR_REFRESH, monitorQueryParams, parseMonitorSearchTerms, useMonitorData, useMonitorVehicleCard, type MonitorViewport } from '../hooks/useMonitorData';
|
||||
|
||||
const protocols = ['', 'GB32960', 'JT808', 'YUTONG_MQTT'];
|
||||
const statuses = ['', 'online', 'offline', 'driving', 'idle'];
|
||||
|
||||
type AddressCoordinate = { longitude: number; latitude: number; key: string };
|
||||
|
||||
function addressCoordinate(vehicle: VehicleRealtimeRow): AddressCoordinate | undefined {
|
||||
if (!Number.isFinite(vehicle.longitude) || !Number.isFinite(vehicle.latitude)
|
||||
|| Math.abs(vehicle.longitude) > 180 || Math.abs(vehicle.latitude) > 90
|
||||
|| (vehicle.longitude === 0 && vehicle.latitude === 0)) return undefined;
|
||||
const longitude = Number(vehicle.longitude.toFixed(4));
|
||||
const latitude = Number(vehicle.latitude.toFixed(4));
|
||||
return { longitude, latitude, key: `${longitude.toFixed(4)},${latitude.toFixed(4)}` };
|
||||
}
|
||||
|
||||
const MonitorAddressCell = memo(function MonitorAddressCell({ vehicle }: { vehicle: VehicleRealtimeRow }) {
|
||||
const current = addressCoordinate(vehicle);
|
||||
const [requested, setRequested] = useState<AddressCoordinate>();
|
||||
const addressQuery = useQuery({
|
||||
queryKey: ['monitor', 'list-address', requested?.key],
|
||||
queryFn: () => api.reverseGeocode(new URLSearchParams({
|
||||
longitude: requested!.longitude.toFixed(4),
|
||||
latitude: requested!.latitude.toFixed(4)
|
||||
})),
|
||||
enabled: Boolean(requested),
|
||||
staleTime: 6 * 60 * 60_000,
|
||||
gcTime: 24 * 60 * 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1
|
||||
});
|
||||
const moved = Boolean(current && requested && current.key !== requested.key);
|
||||
|
||||
if (!current) return <span className="v2-monitor-address-empty">无有效坐标</span>;
|
||||
if (!requested) return <button type="button" className="v2-monitor-address-action" aria-label={`解析${vehicle.plate || vehicle.vin}位置`} title="按需调用高德逆地理编码,不随实时数据刷新重复请求" onClick={() => setRequested(current)}><IconMapPin />解析位置</button>;
|
||||
if (addressQuery.isFetching && !addressQuery.data) return <span className="v2-monitor-address-loading"><i className="v2-spinner" />解析中</span>;
|
||||
if (addressQuery.isError) return <button type="button" className="v2-monitor-address-action is-error" onClick={() => void addressQuery.refetch()}>解析失败,重试</button>;
|
||||
return <div className="v2-monitor-address-result"><span title={addressQuery.data?.formattedAddress}>{addressQuery.data?.formattedAddress || '暂无地址'}</span>{moved ? <button type="button" title="车辆已移动,按需更新地址" onClick={() => setRequested(current)}>位置已移动 · 更新</button> : null}</div>;
|
||||
});
|
||||
|
||||
function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, onSelect, onPage, onLimit }: { rows: VehicleRealtimeRow[]; total: number; page: number; totalPages: number; limit: number; loading: boolean; onSelect: (vin: string) => void; onPage: (page: number) => void; onLimit: (limit: number) => void }) {
|
||||
return <section className="v2-monitor-table-panel">
|
||||
<header><div><strong>车辆实时列表</strong><span>速度、里程和坐标实时刷新,文字地址按需解析</span></div><b>{total.toLocaleString('zh-CN')} 辆车辆</b></header>
|
||||
<div className="v2-monitor-table-scroll"><table><thead><tr><th>车辆</th><th>速度</th><th>总里程</th><th>经纬度</th><th>地理位置</th></tr></thead>
|
||||
<tbody>{rows.map((row) => <tr key={row.vin}>
|
||||
<td className="v2-monitor-table-vehicle"><button type="button" title="在地图中定位" onClick={() => onSelect(row.vin)}><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></button></td>
|
||||
<td><strong className="v2-monitor-live-value">{formatNumber(row.speedKmh, 1)}</strong><small className="v2-monitor-live-unit">km/h</small></td>
|
||||
<td><strong className="v2-monitor-live-value">{formatNumber(row.totalMileageKm, 1)}</strong><small className="v2-monitor-live-unit">km</small></td>
|
||||
<td><code className="v2-monitor-coordinate">{row.longitude.toFixed(6)}<br />{row.latitude.toFixed(6)}</code></td>
|
||||
<td><MonitorAddressCell vehicle={row} /></td>
|
||||
</tr>)}</tbody></table>{loading ? <div className="v2-monitor-table-loading"><i className="v2-spinner" />正在更新车辆实时数据…</div> : null}{!loading && !rows.length ? <EmptyState /> : null}</div>
|
||||
<div className="v2-monitor-mobile-cards">{rows.map((row) => <article key={row.vin}>
|
||||
<header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div><b>{formatNumber(row.speedKmh, 1)}<small>km/h</small></b></header>
|
||||
<dl><div><dt>总里程</dt><dd>{formatNumber(row.totalMileageKm, 1)} km</dd></div><div><dt>经纬度</dt><dd><code>{row.longitude.toFixed(6)}, {row.latitude.toFixed(6)}</code></dd></div><div className="is-address"><dt>地理位置</dt><dd><MonitorAddressCell vehicle={row} /></dd></div></dl>
|
||||
<footer><button type="button" onClick={() => onSelect(row.vin)}><IconMapPin />地图定位</button></footer>
|
||||
</article>)}{loading ? <div className="v2-monitor-table-loading"><i className="v2-spinner" />正在更新车辆实时数据…</div> : null}{!loading && !rows.length ? <EmptyState /> : null}</div>
|
||||
<footer><span>第 {page} / {totalPages} 页</span><div><button type="button" disabled={page <= 1} onClick={() => onPage(page - 1)}>上一页</button><button type="button" disabled={page >= totalPages} onClick={() => onPage(page + 1)}>下一页</button><select aria-label="每页车辆数" value={limit} onChange={(event) => onLimit(Number(event.target.value))}><option value="20">20 辆/页</option><option value="50">50 辆/页</option><option value="100">100 辆/页</option></select></div></footer>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function MobileEntry({ onClose }: { onClose: () => void }) {
|
||||
const [qr, setQr] = useState('');
|
||||
const url = `${window.location.origin}/monitor`;
|
||||
useEffect(() => { void QRCode.toDataURL(url, { width: 240, margin: 2, color: { dark: '#122033', light: '#ffffff' } }).then(setQr); }, [url]);
|
||||
return <div className="v2-monitor-qr-backdrop" role="dialog" aria-modal="true" aria-label="手机端入口"><section><button type="button" onClick={onClose} aria-label="关闭手机端入口"><IconClose /></button><IconQrCode /><h3>手机端全局监控</h3><p>扫码后使用现有账号鉴权,不在二维码中保存 Token</p>{qr ? <img src={qr} alt="全局监控手机端二维码" /> : <span className="v2-spinner" />}<code>{url}</code><button type="button" onClick={() => void navigator.clipboard?.writeText(url)}>复制访问地址</button></section></div>;
|
||||
}
|
||||
|
||||
const VehicleRow = memo(function VehicleRow({ vehicle, selected, onSelect }: { vehicle: VehicleRealtimeRow; selected: boolean; onSelect: (vin: string) => void }) {
|
||||
const status = vehicleStatus(vehicle);
|
||||
return (
|
||||
@@ -93,8 +158,13 @@ function VehicleDetailCard({
|
||||
}
|
||||
|
||||
export default function MonitorPage() {
|
||||
const [mode, setMode] = useState<'map' | 'list'>('map');
|
||||
const [mobileEntryOpen, setMobileEntryOpen] = useState(false);
|
||||
const [listOffset, setListOffset] = useState(0);
|
||||
const [listLimit, setListLimit] = useState(50);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const deferredKeyword = useDeferredValue(keyword);
|
||||
const searchTerms = useMemo(() => parseMonitorSearchTerms(keyword), [keyword]);
|
||||
const [protocol, setProtocol] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [selectedVin, setSelectedVin] = useState('');
|
||||
@@ -103,7 +173,18 @@ export default function MonitorPage() {
|
||||
const updateViewport = useCallback((next: MonitorViewport) => {
|
||||
setViewport((current) => current.zoom === next.zoom && current.bounds === next.bounds ? current : next);
|
||||
}, []);
|
||||
const { summary, vehicles, map, selectedVehicle } = useMonitorData({ keyword: deferredKeyword, protocol, status }, viewport, selectedVin);
|
||||
const filters = useMemo(() => ({ keyword: deferredKeyword, protocol, status }), [deferredKeyword, protocol, status]);
|
||||
const listParams = useMemo(() => {
|
||||
const params = monitorQueryParams(filters, listLimit);
|
||||
params.set('offset', String(listOffset));
|
||||
return params;
|
||||
}, [filters, listLimit, listOffset]);
|
||||
const { summary, vehicles, map, selectedVehicle } = useMonitorData(filters, viewport, selectedVin, mode === 'map', mode === 'map');
|
||||
const realtimeListQuery = useQuery({
|
||||
queryKey: ['monitor', 'vehicle-list', listParams.toString()],
|
||||
queryFn: () => api.vehicleRealtime(listParams),
|
||||
enabled: mode === 'list', placeholderData: (previous) => previous, refetchInterval: mode === 'list' ? MONITOR_REFRESH.fleet : false
|
||||
});
|
||||
const rows = useMemo(() => {
|
||||
const data = vehicles.data?.items ?? [];
|
||||
if (status === 'driving' || status === 'idle') return data.filter((vehicle) => vehicleStatus(vehicle) === status);
|
||||
@@ -115,6 +196,7 @@ export default function MonitorPage() {
|
||||
const selectVehicle = useCallback((vin: string) => {
|
||||
setSelectedVin(vin);
|
||||
setDetailOpen(true);
|
||||
setMode('map');
|
||||
}, []);
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedVin('');
|
||||
@@ -131,15 +213,33 @@ export default function MonitorPage() {
|
||||
return (
|
||||
<div className="v2-monitor-page">
|
||||
<section className="v2-filterbar" aria-label="车辆筛选">
|
||||
<label className="v2-search-field"><IconSearch /><input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="车牌 / VIN / 厂家" /></label>
|
||||
<select value={protocol} onChange={(event) => setProtocol(event.target.value)} aria-label="协议">
|
||||
<label className={`v2-search-field${searchTerms.length > 1 ? ' is-batch' : ''}`}>
|
||||
<IconSearch />
|
||||
<input
|
||||
aria-label="搜索车辆"
|
||||
value={keyword}
|
||||
onChange={(event) => { setKeyword(event.target.value); setListOffset(0); }}
|
||||
onPaste={(event) => {
|
||||
const pastedTerms = parseMonitorSearchTerms(event.clipboardData.getData('text'));
|
||||
if (pastedTerms.length <= 1) return;
|
||||
event.preventDefault();
|
||||
setKeyword(pastedTerms.join(','));
|
||||
setListOffset(0);
|
||||
}}
|
||||
placeholder="车牌 / VIN;可批量粘贴车牌"
|
||||
/>
|
||||
{searchTerms.length > 1 ? <span className="v2-search-batch-count" aria-live="polite" title={searchTerms.length === MAX_MONITOR_SEARCH_TERMS ? `最多支持 ${MAX_MONITOR_SEARCH_TERMS} 条;${searchTerms.join('、')}` : searchTerms.join('、')}>已识别 {searchTerms.length} 辆</span> : null}
|
||||
</label>
|
||||
<select value={protocol} onChange={(event) => { setProtocol(event.target.value); setListOffset(0); }} aria-label="协议">
|
||||
{protocols.map((item) => <option key={item} value={item}>{item || '全部协议'}</option>)}
|
||||
</select>
|
||||
<select value={status} onChange={(event) => setStatus(event.target.value)} aria-label="在线状态">
|
||||
<select value={status} onChange={(event) => { setStatus(event.target.value); setListOffset(0); }} aria-label="在线状态">
|
||||
{statuses.map((item) => <option key={item} value={item}>{item ? statusLabel(item as never) : '全部状态'}</option>)}
|
||||
</select>
|
||||
<button type="button" className="v2-secondary-button" onClick={() => { setKeyword(''); setProtocol(''); setStatus(''); }}><IconRefresh />清空</button>
|
||||
<button type="button" className="v2-secondary-button" onClick={() => { setKeyword(''); setProtocol(''); setStatus(''); setListOffset(0); }}><IconRefresh />清空</button>
|
||||
<button type="button" className="v2-primary-button"><IconFilter />筛选</button>
|
||||
<div className="v2-monitor-mode" aria-label="监控视图"><button type="button" className={mode === 'map' ? 'is-active' : ''} onClick={() => setMode('map')}><IconMapPin />地图</button><button type="button" className={mode === 'list' ? 'is-active' : ''} onClick={() => { setMode('list'); setDetailOpen(false); }}><IconList />列表</button></div>
|
||||
<button type="button" className="v2-monitor-mobile-entry" onClick={() => setMobileEntryOpen(true)}><IconQrCode />手机端</button>
|
||||
</section>
|
||||
|
||||
<section className="v2-kpis" aria-label="车辆整体统计">
|
||||
@@ -155,10 +255,11 @@ export default function MonitorPage() {
|
||||
</section>
|
||||
|
||||
{vehicles.isError ? <InlineError message={vehicles.error instanceof Error ? vehicles.error.message : '车辆数据加载失败'} onRetry={() => vehicles.refetch()} /> : null}
|
||||
<section className={`v2-monitor-workspace${selected && detailOpen ? ' is-detail-open' : ''}${selected && !detailOpen ? ' is-detail-collapsed' : ''}`}>
|
||||
{mode === 'list' && realtimeListQuery.isError ? <InlineError message={realtimeListQuery.error instanceof Error ? realtimeListQuery.error.message : '车辆列表加载失败'} onRetry={() => realtimeListQuery.refetch()} /> : null}
|
||||
{mode === 'map' ? <section className={`v2-monitor-workspace${selected && detailOpen ? ' is-detail-open' : ''}${selected && !detailOpen ? ' is-detail-collapsed' : ''}`}>
|
||||
<div className="v2-vehicle-rail">
|
||||
<header><strong>车辆列表</strong><span>{formatNumber(vehicles.data?.total ?? rows.length)} 辆</span></header>
|
||||
<div className="v2-rail-search"><IconSearch /><span>{deferredKeyword ? `正在筛选“${deferredKeyword}”` : '按最新上报排序'}</span></div>
|
||||
<div className="v2-rail-search"><IconSearch /><span>{searchTerms.length > 1 ? `正在批量筛选 ${searchTerms.length} 辆` : deferredKeyword ? `正在筛选“${deferredKeyword}”` : '按最新上报排序'}</span></div>
|
||||
<div className="v2-vehicle-scroll">
|
||||
{vehicles.isLoading ? <div className="v2-list-loading"><span className="v2-spinner" />加载车辆</div> : null}
|
||||
{!vehicles.isLoading && rows.length === 0 ? <EmptyState /> : null}
|
||||
@@ -193,15 +294,16 @@ export default function MonitorPage() {
|
||||
</button>
|
||||
</aside>
|
||||
) : null}
|
||||
</section>
|
||||
</section> : <MonitorVehicleTable rows={realtimeListQuery.data?.items ?? []} total={realtimeListQuery.data?.total ?? 0} page={Math.floor(listOffset / listLimit) + 1} totalPages={Math.max(1, Math.ceil((realtimeListQuery.data?.total ?? 0) / listLimit))} limit={listLimit} loading={realtimeListQuery.isFetching} onSelect={selectVehicle} onPage={(page) => setListOffset((page - 1) * listLimit)} onLimit={(next) => { setListLimit(next); setListOffset(0); }} />}
|
||||
|
||||
<section className="v2-event-strip">
|
||||
<strong>实时数据状态</strong>
|
||||
<span><i className="is-online" />{vehicles.isFetching ? '正在刷新' : '实时车辆已同步'}</span>
|
||||
<span>列表 {rows.length} 条 · 地图 {map.data?.clusters.length ? `${map.data.clusters.length} 个聚合 + ${map.data.points.length} 个车辆点` : `${map.data?.points.length ?? 0} 个点`}</span>
|
||||
<span>{mode === 'map' ? `列表 ${rows.length} 条 · 地图 ${map.data?.clusters.length ? `${map.data.clusters.length} 个聚合 + ${map.data.points.length} 个车辆点` : `${map.data?.points.length ?? 0} 个点`}` : `实时列表 ${realtimeListQuery.data?.items.length ?? 0} / ${realtimeListQuery.data?.total ?? 0} 辆`}</span>
|
||||
<span className="v2-refresh-cadence"><b>智能刷新</b> 重点车辆 {MONITOR_REFRESH.selected / 1000} 秒 · 车队 {MONITOR_REFRESH.fleet / 1000} 秒 · 统计 {MONITOR_REFRESH.summary / 1000} 秒</span>
|
||||
<time>{new Date().toLocaleString('zh-CN', { hour12: false })}</time>
|
||||
</section>
|
||||
{mobileEntryOpen ? <MobileEntry onClose={() => setMobileEntryOpen(false)} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,126 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import StatisticsPage from './StatisticsPage';
|
||||
|
||||
const mileageStatistics = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../../api/client', () => ({ api: { mileageStatistics } }));
|
||||
const mocks = vi.hoisted(() => ({ mileageStatistics: vi.fn(), dailyMileage: vi.fn(), vehicles: vi.fn(), vehicleCoverage: vi.fn() }));
|
||||
const exportMocks = vi.hoisted(() => ({ downloadMileageWorkbook: vi.fn() }));
|
||||
vi.mock('../../api/client', () => ({ api: mocks }));
|
||||
vi.mock('../domain/mileageExport', () => exportMocks);
|
||||
|
||||
afterEach(() => { cleanup(); mileageStatistics.mockReset(); });
|
||||
afterEach(() => { cleanup(); window.localStorage.clear(); Object.values(mocks).forEach((mock) => mock.mockReset()); exportMocks.downloadMileageWorkbook.mockReset(); });
|
||||
|
||||
test('shows distinct period and latest fleet mileage metrics with exact daily evidence', async () => {
|
||||
mileageStatistics.mockResolvedValue({
|
||||
dateFrom: '2026-07-01', dateTo: '2026-07-14', vehicleCount: 2, recordCount: 2, sourceCount: 2,
|
||||
periodMileageKm: 193.3, fleetLatestMileageKm: 168723.9, averageMileagePerVin: 96.65, averageDailyMileageKm: 96.65,
|
||||
trend: [{ date: '2026-07-14', mileageKm: 193.3, vehicles: 2 }],
|
||||
ranking: [{ vin: 'LTEST000000000001', plate: '粤A12345', mileageKm: 104.6, latestMileageKm: 119925, activeDays: 1 }],
|
||||
function renderPage(initialEntry = '/statistics') {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[initialEntry]}><StatisticsPage /></MemoryRouter></QueryClientProvider>);
|
||||
}
|
||||
|
||||
function prepareData() {
|
||||
mocks.mileageStatistics.mockResolvedValue({
|
||||
dateFrom: '2026-07-01', dateTo: '2026-07-14', vehicleCount: 1, recordCount: 2, sourceCount: 2,
|
||||
periodMileageKm: 193.3, fleetLatestMileageKm: 119925, averageMileagePerVin: 193.3, averageDailyMileageKm: 96.65,
|
||||
trend: [], ranking: [{ vin: 'LTEST000000000001', plate: '粤A12345', mileageKm: 193.3, latestMileageKm: 119925, activeDays: 2 }],
|
||||
asOf: '2026-07-14 13:20:00', evidence: 'production mileage evidence'
|
||||
});
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><MemoryRouter><StatisticsPage /></MemoryRouter></QueryClientProvider>);
|
||||
mocks.dailyMileage.mockResolvedValue({ items: [
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 119731.7, endMileageKm: 119820.4, dailyMileageKm: 88.7, source: 'GB32960' },
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 119820.4, endMileageKm: 119925, dailyMileageKm: 104.6, source: 'GB32960' }
|
||||
], total: 2, limit: 10000, offset: 0 });
|
||||
mocks.vehicles.mockResolvedValue({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345', phone: '', oem: '', protocol: 'GB32960', online: true, lastSeen: '', locationText: '', bindingScore: 100 }], total: 1, limit: 12, offset: 0 });
|
||||
mocks.vehicleCoverage.mockResolvedValue({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345' }], total: 1, limit: 20, offset: 0 });
|
||||
}
|
||||
|
||||
test('renders one vehicle per row with dates as columns and a period total', async () => {
|
||||
prepareData();
|
||||
renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
|
||||
expect(await screen.findByText('车辆每日里程')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('区间总里程').length).toBeGreaterThan(1);
|
||||
expect((await screen.findAllByText('193.3 km')).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('168,723.9 km')).toBeInTheDocument();
|
||||
expect(screen.getByText('按车辆与自然日去重')).toBeInTheDocument();
|
||||
expect(screen.getByText('粤A12345')).toHaveAttribute('href', '/vehicles/LTEST000000000001');
|
||||
expect(screen.getByRole('img', { name: '每日行驶里程趋势图' })).toBeInTheDocument();
|
||||
await waitFor(() => expect(mileageStatistics).toHaveBeenCalledTimes(1));
|
||||
expect(mileageStatistics.mock.calls[0][0].get('dateFrom')).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(screen.getAllByText('104.6 km').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('88.7 km').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('7/13').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('7/14').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('粤A12345').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('当日起始里程')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('数据来源')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('img', { name: '每日行驶里程趋势图' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('车辆里程排名')).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(mocks.dailyMileage).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.dailyMileage.mock.calls[0][0].get('limit')).toBe('10000');
|
||||
expect(mocks.dailyMileage.mock.calls[0][0].get('protocols')).toBe('GB32960,JT808,YUTONG_MQTT');
|
||||
});
|
||||
|
||||
test('supports searching and selecting license plates before querying exact VINs', async () => {
|
||||
prepareData();
|
||||
renderPage();
|
||||
const search = screen.getByRole('textbox', { name: '搜索车牌' });
|
||||
fireEvent.focus(search);
|
||||
fireEvent.change(search, { target: { value: '粤A12' } });
|
||||
expect(await screen.findByRole('option', { name: /粤A12345/ })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('option', { name: /粤A12345/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||
await waitFor(() => {
|
||||
const calls = mocks.mileageStatistics.mock.calls;
|
||||
expect(calls[calls.length - 1]?.[0].get('vins')).toBe('LTEST000000000001');
|
||||
});
|
||||
expect(screen.getByTitle('LTEST000000000001')).toHaveTextContent('粤A12345');
|
||||
});
|
||||
|
||||
test('lets users disable mileage sources and persists the source priority', async () => {
|
||||
prepareData();
|
||||
renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
|
||||
fireEvent.click(screen.getByRole('button', { name: /数据源/ }));
|
||||
expect(screen.getByRole('dialog', { name: '数据源策略配置' })).toBeInTheDocument();
|
||||
expect(screen.getByText('GPS 里程')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('仪表盘里程')).toHaveLength(2);
|
||||
fireEvent.click(screen.getByRole('switch', { name: '禁用 国标 GB32960' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '上移 宇通 MQTT' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||
await waitFor(() => {
|
||||
const calls = mocks.dailyMileage.mock.calls;
|
||||
expect(calls[calls.length - 1]?.[0].get('protocols')).toBe('YUTONG_MQTT,JT808');
|
||||
});
|
||||
expect(window.localStorage.getItem('vehicle-platform:mileage-source-strategy')).toContain('YUTONG_MQTT');
|
||||
expect(screen.getByText('来源优先级:YUTONG_MQTT > JT808')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('paginates all unique vehicles when no license plate is selected', async () => {
|
||||
prepareData();
|
||||
const firstPage = Array.from({ length: 20 }, (_, index) => ({ vin: `VIN${String(index + 1).padStart(14, '0')}`, plate: `粤A${String(index + 1).padStart(5, '0')}` }));
|
||||
const secondPage = Array.from({ length: 12 }, (_, index) => ({ vin: `VIN${String(index + 21).padStart(14, '0')}`, plate: `粤A${String(index + 21).padStart(5, '0')}` }));
|
||||
mocks.vehicleCoverage.mockImplementation((params: URLSearchParams) => Promise.resolve({
|
||||
items: params.get('offset') === '20' ? secondPage : firstPage,
|
||||
total: 32,
|
||||
limit: 20,
|
||||
offset: Number(params.get('offset') ?? 0)
|
||||
}));
|
||||
mocks.dailyMileage.mockResolvedValue({ items: [], total: 0, limit: 10000, offset: 0 });
|
||||
renderPage('/statistics?dateFrom=2026-07-13&dateTo=2026-07-14');
|
||||
|
||||
expect((await screen.findAllByText('粤A00001')).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('第 1 / 2 页 · 共 32 辆 · 每页 20 辆')).toBeInTheDocument();
|
||||
expect(mocks.vehicleCoverage.mock.calls[0][0].get('bindingStatus')).toBe('bound');
|
||||
await waitFor(() => expect(mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1]?.[0].get('vins')).toContain('VIN00000000000001'));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
expect((await screen.findAllByText('粤A00021')).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('第 2 / 2 页 · 共 32 辆 · 每页 20 辆')).toBeInTheDocument();
|
||||
await waitFor(() => expect(mocks.vehicleCoverage.mock.calls[mocks.vehicleCoverage.mock.calls.length - 1]?.[0].get('offset')).toBe('20'));
|
||||
});
|
||||
|
||||
test('exports the full fleet mileage with one paginated query instead of VIN batches', async () => {
|
||||
prepareData();
|
||||
exportMocks.downloadMileageWorkbook.mockResolvedValue(undefined);
|
||||
renderPage('/statistics?dateFrom=2026-07-13&dateTo=2026-07-14');
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /导出 Excel/ })).toBeEnabled());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /导出 Excel/ }));
|
||||
|
||||
await waitFor(() => expect(exportMocks.downloadMileageWorkbook).toHaveBeenCalledTimes(1));
|
||||
const exportMileageCall = mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1][0] as URLSearchParams;
|
||||
expect(exportMileageCall.get('vins')).toBeNull();
|
||||
expect(exportMileageCall.get('vehicleScope')).toBe('bound');
|
||||
expect(exportMocks.downloadMileageWorkbook.mock.calls[0][0].vehicles).toHaveLength(1);
|
||||
expect(screen.getByText(/已导出 1 辆车/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||||
import { IconArrowDown, IconArrowUp, IconClose, IconDownload, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { FormEvent, useMemo, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { MileageStatistics, MileageTrendPoint } from '../../api/types';
|
||||
import type { DailyMileageRow, MileageStatistics, VehicleRow } from '../../api/types';
|
||||
import { downloadMileageWorkbook } from '../domain/mileageExport';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
|
||||
const DAY = 86_400_000;
|
||||
const DETAIL_LIMIT = 10_000;
|
||||
const MAX_SELECTED_VEHICLES = 20;
|
||||
const PAGE_SIZE = 20;
|
||||
const EXPORT_VEHICLE_PAGE_SIZE = 2_000;
|
||||
const EXPORT_VIN_BATCH_SIZE = 50;
|
||||
|
||||
type VehicleOption = Pick<VehicleRow, 'vin' | 'plate'>;
|
||||
type MileageProtocol = 'GB32960' | 'JT808' | 'YUTONG_MQTT';
|
||||
type MileageSourceOption = { protocol: MileageProtocol; label: string; mileageType: string; enabled: boolean };
|
||||
type Criteria = { vehicles: VehicleOption[]; dateFrom: string; dateTo: string; sources: MileageSourceOption[] };
|
||||
|
||||
const SOURCE_STORAGE_KEY = 'vehicle-platform:mileage-source-strategy';
|
||||
const DEFAULT_SOURCES: MileageSourceOption[] = [
|
||||
{ protocol: 'GB32960', label: '国标 GB32960', mileageType: '仪表盘里程', enabled: true },
|
||||
{ protocol: 'JT808', label: '交通部 JT/T 808', mileageType: 'GPS 里程', enabled: true },
|
||||
{ protocol: 'YUTONG_MQTT', label: '宇通 MQTT', mileageType: '仪表盘里程', enabled: true }
|
||||
];
|
||||
|
||||
function localDate(value = new Date()) {
|
||||
const offset = value.getTimezoneOffset() * 60_000;
|
||||
@@ -18,73 +36,293 @@ function defaultWindow(days = 30) {
|
||||
return { dateFrom: localDate(new Date(end.getTime() - (days - 1) * DAY)), dateTo: localDate(end) };
|
||||
}
|
||||
|
||||
function formatKm(value?: number, compact = false) {
|
||||
function formatKm(value?: number) {
|
||||
if (value == null || !Number.isFinite(value)) return '—';
|
||||
return new Intl.NumberFormat('zh-CN', compact ? { notation: 'compact', maximumFractionDigits: 1 } : { maximumFractionDigits: 1 }).format(value);
|
||||
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 }).format(value);
|
||||
}
|
||||
|
||||
function MileageChart({ points }: { points: MileageTrendPoint[] }) {
|
||||
if (!points.length) return <div className="v2-stat-empty">当前筛选范围没有日里程数据</div>;
|
||||
const width = 860; const height = 238; const left = 58; const right = 18; const top = 16; const bottom = 34;
|
||||
const max = Math.max(...points.map((point) => point.mileageKm), 1);
|
||||
const x = (index: number) => left + (width - left - right) * (points.length === 1 ? .5 : index / (points.length - 1));
|
||||
const y = (value: number) => top + (height - top - bottom) * (1 - value / max);
|
||||
const path = points.map((point, index) => `${index ? 'L' : 'M'}${x(index).toFixed(1)},${y(point.mileageKm).toFixed(1)}`).join(' ');
|
||||
const labelIndexes = Array.from(new Set([0, Math.floor((points.length - 1) / 2), points.length - 1]));
|
||||
return <div className="v2-stat-chart-wrap"><svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label="每日行驶里程趋势图">
|
||||
<g className="v2-stat-grid">{[0, .5, 1].map((ratio) => <line key={ratio} x1={left} x2={width - right} y1={y(max * ratio)} y2={y(max * ratio)} />)}</g>
|
||||
<g className="v2-stat-axis"><text x={left - 8} y={y(max) + 4} textAnchor="end">{formatKm(max, true)}</text><text x={left - 8} y={y(max / 2) + 4} textAnchor="end">{formatKm(max / 2, true)}</text><text x={left - 8} y={y(0) + 4} textAnchor="end">0</text>{labelIndexes.map((index) => <text key={index} x={x(index)} y={height - 8} textAnchor={index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'middle'}>{points[index].date.slice(5)}</text>)}</g>
|
||||
<path className="v2-stat-area" d={`${path} L${x(points.length - 1)},${y(0)} L${x(0)},${y(0)} Z`} />
|
||||
<path className="v2-stat-line" d={path} />
|
||||
{points.map((point, index) => <circle key={point.date} className="v2-stat-point" cx={x(index)} cy={y(point.mileageKm)} r="3"><title>{point.date}:{formatKm(point.mileageKm)} km,{point.vehicles} 辆</title></circle>)}
|
||||
</svg></div>;
|
||||
function inclusiveDays(dateFrom: string, dateTo: string) {
|
||||
const from = Date.parse(`${dateFrom}T00:00:00`);
|
||||
const to = Date.parse(`${dateTo}T00:00:00`);
|
||||
return Number.isFinite(from) && Number.isFinite(to) ? Math.max(1, Math.round((to - from) / DAY) + 1) : 0;
|
||||
}
|
||||
|
||||
function Kpis({ data }: { data?: MileageStatistics }) {
|
||||
function mileageParams(criteria: Criteria, offset = 0) {
|
||||
const params = new URLSearchParams({ dateFrom: criteria.dateFrom, dateTo: criteria.dateTo });
|
||||
if (criteria.vehicles.length) params.set('vins', criteria.vehicles.map((vehicle) => vehicle.vin).join(','));
|
||||
else params.set('vehicleScope', 'bound');
|
||||
params.set('protocols', criteria.sources.filter((source) => source.enabled).map((source) => source.protocol).join(','));
|
||||
if (offset >= 0) {
|
||||
params.set('deduplicate', '1');
|
||||
params.set('limit', String(DETAIL_LIMIT));
|
||||
params.set('offset', String(offset));
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function initialCriteria(searchParams: URLSearchParams): Criteria {
|
||||
const defaults = defaultWindow(30);
|
||||
const vins = (searchParams.get('vins') ?? '').split(',').map((vin) => vin.trim()).filter(Boolean).slice(0, MAX_SELECTED_VEHICLES);
|
||||
const requestedProtocols = (searchParams.get('protocols') ?? '').split(',').filter((protocol): protocol is MileageProtocol => DEFAULT_SOURCES.some((source) => source.protocol === protocol));
|
||||
let sources = DEFAULT_SOURCES.map((source) => ({ ...source }));
|
||||
if (requestedProtocols.length) {
|
||||
sources = [...requestedProtocols.map((protocol) => ({ ...DEFAULT_SOURCES.find((source) => source.protocol === protocol)!, enabled: true })), ...DEFAULT_SOURCES.filter((source) => !requestedProtocols.includes(source.protocol)).map((source) => ({ ...source, enabled: false }))];
|
||||
} else {
|
||||
try {
|
||||
const stored = JSON.parse(window.localStorage.getItem(SOURCE_STORAGE_KEY) ?? '[]') as Partial<MileageSourceOption>[];
|
||||
const normalized = stored.flatMap((item) => {
|
||||
const source = DEFAULT_SOURCES.find((candidate) => candidate.protocol === item.protocol);
|
||||
return source ? [{ ...source, enabled: item.enabled !== false }] : [];
|
||||
});
|
||||
if (normalized.length === DEFAULT_SOURCES.length && normalized.some((source) => source.enabled)) sources = normalized;
|
||||
} catch { /* retain safe defaults */ }
|
||||
}
|
||||
return {
|
||||
vehicles: vins.map((vin) => ({ vin, plate: '' })),
|
||||
dateFrom: searchParams.get('dateFrom') ?? defaults.dateFrom,
|
||||
dateTo: searchParams.get('dateTo') ?? defaults.dateTo,
|
||||
sources
|
||||
};
|
||||
}
|
||||
|
||||
function SourceStrategy({ value, onChange }: { value: MileageSourceOption[]; onChange: (sources: MileageSourceOption[]) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const enabled = value.filter((source) => source.enabled);
|
||||
const update = (sources: MileageSourceOption[]) => {
|
||||
onChange(sources);
|
||||
try { window.localStorage.setItem(SOURCE_STORAGE_KEY, JSON.stringify(sources)); } catch { /* preference persistence is optional */ }
|
||||
};
|
||||
const move = (index: number, direction: -1 | 1) => {
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= value.length) return;
|
||||
const next = [...value];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
update(next);
|
||||
};
|
||||
const toggle = (protocol: MileageProtocol) => {
|
||||
const current = value.find((source) => source.protocol === protocol);
|
||||
if (current?.enabled && enabled.length === 1) return;
|
||||
update(value.map((source) => source.protocol === protocol ? { ...source, enabled: !source.enabled } : source));
|
||||
};
|
||||
|
||||
return <div className="v2-mileage-source-strategy">
|
||||
<button type="button" className="v2-mileage-source-trigger" aria-haspopup="dialog" aria-expanded={open} onClick={() => setOpen((current) => !current)}>
|
||||
<IconSetting /><span>数据源</span><b>{enabled.length}/3</b>
|
||||
</button>
|
||||
{open ? <section className="v2-mileage-source-popover" role="dialog" aria-label="数据源策略配置">
|
||||
<header><div><strong>数据源策略</strong><span>同车同日按优先级选择一个里程来源</span></div><button type="button" aria-label="关闭数据源策略" onClick={() => setOpen(false)}><IconClose /></button></header>
|
||||
<div className="v2-mileage-source-list">{value.map((source, index) => <article key={source.protocol} className={source.enabled ? '' : 'is-disabled'}>
|
||||
<button type="button" className={`v2-mileage-source-switch${source.enabled ? ' is-on' : ''}`} role="switch" aria-checked={source.enabled} aria-label={`${source.enabled ? '禁用' : '启用'} ${source.label}`} onClick={() => toggle(source.protocol)}><i /></button>
|
||||
<div><strong>{source.label}</strong><small><b>{source.mileageType}</b><code>{source.protocol}</code></small></div>
|
||||
<em>{source.enabled ? `优先级 ${enabled.findIndex((item) => item.protocol === source.protocol) + 1}` : '已禁用'}</em>
|
||||
<p><button type="button" aria-label={`上移 ${source.label}`} disabled={index === 0} onClick={() => move(index, -1)}><IconArrowUp /></button><button type="button" aria-label={`下移 ${source.label}`} disabled={index === value.length - 1} onClick={() => move(index, 1)}><IconArrowDown /></button></p>
|
||||
</article>)}</div>
|
||||
<footer><span>同车同日只使用一个来源,避免重复累计;至少保留一个来源。</span><button type="button" onClick={() => setOpen(false)}>完成</button></footer>
|
||||
</section> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function VehicleMultiSelect({ value, onChange }: { value: VehicleOption[]; onChange: (vehicles: VehicleOption[]) => void }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [debounced, setDebounced] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
useEffect(() => { const timer = window.setTimeout(() => setDebounced(search.trim()), 220); return () => window.clearTimeout(timer); }, [search]);
|
||||
const candidateParams = useMemo(() => {
|
||||
const params = new URLSearchParams({ limit: '12', offset: '0' });
|
||||
if (debounced) params.set('keyword', debounced);
|
||||
return params;
|
||||
}, [debounced]);
|
||||
const candidates = useQuery({
|
||||
queryKey: ['mileage-vehicle-options', candidateParams.toString()],
|
||||
queryFn: () => api.vehicles(candidateParams),
|
||||
enabled: open,
|
||||
staleTime: 60_000
|
||||
});
|
||||
const selected = useMemo(() => new Set(value.map((vehicle) => vehicle.vin)), [value]);
|
||||
const options = (candidates.data?.items ?? []).filter((vehicle, index, rows) => rows.findIndex((item) => item.vin === vehicle.vin) === index);
|
||||
|
||||
const add = (vehicle: VehicleRow) => {
|
||||
if (selected.has(vehicle.vin) || value.length >= MAX_SELECTED_VEHICLES) return;
|
||||
onChange([...value, { vin: vehicle.vin, plate: vehicle.plate }]);
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
return <label className="v2-mileage-vehicle-field">
|
||||
<span>车牌</span>
|
||||
<div className={`v2-mileage-multiselect${open ? ' is-open' : ''}`}>
|
||||
<IconSearch />
|
||||
<div className="v2-mileage-selection">
|
||||
{value.map((vehicle) => <button key={vehicle.vin} type="button" className="v2-mileage-chip" title={vehicle.vin} onClick={() => onChange(value.filter((item) => item.vin !== vehicle.vin))}>
|
||||
<span>{vehicle.plate || vehicle.vin}</span><IconClose />
|
||||
</button>)}
|
||||
<input value={search} onFocus={() => setOpen(true)} onBlur={() => window.setTimeout(() => setOpen(false), 120)} onChange={(event) => { setSearch(event.target.value); setOpen(true); }} placeholder={value.length ? '继续添加车牌' : '输入车牌搜索,可多选'} aria-label="搜索车牌" />
|
||||
</div>
|
||||
{open ? <div className="v2-mileage-options" role="listbox">
|
||||
<header><span>车牌候选</span><em>{value.length}/{MAX_SELECTED_VEHICLES} 已选</em></header>
|
||||
{candidates.isLoading ? <p>正在搜索车辆…</p> : null}
|
||||
{!candidates.isLoading && options.map((vehicle) => <button type="button" role="option" aria-selected={selected.has(vehicle.vin)} key={vehicle.vin} disabled={selected.has(vehicle.vin)} onMouseDown={(event) => event.preventDefault()} onClick={() => add(vehicle)}>
|
||||
<strong>{vehicle.plate || '未绑定车牌'}</strong><span>{vehicle.vin}</span>{selected.has(vehicle.vin) ? <em>已选择</em> : null}
|
||||
</button>)}
|
||||
{!candidates.isLoading && !options.length ? <p>没有匹配的车牌</p> : null}
|
||||
</div> : null}
|
||||
</div>
|
||||
<small>支持车牌关键字搜索,最多选择 {MAX_SELECTED_VEHICLES} 辆</small>
|
||||
</label>;
|
||||
}
|
||||
|
||||
function SummaryRail({ data, criteria, fleetTotal }: { data?: MileageStatistics; criteria: Criteria; fleetTotal?: number }) {
|
||||
const days = inclusiveDays(criteria.dateFrom, criteria.dateTo);
|
||||
const vehicleCount = criteria.vehicles.length ? data?.vehicleCount ?? 0 : fleetTotal ?? 0;
|
||||
const items = [
|
||||
['统计期行驶里程', `${formatKm(data?.periodMileageKm)} km`, '按车辆与自然日去重'],
|
||||
['最新里程表总和', `${formatKm(data?.fleetLatestMileageKm)} km`, '每车取最新一次上报'],
|
||||
['有里程车辆', formatKm(data?.vehicleCount), `${data?.sourceCount ?? 0} 个数据来源`],
|
||||
['车均行驶里程', `${formatKm(data?.averageMileagePerVin)} km`, '统计期累计 / 车辆'],
|
||||
['车日均里程', `${formatKm(data?.averageDailyMileageKm)} km`, '有效车辆日平均']
|
||||
['查询车辆', `${vehicleCount} 辆`, criteria.vehicles.length ? `已选择 ${criteria.vehicles.length} 辆` : `全部车辆 · ${data?.vehicleCount ?? 0} 辆有里程`],
|
||||
['统计天数', `${days} 天`, `${criteria.dateFrom} 至 ${criteria.dateTo}`],
|
||||
['区间总里程', `${formatKm(data?.periodMileageKm)} km`, `${data?.recordCount ?? 0} 条车辆日记录`],
|
||||
['日均里程', `${formatKm(data?.averageDailyMileageKm)} km`, '按有效车辆日平均']
|
||||
];
|
||||
return <section className="v2-stat-kpis">{items.map(([label, value, note], index) => <article key={label} className={index < 2 ? 'is-primary' : ''}><small>{label}</small><strong>{value}</strong><span>{note}</span></article>)}</section>;
|
||||
return <section className="v2-mileage-summary" aria-label="里程查询统计信息">{items.map(([label, value, note], index) => <article key={label} className={index === 2 ? 'is-primary' : ''}><small>{label}</small><strong>{value}</strong><span>{note}</span></article>)}</section>;
|
||||
}
|
||||
|
||||
type VehicleMileageMatrix = VehicleOption & { days: Map<string, number>; sources: Map<string, string>; totalMileageKm: number };
|
||||
|
||||
function rangeDates(dateFrom: string, dateTo: string) {
|
||||
const dates: string[] = [];
|
||||
const cursor = new Date(`${dateFrom}T00:00:00`);
|
||||
const end = new Date(`${dateTo}T00:00:00`);
|
||||
while (cursor <= end) { dates.push(localDate(cursor)); cursor.setDate(cursor.getDate() + 1); }
|
||||
return dates;
|
||||
}
|
||||
|
||||
function dateLabel(date: string) {
|
||||
const [, month, day] = date.split('-');
|
||||
return `${Number(month)}/${Number(day)}`;
|
||||
}
|
||||
|
||||
function MileageTable({ rows, dates }: { rows: VehicleMileageMatrix[]; dates: string[] }) {
|
||||
const maxDailyMileage = Math.max(1, ...rows.flatMap((row) => Array.from(row.days.values())));
|
||||
return <>
|
||||
<div className="v2-mileage-table-wrap">
|
||||
<table className="v2-mileage-table">
|
||||
<thead><tr><th className="is-sticky is-plate">车牌</th><th className="is-sticky is-vin">VIN</th>{dates.map((date) => <th key={date} className="is-number is-date" title={date}>{dateLabel(date)}</th>)}<th className="is-number is-total">区间总里程</th></tr></thead>
|
||||
<tbody>{rows.map((row) => <tr key={row.vin}><td className="is-sticky is-plate"><strong>{row.plate || '未绑定'}</strong></td><td className="is-sticky is-vin"><code>{row.vin}</code></td>{dates.map((date) => {
|
||||
const mileage = row.days.get(date);
|
||||
const intensity = mileage && mileage > 0 ? .035 + mileage / maxDailyMileage * .13 : 0;
|
||||
return <td key={date} className={`is-number${mileage != null ? ' is-daily' : ' is-empty'}`} title={mileage != null ? `来源:${row.sources.get(date) || '—'}` : undefined} style={intensity ? { backgroundColor: `rgba(37, 99, 235, ${intensity.toFixed(3)})` } : undefined}>{mileage != null ? `${formatKm(mileage)} km` : '—'}</td>;
|
||||
})}<td className="is-number is-period is-total">{formatKm(row.totalMileageKm)} km</td></tr>)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="v2-mileage-mobile-list">{rows.map((row) => <article key={row.vin}><header><div><strong>{row.plate || '未绑定'}</strong><span>{row.vin}</span></div><b>{formatKm(row.totalMileageKm)} km<small>区间总里程</small></b></header><div className="v2-mileage-mobile-days">{dates.map((date) => <div key={date}><time>{dateLabel(date)}</time><strong>{row.days.has(date) ? `${formatKm(row.days.get(date))} km` : '—'}</strong></div>)}</div></article>)}</div>
|
||||
</>;
|
||||
}
|
||||
|
||||
export default function StatisticsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const defaults = useMemo(() => defaultWindow(30), []);
|
||||
const initial = { vin: searchParams.get('vin') ?? '', protocol: searchParams.get('protocol') ?? '', dateFrom: searchParams.get('dateFrom') ?? defaults.dateFrom, dateTo: searchParams.get('dateTo') ?? defaults.dateTo };
|
||||
const [draft, setDraft] = useState(initial); const [criteria, setCriteria] = useState(initial);
|
||||
const params = useMemo(() => { const next = new URLSearchParams({ dateFrom: criteria.dateFrom, dateTo: criteria.dateTo }); if (criteria.vin) next.set('vin', criteria.vin); if (criteria.protocol) next.set('protocol', criteria.protocol); return next; }, [criteria]);
|
||||
const query = useQuery({ queryKey: ['mileage-statistics', params.toString()], queryFn: () => api.mileageStatistics(params), staleTime: 60_000, refetchInterval: 5 * 60_000, placeholderData: (previous) => previous });
|
||||
const submit = (event: FormEvent) => { event.preventDefault(); setCriteria(draft); setSearchParams(paramsFrom(draft), { replace: true }); };
|
||||
const setDays = (days: number) => { const range = defaultWindow(days); const next = { ...draft, ...range }; setDraft(next); setCriteria(next); setSearchParams(paramsFrom(next), { replace: true }); };
|
||||
const data = query.data; const maximumRank = Math.max(...(data?.ranking.map((item) => item.mileageKm) ?? [1]), 1);
|
||||
const [draft, setDraft] = useState<Criteria>(() => initialCriteria(searchParams));
|
||||
const [criteria, setCriteria] = useState<Criteria>(() => initialCriteria(searchParams));
|
||||
const [page, setPage] = useState(1);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [exportFeedback, setExportFeedback] = useState('');
|
||||
const hasVehicles = criteria.vehicles.length > 0;
|
||||
const fleetParams = useMemo(() => new URLSearchParams({ limit: String(PAGE_SIZE), offset: String((page - 1) * PAGE_SIZE), bindingStatus: 'bound' }), [page]);
|
||||
const fleetVehicles = useQuery({
|
||||
queryKey: ['mileage-fleet-page', fleetParams.toString()],
|
||||
queryFn: () => api.vehicleCoverage(fleetParams),
|
||||
enabled: !hasVehicles,
|
||||
staleTime: 60_000
|
||||
});
|
||||
const displayVehicles = useMemo<VehicleOption[]>(() => hasVehicles
|
||||
? criteria.vehicles
|
||||
: (fleetVehicles.data?.items ?? []).map((vehicle) => ({ vin: vehicle.vin, plate: vehicle.plate })), [criteria.vehicles, fleetVehicles.data?.items, hasVehicles]);
|
||||
const statisticsParams = useMemo(() => mileageParams(criteria, -1), [criteria]);
|
||||
const rowsCriteria = useMemo(() => ({ ...criteria, vehicles: displayVehicles }), [criteria, displayVehicles]);
|
||||
const rowsParams = useMemo(() => mileageParams(rowsCriteria, 0), [rowsCriteria]);
|
||||
const statistics = useQuery({ queryKey: ['mileage-statistics', statisticsParams.toString()], queryFn: () => api.mileageStatistics(statisticsParams), staleTime: 60_000, placeholderData: (previous) => previous });
|
||||
const mileage = useQuery({ queryKey: ['daily-mileage-query', rowsParams.toString()], queryFn: () => api.dailyMileage(rowsParams), enabled: displayVehicles.length > 0, staleTime: 60_000, placeholderData: (previous) => previous });
|
||||
const totals = useMemo(() => new Map((statistics.data?.ranking ?? []).map((row) => [row.vin, row.mileageKm])), [statistics.data?.ranking]);
|
||||
const dates = useMemo(() => rangeDates(criteria.dateFrom, criteria.dateTo), [criteria.dateFrom, criteria.dateTo]);
|
||||
const matrixRows = useMemo(() => displayVehicles.map((vehicle) => {
|
||||
const days = new Map<string, number>();
|
||||
const sources = new Map<string, string>();
|
||||
const dailyRows = (mileage.data?.items ?? []).filter((row) => row.vin === vehicle.vin);
|
||||
for (const row of dailyRows) { days.set(row.date, row.dailyMileageKm); sources.set(row.date, row.source); }
|
||||
const plate = vehicle.plate || dailyRows.find((row) => row.plate)?.plate || statistics.data?.ranking.find((row) => row.vin === vehicle.vin)?.plate || '';
|
||||
return { ...vehicle, plate, days, sources, totalMileageKm: totals.get(vehicle.vin) ?? Array.from(days.values()).reduce((sum, value) => sum + value, 0) };
|
||||
}), [displayVehicles, mileage.data?.items, statistics.data?.ranking, totals]);
|
||||
const totalVehicles = hasVehicles ? criteria.vehicles.length : fleetVehicles.data?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(totalVehicles / PAGE_SIZE));
|
||||
const submit = (event: FormEvent) => { event.preventDefault(); setPage(1); setExportFeedback(''); setCriteria(draft); setSearchParams(mileageParams(draft, -1), { replace: true }); };
|
||||
const setDays = (days: number) => { const range = defaultWindow(days); const next = { ...draft, ...range }; setPage(1); setExportFeedback(''); setDraft(next); setCriteria(next); setSearchParams(mileageParams(next, -1), { replace: true }); };
|
||||
const refreshing = statistics.isFetching || mileage.isFetching || fleetVehicles.isFetching;
|
||||
|
||||
return <div className="v2-stat-page">
|
||||
<header className="v2-stat-heading"><div><h2>车辆里程统计</h2><p>日里程按车辆和自然日归并;最新总里程来自每辆车最后一次有效上报。</p></div><button type="button" onClick={() => query.refetch()} disabled={query.isFetching}><IconRefresh />{query.isFetching ? '更新中' : '刷新数据'}</button></header>
|
||||
<form className="v2-stat-filter" onSubmit={submit}>
|
||||
<label className="v2-stat-search"><span>车辆</span><div><IconSearch /><input value={draft.vin} onChange={(event) => setDraft((current) => ({ ...current, vin: event.target.value }))} placeholder="车牌 / VIN(留空统计全车队)" /></div></label>
|
||||
<label><span>开始日期</span><input type="date" value={draft.dateFrom} max={draft.dateTo} onChange={(event) => setDraft((current) => ({ ...current, dateFrom: event.target.value }))} /></label>
|
||||
<label><span>结束日期</span><input type="date" value={draft.dateTo} min={draft.dateFrom} onChange={(event) => setDraft((current) => ({ ...current, dateTo: event.target.value }))} /></label>
|
||||
<label><span>数据来源</span><select value={draft.protocol} onChange={(event) => setDraft((current) => ({ ...current, protocol: event.target.value }))}><option value="">全部来源</option><option value="GB32960">GB32960</option><option value="JT808">JT808</option><option value="YUTONG_MQTT">YUTONG_MQTT</option></select></label>
|
||||
<button className="v2-primary-button" type="submit">查询</button>
|
||||
<div className="v2-stat-ranges"><button type="button" onClick={() => setDays(7)}>近 7 天</button><button type="button" onClick={() => setDays(30)}>近 30 天</button><button type="button" onClick={() => setDays(90)}>近 90 天</button></div>
|
||||
</form>
|
||||
{query.isError ? <InlineError message={query.error instanceof Error ? query.error.message : '统计数据加载失败'} onRetry={() => query.refetch()} /> : null}
|
||||
<Kpis data={data} />
|
||||
<div className="v2-stat-grid-layout">
|
||||
<section className="v2-stat-card v2-stat-trend"><header><div><strong>每日行驶里程</strong><span>{data?.dateFrom || criteria.dateFrom} 至 {data?.dateTo || criteria.dateTo}</span></div><em>{data?.trend.length ?? 0} 个有效自然日</em></header><MileageChart points={data?.trend ?? []} /><div className="v2-stat-daily-list" aria-label="每日里程精确数据">{[...(data?.trend ?? [])].reverse().slice(0, 10).map((point) => <div key={point.date}><time>{point.date}</time><strong>{formatKm(point.mileageKm)} km</strong><span>{point.vehicles} 辆</span></div>)}</div></section>
|
||||
<section className="v2-stat-card v2-stat-ranking"><header><div><strong>车辆里程排名</strong><span>按统计期累计里程排序,最多 20 辆</span></div></header><div>{data?.ranking.map((item, index) => <article key={item.vin}><b>{index + 1}</b><div><header><Link to={`/vehicles/${encodeURIComponent(item.vin)}`}>{item.plate || item.vin}</Link><strong>{formatKm(item.mileageKm)} km</strong></header><span><i style={{ width: `${Math.max(2, item.mileageKm / maximumRank * 100)}%` }} /></span><footer><small>{item.plate ? item.vin : '未绑定车牌'}</small><em>{item.activeDays} 个有效日 · 最新 {formatKm(item.latestMileageKm)} km</em></footer></div></article>)}{!query.isLoading && !data?.ranking.length ? <div className="v2-stat-empty">当前范围没有车辆里程记录</div> : null}</div></section>
|
||||
</div>
|
||||
<footer className="v2-stat-evidence"><span>数据更新时间:{data?.asOf || '—'}</span><span>{data?.evidence || '正在读取生产统计证据'}</span><span>页面每 5 分钟自动更新</span></footer>
|
||||
const exportExcel = async () => {
|
||||
if (isExporting || !totalVehicles) return;
|
||||
setIsExporting(true);
|
||||
setExportFeedback('');
|
||||
try {
|
||||
let vehicles: VehicleOption[] = criteria.vehicles.map((vehicle) => ({ ...vehicle }));
|
||||
if (!vehicles.length) {
|
||||
vehicles = [];
|
||||
let offset = 0;
|
||||
while (offset < totalVehicles) {
|
||||
const result = await api.vehicleCoverage(new URLSearchParams({ limit: String(EXPORT_VEHICLE_PAGE_SIZE), offset: String(offset), bindingStatus: 'bound' }));
|
||||
vehicles.push(...result.items.map((vehicle) => ({ vin: vehicle.vin, plate: vehicle.plate })));
|
||||
if (!result.items.length) break;
|
||||
offset += result.items.length;
|
||||
if (offset >= result.total) break;
|
||||
}
|
||||
}
|
||||
const mileageRows: DailyMileageRow[] = [];
|
||||
const vehicleBatches = criteria.vehicles.length
|
||||
? Array.from({ length: Math.ceil(vehicles.length / EXPORT_VIN_BATCH_SIZE) }, (_, index) => vehicles.slice(index * EXPORT_VIN_BATCH_SIZE, (index + 1) * EXPORT_VIN_BATCH_SIZE))
|
||||
: [[] as VehicleOption[]];
|
||||
for (const vehicleBatch of vehicleBatches) {
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
const params = mileageParams({ ...criteria, vehicles: vehicleBatch }, offset);
|
||||
const result = await api.dailyMileage(params);
|
||||
mileageRows.push(...result.items);
|
||||
offset += result.items.length;
|
||||
if (!result.items.length || offset >= result.total) break;
|
||||
}
|
||||
}
|
||||
const plateByVin = new Map(mileageRows.filter((row) => row.plate).map((row) => [row.vin, row.plate]));
|
||||
vehicles = vehicles.map((vehicle) => ({ ...vehicle, plate: vehicle.plate || plateByVin.get(vehicle.vin) || '' }));
|
||||
await downloadMileageWorkbook({
|
||||
dateFrom: criteria.dateFrom,
|
||||
dateTo: criteria.dateTo,
|
||||
dates,
|
||||
vehicles,
|
||||
mileageRows,
|
||||
sources: criteria.sources.filter((source) => source.enabled),
|
||||
exportedAt: new Date()
|
||||
});
|
||||
setExportFeedback(`已导出 ${vehicles.length} 辆车`);
|
||||
} catch (error) {
|
||||
setExportFeedback(error instanceof Error ? `导出失败:${error.message}` : '导出失败,请稍后重试');
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <div className="v2-mileage-page">
|
||||
<header className="v2-mileage-heading"><div><h2>里程查询</h2><p>未选择车牌时分页展示全部车辆;选择车牌后查询指定车辆。</p></div><button type="button" onClick={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} disabled={refreshing}><IconRefresh />{refreshing ? '更新中' : '刷新数据'}</button></header>
|
||||
<section className="v2-mileage-query-panel">
|
||||
<form className="v2-mileage-filter" onSubmit={submit}>
|
||||
<VehicleMultiSelect value={draft.vehicles} onChange={(vehicles) => setDraft((current) => ({ ...current, vehicles }))} />
|
||||
<label><span>开始日期</span><input type="date" value={draft.dateFrom} max={draft.dateTo} onChange={(event) => setDraft((current) => ({ ...current, dateFrom: event.target.value }))} /></label>
|
||||
<label><span>结束日期</span><input type="date" value={draft.dateTo} min={draft.dateFrom} onChange={(event) => setDraft((current) => ({ ...current, dateTo: event.target.value }))} /></label>
|
||||
<button className="v2-primary-button" type="submit">查询</button>
|
||||
<SourceStrategy value={draft.sources} onChange={(sources) => setDraft((current) => ({ ...current, sources }))} />
|
||||
<div className="v2-mileage-ranges"><span>快捷范围</span><button type="button" onClick={() => setDays(7)}>近 7 天</button><button type="button" onClick={() => setDays(30)}>近 30 天</button><button type="button" onClick={() => setDays(90)}>近 90 天</button></div>
|
||||
</form>
|
||||
<SummaryRail data={statistics.data} criteria={criteria} fleetTotal={fleetVehicles.data?.total} />
|
||||
</section>
|
||||
{statistics.isError || mileage.isError || fleetVehicles.isError ? <InlineError message={(statistics.error ?? mileage.error ?? fleetVehicles.error) instanceof Error ? (statistics.error ?? mileage.error ?? fleetVehicles.error as Error).message : '里程数据加载失败'} onRetry={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} /> : null}
|
||||
<section className="v2-mileage-results">
|
||||
<header><div><strong>车辆每日里程</strong><span>{criteria.dateFrom} 至 {criteria.dateTo}</span></div><div className="v2-mileage-result-actions"><em>{hasVehicles ? `${totalVehicles} 辆车` : `当前 ${displayVehicles.length} 辆 / 共 ${totalVehicles} 辆`} · {dates.length} 个自然日</em><button type="button" onClick={exportExcel} disabled={isExporting || !totalVehicles}><IconDownload />{isExporting ? '正在导出…' : '导出 Excel'}</button></div></header>
|
||||
<MileageTable rows={matrixRows} dates={dates} />
|
||||
{!fleetVehicles.isLoading && !displayVehicles.length ? <div className="v2-mileage-empty">当前没有可展示的车辆</div> : null}
|
||||
<footer><span>{hasVehicles ? `已选择 ${totalVehicles} 辆车辆` : `第 ${page} / ${totalPages} 页 · 共 ${totalVehicles} 辆 · 每页 ${PAGE_SIZE} 辆`}{exportFeedback ? ` · ${exportFeedback}` : ''}</span>{!hasVehicles && totalVehicles ? <div><button type="button" disabled={page <= 1 || fleetVehicles.isFetching} onClick={() => setPage((current) => Math.max(1, current - 1))}>上一页</button><button type="button" disabled={page >= totalPages || fleetVehicles.isFetching} onClick={() => setPage((current) => Math.min(totalPages, current + 1))}>下一页</button></div> : null}</footer>
|
||||
</section>
|
||||
<footer className="v2-mileage-evidence"><span>数据更新时间:{statistics.data?.asOf || '—'}</span><span>来源优先级:{criteria.sources.filter((source) => source.enabled).map((source) => source.protocol).join(' > ')}</span><span>查询结果缓存 1 分钟</span></footer>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function paramsFrom(criteria: { vin: string; protocol: string; dateFrom: string; dateTo: string }) {
|
||||
const next = new URLSearchParams({ dateFrom: criteria.dateFrom, dateTo: criteria.dateTo });
|
||||
if (criteria.vin.trim()) next.set('vin', criteria.vin.trim());
|
||||
if (criteria.protocol) next.set('protocol', criteria.protocol);
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import type { TrackPlaybackResponse } from '../../api/types';
|
||||
import TrackPage from './TrackPage';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ trackPlayback: vi.fn(), reverseGeocode: vi.fn(), vehicles: vi.fn() }));
|
||||
vi.mock('../../api/client', () => ({ api: mocks }));
|
||||
vi.mock('../map/TrackMap', () => ({ TrackMap: ({ activeIndex }: { activeIndex: number }) => <div data-testid="track-map" data-active-index={activeIndex} /> }));
|
||||
|
||||
const track = {
|
||||
vin: 'LTEST000000000001', plate: '粤A12345', total: 3, truncated: false, sampled: false, asOf: '2026-07-15T08:00:00Z',
|
||||
points: [
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'JT808', deviceTime: '2026-07-15T00:00:00Z', serverTime: '2026-07-15T00:00:01Z', longitude: 113.1, latitude: 23.1, speedKmh: 0, totalMileageKm: 100, socPercent: 80, socAvailable: true },
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'JT808', deviceTime: '2026-07-15T00:10:00Z', serverTime: '2026-07-15T00:10:01Z', longitude: 113.2, latitude: 23.2, speedKmh: 35, totalMileageKm: 104, socPercent: 79, socAvailable: true },
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'JT808', deviceTime: '2026-07-15T00:20:00Z', serverTime: '2026-07-15T00:20:01Z', longitude: 113.3, latitude: 23.3, speedKmh: 10, totalMileageKm: 108, socPercent: 78, socAvailable: true }
|
||||
],
|
||||
events: [
|
||||
{ index: 0, sampledIndex: 0, type: 'start', title: '开始行驶', time: '2026-07-15T00:00:00Z', speedKmh: 0, longitude: 113.1, latitude: 23.1 },
|
||||
{ index: 1, sampledIndex: 1, type: 'acceleration', title: '急加速', time: '2026-07-15T00:10:00Z', speedKmh: 35, longitude: 113.2, latitude: 23.2 },
|
||||
{ index: 2, sampledIndex: 2, type: 'end', title: '结束行驶', time: '2026-07-15T00:20:00Z', speedKmh: 10, longitude: 113.3, latitude: 23.3 }
|
||||
],
|
||||
sources: [{ protocol: 'JT808', pointCount: 3, startTime: '2026-07-15T00:00:00Z', endTime: '2026-07-15T00:20:00Z' }],
|
||||
segments: [{ index: 0, type: 'moving', title: '行驶', startTime: '2026-07-15T00:00:00Z', endTime: '2026-07-15T00:20:00Z', durationSeconds: 1200, distanceKm: 8, pointCount: 3, startIndex: 0, endIndex: 2, sampledStartIndex: 0, sampledEndIndex: 2 }],
|
||||
stops: [{ index: 0, startTime: '2026-07-15T00:00:00Z', endTime: '2026-07-15T00:05:00Z', durationSeconds: 300, pointCount: 1, longitude: 113.1, latitude: 23.1, sampledIndex: 0, evidence: 'GPS 推断' }],
|
||||
summary: { startTime: '2026-07-15T00:00:00Z', endTime: '2026-07-15T00:20:00Z', distanceKm: 8, durationSeconds: 1200, averageSpeedKmh: 24, maximumSpeedKmh: 35, pointCount: 3, movingSeconds: 900, stoppedSeconds: 300, stopCount: 1, segmentCount: 1 },
|
||||
coverage: { requestedStart: '', requestedEnd: '', actualStart: '', actualEnd: '', totalPoints: 3, fetchedPoints: 3, processedPoints: 3, returnedPoints: 3, complete: true, limitReasons: [], evidence: '时间窗完整' },
|
||||
quality: { status: 'good', selectedProtocol: 'JT808', rawPoints: 3, validPoints: 3, alternateSourcePoints: 0, invalidCoordinatePoints: 0, duplicatePoints: 0, driftPoints: 0, sourceSwitches: 0, largeGapCount: 0, maximumGapSeconds: 0, evidence: '轨迹质量正常' }
|
||||
} as unknown as TrackPlaybackResponse;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'matchMedia', { configurable: true, value: vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })) });
|
||||
mocks.trackPlayback.mockResolvedValue(track);
|
||||
mocks.reverseGeocode.mockResolvedValue({ formattedAddress: '广东省广州市测试道路' });
|
||||
mocks.vehicles.mockResolvedValue({ items: [], total: 0, limit: 10, offset: 0 });
|
||||
});
|
||||
afterEach(() => { cleanup(); Object.values(mocks).forEach((mock) => mock.mockReset()); });
|
||||
|
||||
test('renders a map-first replay workspace and connects stop, event, and panel interactions', async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59']}><TrackPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
expect((await screen.findAllByText('粤A12345')).length).toBeGreaterThan(0);
|
||||
expect(screen.getByTestId('track-map')).toHaveAttribute('data-active-index', '0');
|
||||
expect(screen.getByText('停留 00:05:00 · 1 个点')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '暂停轨迹播放' })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /事件点/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /急加速/ }));
|
||||
expect(screen.getByTestId('track-map')).toHaveAttribute('data-active-index', '1');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '收起查询面板' }));
|
||||
expect(screen.getByRole('button', { name: /查询与明细/ })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /查询与明细/ }));
|
||||
expect(screen.getByRole('button', { name: '收起查询面板' })).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => expect(mocks.trackPlayback).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.trackPlayback.mock.calls[0][0].get('maxPoints')).toBe('1600');
|
||||
});
|
||||
@@ -1,17 +1,20 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
IconBox, IconChevronLeft, IconChevronRight, IconDownload, IconPause, IconPlay, IconSearch
|
||||
IconBox, IconChevronLeft, IconChevronRight, IconClose, IconDownload, IconEyeClosed,
|
||||
IconEyeOpened, IconList, IconMapPin, IconPause, IconPlay, IconRefresh, IconSearch
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { TrackPlaybackEvent, TrackPlaybackResponse } from '../../api/types';
|
||||
import type { TrackPlaybackEvent, TrackPlaybackResponse, VehicleRow } from '../../api/types';
|
||||
import { downloadTrackCsv, formatDuration, sampledEventIndex } from '../domain/track';
|
||||
import { TrackMap } from '../map/TrackMap';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
|
||||
const speedOptions = [1, 2, 4] as const;
|
||||
const visibleSegmentLimit = 120;
|
||||
const speedOptions = [0.5, 1, 2, 4] as const;
|
||||
type PlaybackSpeed = (typeof speedOptions)[number];
|
||||
type PanelTab = 'stops' | 'events' | 'overview';
|
||||
type Draft = { keyword: string; dateFrom: string; dateTo: string; protocol: string };
|
||||
|
||||
function number(value: number, digits = 1) {
|
||||
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: digits }).format(Number.isFinite(value) ? value : 0);
|
||||
@@ -33,8 +36,14 @@ function time(value?: string) {
|
||||
if (!value) return '—';
|
||||
const parsed = new Date(value);
|
||||
if (!Number.isNaN(parsed.getTime())) return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(parsed);
|
||||
const clock = value.split(' ').pop();
|
||||
return clock?.slice(0, 8) || '—';
|
||||
return value.split(' ').pop()?.slice(0, 8) || '—';
|
||||
}
|
||||
|
||||
function dateTime(value?: string) {
|
||||
if (!value) return '—';
|
||||
const parsed = new Date(value);
|
||||
if (!Number.isNaN(parsed.getTime())) return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false, month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(parsed);
|
||||
return value.replace('T', ' ').slice(5, 19);
|
||||
}
|
||||
|
||||
function localDateTime(value: Date) {
|
||||
@@ -42,11 +51,17 @@ function localDateTime(value: Date) {
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function defaultTrackWindow() {
|
||||
const now = new Date();
|
||||
const start = new Date(now);
|
||||
function trackWindow(daysBack = 0, fullDay = false) {
|
||||
const end = new Date();
|
||||
end.setDate(end.getDate() - daysBack);
|
||||
if (fullDay) end.setHours(23, 59, 59, 999);
|
||||
const start = new Date(end);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
return { dateFrom: localDateTime(start), dateTo: localDateTime(now) };
|
||||
return { dateFrom: localDateTime(start), dateTo: localDateTime(end) };
|
||||
}
|
||||
|
||||
function defaultTrackWindow() {
|
||||
return trackWindow();
|
||||
}
|
||||
|
||||
function eventTone(type: string) {
|
||||
@@ -56,60 +71,122 @@ function eventTone(type: string) {
|
||||
return 'info';
|
||||
}
|
||||
|
||||
function EmptyTrack({ queried }: { queried: boolean }) {
|
||||
return <div className="v2-track-empty"><IconSearch size="extra-large" /><strong>{queried ? '当前条件没有轨迹点' : '选择车辆开始查询轨迹'}</strong><p>{queried ? '请扩大时间范围或切换数据来源。' : '支持车牌、VIN 或终端标识,结果不会回退到本地样例。'}</p></div>;
|
||||
}
|
||||
function VehiclePicker({ value, onChange, onSelect }: { value: string; onChange: (value: string) => void; onSelect: (vehicle: VehicleRow) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [debounced, setDebounced] = useState(value.trim());
|
||||
useEffect(() => { const timer = window.setTimeout(() => setDebounced(value.trim()), 220); return () => window.clearTimeout(timer); }, [value]);
|
||||
const params = useMemo(() => {
|
||||
const next = new URLSearchParams({ limit: '10', offset: '0' });
|
||||
if (debounced) next.set('keyword', debounced);
|
||||
return next;
|
||||
}, [debounced]);
|
||||
const candidates = useQuery({ queryKey: ['track-vehicle-options', params.toString()], queryFn: () => api.vehicles(params), enabled: open, staleTime: 60_000 });
|
||||
|
||||
function CoverageStrip({ track }: { track: TrackPlaybackResponse }) {
|
||||
return <div className={`v2-track-coverage ${track.coverage.complete ? 'is-complete' : 'is-limited'}`}>
|
||||
<strong>{track.coverage.complete ? '时间窗完整' : '仅展示最新切片'}</strong>
|
||||
<span>{track.coverage.evidence}</span>
|
||||
<em>{track.coverage.processedPoints} 个有效点 → {track.coverage.returnedPoints} 个地图点</em>
|
||||
return <div className={`v2-track-vehicle-picker${open ? ' is-open' : ''}`}>
|
||||
<IconSearch />
|
||||
<input
|
||||
aria-label="搜索轨迹车辆" autoComplete="off" placeholder="输入车牌 / VIN / 终端标识"
|
||||
value={value} onFocus={() => setOpen(true)} onBlur={() => window.setTimeout(() => setOpen(false), 140)}
|
||||
onChange={(event) => { onChange(event.target.value); setOpen(true); }}
|
||||
/>
|
||||
{value ? <button type="button" aria-label="清空车辆" onMouseDown={(event) => event.preventDefault()} onClick={() => onChange('')}><IconClose /></button> : null}
|
||||
{open ? <div className="v2-track-vehicle-options" role="listbox">
|
||||
<header><span>车辆候选</span><em>支持车牌或 VIN</em></header>
|
||||
{candidates.isFetching ? <p><span className="v2-spinner" />正在搜索车辆</p> : null}
|
||||
{!candidates.isFetching && (candidates.data?.items ?? []).map((vehicle) => <button
|
||||
type="button" role="option" aria-selected={false} key={vehicle.vin}
|
||||
onMouseDown={(event) => event.preventDefault()} onClick={() => { onSelect(vehicle); setOpen(false); }}
|
||||
><strong>{vehicle.plate || '未绑定车牌'}</strong><span>{vehicle.vin}</span><em>选择</em></button>)}
|
||||
{!candidates.isFetching && !(candidates.data?.items.length) ? <p>没有匹配车辆</p> : null}
|
||||
</div> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function SegmentTimeline({ track, onSelectIndex }: { track: TrackPlaybackResponse; onSelectIndex: (index: number) => void }) {
|
||||
const segments = track.segments.slice(0, visibleSegmentLimit);
|
||||
return <div className="v2-track-timeline">
|
||||
<header><strong>活动时间轴</strong><span>{track.segments.length} 段 · {track.stops.length} 次有效停车</span></header>
|
||||
<div>{segments.map((segment) => <button
|
||||
aria-label={`${segment.title} ${time(segment.startTime)} 至 ${time(segment.endTime)}`}
|
||||
className={`is-${segment.type}`}
|
||||
key={`${segment.index}-${segment.startTime}`}
|
||||
onClick={() => onSelectIndex(segment.sampledStartIndex)}
|
||||
title={`${segment.title} · ${formatDuration(segment.durationSeconds)} · ${number(segment.distanceKm)} km`}
|
||||
type="button"
|
||||
><i /><span>{segment.title}</span></button>)}</div>
|
||||
{track.segments.length > visibleSegmentLimit ? <em>时间轴仅渲染前 {visibleSegmentLimit} 段,完整统计仍基于全部已加工点。</em> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function TripInspector({ track, onEvent }: { track: TrackPlaybackResponse; onEvent: (event: TrackPlaybackEvent) => void }) {
|
||||
return <aside className="v2-track-inspector">
|
||||
<section><header><strong>车辆</strong><span>{track.sampled ? '地图已抽稀' : '完整点集'}</span></header><div className="v2-track-vehicle"><span><IconBox /></span><div><strong>{track.plate || track.vin}</strong><small>VIN {track.vin}</small></div></div></section>
|
||||
<section><header><strong>行程概览</strong></header><dl className="v2-track-summary">
|
||||
<div><dt>开始时间</dt><dd>{track.summary.startTime || '—'}</dd></div><div><dt>结束时间</dt><dd>{track.summary.endTime || '—'}</dd></div><div><dt>行驶里程</dt><dd>{number(track.summary.distanceKm)} km</dd></div><div><dt>总跨度</dt><dd>{formatDuration(track.summary.durationSeconds)}</dd></div><div><dt>移动 / 停车</dt><dd>{formatDuration(track.summary.movingSeconds)} / {formatDuration(track.summary.stoppedSeconds)}</dd></div><div><dt>停车 / 分段</dt><dd>{track.summary.stopCount} / {track.summary.segmentCount}</dd></div><div><dt>平均速度</dt><dd>{number(track.summary.averageSpeedKmh)} km/h</dd></div><div><dt>最高速度</dt><dd>{number(track.summary.maximumSpeedKmh)} km/h</dd></div>
|
||||
function OverviewPanel({ track }: { track: TrackPlaybackResponse }) {
|
||||
return <div className="v2-track-overview-panel">
|
||||
<section><header><strong>行程概览</strong><span>{track.sampled ? '地图已抽稀' : '完整点集'}</span></header><dl>
|
||||
<div><dt>开始时间</dt><dd>{dateTime(track.summary.startTime)}</dd></div>
|
||||
<div><dt>结束时间</dt><dd>{dateTime(track.summary.endTime)}</dd></div>
|
||||
<div><dt>行驶里程</dt><dd>{number(track.summary.distanceKm)} km</dd></div>
|
||||
<div><dt>行驶 / 停车</dt><dd>{formatDuration(track.summary.movingSeconds)} / {formatDuration(track.summary.stoppedSeconds)}</dd></div>
|
||||
<div><dt>平均 / 最高速度</dt><dd>{number(track.summary.averageSpeedKmh)} / {number(track.summary.maximumSpeedKmh)} km/h</dd></div>
|
||||
<div><dt>停车 / 分段</dt><dd>{track.summary.stopCount} / {track.summary.segmentCount}</dd></div>
|
||||
</dl></section>
|
||||
<section className="v2-track-sources"><header><strong>数据来源</strong><b>{track.coverage.totalPoints} 个源点</b></header><div>{track.sources.map((source) => <span key={source.protocol}><strong>{source.protocol}</strong><small>{source.pointCount} 点 · {time(source.startTime)}–{time(source.endTime)}</small></span>)}</div>{!track.coverage.complete ? <p>{track.coverage.evidence}。请缩小到 7 天内更精确的时间窗。</p> : null}</section>
|
||||
<section className={`v2-track-quality is-${track.quality.status}`}><header><strong>轨迹质量</strong><span>{track.quality.status === 'good' ? '通过' : '需关注'}</span></header><dl className="v2-track-summary"><div><dt>主来源 / 其他点</dt><dd>{track.quality.selectedProtocol || 'UNKNOWN'} / {track.quality.alternateSourcePoints}</dd></div><div><dt>有效 / 读取</dt><dd>{track.quality.validPoints} / {track.quality.rawPoints}</dd></div><div><dt>无效 / 重复 / 漂移</dt><dd>{track.quality.invalidCoordinatePoints} / {track.quality.duplicatePoints} / {track.quality.driftPoints}</dd></div><div><dt>大间隔 / 最大间隔</dt><dd>{track.quality.largeGapCount} / {formatDuration(track.quality.maximumGapSeconds)}</dd></div></dl><p>{track.quality.evidence}</p></section>
|
||||
<section className="v2-track-events"><header><strong>轨迹事件</strong><span>{track.events.length} 项</span></header><div>{track.events.map((event, index) => <button key={`${event.type}-${event.index}-${event.time}`} onClick={() => onEvent(event)} type="button"><i className={`is-${eventTone(event.type)}`}>{index + 1}</i><span><strong>{event.title}</strong><small>{time(event.time)}</small></span><em>{number(event.speedKmh, 0)} km/h</em></button>)}</div></section>
|
||||
<section><header><strong>数据来源</strong><span>{track.coverage.totalPoints.toLocaleString('zh-CN')} 个源点</span></header><div className="v2-track-source-list">{track.sources.map((source) => <article key={source.protocol}><strong>{source.protocol}</strong><span>{source.pointCount.toLocaleString('zh-CN')} 点</span><small>{time(source.startTime)}–{time(source.endTime)}</small></article>)}</div></section>
|
||||
<section className={`v2-track-quality-card is-${track.quality.status}`}><header><strong>轨迹质量</strong><span>{track.quality.status === 'good' ? '通过' : '需关注'}</span></header><p>{track.quality.evidence}</p><small>无效 {track.quality.invalidCoordinatePoints} · 重复 {track.quality.duplicatePoints} · 漂移 {track.quality.driftPoints} · 大间隔 {track.quality.largeGapCount}</small></section>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function TrackRail({ draft, track, activeIndex, tab, onDraft, onSubmit, onTab, onSelectIndex, onCollapse }: {
|
||||
draft: Draft;
|
||||
track?: TrackPlaybackResponse;
|
||||
activeIndex: number;
|
||||
tab: PanelTab;
|
||||
onDraft: (draft: Draft) => void;
|
||||
onSubmit: (event: FormEvent) => void;
|
||||
onTab: (tab: PanelTab) => void;
|
||||
onSelectIndex: (index: number) => void;
|
||||
onCollapse: () => void;
|
||||
}) {
|
||||
const choosePreset = (daysBack: number) => onDraft({ ...draft, ...trackWindow(daysBack, daysBack > 0) });
|
||||
return <aside className="v2-track-rail">
|
||||
<form className="v2-track-query" onSubmit={onSubmit}>
|
||||
<header><div><strong>轨迹查询</strong><span>最长支持连续 7 天</span></div><button type="button" aria-label="收起查询面板" onClick={onCollapse}><IconChevronLeft /></button></header>
|
||||
<label><span>车辆</span><VehiclePicker value={draft.keyword} onChange={(keyword) => onDraft({ ...draft, keyword })} onSelect={(vehicle) => onDraft({ ...draft, keyword: vehicle.plate || vehicle.vin })} /></label>
|
||||
<div className="v2-track-presets"><button type="button" onClick={() => choosePreset(0)}>今天</button><button type="button" onClick={() => choosePreset(1)}>昨天</button><button type="button" onClick={() => { const end = new Date(); const start = new Date(end); start.setDate(start.getDate() - 2); start.setHours(0, 0, 0, 0); onDraft({ ...draft, dateFrom: localDateTime(start), dateTo: localDateTime(end) }); }}>近 3 天</button></div>
|
||||
<div className="v2-track-date-grid"><label><span>开始时间</span><input type="datetime-local" value={draft.dateFrom} onChange={(event) => onDraft({ ...draft, dateFrom: event.target.value })} /></label><label><span>结束时间</span><input type="datetime-local" value={draft.dateTo} onChange={(event) => onDraft({ ...draft, dateTo: event.target.value })} /></label></div>
|
||||
<label><span>数据来源</span><select value={draft.protocol} onChange={(event) => onDraft({ ...draft, protocol: event.target.value })}><option value="">自动选择最佳来源</option><option value="GB32960">GB32960 · 仪表盘里程</option><option value="JT808">JT808 · GPS 里程</option><option value="YUTONG_MQTT">YUTONG · 仪表盘里程</option></select></label>
|
||||
<button className="v2-track-query-button" type="submit" disabled={!draft.keyword.trim()}><IconSearch />查询轨迹</button>
|
||||
</form>
|
||||
|
||||
<div className="v2-track-rail-result">
|
||||
{track ? <div className="v2-track-rail-vehicle"><span><IconBox /></span><div><strong>{track.plate || track.vin}</strong><small>{track.vin}</small></div><em>{number(track.summary.distanceKm)} km</em></div> : null}
|
||||
<nav aria-label="轨迹明细分类"><button type="button" className={tab === 'stops' ? 'is-active' : ''} onClick={() => onTab('stops')}>停留点 <b>{track?.stops.length ?? 0}</b></button><button type="button" className={tab === 'events' ? 'is-active' : ''} onClick={() => onTab('events')}>事件点 <b>{track?.events.length ?? 0}</b></button><button type="button" className={tab === 'overview' ? 'is-active' : ''} onClick={() => onTab('overview')}>概览</button></nav>
|
||||
<div className="v2-track-rail-scroll">
|
||||
{!track ? <div className="v2-track-rail-empty"><IconMapPin /><strong>选择车辆后查询轨迹</strong><p>停留点、轨迹事件与行程证据会在这里统一呈现。</p></div> : null}
|
||||
{track && tab === 'stops' ? <div className="v2-track-stop-list">{track.stops.map((stop, index) => <button type="button" className={Math.abs(stop.sampledIndex - activeIndex) < 2 ? 'is-active' : ''} key={`${stop.startTime}-${index}`} onClick={() => onSelectIndex(stop.sampledIndex)}><i>{index + 1}</i><span><strong>{dateTime(stop.startTime)}</strong><small>停留 {formatDuration(stop.durationSeconds)} · {stop.pointCount} 个点</small></span><em>{time(stop.endTime)}</em></button>)}{!track.stops.length ? <p className="v2-track-list-empty">当前时间窗没有超过 3 分钟的停留点</p> : null}</div> : null}
|
||||
{track && tab === 'events' ? <div className="v2-track-event-list">{track.events.map((event, index) => { const sampled = sampledEventIndex(event, track.points.length, track.summary.pointCount); return <button type="button" className={sampled === activeIndex ? 'is-active' : ''} key={`${event.type}-${event.time}-${index}`} onClick={() => onSelectIndex(sampled)}><i className={`is-${eventTone(event.type)}`}>{index + 1}</i><span><strong>{event.title}</strong><small>{dateTime(event.time)}</small></span><em>{number(event.speedKmh, 0)} km/h</em></button>; })}</div> : null}
|
||||
{track && tab === 'overview' ? <OverviewPanel track={track} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</aside>;
|
||||
}
|
||||
|
||||
function SegmentRail({ track, onSelectIndex }: { track: TrackPlaybackResponse; onSelectIndex: (index: number) => void }) {
|
||||
const segments = track.segments.slice(0, 160);
|
||||
const total = Math.max(1, segments.reduce((sum, segment) => sum + Math.max(1, segment.durationSeconds), 0));
|
||||
return <div className="v2-track-segment-rail" aria-label="轨迹活动分段">{segments.map((segment) => <button
|
||||
aria-label={`${segment.title} ${time(segment.startTime)} 至 ${time(segment.endTime)}`}
|
||||
className={`is-${segment.type}`} key={`${segment.index}-${segment.startTime}`}
|
||||
onClick={() => onSelectIndex(segment.sampledStartIndex)} style={{ flexGrow: Math.max(1, segment.durationSeconds) / total }}
|
||||
title={`${segment.title} · ${formatDuration(segment.durationSeconds)} · ${number(segment.distanceKm)} km`}
|
||||
type="button"
|
||||
/>)}</div>;
|
||||
}
|
||||
|
||||
export default function TrackPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const initialKeyword = searchParams.get('vin') || searchParams.get('keyword') || '';
|
||||
const [draft, setDraft] = useState(() => {
|
||||
const fallback = defaultTrackWindow();
|
||||
return { keyword: initialKeyword, dateFrom: searchParams.get('dateFrom') || fallback.dateFrom, dateTo: searchParams.get('dateTo') || fallback.dateTo, protocol: searchParams.get('protocol') || '' };
|
||||
});
|
||||
const [criteria, setCriteria] = useState(draft);
|
||||
const fallback = useMemo(defaultTrackWindow, []);
|
||||
const initialDraft = useMemo<Draft>(() => ({
|
||||
keyword: searchParams.get('vin') || searchParams.get('keyword') || '',
|
||||
dateFrom: searchParams.get('dateFrom') || fallback.dateFrom,
|
||||
dateTo: searchParams.get('dateTo') || fallback.dateTo,
|
||||
protocol: searchParams.get('protocol') || ''
|
||||
}), []);
|
||||
const [draft, setDraft] = useState(initialDraft);
|
||||
const [criteria, setCriteria] = useState(initialDraft);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [playbackSpeed, setPlaybackSpeed] = useState<(typeof speedOptions)[number]>(1);
|
||||
const [playbackSpeed, setPlaybackSpeed] = useState<PlaybackSpeed>(1);
|
||||
const [follow, setFollow] = useState(true);
|
||||
const [showStops, setShowStops] = useState(true);
|
||||
const [panelTab, setPanelTab] = useState<PanelTab>('stops');
|
||||
const [railCollapsed, setRailCollapsed] = useState(false);
|
||||
const animationRef = useRef<number>();
|
||||
const lastFrameRef = useRef(0);
|
||||
|
||||
const params = useMemo(() => {
|
||||
const next = new URLSearchParams({ keyword: criteria.keyword, maxPoints: '1200' });
|
||||
const next = new URLSearchParams({ keyword: criteria.keyword, maxPoints: '1600' });
|
||||
if (criteria.dateFrom) next.set('dateFrom', criteria.dateFrom);
|
||||
if (criteria.dateTo) next.set('dateTo', criteria.dateTo);
|
||||
if (criteria.protocol) next.set('protocol', criteria.protocol);
|
||||
@@ -118,12 +195,13 @@ export default function TrackPage() {
|
||||
const query = useQuery({ queryKey: ['track-playback', params.toString()], enabled: Boolean(criteria.keyword), queryFn: () => api.trackPlayback(params) });
|
||||
const track = query.data;
|
||||
const points = track?.points ?? [];
|
||||
const current = points[Math.min(activeIndex, Math.max(points.length - 1, 0))];
|
||||
const boundedIndex = Math.min(activeIndex, Math.max(points.length - 1, 0));
|
||||
const current = points[boundedIndex];
|
||||
const [addressPoint, setAddressPoint] = useState<{ longitude: number; latitude: number }>();
|
||||
|
||||
useEffect(() => {
|
||||
if (playing || !current) return;
|
||||
const timer = window.setTimeout(() => setAddressPoint({ longitude: current.longitude, latitude: current.latitude }), 350);
|
||||
const timer = window.setTimeout(() => setAddressPoint({ longitude: current.longitude, latitude: current.latitude }), 360);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [current?.latitude, current?.longitude, playing]);
|
||||
const addressQuery = useQuery({
|
||||
@@ -132,14 +210,26 @@ export default function TrackPage() {
|
||||
queryFn: () => api.reverseGeocode(new URLSearchParams({ longitude: addressPoint!.longitude.toFixed(6), latitude: addressPoint!.latitude.toFixed(6) }))
|
||||
});
|
||||
|
||||
useEffect(() => { setActiveIndex(0); setPlaying(false); }, [track?.asOf]);
|
||||
useEffect(() => { setActiveIndex(0); setPlaying(false); setFollow(true); if (track?.points.length) setRailCollapsed(window.matchMedia('(max-width: 700px)').matches); }, [track?.asOf]);
|
||||
useEffect(() => {
|
||||
if (!playing || points.length < 2) return;
|
||||
const timer = window.setInterval(() => setActiveIndex((index) => {
|
||||
if (index >= points.length - 1) { setPlaying(false); return points.length - 1; }
|
||||
return index + 1;
|
||||
}), Math.max(120, 800 / playbackSpeed));
|
||||
return () => window.clearInterval(timer);
|
||||
lastFrameRef.current = 0;
|
||||
const interval = Math.max(55, 300 / playbackSpeed);
|
||||
const tick = (timestamp: number) => {
|
||||
if (!lastFrameRef.current) lastFrameRef.current = timestamp;
|
||||
if (timestamp - lastFrameRef.current >= interval) {
|
||||
const steps = Math.max(1, Math.floor((timestamp - lastFrameRef.current) / interval));
|
||||
lastFrameRef.current = timestamp;
|
||||
setActiveIndex((index) => {
|
||||
const next = Math.min(points.length - 1, index + steps);
|
||||
if (next >= points.length - 1) setPlaying(false);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
animationRef.current = window.requestAnimationFrame(tick);
|
||||
};
|
||||
animationRef.current = window.requestAnimationFrame(tick);
|
||||
return () => { if (animationRef.current) window.cancelAnimationFrame(animationRef.current); };
|
||||
}, [playing, playbackSpeed, points.length]);
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
@@ -148,44 +238,64 @@ export default function TrackPage() {
|
||||
if (!keyword) return;
|
||||
const next = { ...draft, keyword };
|
||||
setCriteria(next);
|
||||
const url = new URLSearchParams({ vin: keyword });
|
||||
const url = new URLSearchParams({ keyword });
|
||||
if (next.dateFrom) url.set('dateFrom', next.dateFrom);
|
||||
if (next.dateTo) url.set('dateTo', next.dateTo);
|
||||
if (next.protocol) url.set('protocol', next.protocol);
|
||||
setSearchParams(url, { replace: true });
|
||||
};
|
||||
const selectEvent = (event: TrackPlaybackEvent) => {
|
||||
if (!track) return;
|
||||
setActiveIndex(sampledEventIndex(event, points.length, track.summary.pointCount));
|
||||
setPlaying(false);
|
||||
const selectIndex = (index: number) => { setPlaying(false); setActiveIndex(Math.max(0, Math.min(points.length - 1, index))); };
|
||||
const togglePlayback = () => {
|
||||
if (points.length < 2) return;
|
||||
if (!playing && boundedIndex >= points.length - 1) setActiveIndex(0);
|
||||
setFollow(true);
|
||||
setPlaying((value) => !value);
|
||||
};
|
||||
const progress = points.length > 1 ? boundedIndex / (points.length - 1) * 100 : 0;
|
||||
|
||||
return <div className="v2-track-page">
|
||||
<form className="v2-track-toolbar" onSubmit={submit}>
|
||||
<label className="v2-track-vehicle-input"><span>车辆</span><div><IconSearch /><input value={draft.keyword} onChange={(event) => setDraft((value) => ({ ...value, keyword: event.target.value }))} placeholder="车牌 / VIN / 终端标识" /></div></label>
|
||||
<label><span>开始时间</span><input type="datetime-local" value={draft.dateFrom} onChange={(event) => setDraft((value) => ({ ...value, dateFrom: event.target.value }))} /></label>
|
||||
<label><span>结束时间</span><input type="datetime-local" value={draft.dateTo} onChange={(event) => setDraft((value) => ({ ...value, dateTo: event.target.value }))} /></label>
|
||||
<label><span>数据来源</span><select value={draft.protocol} onChange={(event) => setDraft((value) => ({ ...value, protocol: event.target.value }))}><option value="">全部来源</option><option value="GB32960">GB32960</option><option value="JT808">JT808</option><option value="YUTONG_MQTT">YUTONG_MQTT</option></select></label>
|
||||
<button className="v2-primary-button" type="submit" disabled={!draft.keyword.trim()}>查询</button>
|
||||
<button className="v2-secondary-button" type="button" disabled={!track?.points.length} onClick={() => track && downloadTrackCsv(track)}><IconDownload />导出地图点</button>
|
||||
</form>
|
||||
return <div className={`v2-track-page${railCollapsed ? ' is-rail-collapsed' : ''}`}>
|
||||
<TrackRail draft={draft} track={track} activeIndex={boundedIndex} tab={panelTab} onDraft={setDraft} onSubmit={submit} onTab={setPanelTab} onSelectIndex={selectIndex} onCollapse={() => setRailCollapsed(true)} />
|
||||
<section className="v2-track-stage">
|
||||
<TrackMap points={points} stops={track?.stops ?? []} activeIndex={boundedIndex} showStops={showStops} follow={follow} onSelectIndex={selectIndex} onFollowChange={setFollow} />
|
||||
|
||||
{query.isError ? <InlineError message={query.error instanceof Error ? query.error.message : '轨迹查询失败'} onRetry={() => query.refetch()} /> : null}
|
||||
<div className="v2-track-workspace">
|
||||
<div className="v2-track-main">
|
||||
{track && points.length ? <CoverageStrip track={track} /> : <div className="v2-track-coverage is-empty"><span>默认查询今天;单次时间窗最长 7 天</span></div>}
|
||||
<div className="v2-track-canvas-wrap">
|
||||
{query.isFetching ? <div className="v2-track-loading"><span className="v2-spinner" />正在读取历史轨迹</div> : null}
|
||||
{points.length ? <TrackMap points={points} events={track?.events ?? []} activeIndex={activeIndex} onSelectIndex={setActiveIndex} /> : <EmptyTrack queried={Boolean(track)} />}
|
||||
</div>
|
||||
{track && points.length ? <SegmentTimeline track={track} onSelectIndex={(index) => { setPlaying(false); setActiveIndex(index); }} /> : <div className="v2-track-timeline is-empty"><span>查询后显示行驶、停车和数据间隔分段</span></div>}
|
||||
<div className="v2-track-playback">
|
||||
<div className="v2-play-controls"><small>播放</small><p><button type="button" onClick={() => setPlaying((value) => !value)} disabled={points.length < 2}>{playing ? <IconPause /> : <IconPlay />}</button><button type="button" onClick={() => { setPlaying(false); setActiveIndex((value) => Math.max(0, value - 1)); }} disabled={!activeIndex}><IconChevronLeft /></button><button type="button" onClick={() => { setPlaying(false); setActiveIndex((value) => Math.min(points.length - 1, value + 1)); }} disabled={!points.length || activeIndex >= points.length - 1}><IconChevronRight /></button><select value={playbackSpeed} onChange={(event) => setPlaybackSpeed(Number(event.target.value) as 1 | 2 | 4)}>{speedOptions.map((speed) => <option value={speed} key={speed}>{speed}×</option>)}</select></p></div>
|
||||
<div className="v2-play-progress"><header><strong>{time(current?.deviceTime)}</strong><span>{track?.summary.endTime ? time(track.summary.endTime) : '—'}</span></header><input aria-label="轨迹播放进度" type="range" min="0" max={Math.max(0, points.length - 1)} value={Math.min(activeIndex, Math.max(0, points.length - 1))} onChange={(event) => { setPlaying(false); setActiveIndex(Number(event.target.value)); }} disabled={!points.length} /><footer>数据点 {points.length ? activeIndex + 1 : 0} / {points.length}</footer></div>
|
||||
<dl className="v2-current-metrics"><div><dt>速度 / 方向</dt><dd>{number(current?.speedKmh ?? 0)}<em>km/h · {direction(current?.directionDeg)}</em></dd></div><div><dt>SOC / 报警</dt><dd>{current?.socAvailable ? `${number(current.socPercent)}%` : '—'}<em> · {alarm(current?.alarmFlag)}</em></dd></div><div><dt>总里程 / 来源</dt><dd>{number(current?.totalMileageKm ?? 0)}<em>km · {current?.protocol || '—'}</em></dd></div><div><dt>当前地址</dt><dd title={addressQuery.data?.formattedAddress}>{playing ? '播放中暂停解析' : addressQuery.isFetching ? '地址解析中…' : addressQuery.data?.formattedAddress || (current ? `${current.longitude.toFixed(6)}, ${current.latitude.toFixed(6)}` : '—')}</dd></div></dl>
|
||||
</div>
|
||||
<div className="v2-track-stage-tools">
|
||||
{railCollapsed ? <button type="button" aria-label="展开查询与明细" onClick={() => setRailCollapsed(false)}><IconList /><span>查询与明细</span></button> : null}
|
||||
<button type="button" aria-label={follow ? '关闭车辆跟随' : '开启车辆跟随'} className={follow ? 'is-active' : ''} disabled={!points.length} onClick={() => setFollow((value) => !value)}><IconMapPin /><span>{follow ? '跟随车辆' : '自由浏览'}</span></button>
|
||||
<button type="button" aria-label={showStops ? '隐藏停留点' : '显示停留点'} className={showStops ? 'is-active' : ''} disabled={!track?.stops.length} onClick={() => setShowStops((value) => !value)}>{showStops ? <IconEyeOpened /> : <IconEyeClosed />}<span>停留点</span></button>
|
||||
<button type="button" aria-label="导出轨迹 CSV" disabled={!track?.points.length} onClick={() => track && downloadTrackCsv(track)}><IconDownload /><span>导出</span></button>
|
||||
</div>
|
||||
{track && points.length ? <TripInspector track={track} onEvent={selectEvent} /> : <aside className="v2-track-inspector is-empty"><strong>行程检查器</strong><p>查询后显示车辆、行程摘要、来源证据和轨迹事件。</p></aside>}
|
||||
</div>
|
||||
|
||||
{track?.points.length ? <>
|
||||
<div className={`v2-track-coverage-float${track.coverage.complete ? '' : ' is-warning'}`}><i />
|
||||
<span><strong>{track.coverage.complete ? '时间窗完整' : '仅展示最新切片'}</strong>{track.coverage.evidence}</span>
|
||||
<em>{track.coverage.processedPoints.toLocaleString('zh-CN')} 有效点 → {track.coverage.returnedPoints.toLocaleString('zh-CN')} 地图点</em>
|
||||
</div>
|
||||
<article className="v2-track-current-card">
|
||||
<header><div><strong>{track.plate || track.vin}</strong><span>{dateTime(current?.deviceTime)}</span></div><b>{number(progress, 0)}%</b></header>
|
||||
<div><span><small>速度</small><strong>{number(current?.speedKmh ?? 0)}<em> km/h</em></strong></span><span><small>方向</small><strong>{direction(current?.directionDeg)}</strong></span><span><small>SOC</small><strong>{current?.socAvailable ? `${number(current.socPercent)}%` : '—'}</strong></span></div>
|
||||
<p title={addressQuery.data?.formattedAddress}>{playing ? '播放中,暂停地址解析' : addressQuery.isFetching ? '地址解析中…' : addressQuery.data?.formattedAddress || `${current?.longitude.toFixed(6)}, ${current?.latitude.toFixed(6)}`}</p>
|
||||
</article>
|
||||
</> : <div className="v2-track-empty-state"><IconMapPin /><strong>先选择车辆,再开始轨迹回放</strong><p>地图会显示完整路径、停留点、事件点和播放位置。</p><button type="button" onClick={() => setRailCollapsed(false)}><IconSearch />选择车辆</button></div>}
|
||||
|
||||
{query.isFetching ? <div className="v2-track-loading"><span className="v2-spinner" />正在读取并加工历史轨迹</div> : null}
|
||||
{query.isError ? <div className="v2-track-error"><InlineError message={query.error instanceof Error ? query.error.message : '轨迹查询失败'} onRetry={() => query.refetch()} /></div> : null}
|
||||
|
||||
<footer className="v2-track-playback-dock">
|
||||
<div className="v2-track-dock-summary"><strong>{track ? `${number(track.summary.distanceKm)} km` : '等待查询'}</strong><span>{track ? `${formatDuration(track.summary.durationSeconds)} · ${track.summary.stopCount} 次停留` : '查询后可播放完整轨迹'}</span></div>
|
||||
<div className="v2-track-dock-progress">
|
||||
{track ? <SegmentRail track={track} onSelectIndex={selectIndex} /> : <div className="v2-track-segment-placeholder" />}
|
||||
<input aria-label="轨迹播放进度" type="range" min="0" max={Math.max(0, points.length - 1)} value={boundedIndex} onChange={(event) => selectIndex(Number(event.target.value))} disabled={!points.length} style={{ '--track-progress': `${progress}%` } as React.CSSProperties} />
|
||||
<div><time>{time(current?.deviceTime)}</time><span>数据点 {points.length ? boundedIndex + 1 : 0} / {points.length}</span><time>{time(track?.summary.endTime)}</time></div>
|
||||
</div>
|
||||
<div className="v2-track-dock-controls">
|
||||
<button type="button" aria-label="上一个轨迹点" onClick={() => selectIndex(boundedIndex - 1)} disabled={!boundedIndex}><IconChevronLeft /></button>
|
||||
<button type="button" className="is-primary" aria-label={playing ? '暂停轨迹播放' : '开始轨迹播放'} onClick={togglePlayback} disabled={points.length < 2}>{playing ? <IconPause /> : <IconPlay />}</button>
|
||||
<button type="button" aria-label="下一个轨迹点" onClick={() => selectIndex(boundedIndex + 1)} disabled={!points.length || boundedIndex >= points.length - 1}><IconChevronRight /></button>
|
||||
<label><span>速度</span><select value={playbackSpeed} onChange={(event) => setPlaybackSpeed(Number(event.target.value) as PlaybackSpeed)}>{speedOptions.map((speed) => <option value={speed} key={speed}>{speed}×</option>)}</select></label>
|
||||
<button type="button" aria-label="回到起点" onClick={() => selectIndex(0)} disabled={!boundedIndex}><IconRefresh /></button>
|
||||
</div>
|
||||
<div className="v2-track-dock-metrics"><span><small>总里程</small><strong>{number(current?.totalMileageKm ?? 0)} km</strong></span><span><small>数据来源</small><strong>{current?.protocol || '—'}</strong></span><span><small>车辆状态</small><strong>{alarm(current?.alarmFlag)}</strong></span></div>
|
||||
</footer>
|
||||
</section>
|
||||
</div>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user