Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx
2026-07-20 04:25:46 +08:00

788 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMutation, useQuery, useQueryClient, type UseQueryResult } from '@tanstack/react-query';
import { IconAlertTriangle, IconCalendar, IconChevronRight, IconClose, IconDownload, IconFilter, IconHistory, IconMapPin, IconMore, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
import { Button, Card, CardGroup, Checkbox, Descriptions, Dropdown, Input, Progress, Select, Table, Tag, Typography } from '@douyinfe/semi-ui';
import { FormEvent, KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { HistoryDataResponse, HistoryDataRow, HistoryExportJob, HistoryExportRequest, HistoryMetricDefinition, HistorySeriesResponse } from '../../api/types';
import { buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, formatSeriesGrain, historyExportPollInterval, parseHistoryKeywords } from '../domain/history';
import { downloadBlob } from '../domain/download';
import { formatShanghaiDateTime } from '../domain/formatters';
import { InlineError, PanelEmpty, PanelLoading, WorkspaceEmptyGuide } from '../shared/AsyncState';
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet';
import { PlatformTime } from '../shared/PlatformTime';
import { ProtocolTag } from '../shared/ProtocolTag';
import { TablePagination } from '../shared/TablePagination';
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import {
normalizeWorkspaceDateTimeRange,
WorkspaceDateTimeRange,
workspaceDateTimePresetWindow
} from '../shared/WorkspaceDateTimeRange';
import { WorkspaceDetailSideSheet } from '../shared/WorkspaceDetailSideSheet';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
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';
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) {
if (quality === 'normal' || quality === 'good') return '正常';
if (quality === 'warning') return '需关注';
if (quality === 'error' || quality === 'critical') return '异常';
return quality || '未知';
}
function isHealthyHistoryQuality(quality: string) {
return quality === 'normal' || quality === 'good';
}
function HistoryQualityTag({ quality }: { quality: string }) {
const color = isHealthyHistoryQuality(quality) ? '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, onRetry }: {
response?: HistorySeriesResponse;
category: string;
loading: boolean;
error?: string;
hasMetrics: boolean;
expanded: boolean;
onToggle: () => void;
onRetry: () => void;
}) {
const panels = useMemo(() => buildHistorySeriesPanels(response), [response]);
const summary = response?.summary;
const coverage = summary?.expectedBucketCount ? Math.max(0, (summary.expectedBucketCount - summary.missingBucketCount) / summary.expectedBucketCount * 100) : 0;
const description = category === 'raw'
? '原始报文通过明细和导出核验证据'
: category === 'mileage'
? '日里程按自然日通过明细核对'
: '按查询时间窗汇总连续指标';
const coverageColor = coverage >= 95 ? 'green' : coverage >= 70 ? 'orange' : 'red';
const header = <WorkspacePanelHeader
title="聚合趋势"
description={description}
meta={summary ? <span className="v2-history-trend-meta">
<Tag color={coverageColor} type="light" size="small"> {coverage.toFixed(1)}%</Tag>
<span>{formatSeriesGrain(summary.grainSeconds)} · {summary.rawPointCount.toLocaleString('zh-CN')} </span>
</span> : undefined}
actions={<Button
className="v2-history-trend-toggle is-expanded"
theme="borderless"
type="tertiary"
size="small"
icon={<IconChevronRight />}
aria-expanded="true"
onClick={onToggle}
></Button>}
/>;
if (!expanded) return null;
if (category !== 'location') return <Card className="v2-history-trend is-expanded" bodyStyle={{ padding: 0 }}>{header}<PanelEmpty
className="v2-history-chart-empty"
compact
tone={category === 'mileage' ? 'success' : 'neutral'}
icon={category === 'mileage' ? <IconCalendar /> : <IconHistory />}
title={category === 'raw' ? '原始报文不生成趋势' : '日里程按自然日展示'}
description={category === 'raw' ? '离散报文请通过明细与导出核验证据。' : '请通过明细表核对每日起止里程。'}
/></Card>;
if (!hasMetrics) return <Card className="v2-history-trend is-expanded" bodyStyle={{ padding: 0 }}>{header}<PanelEmpty
className="v2-history-chart-empty"
compact
tone="primary"
icon={<IconSetting />}
title="尚未选择趋势指标"
description="通过字段设置选择“速度”或“总里程”后展示聚合趋势。"
/></Card>;
return <Card className="v2-history-trend is-expanded" bodyStyle={{ padding: 0 }}>{header}
{error ? <PanelEmpty
className="v2-history-chart-empty is-error"
compact
tone="danger"
icon={<IconAlertTriangle />}
title="趋势加载失败"
description={error}
action={<Button theme="light" type="danger" icon={<IconRefresh />} aria-label="重新聚合趋势" onClick={onRetry}></Button>}
/> : loading && !response ? <PanelLoading className="v2-history-chart-loading" compact title="正在聚合时间序列" description="结果就绪后会保留当前查询范围。" /> : 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> : <PanelEmpty
className="v2-history-chart-empty"
compact
tone="warning"
icon={<IconHistory />}
title="当前时间窗没有趋势点"
description="没有可聚合的数值点,空窗不会被人工补值。"
/>}
{summary ? <footer className="v2-history-trend-evidence">
<Tag color={summary.missingBucketCount ? 'orange' : 'green'} type="light" size="small">{summary.missingBucketCount ? '存在空窗' : '时间窗完整'}</Tag>
<span title={summary.evidence}>{summary.evidence}</span>
<strong> {summary.missingBucketCount.toLocaleString('zh-CN')} / {summary.expectedBucketCount.toLocaleString('zh-CN')} </strong>
<span> {summary.queryDurationMs} ms</span>
</footer> : null}
</Card>;
}
export function formatAxisTime(value: string) {
const parsed = new Date(value);
if (!Number.isFinite(parsed.getTime())) return value.replace('T', ' ').slice(5, 16);
return historyAxisTimeFormatter.format(parsed).replace('/', '-');
}
export function formatHistoryDateTime(value: string) {
return formatShanghaiDateTime(value);
}
function currentHistoryWindow() {
return historyPresetWindow('today');
}
type HistoryRangePreset = 'today' | 'yesterday' | 'last6Hours' | 'last24Hours';
export function historyPresetWindow(preset: HistoryRangePreset, now = new Date()) {
return workspaceDateTimePresetWindow(preset, now);
}
export function historyDateTimePickerRange(
dateValue?: Date | Date[] | string | string[],
formattedValue?: string | string[] | Date | Date[]
) {
return normalizeWorkspaceDateTimeRange(dateValue, formattedValue);
}
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" 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 }) {
const mutation = useMutation({
mutationFn: () => api.downloadHistoryExport(id),
onSuccess: ({ blob, filename }) => downloadBlob(blob, filename)
});
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 useHistoryExportJobs(enabled = true) {
return useQuery({
queryKey: ['history-exports'],
queryFn: ({ signal }) => api.historyExports(signal),
enabled,
refetchInterval: (current) => historyExportPollInterval(current.state.data),
...LIVE_QUERY_POLICY,
gcTime: QUERY_MEMORY.highVolumeGcTime
});
}
function ExportJobsPanel({ query, showHeader = true }: { query: UseQueryResult<HistoryExportJob[], Error>; showHeader?: boolean }) {
const jobs = query.data ?? [];
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 ? <PanelLoading className="v2-history-side-loading" title="正在读取导出任务" description="任务状态就绪后会自动刷新。" /> : query.isError ? <PanelEmpty
className="v2-history-side-empty"
compact
tone="danger"
icon={<IconAlertTriangle />}
title="导出任务加载失败"
description="当前任务列表没有更新,请重新读取。"
action={<Button theme="light" type="danger" icon={<IconRefresh />} aria-label="重新读取导出任务" onClick={() => query.refetch()}></Button>}
/> : !jobs.length ? <PanelEmpty
className="v2-history-side-empty"
compact
tone="primary"
icon={<IconDownload />}
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>
<span><small>{job.ownerUsername || job.ownerName || '历史任务'} · {job.vehicleVins?.length ?? job.keywords.length} </small>{job.downloadUrl ? <ExportDownloadButton id={job.id} /> : null}</span>
<small>{job.dateFrom && job.dateTo ? `${formatHistoryDateTime(job.dateFrom)}${formatHistoryDateTime(job.dateTo)}` : <> <PlatformTime value={job.createdAt} /></>}</small>
</div>
{job.status === 'running' ? <Progress className="v2-export-job-progress" percent={job.progress} showInfo /> : null}
</Card>)}</CardGroup>}</div>
</Card>;
}
function ExportJobsWorkspace({ visible, mobileLayout, onClose }: { visible: boolean; mobileLayout: boolean; onClose: () => void }) {
const query = useHistoryExportJobs(visible);
const jobs = query.data ?? [];
const activeJobs = jobs.filter((job) => job.status === 'queued' || job.status === 'running').length;
const completedJobs = jobs.filter((job) => job.status === 'completed').length;
const failedJobs = jobs.filter((job) => job.status === 'failed').length;
const latestUpdatedAt = jobs.reduce<string | undefined>((latest, job) => {
if (!latest) return job.updatedAt;
return Date.parse(job.updatedAt) > Date.parse(latest) ? job.updatedAt : latest;
}, undefined);
const badge = query.isFetching ? '同步中' : `${jobs.length.toLocaleString('zh-CN')} 个任务`;
const badgeColor = failedJobs ? 'red' : activeJobs ? 'blue' : completedJobs ? 'green' : 'grey';
return <WorkspaceSideSheet
className="v2-history-export-sidesheet"
variant="task"
visible={visible}
ariaLabel="历史数据导出任务"
closeLabel="关闭历史数据导出任务"
dialogId="v2-history-export-jobs"
placement={mobileLayout ? 'bottom' : 'right'}
width={mobileLayout ? undefined : 520}
height={mobileLayout ? 'min(82dvh, 720px)' : undefined}
title="导出任务"
description="查看后台生成进度与可追溯下载记录"
icon={<IconDownload />}
badge={badge}
badgeColor={badgeColor}
summaryItems={[
{ label: '生成中', value: activeJobs, detail: '排队与执行', tone: activeJobs ? 'primary' : 'neutral' },
{ label: '可下载', value: completedJobs, detail: '已完成任务', tone: 'success' },
{ label: '需处理', value: failedJobs, detail: '失败任务', tone: failedJobs ? 'danger' : 'neutral' }
]}
footerNote={query.isError ? '任务状态读取失败,请手动刷新' : latestUpdatedAt ? <> <PlatformTime value={latestUpdatedAt} /></> : '打开后自动同步任务状态'}
secondaryActions={[{
label: '刷新任务',
ariaLabel: '刷新导出任务',
icon: <IconRefresh />,
loading: query.isFetching,
onClick: () => { void query.refetch(); }
}]}
primaryAction={{ label: '完成', onClick: onClose }}
onCancel={onClose}
>
{visible ? <ExportJobsPanel query={query} showHeader={false} /> : null}
</WorkspaceSideSheet>;
}
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 ? <><div className={`v2-history-evidence-status${isHealthyHistoryQuality(row.quality) ? ' is-healthy' : ' is-attention'}`}>
<div>
<span></span>
<strong>{historyQualityLabel(row.quality)}</strong>
<small>{row.qualityReason || '平台未返回质量说明'}</small>
</div>
<div>
<ProtocolTag className="v2-history-protocol-tag" protocol={row.protocol} />
<PlatformTime className="v2-history-time" value={row.deviceTime} />
</div>
</div><Descriptions className="v2-history-evidence-descriptions" align="left" size="small" data={[
{ key: '车牌', value: row.plate || '—' },
{ key: 'VIN', value: row.vin },
{ key: '设备时间', value: <PlatformTime className="v2-history-time" value={row.deviceTime} /> },
{ key: '服务时间', value: <PlatformTime className="v2-history-time" value={row.serverTime} /> },
{ key: '数据来源', value: <ProtocolTag className="v2-history-protocol-tag" protocol={row.protocol} /> },
{ 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) }))} /> : <PanelEmpty
className="v2-evidence-values-empty"
compact
tone="primary"
icon={<IconSetting />}
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}</> : <PanelEmpty
className="v2-history-side-empty"
compact
tone="primary"
icon={<IconSearch />}
title="选择一条数据"
description="查看来源、质量原因、业务字段与 RAW 证据。"
/>}
</Card>;
}
function ColumnVisibilityPanel({ metrics, visible, visibleKeys, onToggle, onShowAll, onReset, onClose }: {
metrics: HistoryMetricDefinition[];
visible: boolean;
visibleKeys: string[];
onToggle: (key: string) => void;
onShowAll: () => void;
onReset: () => void;
onClose: () => void;
}) {
const mobileLayout = useMobileLayout();
const [search, setSearch] = useState('');
const filteredMetrics = useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) return metrics;
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);
const defaultMetricCount = useMemo(() => metrics.filter((metric) => metric.defaultVisible).length, [metrics]);
return <WorkspaceSideSheet
className="v2-history-column-sidesheet"
visible={visible}
ariaLabel="列显示设置"
placement={mobileLayout ? 'bottom' : 'right'}
width={mobileLayout ? undefined : 460}
height={mobileLayout ? 'min(68dvh, 580px)' : undefined}
title="列显示设置"
description="基础身份与时间列保持显示"
badge={`${visibleKeys.length} / ${metrics.length} 已选`}
badgeColor={visibleKeys.length ? 'blue' : 'grey'}
summaryItems={[
{ label: '当前显示', value: `${visibleKeys.length}`, detail: '业务字段', tone: 'primary' },
{ label: '可选字段', value: `${metrics.length}`, detail: '随数据类型变化' },
{ label: '默认字段', value: `${defaultMetricCount}`, detail: '可随时恢复', tone: 'success' }
]}
onCancel={onClose}
footerNote={`已选 ${visibleKeys.length} / ${metrics.length} 个业务字段`}
secondaryActions={[
{ label: '显示全部', onClick: onShowAll, disabled: !metrics.length },
{ label: '恢复默认', onClick: onReset, disabled: !metrics.length }
]}
primaryAction={{ label: '完成', onClick: onClose }}
>
<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>
</WorkspaceSideSheet>;
}
function HistoryDataTable({ rows, metrics, selectedRowID, onSelect }: { rows: HistoryDataRow[]; metrics: HistoryMetricDefinition[]; selectedRowID?: string; onSelect: (row: HistoryDataRow) => void }) {
const columns = useMemo(() => [
{
title: '', dataIndex: 'selection', width: 42, fixed: 'left' as const, className: 'v2-history-selection-column',
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, fixed: 'left' as const, className: 'v2-history-device-time-column', render: (value: string) => <PlatformTime className="v2-history-time" value={value} /> },
{ title: '车牌', dataIndex: 'plate', width: 110, fixed: 'left' as const, className: 'v2-history-plate-column', render: (value: string) => value || '—' },
{ title: '服务时间', dataIndex: 'serverTime', width: 150, render: (value: string) => <PlatformTime className="v2-history-time" value={value} /> },
{ title: 'VIN', dataIndex: 'vin', width: 160, render: (value: string) => <span className="v2-history-vin" title={value}>{value}</span> },
{ title: '协议', dataIndex: 'protocol', width: 118, render: (value: string) => <ProtocolTag className="v2-history-protocol-tag" protocol={value} compact /> },
...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={{ width: '100%' }}
scroll={{ x: 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() {
const { session } = usePlatformSession();
const exportAllowed = canOperate(session) || session.role === 'customer';
const [searchParams, setSearchParams] = useSearchParams();
const monitorReturn = monitorReturnFromParams(searchParams);
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 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 [desktopQueryCollapsed, setDesktopQueryCollapsed] = useState(false);
const [exportJobsOpen, setExportJobsOpen] = useState(false);
const [toolsOpen, setToolsOpen] = 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) });
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 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 result = dataQuery.data;
const resultRows = result?.rows ?? [];
const resultSummary = result?.summary;
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 seriesMetricKey = visibleKeys.filter((key) => key === 'speedKmh' || key === 'totalMileageKm').join(',');
const seriesParams = useMemo(() => {
const next = new URLSearchParams({ keywords: keywords.join(','), category: criteria.category, metrics: seriesMetricKey, 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, seriesMetricKey]);
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 scopedSelectedRowRef = selectedRowRef?.scope === dataScope ? selectedRowRef : undefined;
const selectedRow = scopedSelectedRowRef?.id === ''
? undefined
: scopedSelectedRowRef
? resultRows.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]);
const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
const draftVehicleCount = parseHistoryKeywords(draft.keywords).length;
const draftCategoryLabel = catalogQuery.data?.categories.find((item) => item.key === draft.category)?.label
?? (draft.category === 'raw' ? '原始报文' : draft.category === 'mileage' ? '日里程' : '位置数据');
const draftSourceLabel = draft.protocol || '全部来源';
const draftWindowLabel = `${draft.dateFrom.slice(5, 10)}${draft.dateTo.slice(5, 10)}`;
const historyRangePresets = useMemo(() => [
{ key: 'today' as const, label: '今天', range: historyPresetWindow('today') },
{ key: 'yesterday' as const, label: '昨天', range: historyPresetWindow('yesterday') },
{ key: 'last6Hours' as const, label: '近 6 小时', range: historyPresetWindow('last6Hours') },
{ key: 'last24Hours' as const, label: '近 24 小时', range: historyPresetWindow('last24Hours') }
], []);
const applyDraft = () => {
const parsed = parseHistoryKeywords(draft.keywords);
if (!parsed.length) return;
const next = { ...draft, keywords: parsed.join(',') };
setCriteria(next); setOffset(0);
setFiltersCollapsed(true);
setDesktopQueryCollapsed(true);
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(preserveMonitorReturn(url, monitorReturn), { replace: true });
};
const submit = (event: FormEvent) => { event.preventDefault(); applyDraft(); };
const resetDraft = () => setDraft({ keywords: '', ...currentHistoryWindow(), category: 'location', protocol: '' });
const reset = () => { const next = { keywords: '', ...currentHistoryWindow(), category: 'location', protocol: '' }; setDraft(next); setCriteria(next); setOffset(0); setFiltersCollapsed(true); setDesktopQueryCollapsed(false); setSearchParams(preserveMonitorReturn(new URLSearchParams(), monitorReturn), { 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 scrollHistoryTable = (event: KeyboardEvent<HTMLDivElement>) => {
if (mobileLayout || event.currentTarget !== event.target || (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')) return;
const tableScroller = event.currentTarget.querySelector<HTMLElement>('.semi-table-body');
if (!tableScroller) return;
event.preventDefault();
tableScroller.scrollBy({ left: event.key === 'ArrowRight' ? 260 : -260, behavior: 'smooth' });
};
const totalPages = Math.max(1, Math.ceil((result?.total ?? 0) / limit));
const page = Math.floor(offset / limit) + 1;
const summarySources = resultSummary?.sources?.join('、') || '—';
const summaryLoading = keywords.length > 0 && dataQuery.isFetching && !result;
const appliedCategoryLabel = catalogQuery.data?.categories.find((item) => item.key === criteria.category)?.label
?? (criteria.category === 'raw' ? '原始报文' : criteria.category === 'mileage' ? '日里程' : '位置数据');
const appliedWindowLabel = criteria.dateFrom && criteria.dateTo
? `${criteria.dateFrom.replace('T', ' ').slice(5, 16)} ${criteria.dateTo.replace('T', ' ').slice(5, 16)}`
: '时间待选择';
const historyMetrics: WorkspaceQueueMetricRailItem[] = [
{
label: '结果行数',
value: summaryLoading ? '—' : (result?.total ?? 0).toLocaleString('zh-CN'),
note: summaryLoading ? '正在读取' : '当前范围',
tone: 'primary',
emphasis: 'primary'
},
{
label: '车辆数',
value: summaryLoading ? '—' : <>{resultSummary?.vehicleCount ?? 0}<i className="v2-history-summary-unit"></i></>,
note: `${keywords.length} 个车辆标识`,
tone: 'success',
emphasis: 'primary'
},
{
label: '数据来源',
value: summaryLoading ? '—' : <>{resultSummary?.sources?.length ?? 0}<i className="v2-history-summary-unit"></i></>,
note: summarySources,
tone: 'primary',
emphasis: 'secondary'
}
];
const historyFilterFields = <>
<label className="v2-history-vehicles"><span> 5 </span><Input prefix={<IconSearch />} value={draft.keywords} onChange={(value) => setDraft((current) => ({ ...current, keywords: value }))} placeholder="车牌 / VIN多台用逗号分隔" /></label>
<WorkspaceDateTimeRange
id="v2-history-date-time"
className="v2-history-range"
pickerClassName="v2-history-date-time-range"
presetsClassName="v2-history-range-presets"
presetsAriaLabel="快捷时间范围"
value={draft}
onChange={(range) => setDraft((current) => ({ ...current, ...range }))}
presets={historyRangePresets}
/>
<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>
</>;
const exportRequest: HistoryExportRequest = { keywords, category: criteria.category, protocol: criteria.protocol || undefined, dateFrom: criteria.dateFrom, dateTo: criteria.dateTo, metrics: visibleKeys, format: 'csv' };
return <div className="v2-history-page">
<MonitorReturnBar />
<div className="v2-history-discovery-shell" data-query-collapsed={!mobileLayout && desktopQueryCollapsed}>
<WorkspaceCommandBar
className="v2-history-command-bar"
ariaLabel="历史数据查询操作"
eyebrow="历史证据"
icon={<IconHistory />}
title="遥测证据检索"
description="按车辆、时间和协议核验可追溯的历史数据"
status={keywords.length ? `${keywords.length} 辆 · ${(result?.total ?? 0).toLocaleString('zh-CN')}` : '等待查询'}
statusColor={keywords.length ? 'blue' : 'grey'}
meta={keywords.length ? <span className="v2-history-applied-scope" aria-label="已应用查询范围">
<span><IconCalendar />{appliedWindowLabel}</span>
<span>{appliedCategoryLabel}</span>
<span>{criteria.protocol || '全部来源'}</span>
</span> : <Typography.Text type="tertiary"> · 5 </Typography.Text>}
actions={!mobileLayout ? <Button
className="v2-history-query-toggle"
theme="light"
type="tertiary"
icon={<IconFilter />}
aria-label={desktopQueryCollapsed ? '修改查询条件' : '收起查询条件'}
aria-expanded={!desktopQueryCollapsed}
aria-controls="v2-history-query-panel"
onClick={() => setDesktopQueryCollapsed((value) => !value)}
>{desktopQueryCollapsed ? '修改条件' : '收起条件'}</Button> : undefined}
/>
{mobileLayout ? <div className="v2-history-mobile-discovery">
<MobileFilterToggle
title="查询范围"
summary={keywords.length ? `${keywords.length} 辆 · ${criteria.category === 'location' ? '位置数据' : criteria.category === 'raw' ? '原始报文' : '日里程'}` : '请选择车辆'}
expanded={mobileFiltersOpen}
controls="v2-history-filter-sheet"
expandedLabel="关闭"
collapsedLabel="修改"
onToggle={() => setFiltersCollapsed((value) => !value)}
/>
<MobileFilterSheet
className="v2-history-filter-sidesheet"
visible={mobileFiltersOpen}
height="min(82dvh, 700px)"
ariaLabel="历史查询范围"
dialogId="v2-history-filter-sheet"
closeLabel="关闭历史查询范围"
title="筛选历史数据"
description="车辆、时间、数据类型与协议来源"
summaryItems={[
{
label: '车辆范围',
value: draftVehicleCount ? `${draftVehicleCount}` : '待选择',
detail: '最多 5 台车辆',
tone: 'primary'
},
{
label: '时间范围',
value: draftWindowLabel,
detail: '上海时区'
},
{
label: '数据范围',
value: draftCategoryLabel,
detail: draftSourceLabel,
tone: draft.protocol ? 'success' : 'neutral'
}
]}
onCancel={() => setFiltersCollapsed(true)}
secondaryAction={{ label: '重置条件', onClick: resetDraft }}
primaryAction={{ label: '应用并查询', disabled: !parseHistoryKeywords(draft.keywords).length, onClick: applyDraft }}
>
<form className="v2-history-mobile-filter-form" onSubmit={submit}>
<MobileFilterSheetSection className="v2-history-mobile-filter-section" ariaLabel="历史查询条件" title="查询范围" description="最多 5 台车辆,按上海时区检索可追溯数据">
<div className="v2-history-mobile-filter-grid">{historyFilterFields}</div>
</MobileFilterSheetSection>
{exportAllowed ? <section className="v2-history-mobile-export-action" aria-label="导出当前历史查询"><span><strong></strong><small> CSV</small></span><CreateExportButton disabled={!resultRows.length} request={exportRequest} /></section> : <Tag className="v2-history-permission-tag" color="grey" type="light" size="large"></Tag>}
</form>
</MobileFilterSheet>
</div> : <WorkspaceFilterPanel
className="v2-history-filter-card"
title="查询范围"
description="车辆、时间、数据类型与协议来源"
mobileSummary={keywords.length ? `${keywords.length} 辆 · ${criteria.category === 'location' ? '位置数据' : criteria.category === 'raw' ? '原始报文' : '日里程'}` : '请选择车辆'}
expanded={!filtersCollapsed}
status={keywords.length ? `${keywords.length} 辆已选` : '待选择车辆'}
statusColor={keywords.length ? 'blue' : 'grey'}
onToggle={() => setFiltersCollapsed((value) => !value)}
>
<form id="v2-history-query-panel" className={`v2-history-toolbar${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}>
{historyFilterFields}
<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 ? <Tag className="v2-history-permission-tag" color="grey" type="light" size="large"></Tag> : null}</div>
</form>
</WorkspaceFilterPanel>}
</div>
{keywords.length ? <WorkspaceMetricRail
variant="queue"
className="v2-history-summary-rail"
ariaLabel="历史数据查询统计信息"
items={historyMetrics}
/> : null}
{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' : ''}`}>
<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)} onRetry={() => { void seriesQuery.refetch(); }} />
<Card className={`v2-history-table-card is-${density}`} bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
eyebrow="证据结果"
tone="primary"
title="历史明细"
description={keywords.length ? `${(result?.total ?? 0).toLocaleString('zh-CN')} 条记录 · ${resultSummary?.vehicleCount ?? 0} 辆车辆 · ${summarySources}` : '选择车辆和时间范围后查询'}
meta={<span className="v2-history-result-meta"><Tag color="blue" type="light" size="small">{visibleMetrics.length} / {allMetrics.length} </Tag>{resultSummary ? <span className="v2-history-query-duration"> {resultSummary.queryDurationMs} ms</span> : null}</span>}
actionsClassName={`v2-history-result-actions${mobileLayout ? ' is-mobile-actions' : ''}`}
actions={mobileLayout ? keywords.length ? <>
<Button className={`v2-history-table-trend v2-workspace-mobile-tool-button is-muted${trendExpanded ? ' is-expanded' : ''}`} theme="light" aria-label={trendExpanded ? '收起趋势' : '展开趋势'} aria-expanded={trendExpanded} icon={<IconChevronRight />} onClick={() => setTrendExpanded((value) => !value)}>{trendExpanded ? '收起' : '趋势'}</Button>
<Dropdown
trigger="click"
position="bottomRight"
visible={toolsOpen}
onVisibleChange={setToolsOpen}
contentClassName="v2-workspace-mobile-tools-menu"
render={<Dropdown.Menu>
<Dropdown.Item icon={<IconSetting />} onClick={() => { setToolsOpen(false); setColumnSettingsOpen(true); }}></Dropdown.Item>
{exportAllowed ? <Dropdown.Item icon={<IconDownload />} onClick={() => { setToolsOpen(false); setExportJobsOpen(true); }}></Dropdown.Item> : null}
<Dropdown.Item icon={<IconRefresh />} onClick={() => { setToolsOpen(false); void dataQuery.refetch(); }}></Dropdown.Item>
</Dropdown.Menu>}
>
<Button className="v2-workspace-mobile-tool-button is-muted" theme="light" aria-label="打开历史数据工具" aria-haspopup="menu" aria-expanded={toolsOpen} icon={<IconMore />}></Button>
</Dropdown>
</> : null : <>
<Button className={`v2-history-table-trend${trendExpanded ? ' is-expanded' : ''}`} theme="borderless" aria-label={trendExpanded ? '收起趋势' : '展开趋势'} aria-expanded={trendExpanded} icon={<IconChevronRight />} onClick={() => setTrendExpanded((value) => !value)}>{trendExpanded ? '收起' : '趋势'}</Button>
<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 ? <CreateExportButton disabled={!resultRows.length} request={exportRequest} /> : null}
<Dropdown
trigger="click"
position="bottomRight"
visible={toolsOpen}
onVisibleChange={setToolsOpen}
contentClassName="v2-history-tools-menu"
render={<Dropdown.Menu>
{exportAllowed ? <Dropdown.Item icon={<IconDownload />} onClick={() => { setToolsOpen(false); setExportJobsOpen(true); }}></Dropdown.Item> : null}
<Dropdown.Item icon={<IconSetting />} onClick={() => { setToolsOpen(false); setDensity((value) => value === 'compact' ? 'comfortable' : 'compact'); }}>{density === 'compact' ? '切换为舒适密度' : '切换为紧凑密度'}</Dropdown.Item>
<Dropdown.Item icon={<IconRefresh />} onClick={() => { setToolsOpen(false); void dataQuery.refetch(); }}></Dropdown.Item>
</Dropdown.Menu>}
>
<Button className="v2-history-more-button" theme="borderless" aria-label="打开历史数据更多操作" aria-haspopup="menu" aria-expanded={toolsOpen} icon={<IconMore />}></Button>
</Dropdown>
</>}
/>
<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 ? ' is-mobile-scroll' : ''}`}
tabIndex={0}
aria-label={mobileLayout ? '历史明细列表,可上下滚动' : '历史明细表格,可使用左右方向键浏览业务字段'}
onKeyDown={scrollHistoryTable}
>
{mobileLayout
? <div className="v2-history-mobile-list">{resultRows.map((row) => <Card key={row.id} className={`v2-history-mobile-card is-quality-${row.quality}${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><span className="v2-history-mobile-meta"><PlatformTime className="v2-history-time" value={row.deviceTime} /><ProtocolTag className="v2-history-protocol-tag" protocol={row.protocol} compact /></span>{isHealthyHistoryQuality(row.quality) ? null : <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>
: resultRows.length ? <HistoryDataTable rows={resultRows} metrics={visibleMetrics} selectedRowID={selectedRow?.id} onSelect={selectRow} /> : null}
{dataQuery.isPending && keywords.length
? <PanelLoading className="v2-history-loading" title="正在加载当前筛选范围的历史数据" description="按车辆、时间和协议整理可追溯证据。" />
: !resultRows.length
? keywords.length
? <PanelEmpty
className="v2-history-empty"
title="当前条件没有历史记录"
description="调整车辆、时间或数据来源后重试。"
action={<Button className="v2-history-empty-action" theme="light" icon={<IconSearch />} aria-label="调整查询范围" onClick={() => setFiltersCollapsed(false)}></Button>}
/>
: <WorkspaceEmptyGuide
className="v2-history-empty"
eyebrow="从一个明确的车辆范围开始"
icon={<IconSearch />}
title="选择车辆,开始核验历史证据"
description="一次最多查询 5 台车辆,结果会保留协议来源、质量说明与 RAW 证据。"
steps={[
{ icon: <IconSearch />, title: '选择车辆', description: '支持车牌、VIN 与批量粘贴' },
{ icon: <IconCalendar />, title: '确认时间', description: '按上海时区精确到分钟' },
{ icon: <IconMapPin />, title: '核验数据', description: '位置、RAW 与日里程统一检索' }
]}
action={<Button className="v2-history-empty-action" theme="solid" icon={<IconSearch />} aria-label="选择车辆并查询" onClick={() => setFiltersCollapsed(false)}></Button>}
/>
: null}
</div>
{keywords.length ? <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> : null}
</Card>
</div>
{!mobileLayout && selectedRow ? <aside className="v2-history-side"><EvidencePanel row={selectedRow} metrics={visibleMetrics} onClose={closeSelectedRow} /></aside> : null}
</div>
<WorkspaceDetailSideSheet
className="v2-history-detail-sidesheet"
visible={mobileLayout && Boolean(selectedRow)}
ariaLabel="历史数据详情"
placement="bottom"
height="min(82dvh, 720px)"
title="数据详情"
description={selectedRow ? `${selectedRow.plate || '未绑定车牌'} · ${selectedRow.protocol}` : '来源、质量与原始证据'}
badge={selectedRow ? historyQualityLabel(selectedRow.quality) : undefined}
badgeColor={selectedRow && isHealthyHistoryQuality(selectedRow.quality) ? 'green' : selectedRow?.quality === 'error' || selectedRow?.quality === 'critical' ? 'red' : 'orange'}
onCancel={closeSelectedRow}
>
{mobileLayout && selectedRow ? <EvidencePanel row={selectedRow} metrics={visibleMetrics} onClose={closeSelectedRow} showHeader={false} /> : null}
</WorkspaceDetailSideSheet>
{exportAllowed ? <ExportJobsWorkspace
visible={exportJobsOpen}
mobileLayout={mobileLayout}
onClose={() => setExportJobsOpen(false)}
/> : null}
</div>;
}