788 lines
52 KiB
TypeScript
788 lines
52 KiB
TypeScript
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>;
|
||
}
|