Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx
2026-07-19 07:49:13 +08:00

627 lines
46 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 { IconChevronRight, IconClose, IconDownload, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
import { Button, Card, CardGroup, Checkbox, Descriptions, Dropdown, Empty, Input, Progress, Select, Spin, 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 } 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 { WorkspaceDetailSideSheet } from '../shared/WorkspaceDetailSideSheet';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
import { WorkspaceMetricRail } 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 }: {
response?: HistorySeriesResponse;
category: string;
loading: boolean;
error?: string;
hasMetrics: boolean;
expanded: boolean;
onToggle: () => 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}<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> : <Empty className="v2-history-chart-empty" 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() {
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" 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 ? <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>
<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) }))} /> : <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, 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 [exportJobsOpen, setExportJobsOpen] = useState(false);
const [mobileToolsOpen, setMobileToolsOpen] = 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 applyDraft = () => {
const parsed = parseHistoryKeywords(draft.keywords);
if (!parsed.length) return;
const next = { ...draft, keywords: parsed.join(',') };
setCriteria(next); setOffset(0);
setFiltersCollapsed(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); 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 summaryPrimary = {
label: '结果行数',
value: summaryLoading ? '—' : (result?.total ?? 0).toLocaleString('zh-CN'),
note: summaryLoading ? '正在读取当前筛选范围' : `${resultSummary?.vehicleCount ?? 0} 辆车辆`
};
const summarySecondary = [
{
label: '车辆数',
value: summaryLoading ? '—' : String(resultSummary?.vehicleCount ?? 0),
note: `当前筛选 ${keywords.length} 个车辆标识`,
className: 'is-vehicles'
},
{
label: '数据来源',
value: summaryLoading ? '—' : `${resultSummary?.sources?.length ?? 0} 个来源`,
note: summarySources,
className: 'is-sources'
},
{
label: '查询耗时',
value: summaryLoading ? '—' : resultSummary ? `${resultSummary.queryDurationMs} ms` : '—',
note: `${visibleMetrics.length} / ${allMetrics.length} 个业务字段`,
className: 'is-duration'
}
];
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>
<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>
</>;
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">
<WorkspaceCommandBar
className="v2-history-command-bar"
ariaLabel="历史数据查询操作"
title="遥测证据检索"
description="按车辆、时间和协议核验可追溯的历史数据"
status={keywords.length ? `${keywords.length} 辆 · ${(result?.total ?? 0).toLocaleString('zh-CN')}` : '等待查询'}
statusColor={keywords.length ? 'blue' : 'grey'}
meta={<Typography.Text type="tertiary"> · 5 </Typography.Text>}
/>
{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="车辆、时间、数据类型与协议来源"
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 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 ? <CreateExportButton disabled={!resultRows.length} request={exportRequest} /> : <Tag className="v2-history-permission-tag" color="grey" type="light" size="large"></Tag>}</div>
</form>
</WorkspaceFilterPanel>}
</div>
{keywords.length ? <WorkspaceMetricRail
className="v2-history-summary-rail"
cardClassName="v2-history-summary-card"
secondaryClassName="v2-history-summary-secondary"
primaryValueClassName="v2-history-summary-value"
ariaLabel="历史数据查询统计信息"
primary={summaryPrimary}
secondary={summarySecondary}
/> : 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)} />
<Card className={`v2-history-table-card is-${density}`} bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
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>{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={mobileToolsOpen}
onVisibleChange={setMobileToolsOpen}
contentClassName="v2-workspace-mobile-tools-menu"
render={<Dropdown.Menu>
<Dropdown.Item icon={<IconSetting />} onClick={() => { setMobileToolsOpen(false); setColumnSettingsOpen(true); }}></Dropdown.Item>
{exportAllowed ? <Dropdown.Item icon={<IconDownload />} onClick={() => { setMobileToolsOpen(false); setExportJobsOpen(true); }}></Dropdown.Item> : null}
<Dropdown.Item icon={<IconRefresh />} onClick={() => { setMobileToolsOpen(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={mobileToolsOpen} icon={<IconSetting />}></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 ? <Button theme="borderless" aria-label="查看导出任务" aria-haspopup="dialog" aria-expanded={exportJobsOpen} aria-controls="v2-history-export-jobs" icon={<IconDownload />} onClick={() => setExportJobsOpen(true)}></Button> : null}
<Select aria-label="表格密度" value={density} onChange={(value) => setDensity(String(value) as typeof density)} optionList={[{ value: 'compact', label: '紧凑' }, { value: 'comfortable', label: '舒适' }]} />
<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 ? ' 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${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
? <PanelEmpty className="v2-history-empty"
title={keywords.length ? '当前条件没有历史记录' : '选择车辆开始查询'}
description={keywords.length ? '调整车辆、时间或数据来源后重试。' : '支持车牌或 VIN最多可同时查询 5 台车辆。'}
action={mobileLayout ? <Button className="v2-history-empty-action" theme="solid" icon={<IconSearch />} aria-label={keywords.length ? '调整查询范围' : '选择车辆并查询'} onClick={() => setFiltersCollapsed(false)}>{keywords.length ? '调整查询范围' : '选择车辆并查询'}</Button> : null}
/>
: 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>;
}