@@ -1,57 +1,42 @@
import { useEffect , useMemo , useState } from 'react' ;
import { motion , AnimatePresence , useDragControls } from 'motion/react' ;
import { X , Truck } from 'lucide-react' ;
import { RefreshCw , X , Truck } from 'lucide-react' ;
import {
BarChart , Bar , XAxis , YAxis , ResponsiveContainer , Tooltip , Cell ,
} from 'recharts' ;
import type { MileageSourceGroup , MonitoringVehicle } from './types' ;
import { fetchVehicleRecent , type VehicleRecentDay } from './api' ;
import Blur from '../../components/Blur' ;
import { EmptyState , ErrorState } from '../../components/ui/surface' ;
import {
includeCurrentDayInVehicleDetail ,
normalizeVehicleDetailContext ,
resolveVehicleDetailRange ,
vehicleDetailContextLabel ,
type VehicleDetailRangeKey ,
} from './vehicle-detail-range' ;
interface Props {
vehicle : MonitoringVehicle | null ;
onClose : ( ) = > void ;
sourcePriority : MileageSourceGroup [ ] ;
initialStartDate? : string ;
initialEndDate? : string ;
}
type RangeKey = 'last15' | 'month' | 'quarter' ;
const RANGE_TABS : { key : RangeKey ; label : string } [ ] = [
const RANGE_TABS : { key : VehicleDetailRangeKey ; label : string } [ ] = [
{ key : 'last15' , label : '近 15 天' } ,
{ key : 'month' , label : '本月' } ,
{ key : 'quarter' , label : '本季度' } ,
] ;
function fmtYmd ( d : Date ) : string {
const y = d . getFullYear ( ) ;
const m = String ( d . getMonth ( ) + 1 ) . padStart ( 2 , '0' ) ;
const dd = String ( d . getDate ( ) ) . padStart ( 2 , '0' ) ;
return ` ${ y } - ${ m } - ${ dd } ` ;
}
function rangeFor ( key : RangeKey ) : { start : string ; end : string ; rangeLabel : string } {
const today = new Date ( ) ;
today . setHours ( 0 , 0 , 0 , 0 ) ;
const end = fmtYmd ( today ) ;
if ( key === 'last15' ) {
const start = new Date ( today ) ;
start . setDate ( today . getDate ( ) - 14 ) ;
return { start : fmtYmd ( start ) , end , rangeLabel : '近 15 天' } ;
}
if ( key === 'month' ) {
const start = new Date ( today . getFullYear ( ) , today . getMonth ( ) , 1 ) ;
return { start : fmtYmd ( start ) , end , rangeLabel : '本月' } ;
}
const q = Math . floor ( today . getMonth ( ) / 3 ) ;
const start = new Date ( today . getFullYear ( ) , q * 3 , 1 ) ;
return { start : fmtYmd ( start ) , end , rangeLabel : '本季度' } ;
}
function isToday ( date : string ) : boolean {
return date === fmtYmd ( new Date ( ) ) ;
const now = new Date ( ) ;
const today = ` ${ now . getFullYear ( ) } - ${ String ( now . getMonth ( ) + 1 ) . padStart ( 2 , '0' ) } - ${ String ( now . getDate ( ) ) . padStart ( 2 , '0' ) } ` ;
return date === today ;
}
function formatLabel ( date : string , key : RangeKey ) : string {
function formatLabel ( date : string , key : VehicleDetailRangeKey ) : string {
// YYYY-MM-DD → MM-DD(季度时仍展示 MM-DD)
void key ;
return date . slice ( 5 ) ;
@@ -63,30 +48,65 @@ function daySourceLabel(day: VehicleRecentDay): string {
return day . isDataSynced ? '来源待接口' : '无数据' ;
}
export default function VehicleDetailModal ( { vehicle , onClose , sourcePriority } : Props ) {
export default function VehicleDetailModal ( {
vehicle ,
onClose ,
sourcePriority ,
initialStartDate ,
initialEndDate ,
} : Props ) {
const contextRange = useMemo (
( ) = > normalizeVehicleDetailContext ( initialStartDate , initialEndDate ) ,
[ initialEndDate , initialStartDate ] ,
) ;
const [ days , setDays ] = useState < VehicleRecentDay [ ] > ( [ ] ) ;
const [ loadedRequestKey , setLoadedRequestKey ] = useState < string | null > ( null ) ;
const [ loading , setLoading ] = useState ( false ) ;
const [ range , setRange ] = useState < RangeKey > ( 'last15' ) ;
const [ error , setError ] = useState < string | null > ( null ) ;
const [ requestVersion , setRequestVersion ] = useState ( 0 ) ;
const [ range , setRange ] = useState < VehicleDetailRangeKey > ( ( ) = > (
contextRange ? 'context' : 'last15'
) ) ;
const dragControls = useDragControls ( ) ;
// 切换车辆时重置区间为默认
useEffect ( ( ) = > {
if ( vehicle ) setRange ( 'last15' ) ;
} , [ vehicle ? . plate ] ) ; // eslint-disable-line react-hooks/exhaustive-deps
const resolvedRange = useMemo (
( ) = > resolveVehicleDetailRange ( range , contextRange ) ,
[ contextRange , range ] ,
) ;
const sourceKey = sourcePriority . join ( ',' ) ;
const requestKey = vehicle
? ` ${ vehicle . plate } | ${ resolvedRange . start } | ${ resolvedRange . end } | ${ sourceKey } `
: null ;
const hasSnapshot = requestKey !== null && loadedRequestKey === requestKey ;
const rangeTabs = useMemo ( ( ) = > (
contextRange
? [ { key : 'context' as const , label : vehicleDetailContextLabel ( contextRange ) } , . . . RANGE_TABS ]
: RANGE_TABS
) , [ contextRange ] ) ;
// 拉取数据(车辆或区间变化)
useEffect ( ( ) = > {
if ( ! vehicle ) return ;
const { start , end } = rangeFor ( range ) ;
if ( ! vehicle || ! requestKey ) return ;
setLoading ( true ) ;
setDays ( [ ] ) ;
setError ( null ) ;
let cancelled = false ;
fetchVehicleRecent ( vehicle . plate , { start , end , sourcePriority } )
. then ( d = > { if ( ! cancelled ) setDays ( d . days ) ; } )
. catch ( ( ) = > { if ( ! cancelled ) setDays ( [ ] ) ; } )
fetchVehicleRecent ( vehicle . plate , {
start : resolvedRange.start ,
end : resolvedRange.end ,
sourcePriority ,
} )
. then ( d = > {
if ( cancelled ) return ;
setDays ( d . days ) ;
setLoadedRequestKey ( requestKey ) ;
} )
. catch ( cause = > {
if ( ! cancelled ) {
setError ( cause instanceof Error ? cause . message : '车辆近期里程加载失败,请稍后重试。' ) ;
}
} )
. finally ( ( ) = > { if ( ! cancelled ) setLoading ( false ) ; } ) ;
return ( ) = > { cancelled = true ; } ;
} , [ vehicle ? . plate , range , sourcePriority ] ) ; // eslint-disable-line react-hooks/exhaustive-deps
} , [ requestKey , requestVersion ] ) ; // eslint-disable-line react-hooks/exhaustive-deps
// 锁滚动
useEffect ( ( ) = > {
@@ -95,8 +115,11 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
return ( ) = > { document . body . style . overflow = '' ; } ;
} , [ vehicle ] ) ;
// 排除"今日"列(数据未到位时易引起误读)
const historyDays = useMemo ( ( ) = > days . filter ( d = > ! isToday ( d . date ) ) , [ days ] ) ;
// 预设区间排除未完整的今日;显式下钻区间严格保留用户选择的日期。
const historyDays = useMemo ( ( ) = > {
if ( ! hasSnapshot ) return [ ] ;
return includeCurrentDayInVehicleDetail ( range ) ? days : days.filter ( d = > ! isToday ( d . date ) ) ;
} , [ days , hasSnapshot , range ] ) ;
const stats = useMemo ( ( ) = > {
const totalKm = historyDays . reduce ( ( s , d ) = > s + d . dailyKm , 0 ) ;
const synced = historyDays . filter ( d = > d . isDataSynced ) . length ;
@@ -107,12 +130,12 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
// 骨架天数:根据区间预估
const skeletonCount = useMemo ( ( ) = > {
if ( range === 'last15' ) return 15 ;
const { start , end } = rangeFor ( range ) ;
const s = new Date ( start ) ;
const e = new Date ( end ) ;
return Math . max ( 1 , Math . round ( ( e . getTime ( ) - s . getTime ( ) ) / 86400000 ) ) ;
} , [ range ] ) ;
const start = new Date ( ` ${ resolvedRange . start } T00:00:00 ` ) ;
const end = new Date ( ` ${ resolvedRange . end } T00:00:00 ` ) ;
return Math . max ( 1 , Math . round ( ( end . getTime ( ) - start . getTime ( ) ) / 86400000 ) + 1 ) ;
} , [ resolvedRange . end , resolvedRange . start ] ) ;
const showInitialLoading = loading && ! hasSnapshot ;
const showUnavailable = ! ! error && ! hasSnapshot ;
return (
< AnimatePresence >
@@ -142,6 +165,9 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
} }
className = "bg-white w-full md:max-w-md md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col touch-pan-y"
onClick = { ( e ) = > e . stopPropagation ( ) }
role = "dialog"
aria-modal = "true"
aria-labelledby = "vehicle-detail-title"
>
{ /* iOS 风格 drag handle —— 长按下滑可关闭 */ }
< div
@@ -160,7 +186,7 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
< / div >
< div className = "min-w-0" >
< div className = "flex items-center gap-1.5" >
< span className = "text-sm font-black text-slate-900 font-mono truncate" > < Blur > { vehicle . plate } < / Blur > < / span >
< span id = "vehicle-detail-title" className = "text-sm font-black text-slate-900 font-mono truncate" > < Blur > { vehicle . plate } < / Blur > < / span >
< span className = { ` text-[8px] px-1 rounded font-bold ${ vehicle . isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400' } ` } >
{ vehicle . isOnline ? '在线' : '离线' }
< / span >
@@ -173,19 +199,22 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
< / div >
< / div >
< / div >
< button onClick = { onClose } className = "p-2 -mr-1 text-slate-400 hover:text-slate-700 flex-shrink-0" >
< button type = "button" onClick = { onClose } aria-label = "关闭车辆详情" className = "p-2 -mr-1 text-slate-400 hover:text-slate-700 flex-shrink-0" >
< X size = { 18 } / >
< / button >
< / div >
{ /* 时间范围切换 */ }
< div className = "px-4 pt-3" >
< div className = "relative inline-flex bg-slate-100 p-0.5 rounded-lg" >
{ RANGE_TABS . map ( tab = > (
< div className = "relative inline-flex max-w-full overflow-x-auto bg-slate-100 p-0.5 rounded-lg" role = "tablist" aria-label = "车辆里程区间" >
{ rangeTabs . map ( tab = > (
< button
type = "button"
key = { tab . key }
onClick = { ( ) = > setRange ( tab . key ) }
className = { ` relative px-3 py-1 text-[10px] font-bold rounded-md transition-colors ${ range === tab . key ? 'text-blue-600' : 'text-slate-500 hover:text-slate-700' } ` }
role = "tab"
aria-selected = { range === tab . key }
className = { ` relative shrink-0 px-3 py-1 text-[10px] font-bold rounded-md transition-colors ${ range === tab . key ? 'text-blue-600' : 'text-slate-500 hover:text-slate-700' } ` }
>
{ range === tab . key && (
< motion.div
@@ -198,29 +227,49 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
< / button >
) ) }
< / div >
{ loading && hasSnapshot ? (
< span className = "ml-2 inline-flex items-center gap-1 text-[9px] font-bold text-slate-400" >
< RefreshCw size = { 10 } className = "animate-spin" / > 刷 新 中
< / span >
) : null }
< / div >
{ error ? (
< div className = "px-4 pt-3" >
< ErrorState
title = "车辆近期里程加载失败"
message = { hasSnapshot ? ` ${ error } 当前保留上一次成功加载的数据。 ` : error }
onRetry = { ( ) = > setRequestVersion ( version = > version + 1 ) }
retrying = { loading }
variant = "inline"
/ >
< / div >
) : null }
{ /* KPI cards */ }
< div className = "px-4 py-3 grid grid-cols-3 gap-2" >
< div className = "bg-slate-50 rounded-xl p-2.5" >
< div className = "text-[9px] font-bold text-slate-400 uppercase" > 区 间 合 计 < / div >
< div className = "text-base font-black text-slate-900 leading-tight" >
{ loading ? < span className = "inline-block h-4 w-14 bg-slate-200 rounded animate-pulse align-middle" / >
: < > { Math . round ( stats . totalKm ) . toLocaleString ( ) } < span className = "text-[9px] font-bold text-slate-4 00 ml-0.5 "> km < / span > < / > }
{ showInitialLoading ? < span className = "inline-block h-4 w-14 bg-slate-200 rounded animate-pulse align-middle" / >
: showUnavailable ? < span className = " text-slate-3 00"> — < / span >
: < > { Math . round ( stats . totalKm ) . toLocaleString ( ) } < span className = "text-[9px] font-bold text-slate-400 ml-0.5" > km < / span > < / > }
< / div >
< / div >
< div className = "bg-slate-50 rounded-xl p-2.5" >
< div className = "text-[9px] font-bold text-slate-400 uppercase" > 日 均 < / div >
< div className = "text-base font-black text-slate-900 leading-tight" >
{ loading ? < span className = "inline-block h-4 w-10 bg-slate-200 rounded animate-pulse align-middle" / >
: < > { Math . round ( stats . avg ) . toLocaleString ( ) } < span className = "text-[9px] font-bold text-slate-4 00 ml-0.5 "> km < / span > < / > }
{ showInitialLoading ? < span className = "inline-block h-4 w-10 bg-slate-200 rounded animate-pulse align-middle" / >
: showUnavailable ? < span className = " text-slate-3 00"> — < / span >
: < > { Math . round ( stats . avg ) . toLocaleString ( ) } < span className = "text-[9px] font-bold text-slate-400 ml-0.5" > km < / span > < / > }
< / div >
< / div >
< div className = "bg-slate-50 rounded-xl p-2.5" >
< div className = "text-[9px] font-bold text-slate-400 uppercase" > 有 数 据 天 < / div >
< div className = "text-base font-black text-slate-900 leading-tight" >
{ loading ? < span className = "inline-block h-4 w-12 bg-slate-200 rounded animate-pulse align-middle" / >
: < > { stats . synced } < span className = "text-[9px] font-bold text-slate-400 ml-0.5" > / { stats . totalDays } < / span > < / > }
{ showInitialLoading ? < span className = "inline-block h-4 w-12 bg-slate-200 rounded animate-pulse align-middle" / >
: showUnavailable ? < span className = "text-slate-300" > — < / span >
: < > { stats . synced } < span className = "text-[9px] font-bold text-slate-400 ml-0.5" > / { stats . totalDays } < / span > < / > }
< / div >
< / div >
< / div >
@@ -233,8 +282,12 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
< / div >
< div className = "bg-white rounded-xl border border-slate-50" >
< div className = "h-[140px]" >
{ loading ? (
{ showInitialLoading ? (
< SkeletonBars count = { Math . min ( skeletonCount , 30 ) } / >
) : showUnavailable ? (
< div className = "flex h-full items-center justify-center text-[10px] font-bold text-slate-300" > 所 选 区 间 趋 势 暂 不 可 用 < / div >
) : historyDays . length === 0 ? (
< EmptyState title = "暂无里程数据" description = "所选区间没有可展示的车辆里程" variant = "inline" / >
) : (
< ResponsiveContainer width = "100%" height = "100%" >
< BarChart data = { historyDays } margin = { { top : 8 , right : 8 , bottom : 0 , left : 0 } } >
@@ -269,8 +322,12 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
{ /* 每日明细 */ }
< div className = "flex-1 overflow-y-auto px-4 pb-4" >
< div className = "text-[10px] font-bold text-slate-500 mb-1.5" > 每 日 明 细 < / div >
{ loading ? (
{ showInitialLoading ? (
< SkeletonList count = { Math . min ( skeletonCount , 15 ) } / >
) : showUnavailable ? (
< div className = "py-6 text-center text-[10px] font-bold text-slate-300" > 请 重 试 加 载 车 辆 每 日 明 细 < / div >
) : historyDays . length === 0 ? (
< div className = "py-6 text-center text-[10px] font-bold text-slate-300" > 所 选 区 间 暂 无 每 日 明 细 < / div >
) : (
< motion.div
key = { range }