import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { IconClose, IconDownload, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons'; import { FormEvent, useEffect, useMemo, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { api } from '../../api/client'; import type { HistoryDataResponse, HistoryDataRow, HistoryExportRequest, HistoryMetricDefinition, HistorySeriesResponse } from '../../api/types'; import { buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, formatSeriesGrain, parseHistoryKeywords } from '../domain/history'; import { InlineError } from '../shared/AsyncState'; import { QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy'; function HistoryTrend({ response, category, loading, error }: { response?: HistorySeriesResponse; category: string; loading: boolean; error?: string }) { const panels = useMemo(() => buildHistorySeriesPanels(response), [response]); if (category !== 'location') return
聚合趋势
{category === 'raw' ? '原始报文是离散证据,不生成可能误导的连续趋势;请使用明细与导出。' : '日里程按自然日展示,当前请使用明细表核对起止里程。'}
; const summary = response?.summary; const coverage = summary?.expectedBucketCount ? Math.max(0, (summary.expectedBucketCount - summary.missingBucketCount) / summary.expectedBucketCount * 100) : 0; return
聚合趋势
{summary ? <>{formatSeriesGrain(summary.grainSeconds)}粒度覆盖 {coverage.toFixed(1)}%{summary.rawPointCount.toLocaleString('zh-CN')} 原始点 : null}
{error ?
趋势加载失败:{error}
: loading && !response ?
正在聚合时间序列…
: panels.length ?
{panels.map((panel) =>
{panel.label}{panel.unit || '数值'} · {panel.lines.reduce((sum, line) => sum + line.points, 0)} 个桶点
{panel.maximum.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}{panel.minimum.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}{formatAxisTime(panel.start)}{formatAxisTime(panel.end)} {panel.lines.flatMap((line) => line.paths.map((path, index) => {line.label}))}
{panel.lines.map((line) => {line.label})}
)}
:
当前时间窗没有可聚合的数值点;空窗未补值。
} {summary ? {summary.evidence} · 缺失 {summary.missingBucketCount.toLocaleString('zh-CN')} / {summary.expectedBucketCount.toLocaleString('zh-CN')} 桶 · 查询 {summary.queryDurationMs} ms : null}
; } export function formatAxisTime(value: string) { const parsed = new Date(value); if (!Number.isFinite(parsed.getTime())) return value.replace('T', ' ').slice(5, 16); return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(parsed).replace('/', '-'); } function currentHistoryWindow() { const now = new Date(); const pad = (value: number) => String(value).padStart(2, '0'); const day = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; return { dateFrom: `${day}T00:00`, dateTo: `${day}T${pad(now.getHours())}:${pad(now.getMinutes())}` }; } function CreateExportButton({ request, disabled }: { request: HistoryExportRequest; disabled: boolean }) { const queryClient = useQueryClient(); const mutation = useMutation({ mutationFn: api.createHistoryExport, onSuccess: () => queryClient.invalidateQueries({ queryKey: ['history-exports'] }) }); const label = mutation.isPending ? '任务排队中' : mutation.isError ? '导出失败,重试' : '创建导出'; return ; } function ExportJobsPanel() { const query = useQuery({ queryKey: ['history-exports'], queryFn: ({ signal }) => api.historyExports(signal), refetchInterval: (current) => current.state.data?.some((job) => job.status === 'queued' || job.status === 'running') ? 1000 : false }); const jobs = query.data ?? []; return
导出任务{jobs.length}
{jobs.slice(0, 6).map((job) =>
{job.name}{job.status === 'queued' ? '等待单并发执行' : job.status === 'running' ? `${job.processedRows.toLocaleString('zh-CN')} / ${job.totalRows.toLocaleString('zh-CN')} 行 · ${job.progress}%` : job.status === 'completed' ? `${job.rowCount.toLocaleString('zh-CN')} 行 · ${formatExportFileSize(job.fileSizeBytes)} · 已完成` : job.error || '失败'}
{job.downloadUrl ? 下载 : {job.status === 'running' ? `${job.progress}%` : '—'}}
)}{query.isError ?
导出任务加载失败
: !jobs.length ?
尚未创建导出任务
: null}
; } function EvidencePanel({ row, metrics, onClose }: { row?: HistoryDataRow; metrics: HistoryMetricDefinition[]; onClose: () => void }) { return
行证据{row ? : null}
{row ? <>
设备时间
{row.deviceTime}
服务时间
{row.serverTime}
车牌
{row.plate || '—'}
VIN
{row.vin}
数据来源
{row.protocol}
数据质量
{row.quality === 'normal' ? '正常' : row.quality}
解析字段{metrics.slice(0, 12).map((metric) =>
{metric.label}{metric.key}{formatHistoryValue(row.values[metric.key], metric)}
)}
RAW 证据{row.evidenceId || '该数据类型没有独立 RAW 帧 ID'}
:
选择一行查看来源、时间和解析字段证据。
}
; } export default function HistoryPage() { const [searchParams, setSearchParams] = useSearchParams(); const today = useMemo(currentHistoryWindow, []); const initial = { keywords: searchParams.get('vin') || searchParams.get('keywords') || '', dateFrom: searchParams.get('dateFrom') || today.dateFrom, dateTo: searchParams.get('dateTo') || today.dateTo, category: searchParams.get('category') || 'location', protocol: searchParams.get('protocol') || '' }; const [draft, setDraft] = useState(initial); const [criteria, setCriteria] = useState(initial); const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(50); const [visibleByCategory, setVisibleByCategory] = useState>({}); const [selectedRowRef, setSelectedRowRef] = useState<{ scope: string; id: string }>(); const [density, setDensity] = useState<'compact' | 'comfortable'>('compact'); const keywords = useMemo(() => parseHistoryKeywords(criteria.keywords), [criteria.keywords]); const params = useMemo(() => { const next = new URLSearchParams({ keywords: keywords.join(','), category: criteria.category, limit: String(limit), offset: String(offset) }); if (criteria.dateFrom) next.set('dateFrom', criteria.dateFrom); if (criteria.dateTo) next.set('dateTo', criteria.dateTo); if (criteria.protocol) next.set('protocol', criteria.protocol); return next; }, [criteria, keywords, limit, offset]); const seriesParams = useMemo(() => { const next = new URLSearchParams({ keywords: keywords.join(','), category: criteria.category, metrics: 'speedKmh,totalMileageKm', targetPoints: '240' }); if (criteria.dateFrom) next.set('dateFrom', criteria.dateFrom); if (criteria.dateTo) next.set('dateTo', criteria.dateTo); if (criteria.protocol) next.set('protocol', criteria.protocol); return next; }, [criteria, keywords]); const dataScope = useMemo(() => { const scope = new URLSearchParams(params); scope.delete('limit'); scope.delete('offset'); return scope.toString(); }, [params]); const catalogQuery = useQuery({ queryKey: ['history-metric-catalog'], queryFn: ({ signal }) => api.historyMetricCatalog(signal), staleTime: 30 * 60_000 }); const dataQuery = useQuery({ queryKey: ['history-data', dataScope, limit, offset], enabled: keywords.length > 0, queryFn: ({ signal }) => api.historyData(params, signal), placeholderData: retainPreviousPageWithinScope(dataScope), gcTime: QUERY_MEMORY.highVolumeGcTime }); const seriesQuery = useQuery({ queryKey: ['history-series', seriesParams.toString()], enabled: keywords.length > 0 && criteria.category === 'location', queryFn: ({ signal }) => api.historySeries(seriesParams, signal), gcTime: QUERY_MEMORY.highVolumeGcTime }); const result = dataQuery.data; const allMetrics = result?.columns ?? catalogQuery.data?.metrics.filter((metric) => metric.category === criteria.category) ?? []; const visibleKeys = visibleByCategory[criteria.category] ?? allMetrics.filter((metric) => metric.defaultVisible).map((metric) => metric.key); const visibleMetrics = allMetrics.filter((metric) => visibleKeys.includes(metric.key)); const selectedRow = selectedRowRef?.scope === dataScope ? result?.rows.find((row) => row.id === selectedRowRef.id) : undefined; useEffect(() => { const first = result?.rows[0]; setSelectedRowRef(first ? { scope: dataScope, id: first.id } : undefined); }, [dataScope, result]); const submit = (event: FormEvent) => { event.preventDefault(); const parsed = parseHistoryKeywords(draft.keywords); if (!parsed.length) return; const next = { ...draft, keywords: parsed.join(',') }; setCriteria(next); setOffset(0); const url = new URLSearchParams({ keywords: next.keywords, category: next.category }); 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 reset = () => { const next = { keywords: '', ...currentHistoryWindow(), category: 'location', protocol: '' }; setDraft(next); setCriteria(next); setOffset(0); setSearchParams({}, { replace: true }); }; const toggleMetric = (key: string) => setVisibleByCategory((current) => { const baseline = current[criteria.category] ?? allMetrics.filter((metric) => metric.defaultVisible).map((metric) => metric.key); const next = baseline.includes(key) ? baseline.filter((item) => item !== key) : [...baseline, key]; return { ...current, [criteria.category]: next }; }); const totalPages = Math.max(1, Math.ceil((result?.total ?? 0) / limit)); const page = Math.floor(offset / limit) + 1; return
指标字段{allMetrics.map((metric) => )}{!allMetrics.length ? 查询后加载可用指标 : null}
{dataQuery.isError ? dataQuery.refetch()} /> : null}
结果行数{result?.total.toLocaleString('zh-CN') ?? '—'}
车辆数{result?.summary.vehicleCount ?? '—'}
数据源{result?.summary.sources.join('、') || '—'}
查询耗时{result ? `${result.summary.queryDurationMs} ms` : '—'}
数据明细
{visibleMetrics.map((metric) => )}{result?.rows.map((row) => {visibleMetrics.map((metric) => )})}
设备时间服务时间车牌VIN协议{metric.label}{metric.unit ? ` (${metric.unit})` : ''}质量操作
setSelectedRowRef(selectedRow?.id === row.id ? undefined : { scope: dataScope, id: row.id })} aria-label={`选择 ${row.plate || row.vin} ${row.deviceTime}`} />{row.deviceTime}{row.serverTime}{row.plate || '—'}{row.vin}{row.protocol}{formatHistoryValue(row.values[metric.key], metric)}{row.quality === 'normal' ? '正常' : row.quality}
{dataQuery.isPending && keywords.length ?
正在加载当前筛选范围的历史数据…
: !result?.rows.length ?
{keywords.length ? '当前条件没有历史记录' : '输入车辆并查询历史数据'}
: null}
第 {page} / {totalPages} 页,共 {result?.total ?? 0} 条
; }