feat(platform): harden telemetry pipeline and unify Semi UI workspaces

This commit is contained in:
lingniu
2026-07-18 00:26:36 +08:00
parent 65b4e4f055
commit 159c80b0ae
136 changed files with 21616 additions and 1785 deletions

View File

@@ -1,6 +1,7 @@
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 { IconChevronRight, IconClose, IconDownload, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
import { Button, Card, CardGroup, Checkbox, Descriptions, Empty, Input, Progress, Select, SideSheet, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui';
import { FormEvent, useCallback, 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';
@@ -8,11 +9,20 @@ import { buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, for
import { downloadBlob } from '../domain/download';
import { InlineError } from '../shared/AsyncState';
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { PageHeader } from '../shared/PageHeader';
import { TablePagination } from '../shared/TablePagination';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { detailTriggerRow } from '../shared/detailTriggerRow';
import { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
import { usePlatformSession } from '../auth/AuthGate';
import { canOperate } from '../auth/session';
import { monitorReturnFromParams, preserveMonitorReturn } from '../routing/monitorContext';
import { useMobileLayout } from '../hooks/useMobileLayout';
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
const DESKTOP_HISTORY_PAGE_SIZE = 50;
const MOBILE_HISTORY_PAGE_SIZE = 20;
const historyAxisTimeFormatter = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });
function historyQualityLabel(quality: string) {
@@ -22,20 +32,53 @@ function historyQualityLabel(quality: string) {
return quality || '未知';
}
function HistoryTrend({ response, category, loading, error, hasMetrics }: { response?: HistorySeriesResponse; category: string; loading: boolean; error?: string; hasMetrics: boolean }) {
function HistoryQualityTag({ quality }: { quality: string }) {
const color = quality === 'normal' || quality === 'good' ? 'green' : quality === 'error' || quality === 'critical' ? 'red' : 'orange';
return <Tag className="v2-history-quality-tag" color={color} type="light" size="small">{historyQualityLabel(quality)}</Tag>;
}
function HistoryTrend({ response, category, loading, error, hasMetrics, expanded, onToggle }: {
response?: HistorySeriesResponse;
category: string;
loading: boolean;
error?: string;
hasMetrics: boolean;
expanded: boolean;
onToggle: () => void;
}) {
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>;
if (!hasMetrics) return <section className="v2-history-trend"><header><strong></strong></header><div className="v2-history-chart-empty"></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}按时间变化趋势`}>
const description = category === 'raw'
? '原始报文通过明细和导出核验证据'
: category === 'mileage'
? '日里程按自然日通过明细核对'
: '按查询时间窗汇总连续指标';
const header = <WorkspacePanelHeader
title="聚合趋势"
description={description}
meta={expanded && summary ? `${formatSeriesGrain(summary.grainSeconds)}粒度 · 覆盖 ${coverage.toFixed(1)}% · ${summary.rawPointCount.toLocaleString('zh-CN')} 原始点` : undefined}
actions={<Button
className={`v2-history-trend-toggle${expanded ? ' is-expanded' : ''}`}
theme="borderless"
type="tertiary"
size="small"
icon={<IconChevronRight />}
aria-expanded={expanded}
onClick={onToggle}
>{expanded ? '收起趋势' : '展开趋势'}</Button>}
/>;
if (!expanded) return <Card className="v2-history-trend is-collapsed" bodyStyle={{ padding: 0 }}>{header}</Card>;
if (category !== 'location') return <Card className="v2-history-trend is-expanded" bodyStyle={{ padding: 0 }}>{header}<Empty className="v2-history-chart-empty" title={category === 'raw' ? '原始报文不生成趋势' : '日里程按自然日展示'} description={category === 'raw' ? '离散报文请通过明细与导出核验证据。' : '请通过明细表核对每日起止里程。'} /></Card>;
if (!hasMetrics) return <Card className="v2-history-trend is-expanded" bodyStyle={{ padding: 0 }}>{header}<Empty className="v2-history-chart-empty" title="尚未选择趋势指标" description="选择“速度”或“总里程”后展示聚合趋势。" /></Card>;
return <Card className="v2-history-trend is-expanded" bodyStyle={{ padding: 0 }}>{header}
{error ? <Empty className="v2-history-chart-empty is-error" title="趋势加载失败" description={error} /> : loading && !response ? <div className="v2-history-chart-loading"><Spin size="middle" tip="正在聚合时间序列…" /></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>}
</svg><footer>{panel.lines.map((line) => <span key={line.key}><i style={{ background: line.color }} />{line.label}</span>)}</footer></article>)}</div> : <Empty className="v2-history-chart-empty" title="当前时间窗没有趋势点" description="没有可聚合的数值点,空窗不会被人工补值。" />}
{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>;
</Card>;
}
export function formatAxisTime(value: string) {
@@ -53,7 +96,7 @@ function CreateExportButton({ request, disabled }: { request: HistoryExportReque
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>;
return <Button className="v2-secondary-button" theme="light" icon={<IconDownload />} disabled={disabled || mutation.isPending} title={mutation.error instanceof Error ? mutation.error.message : '最多 100 万行;任务按创建顺序单并发流式执行'} onClick={() => mutation.mutate(request)}>{label}</Button>;
}
function ExportDownloadButton({ id }: { id: string }) {
@@ -61,10 +104,10 @@ function ExportDownloadButton({ id }: { id: string }) {
mutationFn: () => api.downloadHistoryExport(id),
onSuccess: ({ blob, filename }) => downloadBlob(blob, filename)
});
return <button type="button" className="v2-export-download" disabled={mutation.isPending} title={mutation.error instanceof Error ? mutation.error.message : '下载当前账号有权访问的导出文件'} onClick={() => mutation.mutate()}><IconDownload />{mutation.isPending ? '下载中' : mutation.isError ? '重试' : '下载'}</button>;
return <Button className="v2-export-download" theme="borderless" icon={<IconDownload />} disabled={mutation.isPending} title={mutation.error instanceof Error ? mutation.error.message : '下载当前账号有权访问的导出文件'} onClick={() => mutation.mutate()}>{mutation.isPending ? '下载中' : mutation.isError ? '重试' : '下载'}</Button>;
}
function ExportJobsPanel() {
function ExportJobsPanel({ showHeader = true }: { showHeader?: boolean }) {
const query = useQuery({
queryKey: ['history-exports'],
queryFn: ({ signal }) => api.historyExports(signal),
@@ -73,17 +116,37 @@ function ExportJobsPanel() {
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><small className="v2-export-owner">{job.ownerUsername || job.ownerName || '历史任务'} · {job.vehicleVins?.length ?? job.keywords.length} · {job.dateFrom && job.dateTo ? `${job.dateFrom.replace('T', ' ')}${job.dateTo.replace('T', ' ')}` : job.createdAt}</small></div>{job.downloadUrl ? <ExportDownloadButton id={job.id} /> : <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>;
const statusLabel = (status: string) => status === 'queued' ? '排队中' : status === 'running' ? '执行中' : status === 'completed' ? '已完成' : '失败';
const statusColor = (status: string) => status === 'completed' ? 'green' : status === 'running' ? 'blue' : status === 'failed' ? 'red' : 'grey';
return <Card className="v2-export-jobs" bodyStyle={{ padding: 0 }}>{showHeader ? <WorkspacePanelHeader title="导出任务" description="异步生成并保留可追溯下载记录" meta={`${jobs.length.toLocaleString('zh-CN')} 个任务`} /> : null}
<div className="v2-export-job-list">{query.isPending ? <div className="v2-history-side-loading"><Spin size="middle" tip="正在读取导出任务" /></div> : query.isError ? <Empty className="v2-history-side-empty" title="导出任务加载失败" description="请稍后刷新重试。" /> : !jobs.length ? <Empty className="v2-history-side-empty" title="暂无导出任务" description="创建任务后可在这里查看进度并下载。" /> : <CardGroup type="grid" spacing={0}>{jobs.slice(0, 6).map((job) => <Card className={`v2-export-job-card is-${job.status}`} key={job.id} title={<span className="v2-export-job-name" title={job.name}>{job.name}</span>} headerExtraContent={<Tag color={statusColor(job.status)} type="light" size="small">{statusLabel(job.status)}</Tag>} headerLine bodyStyle={{ padding: 0 }}>
<div className="v2-export-job-summary"><strong>{job.status === 'queued' ? '等待单并发执行' : job.status === 'running' ? `${job.processedRows.toLocaleString('zh-CN')} / ${job.totalRows.toLocaleString('zh-CN')}` : job.status === 'completed' ? `${job.rowCount.toLocaleString('zh-CN')} 行 · ${formatExportFileSize(job.fileSizeBytes)}` : job.error || '任务失败'}</strong><small>{job.ownerUsername || job.ownerName || '历史任务'} · {job.vehicleVins?.length ?? job.keywords.length} </small><small>{job.dateFrom && job.dateTo ? `${job.dateFrom.replace('T', ' ')}${job.dateTo.replace('T', ' ')}` : job.createdAt}</small></div>
{job.status === 'running' ? <Progress className="v2-export-job-progress" percent={job.progress} showInfo /> : null}
{job.downloadUrl ? <ExportDownloadButton id={job.id} /> : null}
</Card>)}</CardGroup>}</div>
</Card>;
}
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.plate || '—'}</dd></div><div><dt>VIN</dt><dd>{row.vin}</dd></div><div><dt></dt><dd>{row.deviceTime}</dd></div><div><dt></dt><dd>{row.serverTime}</dd></div><div><dt></dt><dd>{row.protocol}</dd></div><div><dt></dt><dd><i className={`is-${row.quality}`} />{historyQualityLabel(row.quality)}</dd></div><div className="v2-history-quality-detail"><dt></dt><dd>{row.qualityReason || '平台未返回质量说明'}</dd></div></dl><div className="v2-evidence-values"><strong></strong>{metrics.length ? metrics.slice(0, 20).map((metric) => <div key={metric.key}><span>{metric.label}<small>{metric.key}</small></span><b>{formatHistoryValue(row.values[metric.key], metric)}</b></div>) : <p></p>}</div>{row.evidenceId ? <footer><span>RAW </span><b>{row.evidenceId}</b></footer> : null}</> : <div className="v2-history-side-empty"></div>}
</section>;
function EvidencePanel({ row, metrics, onClose, showHeader = true }: { row?: HistoryDataRow; metrics: HistoryMetricDefinition[]; onClose: () => void; showHeader?: boolean }) {
return <Card className="v2-history-evidence" bodyStyle={{ padding: 0 }}>{showHeader ? <WorkspacePanelHeader title="数据详情" description={row ? `${row.plate || '未绑定车牌'} · ${row.protocol}` : '选择明细后核对来源与质量'} actions={row ? <Button theme="borderless" type="tertiary" icon={<IconClose />} onClick={onClose} aria-label="关闭数据详情" /> : null} /> : null}
{row ? <><Descriptions className="v2-history-evidence-descriptions" align="left" size="small" data={[
{ key: '车牌', value: row.plate || '—' },
{ key: 'VIN', value: row.vin },
{ key: '设备时间', value: row.deviceTime },
{ key: '服务时间', value: row.serverTime },
{ key: '数据来源', value: <Tag color="blue" type="light" size="small">{row.protocol}</Tag> },
{ key: '数据质量', value: <HistoryQualityTag quality={row.quality} /> },
{ key: '质量原因', value: row.qualityReason || '平台未返回质量说明' }
]} />
<div className="v2-evidence-section-title"><strong></strong><Tag type="light" color="grey" size="small">{metrics.length}</Tag></div>
{metrics.length ? <Descriptions className="v2-evidence-values" align="left" size="small" data={metrics.slice(0, 20).map((metric) => ({ key: <span>{metric.label}<small>{metric.key}</small></span>, value: formatHistoryValue(row.values[metric.key], metric) }))} /> : <Empty className="v2-evidence-values-empty" title="没有选中业务字段" description="通过列设置选择需要核对的字段。" />}
{row.evidenceId ? <footer><Tag color="blue" type="light" size="small">RAW </Tag><Typography.Text copyable={{ content: row.evidenceId }}>{row.evidenceId}</Typography.Text></footer> : null}</> : <Empty className="v2-history-side-empty" title="选择一条数据" description="查看来源、质量原因、业务字段与 RAW 证据。" />}
</Card>;
}
function ColumnVisibilityPanel({ metrics, visibleKeys, onToggle, onShowAll, onReset, onClose }: {
function ColumnVisibilityPanel({ metrics, visible, visibleKeys, onToggle, onShowAll, onReset, onClose }: {
metrics: HistoryMetricDefinition[];
visible: boolean;
visibleKeys: string[];
onToggle: (key: string) => void;
onShowAll: () => void;
@@ -94,20 +157,75 @@ function ColumnVisibilityPanel({ metrics, visibleKeys, onToggle, onShowAll, onRe
const filteredMetrics = useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) return metrics;
return metrics.filter((metric) => `${metric.label} ${metric.key} ${metric.unit}`.toLowerCase().includes(keyword));
return metrics.filter((metric) => `${metric.label} ${metric.key} ${metric.unit} ${metric.description ?? ''} ${metric.valueMappings?.map((item) => `${item.value} ${item.label}`).join(' ') ?? ''}`.toLowerCase().includes(keyword));
}, [metrics, search]);
const displayedMetrics = filteredMetrics.slice(0, 200);
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>
<label className="v2-history-column-search"><IconSearch /><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索字段名 / 字段 key" /></label>
<div>{displayedMetrics.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> : search && !filteredMetrics.length ? <p></p> : filteredMetrics.length > displayedMetrics.length ? <p> 200 key </p> : null}</div>
<footer><span> {visibleKeys.length} / {metrics.length}</span><button type="button" onClick={onShowAll} disabled={!metrics.length}></button><button type="button" onClick={onReset} disabled={!metrics.length}></button></footer>
</aside>;
useSideSheetA11y(visible, '.v2-history-column-sidesheet', 'v2-history-column-settings', '列显示设置', '关闭列显示设置');
return <SideSheet
className="v2-history-column-sidesheet"
visible={visible}
aria-label="列显示设置"
width={460}
title={<div className="v2-history-column-title"><strong></strong><span></span></div>}
onCancel={onClose}
footer={<div className="v2-history-column-footer"><span> {visibleKeys.length} / {metrics.length}</span><Button theme="light" onClick={onShowAll} disabled={!metrics.length}></Button><Button theme="light" onClick={onReset} disabled={!metrics.length}></Button></div>}
>
<div className="v2-history-column-content">
<label className="v2-history-column-search"><Input prefix={<IconSearch />} value={search} onChange={setSearch} placeholder="搜索中文、状态含义或字段 key" /></label>
<div className="v2-history-column-list">{displayedMetrics.map((metric) => <label key={metric.key} title={metric.description}><Checkbox aria-label={`${metric.label}${metric.unit ? ` (${metric.unit})` : ''}`} checked={visibleKeys.includes(metric.key)} onChange={() => onToggle(metric.key)} /><span>{metric.label}{metric.unit ? ` (${metric.unit})` : ''}{metric.valueMappings?.length ? <em>{metric.valueMappings.slice(0, 4).map((item) => `${item.value}=${item.label}`).join(' · ')}</em> : null}</span><small>{metric.key}</small></label>)}{!metrics.length ? <p></p> : search && !filteredMetrics.length ? <p></p> : filteredMetrics.length > displayedMetrics.length ? <p> 200 key </p> : null}</div>
</div>
</SideSheet>;
}
function HistoryDataTable({ rows, metrics, selectedRowID, onSelect }: { rows: HistoryDataRow[]; metrics: HistoryMetricDefinition[]; selectedRowID?: string; onSelect: (row: HistoryDataRow) => void }) {
const columns = useMemo(() => [
{
title: '', dataIndex: 'selection', width: 42,
render: (_: unknown, row: HistoryDataRow) => <Checkbox checked={selectedRowID === row.id} onChange={() => onSelect(row)} aria-label={`选择 ${row.plate || row.vin} ${row.deviceTime}`} />
},
{ title: '设备时间', dataIndex: 'deviceTime', width: 150 },
{ title: '服务时间', dataIndex: 'serverTime', width: 150 },
{ title: '车牌', dataIndex: 'plate', width: 110, render: (value: string) => value || '—' },
{ title: 'VIN', dataIndex: 'vin', width: 160, render: (value: string) => <span className="v2-history-vin" title={value}>{value}</span> },
{ title: '协议', dataIndex: 'protocol', width: 100 },
...metrics.map((metric) => ({
title: `${metric.label}${metric.unit ? ` (${metric.unit})` : ''}`,
dataIndex: metric.key,
key: metric.key,
width: 120,
render: (_: unknown, row: HistoryDataRow) => formatHistoryValue(row.values[metric.key], metric)
})),
{
title: '质量', dataIndex: 'quality', width: 90,
render: (_: string, row: HistoryDataRow) => <span className={`v2-quality is-${row.quality}`}><i />{historyQualityLabel(row.quality)}</span>
},
{
title: '质量原因', dataIndex: 'qualityReason', width: 220, className: 'v2-history-quality-reason',
render: (value: string) => <span title={value}>{value || '—'}</span>
},
{
title: '操作', dataIndex: 'action', width: 90,
render: (_: unknown, row: HistoryDataRow) => <Button size="small" theme="borderless" onClick={() => onSelect(row)}></Button>
}
], [metrics, onSelect, selectedRowID]);
const minWidth = 1112 + metrics.length * 120;
return <Table
className="v2-history-data-table"
style={{ minWidth }}
columns={columns}
dataSource={rows}
rowKey="id"
pagination={false}
empty={null}
onRow={(row) => row ? detailTriggerRow({
className: selectedRowID === row.id ? 'is-selected' : '',
expanded: selectedRowID === row.id,
label: `查看 ${row.plate || row.vin} ${row.deviceTime} 数据详情`,
testId: `history-row-${row.id}`,
onOpen: () => onSelect(row)
}) : ({})}
/>;
}
export default function HistoryPage() {
@@ -120,12 +238,20 @@ export default function HistoryPage() {
const [draft, setDraft] = useState(initial);
const [criteria, setCriteria] = useState(initial);
const [offset, setOffset] = useState(0);
const [limit, setLimit] = useState(50);
const mobileLayout = useMobileLayout();
const [limit, setLimit] = useState(() => mobileLayout ? MOBILE_HISTORY_PAGE_SIZE : DESKTOP_HISTORY_PAGE_SIZE);
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 [trendExpanded, setTrendExpanded] = useState(false);
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
const [exportJobsOpen, setExportJobsOpen] = useState(false);
useEffect(() => {
if (!mobileLayout) return;
setLimit(MOBILE_HISTORY_PAGE_SIZE);
setOffset(0);
}, [mobileLayout]);
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) });
@@ -160,13 +286,22 @@ export default function HistoryPage() {
if (criteria.protocol) next.set('protocol', criteria.protocol);
return next;
}, [criteria, keywords, seriesMetricKey]);
const seriesQuery = useQuery({ queryKey: ['history-series', seriesParams.toString()], enabled: keywords.length > 0 && criteria.category === 'location' && Boolean(seriesMetricKey), queryFn: ({ signal }) => api.historySeries(seriesParams, signal), gcTime: QUERY_MEMORY.highVolumeGcTime });
const seriesQuery = useQuery({ queryKey: ['history-series', seriesParams.toString()], enabled: trendExpanded && keywords.length > 0 && criteria.category === 'location' && Boolean(seriesMetricKey), queryFn: ({ signal }) => api.historySeries(seriesParams, signal), gcTime: QUERY_MEMORY.highVolumeGcTime });
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 scopedSelectedRowRef = selectedRowRef?.scope === dataScope ? selectedRowRef : undefined;
const selectedRow = scopedSelectedRowRef?.id === ''
? undefined
: scopedSelectedRowRef
? result?.rows.find((row) => row.id === scopedSelectedRowRef.id)
: undefined;
const selectRow = useCallback((row: HistoryDataRow) => {
setSelectedRowRef({ scope: dataScope, id: row.id });
}, [dataScope]);
const closeSelectedRow = useCallback(() => {
setSelectedRowRef({ scope: dataScope, id: '' });
}, [dataScope]);
useSideSheetA11y(mobileLayout && Boolean(selectedRow), '.v2-history-detail-sidesheet', 'v2-history-detail', '历史数据详情', '关闭历史数据详情');
useSideSheetA11y(exportAllowed && exportJobsOpen, '.v2-history-export-sidesheet', 'v2-history-export-jobs', '历史数据导出任务', '关闭历史数据导出任务');
const submit = (event: FormEvent) => {
event.preventDefault();
@@ -190,26 +325,74 @@ export default function HistoryPage() {
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;
const summarySources = result?.summary.sources.join('、') || '—';
return <div className="v2-history-page">
<MonitorReturnBar />
<button type="button" className="v2-mobile-filter-toggle" aria-expanded={!filtersCollapsed} onClick={() => setFiltersCollapsed((value) => !value)}><span><b></b><small>{keywords.length ? `${keywords.length} 辆 · ${criteria.category === 'location' ? '位置数据' : criteria.category === 'raw' ? '原始报文' : '日里程'}` : '请选择车辆'}</small></span><em>{filtersCollapsed ? '修改' : '收起'}</em></button>
<form className={`v2-history-toolbar${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} 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>
<fieldset className="v2-history-range"><legend></legend><div><input aria-label="开始时间" type="datetime-local" value={draft.dateFrom} onChange={(event) => setDraft((value) => ({ ...value, dateFrom: event.target.value }))} /><span></span><input aria-label="结束时间" type="datetime-local" value={draft.dateTo} onChange={(event) => setDraft((value) => ({ ...value, dateTo: event.target.value }))} /></div></fieldset>
<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>{exportAllowed ? <CreateExportButton disabled={!result?.rows.length} request={{ keywords, category: criteria.category, protocol: criteria.protocol || undefined, dateFrom: criteria.dateFrom, dateTo: criteria.dateTo, metrics: visibleKeys, format: 'csv' }} /> : <span className="v2-role-badge"> · </span>}
</form>
<div className="v2-history-metrics"><strong></strong>{visibleMetrics.map((metric) => <button type="button" className="is-active" onClick={() => toggleMetric(metric.key)} key={metric.key} title="点击取消显示"><i />{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</button>)}{allMetrics.length > visibleMetrics.length ? <span> {allMetrics.length - visibleMetrics.length} </span> : !allMetrics.length ? <span></span> : null}<button type="button" className="v2-history-metrics-manage" onClick={() => setColumnSettingsOpen(true)} disabled={!allMetrics.length}><IconSetting /></button></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} hasMetrics={Boolean(seriesMetricKey)} />
<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><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 />{historyQualityLabel(row.quality)}</span></td><td className="v2-history-quality-reason" title={row.qualityReason}>{row.qualityReason || '—'}</td><td><button type="button" onClick={() => setSelectedRowRef({ scope: dataScope, id: row.id })}></button></td></tr>)}</tbody></table><div className="v2-history-mobile-list">{result?.rows.map((row) => <button type="button" key={row.id} className={selectedRow?.id === row.id ? 'is-selected' : ''} onClick={() => setSelectedRowRef({ scope: dataScope, id: row.id })}><header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div><span className={`v2-quality is-${row.quality}`}><i />{historyQualityLabel(row.quality)}</span></header><p><time>{row.deviceTime}</time><b>{row.protocol}</b></p><small className="v2-history-mobile-quality">{row.qualityReason || '平台未返回质量说明'}</small><dl>{visibleMetrics.slice(0, 4).map((metric) => <div key={metric.key}><dt>{metric.label}</dt><dd>{formatHistoryValue(row.values[metric.key], metric)}</dd></div>)}</dl><em></em></button>)}</div>{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>
<PageHeader
title="历史数据"
description="按车辆、时间与协议查询可追溯数据,统一核对设备时间、服务时间、字段质量和原始证据。"
status="证据可追溯"
meta={<Typography.Text type="tertiary">{keywords.length ? `${keywords.length} 辆车辆` : '等待选择车辆'}</Typography.Text>}
/>
<MobileFilterToggle title="查询条件" summary={keywords.length ? `${keywords.length} 辆 · ${criteria.category === 'location' ? '位置数据' : criteria.category === 'raw' ? '原始报文' : '日里程'}` : '请选择车辆'} expanded={!filtersCollapsed} collapsedLabel="修改" onToggle={() => setFiltersCollapsed((value) => !value)} />
<Card className={`v2-history-filter-card${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} bodyStyle={{ padding: 0 }}>
<form className={`v2-history-toolbar${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}>
<label className="v2-history-vehicles"><span> 5 </span><Input prefix={<IconSearch />} value={draft.keywords} onChange={(value) => setDraft((current) => ({ ...current, keywords: value }))} placeholder="车牌 / VIN多台用逗号分隔" /></label>
<fieldset className="v2-history-range"><legend></legend><div><Input aria-label="开始时间" type="datetime-local" value={draft.dateFrom} onChange={(value) => setDraft((current) => ({ ...current, dateFrom: value }))} /><span></span><Input aria-label="结束时间" type="datetime-local" value={draft.dateTo} onChange={(value) => setDraft((current) => ({ ...current, dateTo: value }))} /></div></fieldset>
<label><span id="history-category-label"></span><Select aria-labelledby="history-category-label" value={draft.category} onChange={(value) => setDraft((current) => ({ ...current, category: String(value) }))} optionList={(catalogQuery.data?.categories ?? [{ key: 'location', label: '位置数据' }, { key: 'raw', label: '原始报文' }, { key: 'mileage', label: '日里程' }]).map((item) => ({ value: item.key, label: item.label }))} /></label>
<label><span id="history-protocol-label"></span><Select aria-labelledby="history-protocol-label" value={draft.protocol} onChange={(value) => setDraft((current) => ({ ...current, protocol: String(value) }))} optionList={[{ value: '', label: '全部来源' }, { value: 'GB32960', label: 'GB32960' }, { value: 'JT808', label: 'JT808' }, { value: 'YUTONG_MQTT', label: 'YUTONG_MQTT' }]} /></label>
<div className="v2-history-toolbar-actions"><Button className="v2-primary-button" theme="solid" htmlType="submit" disabled={!parseHistoryKeywords(draft.keywords).length}></Button><Button className="v2-secondary-button" theme="light" onClick={reset}></Button>{exportAllowed ? <CreateExportButton disabled={!result?.rows.length} request={{ keywords, category: criteria.category, protocol: criteria.protocol || undefined, dateFrom: criteria.dateFrom, dateTo: criteria.dateTo, metrics: visibleKeys, format: 'csv' }} /> : <Tag className="v2-history-permission-tag" color="grey" type="light" size="large"></Tag>}</div>
</form>
</Card>
<Card className="v2-history-metrics-card" bodyStyle={{ padding: 0 }}>
<div className="v2-history-metrics">
<strong></strong>
<div className="v2-history-metric-scroll" aria-label="当前显示字段">
{visibleMetrics.map((metric) => <Tag className="v2-history-metric-tag" color="blue" type="light" size="large" closable tabIndex={0} aria-label={`已选字段 ${metric.label}${metric.unit ? ` ${metric.unit}` : ''},点击取消显示`} onClick={() => toggleMetric(metric.key)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); toggleMetric(metric.key); } }} onClose={(_, event) => { event.stopPropagation(); toggleMetric(metric.key); }} key={metric.key}>{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</Tag>)}
{allMetrics.length > visibleMetrics.length ? <span> {allMetrics.length - visibleMetrics.length} </span> : !allMetrics.length ? <span></span> : null}
</div>
<Button className="v2-history-metrics-manage" theme="borderless" icon={<IconSetting />} aria-label="管理字段" onClick={() => setColumnSettingsOpen(true)} disabled={!allMetrics.length}></Button>
</div>
<aside className="v2-history-side"><EvidencePanel row={selectedRow} metrics={visibleMetrics} onClose={() => setSelectedRowRef(undefined)} />{exportAllowed ? <ExportJobsPanel /> : null}</aside>
</Card>
{dataQuery.isError ? <InlineError message={dataQuery.error instanceof Error ? dataQuery.error.message : '历史查询失败'} onRetry={() => dataQuery.refetch()} /> : null}
<div className={`v2-history-workspace${selectedRow && !mobileLayout ? ' is-inspector-open' : ''}`}>
<div className={`v2-history-main${trendExpanded ? ' has-expanded-trend' : ''}`}>
<Card className="v2-history-summary" bodyStyle={{ padding: 0 }}><div><small></small><strong>{result?.total.toLocaleString('zh-CN') ?? '—'}</strong></div><div><small></small><strong>{result?.summary.vehicleCount ?? '—'}</strong></div><div><small></small><strong className="is-source" title={summarySources}>{summarySources}</strong></div><div><small></small><strong>{result ? `${result.summary.queryDurationMs} ms` : '—'}</strong></div></Card>
<HistoryTrend response={seriesQuery.data} category={criteria.category} loading={seriesQuery.isFetching} error={seriesQuery.isError ? (seriesQuery.error instanceof Error ? seriesQuery.error.message : '未知错误') : undefined} hasMetrics={Boolean(seriesMetricKey)} expanded={trendExpanded} onToggle={() => setTrendExpanded((value) => !value)} />
<Card className={`v2-history-table-card is-${density}`} bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader title="数据明细" actions={<><Button theme="borderless" aria-label="列设置" aria-haspopup="dialog" aria-expanded={columnSettingsOpen} aria-controls="v2-history-column-settings" icon={<IconSetting />} onClick={() => setColumnSettingsOpen((value) => !value)}></Button>{exportAllowed ? <Button theme="borderless" aria-label="查看导出任务" aria-haspopup="dialog" aria-expanded={exportJobsOpen} aria-controls="v2-history-export-jobs" icon={<IconDownload />} onClick={() => setExportJobsOpen(true)}></Button> : null}{!mobileLayout ? <Select aria-label="表格密度" value={density} onChange={(value) => setDensity(String(value) as typeof density)} optionList={[{ value: 'compact', label: '紧凑' }, { value: 'comfortable', label: '舒适' }]} /> : null}<Button theme="borderless" onClick={() => dataQuery.refetch()} aria-label="刷新历史数据" icon={<IconRefresh />} /></>} />
<ColumnVisibilityPanel visible={columnSettingsOpen} 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)} />
<div className="v2-history-table-scroll">
{mobileLayout
? <div className="v2-history-mobile-list">{result?.rows.map((row) => <Card key={row.id} className={`v2-history-mobile-card${selectedRow?.id === row.id ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}><Button theme="borderless" type="tertiary" className="v2-history-mobile-action" aria-pressed={selectedRow?.id === row.id} aria-expanded={selectedRow?.id === row.id} aria-label={`查看 ${row.plate || row.vin} ${row.deviceTime} 数据详情`} onClick={() => selectRow(row)}><span className="v2-history-mobile-card-content"><header><span><strong>{row.plate || '未绑定车牌'}</strong><small>{row.vin}</small></span><span className={`v2-quality is-${row.quality}`}><i />{historyQualityLabel(row.quality)}</span></header><p><time>{row.deviceTime}</time><b>{row.protocol}</b></p><small className="v2-history-mobile-quality">{row.qualityReason || '平台未返回质量说明'}</small><dl>{visibleMetrics.slice(0, 4).map((metric) => <div key={metric.key}><dt>{metric.label}</dt><dd>{formatHistoryValue(row.values[metric.key], metric)}</dd></div>)}</dl><footer><IconChevronRight /></footer></span></Button></Card>)}</div>
: <HistoryDataTable rows={result?.rows ?? []} metrics={visibleMetrics} selectedRowID={selectedRow?.id} onSelect={selectRow} />}
{dataQuery.isPending && keywords.length ? <div className="v2-history-loading" role="status"><Spin size="middle" tip="正在加载当前筛选范围的历史数据…" /></div> : !result?.rows.length ? <Empty className="v2-history-empty" title={keywords.length ? '当前条件没有历史记录' : '等待查询历史数据'} description={keywords.length ? '调整车辆、时间或数据来源后重试。' : '输入车牌或 VIN最多可同时查询 5 台车辆。'} /> : null}
</div>
<footer><TablePagination page={page} totalPages={totalPages} info={`${(result?.total ?? 0).toLocaleString('zh-CN')}`} onPageChange={(next) => setOffset((next - 1) * limit)} pageSize={mobileLayout ? undefined : limit} onPageSizeChange={(next) => { setLimit(next); setOffset(0); }} pageSizeOptions={mobileLayout ? undefined : [{ value: 20, label: '20 条/页' }, { value: 50, label: '50 条/页' }, { value: 100, label: '100 条/页' }]} /></footer>
</Card>
</div>
{!mobileLayout && selectedRow ? <aside className="v2-history-side"><EvidencePanel row={selectedRow} metrics={visibleMetrics} onClose={closeSelectedRow} /></aside> : null}
</div>
<SideSheet
className="v2-history-detail-sidesheet"
visible={mobileLayout && Boolean(selectedRow)}
aria-label="历史数据详情"
width="100%"
title={<div className="v2-history-mobile-sheet-title"><strong></strong><span>{selectedRow ? `${selectedRow.plate || '未绑定车牌'} · ${selectedRow.protocol}` : '来源、质量与原始证据'}</span></div>}
onCancel={closeSelectedRow}
>
{mobileLayout && selectedRow ? <EvidencePanel row={selectedRow} metrics={visibleMetrics} onClose={closeSelectedRow} showHeader={false} /> : null}
</SideSheet>
<SideSheet
className="v2-history-export-sidesheet"
visible={exportAllowed && exportJobsOpen}
aria-label="历史数据导出任务"
width={mobileLayout ? '100%' : 520}
title={<div className="v2-history-mobile-sheet-title"><strong></strong><span></span></div>}
onCancel={() => setExportJobsOpen(false)}
>
{exportJobsOpen ? <ExportJobsPanel showHeader={false} /> : null}
</SideSheet>
</div>;
}