fix: preserve dated vehicle panel snapshots

This commit is contained in:
kkfluous
2026-08-07 18:09:52 +08:00
parent b11187aba7
commit 017001e889
2 changed files with 118 additions and 47 deletions
+1
View File
@@ -174,6 +174,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
- 里程考核接口区分目标声明台数与当前有效车辆数;两者不一致时页面明确标注缺少或超配数量,并说明完成率、缺口和归因的实际计算范围。 - 里程考核接口区分目标声明台数与当前有效车辆数;两者不一致时页面明确标注缺少或超配数量,并说明完成率、缺口和归因的实际计算范围。
- 里程考核统计主数据已区分加载、真实空数据、首次请求故障和保留旧数据的刷新故障,并提供统一重试入口,接口失败不再显示为零值或空页面。 - 里程考核统计主数据已区分加载、真实空数据、首次请求故障和保留旧数据的刷新故障,并提供统一重试入口,接口失败不再显示为零值或空页面。
- 里程考核归因车辆与 7 天趋势已提供独立加载、故障、空数据和重试状态;子请求失败时停止展示“0 台未达标”和“最新日变化 +0”等伪业务结果。 - 里程考核归因车辆与 7 天趋势已提供独立加载、故障、空数据和重试状态;子请求失败时停止展示“0 台未达标”和“最新日变化 +0”等伪业务结果。
- 里程考核车辆日期侧栏仅展示带目标和日期标识的成功快照;切换日期失败时保留并标注上一成功日期,首次失败不再回退为当前日数据或伪造“0 辆”。
- 已完成电能数据截至时间和实际趋势月展示。 - 已完成电能数据截至时间和实际趋势月展示。
- 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。 - 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。
- 已完成氢能结构化故障状态与重试体验。 - 已完成氢能结构化故障状态与重试体验。
+117 -47
View File
@@ -104,7 +104,11 @@ export default function StatisticsView() {
const [viewAllSort, setViewAllSort] = useState<'asc' | 'desc'>('desc'); const [viewAllSort, setViewAllSort] = useState<'asc' | 'desc'>('desc');
const [viewAllDate, setViewAllDate] = useState(getDefaultDate); const [viewAllDate, setViewAllDate] = useState(getDefaultDate);
const [viewAllLoading, setViewAllLoading] = useState(false); const [viewAllLoading, setViewAllLoading] = useState(false);
const [viewAllError, setViewAllError] = useState<string | null>(null);
const [viewAllRequestVersion, setViewAllRequestVersion] = useState(0);
const [viewAllHistoricalVehicles, setViewAllHistoricalVehicles] = useState<TargetVehicle[] | null>(null); const [viewAllHistoricalVehicles, setViewAllHistoricalVehicles] = useState<TargetVehicle[] | null>(null);
const [viewAllLoadedDate, setViewAllLoadedDate] = useState<string | null>(null);
const [viewAllLoadedTargetId, setViewAllLoadedTargetId] = useState<number | null>(null);
const targetVehicleRequestsRef = useRef(new Set<number>()); const targetVehicleRequestsRef = useRef(new Set<number>());
const initialDrillContextRef = useRef(drillContext); const initialDrillContextRef = useRef(drillContext);
@@ -148,6 +152,10 @@ export default function StatisticsView() {
setTrendRequestVersion(version => version + 1); setTrendRequestVersion(version => version + 1);
}, []); }, []);
const retryViewAllVehicles = useCallback(() => {
setViewAllRequestVersion(version => version + 1);
}, []);
const loadCurrentTargetVehicles = useCallback((targetId: number) => { const loadCurrentTargetVehicles = useCallback((targetId: number) => {
if (targetVehicleRequestsRef.current.has(targetId)) return; if (targetVehicleRequestsRef.current.has(targetId)) return;
targetVehicleRequestsRef.current.add(targetId); targetVehicleRequestsRef.current.add(targetId);
@@ -194,6 +202,9 @@ export default function StatisticsView() {
setViewAllSearch(''); setViewAllSearch('');
setViewAllDate(getDefaultDate()); setViewAllDate(getDefaultDate());
setViewAllHistoricalVehicles(null); setViewAllHistoricalVehicles(null);
setViewAllLoadedDate(null);
setViewAllLoadedTargetId(null);
setViewAllError(null);
commitDrillContext({ commitDrillContext({
level: 'breakdown', level: 'breakdown',
targetId: selectedTargetId, targetId: selectedTargetId,
@@ -211,6 +222,9 @@ export default function StatisticsView() {
setViewAllSearch(''); setViewAllSearch('');
setViewAllDate(getDefaultDate()); setViewAllDate(getDefaultDate());
setViewAllHistoricalVehicles(null); setViewAllHistoricalVehicles(null);
setViewAllLoadedDate(null);
setViewAllLoadedTargetId(null);
setViewAllError(null);
commitDrillContext({ commitDrillContext({
level: 'breakdown', level: 'breakdown',
targetId: target.id, targetId: target.id,
@@ -332,11 +346,25 @@ export default function StatisticsView() {
// Re-fetch target vehicles when viewAllDate changes // Re-fetch target vehicles when viewAllDate changes
useEffect(() => { useEffect(() => {
if (viewAllTargetId === null) return; if (viewAllTargetId === null) return;
let cancelled = false;
setViewAllLoading(true); setViewAllLoading(true);
setViewAllError(null);
fetchTargetVehicles(viewAllTargetId, viewAllDate).then(data => { fetchTargetVehicles(viewAllTargetId, viewAllDate).then(data => {
if (cancelled) return;
setViewAllHistoricalVehicles(data); setViewAllHistoricalVehicles(data);
}).catch(() => {}).finally(() => setViewAllLoading(false)); setViewAllLoadedDate(viewAllDate);
}, [viewAllTargetId, viewAllDate]); setViewAllLoadedTargetId(viewAllTargetId);
}).catch(error => {
if (!cancelled) {
setViewAllError(getRequestErrorMessage(error, '无法获取所选日期的车辆明细,请稍后重试。'));
}
}).finally(() => {
if (!cancelled) setViewAllLoading(false);
});
return () => {
cancelled = true;
};
}, [viewAllTargetId, viewAllDate, viewAllRequestVersion]);
useEffect(() => { useEffect(() => {
const handlePopState = () => { const handlePopState = () => {
@@ -361,9 +389,10 @@ export default function StatisticsView() {
return () => window.removeEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState);
}, [targets]); }, [targets]);
const hasViewAllSnapshot = viewAllHistoricalVehicles !== null
&& viewAllLoadedTargetId === viewAllTargetId;
const viewAllVehicles = useMemo(() => { const viewAllVehicles = useMemo(() => {
const vehicles = viewAllHistoricalVehicles const vehicles = hasViewAllSnapshot ? viewAllHistoricalVehicles : [];
|| (viewAllTargetId === null ? [] : targetVehiclesMap[viewAllTargetId] || []);
if ( if (
drillContext.level === 'breakdown' drillContext.level === 'breakdown'
&& drillContext.dimensionValue && drillContext.dimensionValue
@@ -372,7 +401,7 @@ export default function StatisticsView() {
return filterAttributionVehicles(vehicles, drillContext.dimension, drillContext.dimensionValue); return filterAttributionVehicles(vehicles, drillContext.dimension, drillContext.dimensionValue);
} }
return vehicles; return vehicles;
}, [drillContext, targetVehiclesMap, viewAllHistoricalVehicles, viewAllTargetId]); }, [drillContext, hasViewAllSnapshot, viewAllHistoricalVehicles]);
const searchedViewAllVehicles = useMemo(() => ( const searchedViewAllVehicles = useMemo(() => (
viewAllVehicles.filter(vehicle => ( viewAllVehicles.filter(vehicle => (
@@ -996,7 +1025,7 @@ export default function StatisticsView() {
{viewAllTargetName || selectedTarget?.targetName || '考核车辆'} {viewAllTargetName || selectedTarget?.targetName || '考核车辆'}
</h3> </h3>
<p className="mt-1 text-xs font-bold uppercase tracking-widest text-slate-400"> <p className="mt-1 text-xs font-bold uppercase tracking-widest text-slate-400">
{selectedAssessment ? `${selectedAssessment.label} · ` : ''} {selectedAssessment ? `${selectedAssessment.label} · ` : ''}
</p> </p>
{selectedBreakdownValue && drillContext.dimension !== 'assessment-target' && ( {selectedBreakdownValue && drillContext.dimension !== 'assessment-target' && (
<p className="mt-1 text-[10px] font-bold text-blue-600">{selectedBreakdownValue}</p> <p className="mt-1 text-[10px] font-bold text-blue-600">{selectedBreakdownValue}</p>
@@ -1010,7 +1039,7 @@ export default function StatisticsView() {
</button> </button>
</div> </div>
<div className="px-6 py-3 border-b border-slate-50 space-y-2"> <div className="px-4 py-3 border-b border-slate-50 space-y-2 md:px-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
@@ -1028,16 +1057,27 @@ export default function StatisticsView() {
type="date" type="date"
value={viewAllDate} value={viewAllDate}
onChange={(e) => setViewAllDate(e.target.value)} onChange={(e) => setViewAllDate(e.target.value)}
className="pl-8 pr-2 py-2 bg-slate-50 border border-slate-100 rounded-xl text-xs font-bold text-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all w-[130px]" className="w-[145px] bg-slate-50 py-2 pl-8 pr-2 text-xs font-bold text-slate-700 border border-slate-100 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
/> />
</div> </div>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider"> <span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
{viewAllLoading ? '加载中...' : (() => { {viewAllLoading
const totalKm = searchedViewAllVehicles.reduce((sum, vehicle) => sum + (vehicle.todayMileage || 0), 0); ? hasViewAllSnapshot
return `${searchedViewAllVehicles.length} · 合计 ${fmtKm(totalKm)} km`; ? `正在加载 ${viewAllDate} · 当前显示 ${viewAllLoadedDate}`
})()} : `正在加载 ${viewAllDate}`
: viewAllError
? hasViewAllSnapshot
? `当前显示 ${viewAllLoadedDate} 已成功数据`
: `${viewAllDate} 暂不可用`
: (() => {
const totalKm = searchedViewAllVehicles.reduce(
(sum, vehicle) => sum + (vehicle.todayMileage || 0),
0,
);
return `${viewAllLoadedDate || viewAllDate} · ${searchedViewAllVehicles.length} 辆 · 合计 ${fmtKm(totalKm)} km`;
})()}
</span> </span>
<button <button
onClick={() => setViewAllSort(prev => prev === 'desc' ? 'asc' : 'desc')} onClick={() => setViewAllSort(prev => prev === 'desc' ? 'asc' : 'desc')}
@@ -1050,43 +1090,73 @@ export default function StatisticsView() {
</div> </div>
<div className="flex-1 overflow-y-auto p-4 space-y-2 no-scrollbar"> <div className="flex-1 overflow-y-auto p-4 space-y-2 no-scrollbar">
{[...searchedViewAllVehicles].sort((a, b) => { {viewAllLoading && !hasViewAllSnapshot ? (
const valA = a.todayMileage || 0; <LoadingState label={`正在加载 ${viewAllDate} 车辆明细`} variant="inline" />
const valB = b.todayMileage || 0; ) : viewAllError && !hasViewAllSnapshot ? (
return viewAllSort === 'desc' ? valB - valA : valA - valB; <ErrorState
}).map(tv => ( title="车辆明细加载失败"
<button message={viewAllError}
type="button" onRetry={retryViewAllVehicles}
key={tv.plateNumber} retrying={viewAllLoading}
onClick={() => openVehicleDiagnostic(tv)} variant="inline"
className="flex w-full items-center justify-between rounded-xl border border-slate-100 bg-white px-3 py-2 text-left shadow-sm transition-all hover:border-blue-200 hover:shadow-md" />
> ) : (
<div className="flex items-center gap-3 overflow-hidden flex-1"> <>
<div className="relative flex-shrink-0"> {viewAllError && (
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center"> <ErrorState
<Truck size={14} className="text-slate-400" /> title={`${viewAllDate} 车辆明细刷新失败`}
message={`${viewAllError} 当前保留 ${viewAllLoadedDate} 已成功加载的数据。`}
onRetry={retryViewAllVehicles}
retrying={viewAllLoading}
variant="inline"
/>
)}
{!viewAllLoading && !viewAllError && searchedViewAllVehicles.length === 0 ? (
<EmptyState
title={viewAllHistoricalVehicles?.length ? '没有匹配车辆' : '该日期暂无车辆里程'}
description={viewAllHistoricalVehicles?.length ? '请调整车牌搜索条件' : `${viewAllLoadedDate || viewAllDate} 未返回可展示的车辆数据`}
variant="inline"
/>
) : null}
{[...searchedViewAllVehicles].sort((a, b) => {
const valA = a.todayMileage || 0;
const valB = b.todayMileage || 0;
return viewAllSort === 'desc' ? valB - valA : valA - valB;
}).map(tv => (
<button
type="button"
key={tv.plateNumber}
onClick={() => openVehicleDiagnostic(tv)}
className="flex w-full items-center justify-between rounded-xl border border-slate-100 bg-white px-3 py-2 text-left shadow-sm transition-all hover:border-blue-200 hover:shadow-md"
>
<div className="flex items-center gap-3 overflow-hidden flex-1">
<div className="relative flex-shrink-0">
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center">
<Truck size={14} className="text-slate-400" />
</div>
<div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white ${tv.isOnline ? 'bg-green-500' : 'bg-slate-300'}`} />
</div>
<div className="overflow-hidden flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{tv.plateNumber}</Blur></span>
<span className={`text-[8px] px-1 rounded ${tv.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
{tv.isOnline ? '在线' : '离线'}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[8px] text-slate-300 font-bold">{tv.rentStatus || ''}{tv.department ? ` · ${tv.department.replace('业务', '')}` : ''}</span>
<span className="text-[9px] font-bold text-slate-600 truncate">{tv.customer || '-'}</span>
</div>
</div>
</div> </div>
<div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white ${tv.isOnline ? 'bg-green-500' : 'bg-slate-300'}`} /> <div className="text-right flex-shrink-0 ml-2">
</div> <div className="text-sm font-black text-blue-600">{tv.todayMileage.toLocaleString()} <span className="text-[8px] text-slate-400">KM</span></div>
<div className="overflow-hidden flex-1"> <div className="text-[9px] font-bold text-slate-300 mt-0.5">: {fmtKm(tv.totalMileage || 0)} km</div>
<div className="flex items-center gap-1.5">
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{tv.plateNumber}</Blur></span>
<span className={`text-[8px] px-1 rounded ${tv.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
{tv.isOnline ? '在线' : '离线'}
</span>
</div> </div>
<div className="flex items-center gap-1.5"> </button>
<span className="text-[8px] text-slate-300 font-bold">{tv.rentStatus || ''}{tv.department ? ` · ${tv.department.replace('业务', '')}` : ''}</span> ))}
<span className="text-[9px] font-bold text-slate-600 truncate">{tv.customer || '-'}</span> </>
</div> )}
</div>
</div>
<div className="text-right flex-shrink-0 ml-2">
<div className="text-sm font-black text-blue-600">{tv.todayMileage.toLocaleString()} <span className="text-[8px] text-slate-400">KM</span></div>
<div className="text-[9px] font-bold text-slate-300 mt-0.5">: {fmtKm(tv.totalMileage || 0)} km</div>
</div>
</button>
))}
</div> </div>
<div className="p-4 bg-slate-50 border-t border-slate-100"> <div className="p-4 bg-slate-50 border-t border-slate-100">