feat: build vehicle data platform and production pipeline
This commit is contained in:
132
vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx
Normal file
132
vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
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 { HistoryDataRow, HistoryExportRequest, HistoryMetricDefinition, HistorySeriesResponse } from '../../api/types';
|
||||
import { buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, formatSeriesGrain, parseHistoryKeywords } from '../domain/history';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
|
||||
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: api.historyExports, refetchInterval: (current) => current.state.data?.some((job) => job.status === 'queued' || job.status === 'running') ? 1000 : false });
|
||||
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>;
|
||||
}
|
||||
|
||||
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 [selectedRow, setSelectedRow] = useState<HistoryDataRow>();
|
||||
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 catalogQuery = useQuery({ queryKey: ['history-metric-catalog'], queryFn: api.historyMetricCatalog, staleTime: 30 * 60_000 });
|
||||
const dataQuery = useQuery({ queryKey: ['history-data', params.toString()], enabled: keywords.length > 0, queryFn: () => api.historyData(params), placeholderData: (previous) => previous });
|
||||
const seriesQuery = useQuery({ queryKey: ['history-series', seriesParams.toString()], enabled: keywords.length > 0 && criteria.category === 'location', queryFn: () => api.historySeries(seriesParams), placeholderData: (previous) => previous });
|
||||
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));
|
||||
|
||||
useEffect(() => { setSelectedRow(result?.rows[0]); }, [result?.asOf]);
|
||||
|
||||
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 <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') ?? 0}</strong></div><div><small>车辆数</small><strong>{result?.summary.vehicleCount ?? 0}</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"><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><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={() => setSelectedRow(selectedRow?.id === row.id ? undefined : row)} 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={() => setSelectedRow(row)}>查看证据</button></td></tr>)}</tbody></table>{!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={() => setSelectedRow(undefined)} /><ExportJobsPanel /></aside>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
Reference in New Issue
Block a user