@@ -10,6 +10,7 @@ import { downloadBlob } from '../domain/download';
import { InlineError , PanelEmpty , PanelLoading } from '../shared/AsyncState' ;
import { MonitorReturnBar } from '../shared/MonitorReturnBar' ;
import { TablePagination } from '../shared/TablePagination' ;
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar' ;
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel' ;
import { WorkspaceMetricRail } from '../shared/WorkspaceMetricRail' ;
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader' ;
@@ -24,6 +25,16 @@ import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
const DESKTOP_HISTORY_PAGE_SIZE = 50 ;
const MOBILE_HISTORY_PAGE_SIZE = 20 ;
const historyAxisTimeFormatter = new Intl . DateTimeFormat ( 'zh-CN' , { timeZone : 'Asia/Shanghai' , month : '2-digit' , day : '2-digit' , hour : '2-digit' , minute : '2-digit' , hour12 : false } ) ;
const historyDateTimeFormatter = new Intl . DateTimeFormat ( 'sv-SE' , {
timeZone : 'Asia/Shanghai' ,
year : 'numeric' ,
month : '2-digit' ,
day : '2-digit' ,
hour : '2-digit' ,
minute : '2-digit' ,
second : '2-digit' ,
hour12 : false
} ) ;
function historyQualityLabel ( quality : string ) {
if ( quality === 'normal' || quality === 'good' ) return '正常' ;
@@ -86,6 +97,15 @@ export function formatAxisTime(value: string) {
if ( ! Number . isFinite ( parsed . getTime ( ) ) ) return value . replace ( 'T' , ' ' ) . slice ( 5 , 16 ) ;
return historyAxisTimeFormatter . format ( parsed ) . replace ( '/' , '-' ) ;
}
export function formatHistoryDateTime ( value : string ) {
const fallback = value . replace ( 'T' , ' ' ) . replace ( /\.\d{1,9}/ , '' ) . replace ( /Z$/ , '' ) . slice ( 0 , 19 ) ;
if ( ! /(?:Z|[+-]\d{2}:?\d{2})$/i . test ( value ) ) return fallback ;
const parsed = new Date ( value ) ;
if ( ! Number . isFinite ( parsed . getTime ( ) ) ) return fallback ;
return historyDateTimeFormatter . format ( parsed ) . replace ( 'T' , ' ' ) ;
}
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 ( ) ) } ` ;
@@ -132,8 +152,8 @@ function EvidencePanel({ row, metrics, onClose, showHeader = true }: { row?: His
{ row ? < > < Descriptions className = "v2-history-evidence-descriptions" align = "left" size = "small" data = { [
{ key : '车牌' , value : row.plate || '—' } ,
{ key : 'VIN' , value : row.vin } ,
{ key : '设备时间' , value : row.deviceTime } ,
{ key : '服务时间' , value : row.serverTime } ,
{ key : '设备时间' , value : < time className = "v2-history-time" dateTime = { row . deviceTime } title = { row . deviceTime } > { formatHistoryDateTime ( row . deviceTime ) } < / time > } ,
{ key : '服务时间' , value : < time className = "v2-history-time" dateTime = { row . serverTime } title = { row . serverTime } > { formatHistoryDateTime ( row . serverTime ) } < / time > } ,
{ key : '数据来源' , value : < Tag color = "blue" type = "light" size = "small" > { row . protocol } < / Tag > } ,
{ key : '数据质量' , value : < HistoryQualityTag quality = { row . quality } / > } ,
{ key : '质量原因' , value : row.qualityReason || '平台未返回质量说明' }
@@ -183,8 +203,8 @@ function HistoryDataTable({ rows, metrics, selectedRowID, onSelect }: { rows: Hi
title : '' , dataIndex : 'selection' , width : 42 ,
render : ( _ : unknown , row : HistoryDataRow ) = > < Checkbox checked = { selectedRowID === row . id } onChange = { ( ) = > onSelect ( row ) } aria-label = { ` 选择 ${ row . plate || row . vin } ${ row . deviceTime } ` } / >
} ,
{ title : '设备时间' , dataIndex : 'deviceTime' , width : 150 } ,
{ title : '服务时间' , dataIndex : 'serverTime' , width : 150 } ,
{ title : '设备时间' , dataIndex : 'deviceTime' , width : 150 , render : ( value : string ) = > < time className = "v2-history-time" dateTime = { value } title = { value } > { formatHistoryDateTime ( value ) } < / time > } ,
{ title : '服务时间' , dataIndex : 'serverTime' , width : 150 , render : ( value : string ) = > < time className = "v2-history-time" dateTime = { value } title = { value } > { formatHistoryDateTime ( value ) } < / time > } ,
{ title : '车牌' , dataIndex : 'plate' , width : 110 , render : ( value : string ) = > value || '—' } ,
{ title : 'VIN' , dataIndex : 'vin' , width : 160 , render : ( value : string ) = > < span className = "v2-history-vin" title = { value } > { value } < / span > } ,
{ title : '协议' , dataIndex : 'protocol' , width : 100 } ,
@@ -357,24 +377,35 @@ export default function HistoryPage() {
return < div className = "v2-history-page" >
< MonitorReturnBar / >
< 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 ) }
>
< f orm className = { ` v2-history-toolbar ${ filtersCollapsed ? ' is-mobile-collapsed' : '' } ` } onSubmit = { submit } >
< label className = "v2-history-vehicles" > < span > 车 辆 ( 最 多 5 台 ) < / span > < Input prefix = { < IconSearch / > } value = { draft . keywords } onChange = { ( value ) = > setDraft ( ( current ) = > ( { . . . current , keywords : value } ) ) } placeholder = "车牌 / VIN, 多台用逗号分隔" / > < / label >
< fieldset className = "v2-history-range" > < legend > 时 间 范 围 < / legend > < div > < Input aria-label = "开始时间" type = "datetime-local" value = { draft . dateFrom } onChange = { ( value ) = > setDraft ( ( current ) = > ( { . . . current , dateFrom : value } ) ) } / > < span > 至 < / span > < Input aria-label = "结束时间" type = "datetime-local" value = { draft . dateTo } onChange = { ( value ) = > setDraft ( ( current ) = > ( { . . . current , dateTo : value } ) ) } / > < / div > < / fieldset >
< label > < span id = "history-category-label" > 数 据 类 型 < / span > < Select aria-labelledby = "history-category-label" value = { draft . category } onChange = { ( value ) = > setDraft ( ( current ) = > ( { . . . current , category : String ( value ) } ) ) } optionList = { ( catalogQuery . data ? . categories ? ? [ { key : 'location' , label : '位置数据' } , { key : 'raw' , label : '原始报文' } , { key : 'mileage' , label : '日里程' } ] ) . map ( ( item ) = > ( { value : item.key , label : item.label } ) ) } / > < / label >
< label > < span id = "history-protocol-label" > 数 据 来 源 < / span > < Select aria-labelledby = "history-protocol-label" value = { draft . protocol } onChange = { ( value ) = > setDraft ( ( current ) = > ( { . . . current , protocol : String ( value ) } ) ) } optionList = { [ { value : '' , label : '全部来源' } , { value : 'GB32960' , label : 'GB32960' } , { value : 'JT808' , label : 'JT808' } , { value : 'YUTONG_MQTT' , label : 'YUTONG_MQTT ' } ] } / > < / label >
< div className = "v2-history-toolbar-actions" > < Button className = "v2-primary-button" theme = "solid" htmlType = "submit" disabled = { ! parseHistoryKeywords ( draft . keywords ) . length } > 查 询 < / Button > < Button className = "v2-secondary-button" theme = "light" onClick = { reset } > 重 置 < / Button > { exportAllowed ? < CreateExportButton disabled = { ! resultRows . length } request = { { keywords , category : criteria.category , protocol : criteria.protocol || undefined , dateFrom : criteria.dateFrom , dateTo : criteria.dateTo , metrics : visibleKeys , format : 'csv' } } / > : < Tag className = "v2-history-permission-tag" color = "grey" type = "light" size = "large" > 只 读 < / Tag > } < / div >
< / form >
< / WorkspaceFilterPanel >
< 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 > }
/ >
< W orkspaceFilterPanel
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 } >
< label className = "v2-history-vehicles" > < span > 车 辆 ( 最 多 5 台 ) < / span > < Input prefix = { < IconSearch / > } value = { draft . keywords } onChange = { ( value ) = > setDraft ( ( current ) = > ( { . . . current , keywords : value } ) ) } placeholder = "车牌 / VIN, 多台用逗号分隔" / > < / label >
< fieldset className = "v2-history-range" > < legend > 时 间 范 围 < / legend > < div > < Input aria-label = "开始时间" type = "datetime-local" value = { draft . dateFrom } onChange = { ( value ) = > setDraft ( ( current ) = > ( { . . . current , dateFrom : value } ) ) } / > < span > 至 < / span > < Input aria-label = "结束时间" type = "datetime-local" value = { draft . dateTo } onChange = { ( value ) = > setDraft ( ( current ) = > ( { . . . current , dateTo : value } ) ) } / > < / div > < / fieldset >
< label > < span id = "history-category-label" > 数 据 类 型 < / span > < Select aria-labelledby = "history-category-label" value = { draft . category } onChange = { ( value ) = > setDraft ( ( current ) = > ( { . . . current , category : String ( value ) } ) ) } optionList = { ( catalogQuery . data ? . categories ? ? [ { key : 'location' , label : '位置数据' } , { key : 'raw' , label : '原始报文' } , { key : 'mileage' , label : '日里程' } ] ) . map ( ( item ) = > ( { value : item.key , label : item.label } ) ) } / > < / label >
< label > < span id = "history-protocol-label" > 数 据 来 源 < / span > < Select aria-labelledby = "history-protocol-label" value = { draft . protocol } onChange = { ( value ) = > setDraft ( ( current ) = > ( { . . . current , protocol : String ( value ) } ) ) } optionList = { [ { value : '' , label : '全部来源' } , { value : 'GB32960' , label : 'GB32960' } , { value : 'JT808' , label : 'JT808' } , { value : 'YUTONG_MQTT' , label : 'YUTONG_MQTT' } ] } / > < / label >
< div className = "v2-history-toolbar-actions" > < Button className = "v2-primary-button" theme = "solid" htmlType = "submit" disabled = { ! parseHistoryKeywords ( draft . keywords ) . length } > 查 询 < / Button > < Button className = "v2-secondary-button" theme = "light" onClick = { reset } > 重 置 < / Button > { exportAllowed ? < CreateExportButton disabled = { ! resultRows . length } request = { { keywords , category : criteria.category , protocol : criteria.protocol || undefined , dateFrom : criteria.dateFrom , dateTo : criteria.dateTo , metrics : visibleKeys , format : 'csv' } } / > : < Tag className = "v2-history-permission-tag" color = "grey" type = "light" size = "large" > 只 读 < / Tag > } < / div >
< / form >
< / WorkspaceFilterPanel >
< / div >
{ keywords . length ? < WorkspaceMetricRail
className = "v2-history-summary-rail"
cardClassName = "v2-history-summary-card"
@@ -409,7 +440,7 @@ export default function HistoryPage() {
aria-label = { mobileLayout ? '历史明细列表,可上下滚动' : undefined }
>
{ 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 > < p > < time > { row . deviceTime } < / time > < b > { row . protocol } < / b > < / p > < small className = "v2-history-mobile-quality" > { row . qualityReason || '平台未返回质量说明' } < / small > < dl > { visibleMetrics . slice ( 0 , 4 ) . map ( ( metric ) = > < div key = { metric . key } > < dt > { metric . label } < / dt > < dd > { formatHistoryValue ( row . values [ metric . key ] , metric ) } < / dd > < / div > ) } < / dl > < footer > 查 看 完 整 详 情 < IconChevronRight / > < / footer > < / span > < / Button > < / Card > ) } < / div >
? < 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 > < p > < time className = "v2-history-time" dateTime = { row . deviceTime } title = { row . deviceTime } > { formatHistoryDateTime ( row . deviceTime ) }< / time > < b > { row . protocol } < / b > < / p > < small className = "v2-history-mobile-quality" > { row . qualityReason || '平台未返回质量说明' } < / small > < dl > { visibleMetrics . slice ( 0 , 4 ) . map ( ( metric ) = > < div key = { metric . key } > < dt > { metric . label } < / dt > < dd > { formatHistoryValue ( row . values [ metric . key ] , metric ) } < / dd > < / div > ) } < / dl > < footer > 查 看 完 整 详 情 < IconChevronRight / > < / footer > < / span > < / Button > < / Card > ) } < / div >
: < HistoryDataTable rows = { resultRows } metrics = { visibleMetrics } selectedRowID = { selectedRow ? . id } onSelect = { selectRow } / > }
{ dataQuery . isPending && keywords . length
? < PanelLoading className = "v2-history-loading" title = "正在加载当前筛选范围的历史数据" description = "按车辆、时间和协议整理可追溯证据。" / >