178 lines
20 KiB
TypeScript
178 lines
20 KiB
TypeScript
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, historyExportPollInterval, 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 <section className="v2-history-trend"><header><strong>聚合趋势</strong></header><div className="v2-history-chart-empty">{category === 'raw' ? '原始报文是离散证据,不生成可能误导的连续趋势;请使用明细与导出。' : '日里程按自然日展示,当前请使用明细表核对起止里程。'}</div></section>;
|
||
const summary = response?.summary;
|
||
const coverage = summary?.expectedBucketCount ? Math.max(0, (summary.expectedBucketCount - summary.missingBucketCount) / summary.expectedBucketCount * 100) : 0;
|
||
return <section className="v2-history-trend"><header><strong>聚合趋势</strong><div>{summary ? <><span>{formatSeriesGrain(summary.grainSeconds)}粒度</span><span>覆盖 {coverage.toFixed(1)}%</span><span>{summary.rawPointCount.toLocaleString('zh-CN')} 原始点</span></> : null}</div></header>
|
||
{error ? <div className="v2-history-chart-empty">趋势加载失败:{error}</div> : loading && !response ? <div className="v2-history-chart-empty">正在聚合时间序列…</div> : panels.length ? <div className="v2-history-trend-panels">{panels.map((panel) => <article key={panel.key}><header><strong>{panel.label}</strong><span>{panel.unit || '数值'} · {panel.lines.reduce((sum, line) => sum + line.points, 0)} 个桶点</span></header><svg viewBox="0 0 800 116" role="img" aria-label={`${panel.label}按时间变化趋势`}>
|
||
<g className="v2-chart-grid"><line x1="54" y1="10" x2="786" y2="10" /><line x1="54" y1="53" x2="786" y2="53" /><line x1="54" y1="96" x2="786" y2="96" /></g>
|
||
<g className="v2-chart-axis"><text x="49" y="14">{panel.maximum.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</text><text x="49" y="100">{panel.minimum.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</text><text x="54" y="112">{formatAxisTime(panel.start)}</text><text x="786" y="112" textAnchor="end">{formatAxisTime(panel.end)}</text></g>
|
||
{panel.lines.flatMap((line) => line.paths.map((path, index) => <path key={`${line.key}-${index}`} d={path} fill="none" stroke={line.color} strokeWidth="2" vectorEffect="non-scaling-stroke"><title>{line.label}</title></path>))}
|
||
</svg><footer>{panel.lines.map((line) => <span key={line.key}><i style={{ background: line.color }} />{line.label}</span>)}</footer></article>)}</div> : <div className="v2-history-chart-empty">当前时间窗没有可聚合的数值点;空窗未补值。</div>}
|
||
{summary ? <small className="v2-history-trend-evidence">{summary.evidence} · 缺失 {summary.missingBucketCount.toLocaleString('zh-CN')} / {summary.expectedBucketCount.toLocaleString('zh-CN')} 桶 · 查询 {summary.queryDurationMs} ms</small> : null}
|
||
</section>;
|
||
}
|
||
|
||
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 <button className="v2-secondary-button" type="button" disabled={disabled || mutation.isPending} title={mutation.error instanceof Error ? mutation.error.message : '最多 100 万行;任务按创建顺序单并发流式执行'} onClick={() => mutation.mutate(request)}><IconDownload />{label}</button>;
|
||
}
|
||
|
||
function ExportJobsPanel() {
|
||
const query = useQuery({
|
||
queryKey: ['history-exports'],
|
||
queryFn: ({ signal }) => api.historyExports(signal),
|
||
refetchInterval: (current) => historyExportPollInterval(current.state.data),
|
||
refetchIntervalInBackground: false,
|
||
gcTime: QUERY_MEMORY.highVolumeGcTime
|
||
});
|
||
const jobs = query.data ?? [];
|
||
return <section className="v2-export-jobs"><header><strong>导出任务</strong><span>{jobs.length}</span></header><div>{jobs.slice(0, 6).map((job) => <article key={job.id} title={job.evidence}><i className={`is-${job.status}`} /><div><strong>{job.name}</strong><small>{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 || '失败'}</small></div>{job.downloadUrl ? <a href={job.downloadUrl}><IconDownload />下载</a> : <em>{job.status === 'running' ? `${job.progress}%` : '—'}</em>}</article>)}{query.isError ? <div className="v2-history-side-empty">导出任务加载失败</div> : !jobs.length ? <div className="v2-history-side-empty">尚未创建导出任务</div> : null}</div></section>;
|
||
}
|
||
|
||
function EvidencePanel({ row, metrics, onClose }: { row?: HistoryDataRow; metrics: HistoryMetricDefinition[]; onClose: () => void }) {
|
||
return <section className="v2-history-evidence"><header><strong>行证据</strong>{row ? <button onClick={onClose} type="button" aria-label="关闭行证据"><IconClose /></button> : null}</header>
|
||
{row ? <><dl><div><dt>设备时间</dt><dd>{row.deviceTime}</dd></div><div><dt>服务时间</dt><dd>{row.serverTime}</dd></div><div><dt>车牌</dt><dd>{row.plate || '—'}</dd></div><div><dt>VIN</dt><dd>{row.vin}</dd></div><div><dt>数据来源</dt><dd>{row.protocol}</dd></div><div><dt>数据质量</dt><dd><i className={`is-${row.quality}`} />{row.quality === 'normal' ? '正常' : row.quality}</dd></div></dl><div className="v2-evidence-values"><strong>解析字段</strong>{metrics.slice(0, 12).map((metric) => <div key={metric.key}><span>{metric.label}<small>{metric.key}</small></span><b>{formatHistoryValue(row.values[metric.key], metric)}</b></div>)}</div><footer><span>RAW 证据</span><b>{row.evidenceId || '该数据类型没有独立 RAW 帧 ID'}</b></footer></> : <div className="v2-history-side-empty">选择一行查看来源、时间和解析字段证据。</div>}
|
||
</section>;
|
||
}
|
||
|
||
function ColumnVisibilityPanel({ metrics, visibleKeys, onToggle, onShowAll, onReset, onClose }: {
|
||
metrics: HistoryMetricDefinition[];
|
||
visibleKeys: string[];
|
||
onToggle: (key: string) => void;
|
||
onShowAll: () => void;
|
||
onReset: () => void;
|
||
onClose: () => void;
|
||
}) {
|
||
useEffect(() => {
|
||
const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); };
|
||
window.addEventListener('keydown', closeOnEscape);
|
||
return () => window.removeEventListener('keydown', closeOnEscape);
|
||
}, [onClose]);
|
||
return <aside className="v2-history-column-panel" role="dialog" aria-modal="false" aria-label="列显示设置">
|
||
<header><div><strong>列显示设置</strong><span>基础身份与时间列保持显示</span></div><button type="button" aria-label="关闭列显示设置" onClick={onClose}><IconClose /></button></header>
|
||
<div>{metrics.map((metric) => <label key={metric.key}><input type="checkbox" checked={visibleKeys.includes(metric.key)} onChange={() => onToggle(metric.key)} /><span>{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</span><small>{metric.key}</small></label>)}{!metrics.length ? <p>当前数据类型没有可配置指标</p> : null}</div>
|
||
<footer><button type="button" onClick={onShowAll} disabled={!metrics.length}>显示全部</button><button type="button" onClick={onReset} disabled={!metrics.length}>恢复默认</button></footer>
|
||
</aside>;
|
||
}
|
||
|
||
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<Record<string, string[]>>({});
|
||
const [selectedRowRef, setSelectedRowRef] = useState<{ scope: string; id: string }>();
|
||
const [density, setDensity] = useState<'compact' | 'comfortable'>('compact');
|
||
const [columnSettingsOpen, setColumnSettingsOpen] = useState(false);
|
||
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<HistoryDataResponse>({
|
||
queryKey: ['history-data', dataScope, limit, offset],
|
||
enabled: keywords.length > 0,
|
||
queryFn: ({ signal }) => api.historyData(params, signal),
|
||
placeholderData: retainPreviousPageWithinScope<HistoryDataResponse>(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 setVisibleMetrics = (keys: string[]) => setVisibleByCategory((current) => ({ ...current, [criteria.category]: keys }));
|
||
const totalPages = Math.max(1, Math.ceil((result?.total ?? 0) / limit));
|
||
const page = Math.floor(offset / limit) + 1;
|
||
|
||
return <div className="v2-history-page">
|
||
<form className="v2-history-toolbar" onSubmit={submit}>
|
||
<label className="v2-history-vehicles"><span>车辆(最多 5 台)</span><div><IconSearch /><input value={draft.keywords} onChange={(event) => setDraft((value) => ({ ...value, keywords: 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.category} onChange={(event) => setDraft((value) => ({ ...value, category: event.target.value }))}>{(catalogQuery.data?.categories ?? [{ key: 'location', label: '位置数据' }, { key: 'raw', label: '原始报文' }, { key: 'mileage', label: '日里程' }]).map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}</select></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={!parseHistoryKeywords(draft.keywords).length}>查询</button><button className="v2-secondary-button" type="button" onClick={reset}>重置</button><CreateExportButton disabled={!result?.rows.length} request={{ keywords, category: criteria.category, protocol: criteria.protocol || undefined, dateFrom: criteria.dateFrom, dateTo: criteria.dateTo, metrics: visibleKeys, format: 'csv' }} />
|
||
</form>
|
||
<div className="v2-history-metrics"><strong>指标字段</strong>{allMetrics.map((metric) => <button type="button" className={visibleKeys.includes(metric.key) ? 'is-active' : ''} onClick={() => toggleMetric(metric.key)} key={metric.key}><i />{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</button>)}{!allMetrics.length ? <span>查询后加载可用指标</span> : null}</div>
|
||
{dataQuery.isError ? <InlineError message={dataQuery.error instanceof Error ? dataQuery.error.message : '历史查询失败'} onRetry={() => dataQuery.refetch()} /> : null}
|
||
<div className="v2-history-workspace">
|
||
<div className="v2-history-main">
|
||
<div className="v2-history-summary"><div><small>结果行数</small><strong>{result?.total.toLocaleString('zh-CN') ?? '—'}</strong></div><div><small>车辆数</small><strong>{result?.summary.vehicleCount ?? '—'}</strong></div><div><small>数据源</small><strong>{result?.summary.sources.join('、') || '—'}</strong></div><div><small>查询耗时</small><strong>{result ? `${result.summary.queryDurationMs} ms` : '—'}</strong></div></div>
|
||
<HistoryTrend response={seriesQuery.data} category={criteria.category} loading={seriesQuery.isFetching} error={seriesQuery.isError ? (seriesQuery.error instanceof Error ? seriesQuery.error.message : '未知错误') : undefined} />
|
||
<section className={`v2-history-table-card is-${density}`}><header><strong>数据明细</strong><div><button type="button" aria-label="列设置" aria-haspopup="dialog" aria-expanded={columnSettingsOpen} onClick={() => setColumnSettingsOpen((value) => !value)}><IconSetting />列设置</button><select value={density} onChange={(event) => setDensity(event.target.value as typeof density)}><option value="compact">紧凑</option><option value="comfortable">舒适</option></select><button type="button" onClick={() => dataQuery.refetch()} aria-label="刷新历史数据"><IconRefresh /></button></div></header>{columnSettingsOpen ? <ColumnVisibilityPanel metrics={allMetrics} visibleKeys={visibleKeys} onToggle={toggleMetric} onShowAll={() => setVisibleMetrics(allMetrics.map((metric) => metric.key))} onReset={() => setVisibleMetrics(allMetrics.filter((metric) => metric.defaultVisible).map((metric) => metric.key))} onClose={() => setColumnSettingsOpen(false)} /> : null}<div className="v2-history-table-scroll"><table><thead><tr><th aria-label="选择行" /><th>设备时间</th><th>服务时间</th><th>车牌</th><th>VIN</th><th>协议</th>{visibleMetrics.map((metric) => <th key={metric.key}>{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</th>)}<th>质量</th><th>操作</th></tr></thead><tbody>{result?.rows.map((row) => <tr className={selectedRow?.id === row.id ? 'is-selected' : ''} key={row.id}><td><input type="checkbox" checked={selectedRow?.id === row.id} onChange={() => setSelectedRowRef(selectedRow?.id === row.id ? undefined : { scope: dataScope, id: row.id })} aria-label={`选择 ${row.plate || row.vin} ${row.deviceTime}`} /></td><td>{row.deviceTime}</td><td>{row.serverTime}</td><td>{row.plate || '—'}</td><td title={row.vin}>{row.vin}</td><td>{row.protocol}</td>{visibleMetrics.map((metric) => <td key={metric.key}>{formatHistoryValue(row.values[metric.key], metric)}</td>)}<td><span className={`v2-quality is-${row.quality}`}><i />{row.quality === 'normal' ? '正常' : row.quality}</span></td><td><button type="button" onClick={() => setSelectedRowRef({ scope: dataScope, id: row.id })}>查看证据</button></td></tr>)}</tbody></table>{dataQuery.isPending && keywords.length ? <div className="v2-history-loading" role="status"><i />正在加载当前筛选范围的历史数据…</div> : !result?.rows.length ? <div className="v2-history-empty">{keywords.length ? '当前条件没有历史记录' : '输入车辆并查询历史数据'}</div> : null}</div><footer><span>第 {page} / {totalPages} 页,共 {result?.total ?? 0} 条</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>
|
||
</div>
|
||
<aside className="v2-history-side"><EvidencePanel row={selectedRow} metrics={allMetrics} onClose={() => setSelectedRowRef(undefined)} /><ExportJobsPanel /></aside>
|
||
</div>
|
||
</div>;
|
||
}
|