@@ -3,10 +3,10 @@ import { IconClose, IconDownload, IconRefresh, IconSearch, IconSetting } from '@
import { FormEvent , useEffect , useMemo , useState } from 'react' ;
import { useSearchParams } from 'react-router-dom' ;
import { api } from '../../api/client' ;
import type { HistoryDataRow , HistoryExportRequest , HistoryMetricDefinition , HistorySeriesResponse } from '../../api/types' ;
import type { HistoryDataResponse , HistoryDataRow, HistoryExportRequest , HistoryMetricDefinition , HistorySeriesResponse } from '../../api/types' ;
import { buildHistorySeriesPanels , formatExportFileSize , formatHistoryValue , formatSeriesGrain , parseHistoryKeywords } from '../domain/history' ;
import { InlineError } from '../shared/AsyncState' ;
import { QUERY_MEMORY } from '../queryPolicy' ;
import { QUERY_MEMORY , retainPreviousPageWithinScope } from '../queryPolicy' ;
function HistoryTrend ( { response , category , loading , error } : { response? : HistorySeriesResponse ; category : string ; loading : boolean ; error? : string } ) {
const panels = useMemo ( ( ) = > buildHistorySeriesPanels ( response ) , [ response ] ) ;
@@ -62,7 +62,7 @@ export default function HistoryPage() {
const [ offset , setOffset ] = useState ( 0 ) ;
const [ limit , setLimit ] = useState ( 50 ) ;
const [ visibleByCategory , setVisibleByCategory ] = useState < Record < string , string [ ] > > ( { } ) ;
const [ selectedRow , setSelectedRow ] = useState < HistoryDataRow >( ) ;
const [ selectedRowRef , setSelectedRowRef ] = useState < { scope : string ; id : string } >( ) ;
const [ density , setDensity ] = useState < 'compact' | 'comfortable' > ( 'compact' ) ;
const keywords = useMemo ( ( ) = > parseHistoryKeywords ( criteria . keywords ) , [ criteria . keywords ] ) ;
const params = useMemo ( ( ) = > {
@@ -79,15 +79,31 @@ export default function HistoryPage() {
if ( criteria . protocol ) next . set ( 'protocol' , criteria . protocol ) ;
return next ;
} , [ criteria , keywords ] ) ;
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 ( { queryKey : [ 'h istory-d ata' , params . toString ( ) ] , enabled : keywords.length > 0 , queryFn : ( { signal } ) = > api . historyData ( params , signal ) , placeholderData : ( previous ) = > previous , gcTime : QUERY_MEMORY.highVolumeGcTime } ) ;
const seriesQuery = useQuery ( { queryKey : [ 'history-series' , seriesParams . toString ( ) ] , enabled : keywords.length > 0 && criteria . category === 'location' , queryFn : ( { signal } ) = > api . historySeries ( seriesParams , signal ) , placeholderData : ( previous ) = > previous , gcTime : QUERY_MEMORY.highVolumeGcTime } ) ;
const dataQuery = useQuery < H istoryD ataResponse > ( {
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 seriesQuery = useQuery ( { queryKey : [ 'history-series' , seriesParams . toString ( ) ] , enabled : keywords.length > 0 && criteria . category === 'location' , queryFn : ( { signal } ) = > api . historySeries ( seriesParams , signal ) , gcTime : QUERY_MEMORY.highVolumeGcTime } ) ;
const result = dataQuery . data ;
const allMetrics = result ? . columns ? ? catalogQuery . data ? . metrics . filter ( ( metric ) = > metric . category === criteria . category ) ? ? [ ] ;
const visibleKeys = visibleByCategory [ criteria . category ] ? ? allMetrics . filter ( ( metric ) = > metric . defaultVisible ) . map ( ( metric ) = > metric . key ) ;
const visibleMetrics = allMetrics . filter ( ( metric ) = > visibleKeys . includes ( metric . key ) ) ;
useEffect ( ( ) = > { setSelectedRow ( result ? . rows [ 0 ] ) ; } , [ result ? . asOf ] ) ;
const selectedRow = selectedRowRef ? . scope === dataScope ? result ? . rows . find ( ( row ) = > row . id === selectedRowRef . id ) : undefined ;
useEffect ( ( ) = > {
const first = result ? . rows [ 0 ] ;
setSelectedRowRef ( first ? { scope : dataScope , id : first.id } : undefined ) ;
} , [ dataScope , result ] ) ;
const submit = ( event : FormEvent ) = > {
event . preventDefault ( ) ;
@@ -123,11 +139,11 @@ export default function HistoryPage() {
{ dataQuery . isError ? < InlineError message = { dataQuery . error instanceof Error ? dataQuery . error . message : '历史查询失败' } onRetry = { ( ) = > dataQuery . refetch ( ) } / > : null }
< div className = "v2-history-workspace" >
< div className = "v2-history-main" >
< div className = "v2-history-summary" > < div > < small > 结 果 行 数 < / small > < strong > { result ? . total . toLocaleString ( 'zh-CN' ) ? ? 0 } < / strong > < / div > < div > < small > 车 辆 数 < / small > < strong > { result ? . summary . vehicleCount ? ? 0 } < / strong > < / div > < div > < small > 数 据 源 < / small > < strong > { result ? . summary . sources . join ( '、' ) || '—' } < / strong > < / div > < div > < small > 查 询 耗 时 < / small > < strong > { result ? ` ${ result . summary . queryDurationMs } ms ` : '—' } < / strong > < / div > < / div >
< div className = "v2-history-summary" > < div > < small > 结 果 行 数 < / small > < strong > { result ? . total . toLocaleString ( 'zh-CN' ) ? ? '—' } < / strong > < / div > < div > < small > 车 辆 数 < / small > < strong > { result ? . summary . vehicleCount ? ? '—' } < / strong > < / div > < div > < small > 数 据 源 < / small > < strong > { result ? . summary . sources . join ( '、' ) || '—' } < / strong > < / div > < div > < small > 查 询 耗 时 < / small > < strong > { result ? ` ${ result . summary . queryDurationMs } ms ` : '—' } < / strong > < / div > < / div >
< HistoryTrend response = { seriesQuery . data } category = { criteria . category } loading = { seriesQuery . isFetching } error = { seriesQuery . isError ? ( seriesQuery . error instanceof Error ? seriesQuery . error . message : '未知错误' ) : undefined } / >
< section className = { ` v2-history-table-card is- ${ density } ` } > < header > < strong > 数 据 明 细 < / strong > < div > < button type = "button" > < IconSetting / > 列 设 置 < / button > < select value = { density } onChange = { ( event ) = > setDensity ( event . target . value as typeof density ) } > < option value = "compact" > 紧 凑 < / option > < option value = "comfortable" > 舒 适 < / option > < / select > < button type = "button" onClick = { ( ) = > dataQuery . refetch ( ) } aria-label = "刷新历史数据" > < IconRefresh / > < / button > < / div > < / header > < div className = "v2-history-table-scroll" > < table > < thead > < tr > < th aria-label = "选择行" / > < th > 设 备 时 间 < / th > < th > 服 务 时 间 < / th > < th > 车 牌 < / th > < th > VIN < / th > < th > 协 议 < / th > { visibleMetrics . map ( ( metric ) = > < th key = { metric . key } > { metric . label } { metric . unit ? ` ( ${ metric . unit } ) ` : '' } < / th > ) } < th > 质 量 < / th > < th > 操 作 < / th > < / tr > < / thead > < tbody > { result ? . rows . map ( ( row ) = > < tr className = { selectedRow ? . id === row . id ? 'is-selected' : '' } key = { row . id } > < td > < input type = "checkbox" checked = { selectedRow ? . id === row . id } onChange = { ( ) = > setSelectedRow ( selectedRow ? . id === row . id ? undefined : row ) } aria-label = { ` 选择 ${ row . plate || row . vin } ${ row . deviceTime } ` } / > < / td > < td > { row . deviceTime } < / td > < td > { row . serverTime } < / td > < td > { row . plate || '—' } < / td > < td title = { row . vin } > { row . vin } < / td > < td > { row . protocol } < / td > { visibleMetrics . map ( ( metric ) = > < td key = { metric . key } > { formatHistoryValue ( row . values [ metric . key ] , metric ) } < / td > ) } < td > < span className = { ` v2-quality is- ${ row . quality } ` } > < i / > { row . quality === 'normal' ? '正常' : row . quality } < / span > < / td > < td > < button type = "button" onClick = { ( ) = > setSelectedRow ( row ) } > 查 看 证 据 < / button > < / td > < / tr > ) } < / tbody > < / table > { ! result ? . rows . length ? < div className = "v2-history-empty" > { keywords . length ? '当前条件没有历史记录' : '输入车辆并查询历史数据' } < / div > : null } < / div > < footer > < span > 第 { page } / { totalPages } 页 , 共 { result ? . total ? ? 0 } 条 < / span > < div > < button type = "button" disabled = { page <= 1 } onClick = { ( ) = > setOffset ( Math . max ( 0 , offset - limit ) ) } > 上 一 页 < / button > < button type = "button" disabled = { page >= totalPages } onClick = { ( ) = > setOffset ( offset + limit ) } > 下 一 页 < / button > < select value = { limit } onChange = { ( event ) = > { setLimit ( Number ( event . target . value ) ) ; setOffset ( 0 ) ; } } > < option value = "20" > 20 条 / 页 < / option > < option value = "50" > 50 条 / 页 < / option > < option value = "100" > 100 条 / 页 < / option > < / select > < / div > < / footer > < / section >
< section className = { ` v2-history-table-card is- ${ density } ` } > < header > < strong > 数 据 明 细 < / strong > < div > < button type = "button" > < IconSetting / > 列 设 置 < / button > < select value = { density } onChange = { ( event ) = > setDensity ( event . target . value as typeof density ) } > < option value = "compact" > 紧 凑 < / option > < option value = "comfortable" > 舒 适 < / option > < / select > < button type = "button" onClick = { ( ) = > dataQuery . refetch ( ) } aria-label = "刷新历史数据" > < IconRefresh / > < / button > < / div > < / header > < div className = "v2-history-table-scroll" > < table > < thead > < tr > < th aria-label = "选择行" / > < th > 设 备 时 间 < / th > < th > 服 务 时 间 < / th > < th > 车 牌 < / th > < th > VIN < / th > < th > 协 议 < / th > { visibleMetrics . map ( ( metric ) = > < th key = { metric . key } > { metric . label } { metric . unit ? ` ( ${ metric . unit } ) ` : '' } < / th > ) } < th > 质 量 < / th > < th > 操 作 < / th > < / tr > < / thead > < tbody > { result ? . rows . map ( ( row ) = > < tr className = { selectedRow ? . id === row . id ? 'is-selected' : '' } key = { row . id } > < td > < input type = "checkbox" checked = { selectedRow ? . id === row . id } onChange = { ( ) = > setSelectedRowRef ( selectedRow ? . id === row . id ? undefined : { scope : dataScope , id : row.id } ) } aria-label = { ` 选择 ${ row . plate || row . vin } ${ row . deviceTime } ` } / > < / td > < td > { row . deviceTime } < / td > < td > { row . serverTime } < / td > < td > { row . plate || '—' } < / td > < td title = { row . vin } > { row . vin } < / td > < td > { row . protocol } < / td > { visibleMetrics . map ( ( metric ) = > < td key = { metric . key } > { formatHistoryValue ( row . values [ metric . key ] , metric ) } < / td > ) } < td > < span className = { ` v2-quality is- ${ row . quality } ` } > < i / > { row . quality === 'normal' ? '正常' : row . quality } < / span > < / td > < td > < button type = "button" onClick = { ( ) = > setSelectedRowRef ( { scope : dataScope , id : row.id } ) } > 查 看 证 据 < / button > < / td > < / tr > ) } < / tbody > < / table > { dataQuery . isPending && keywords . length ? < div className = "v2-history-loading" role = "status" > < i / > 正 在 加 载 当 前 筛 选 范 围 的 历 史 数 据 … < / div > : ! result ? . rows . length ? < div className = "v2-history-empty" > { keywords . length ? '当前条件没有历史记录' : '输入车辆并查询历史数据' } < / div > : null } < / div > < footer > < span > 第 { page } / { totalPages } 页 , 共 { result ? . total ? ? 0 } 条 < / span > < div > < button type = "button" disabled = { page <= 1 } onClick = { ( ) = > setOffset ( Math . max ( 0 , offset - limit ) ) } > 上 一 页 < / button > < button type = "button" disabled = { page >= totalPages } onClick = { ( ) = > setOffset ( offset + limit ) } > 下 一 页 < / button > < select value = { limit } onChange = { ( event ) = > { setLimit ( Number ( event . target . value ) ) ; setOffset ( 0 ) ; } } > < option value = "20" > 20 条 / 页 < / option > < option value = "50" > 50 条 / 页 < / option > < option value = "100" > 100 条 / 页 < / option > < / select > < / div > < / footer > < / section >
< / div >
< aside className = "v2-history-side" > < EvidencePanel row = { selectedRow } metrics = { allMetrics } onClose = { ( ) = > setSelectedRow ( undefined ) } / > < ExportJobsPanel / > < / aside >
< aside className = "v2-history-side" > < EvidencePanel row = { selectedRow } metrics = { allMetrics } onClose = { ( ) = > setSelectedRowRef ( undefined ) } / > < ExportJobsPanel / > < / aside >
< / div >
< / div > ;
}