fix: bind mileage list to request context

This commit is contained in:
kkfluous
2026-08-07 18:30:49 +08:00
parent f8e926876c
commit d958f3b28f
2 changed files with 98 additions and 32 deletions
+1
View File
@@ -177,6 +177,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
- 里程考核归因车辆与 7 天趋势已提供独立加载、故障、空数据和重试状态;子请求失败时停止展示“0 台未达标”和“最新日变化 +0”等伪业务结果。 - 里程考核归因车辆与 7 天趋势已提供独立加载、故障、空数据和重试状态;子请求失败时停止展示“0 台未达标”和“最新日变化 +0”等伪业务结果。
- 里程考核车辆日期侧栏仅展示带目标和日期标识的成功快照;切换日期失败时保留并标注上一成功日期,首次失败不再回退为当前日数据或伪造“0 辆”。 - 里程考核车辆日期侧栏仅展示带目标和日期标识的成功快照;切换日期失败时保留并标注上一成功日期,首次失败不再回退为当前日数据或伪造“0 辆”。
- 里程实时监控的首页、分页、全屏和导出已共用同一组筛选参数;全屏首次故障不再显示零值,刷新故障会保留并标注上一成功快照,导出成功或失败均提供可见结果和重试入口。 - 里程实时监控的首页、分页、全屏和导出已共用同一组筛选参数;全屏首次故障不再显示零值,刷新故障会保留并标注上一成功快照,导出成功或失败均提供可见结果和重试入口。
- 里程实时监控主列表快照已绑定完整筛选与排序口径;新口径请求失败时隐藏旧 KPI、趋势和车辆,原口径刷新失败时保留并标注旧快照,快速筛选与分页的过期响应不会覆盖当前结果。
- 已完成电能数据截至时间和实际趋势月展示。 - 已完成电能数据截至时间和实际趋势月展示。
- 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。 - 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。
- 已完成氢能结构化故障状态与重试体验。 - 已完成氢能结构化故障状态与重试体验。
+97 -32
View File
@@ -499,14 +499,14 @@ export default function MonitoringView() {
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const [pageLoading, setPageLoading] = useState(true); const [pageLoading, setPageLoading] = useState(true);
const [pageError, setPageError] = useState<{ message: string; scope: 'first' | 'more' } | null>(null); const [pageError, setPageError] = useState<{ message: string; scope: 'first' | 'more' } | null>(null);
const [pageSnapshotKey, setPageSnapshotKey] = useState<string | null>(null);
const [manualRefreshing, setManualRefreshing] = useState(false); const [manualRefreshing, setManualRefreshing] = useState(false);
const [dataUpdatedAt, setDataUpdatedAt] = useState<string | null>(null); const [dataUpdatedAt, setDataUpdatedAt] = useState<string | null>(null);
const [showBackToTop, setShowBackToTop] = useState(false); const [showBackToTop, setShowBackToTop] = useState(false);
const [relativeNow, setRelativeNow] = useState(() => Date.now()); const [relativeNow, setRelativeNow] = useState(() => Date.now());
const firstPageRequestRef = useRef(0);
const loadMoreRequestRef = useRef(0);
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
const detailVehicle = drillContext.level === 'vehicle' && drillContext.plate
? vehicles.find(vehicle => vehicle.plate === drillContext.plate) || null
: null;
const commitDrillContext = useCallback((next: MileageDrillContext, mode: 'push' | 'replace') => { const commitDrillContext = useCallback((next: MileageDrillContext, mode: 'push' | 'replace') => {
const url = buildMileageDrillUrl(window.location, next); const url = buildMileageDrillUrl(window.location, next);
@@ -553,11 +553,6 @@ export default function MonitoringView() {
const departments = filterOptions.departments; const departments = filterOptions.departments;
const plateNumbers = filterOptions.plates; const plateNumbers = filterOptions.plates;
const rangeLabel = normalizeRangeLabel(effectiveRange.start, effectiveRange.end);
const isRangeMode = !!effectiveRange.start && !!effectiveRange.end && effectiveRange.start !== effectiveRange.end;
const averageDailyKm = rangeDailyTotals.length > 0
? rangeDailyTotals.reduce((sum, item) => sum + item.totalKm, 0) / rangeDailyTotals.length
: 0;
const applyRangePreset = useCallback((preset: RangePreset) => { const applyRangePreset = useCallback((preset: RangePreset) => {
const range = getRangePreset(preset); const range = getRangePreset(preset);
setRangeStart(range.start); setRangeStart(range.start);
@@ -605,11 +600,31 @@ export default function MonitoringView() {
searchTerm, searchTerm,
sourcePriority, sourcePriority,
]); ]);
const monitoringFilterKey = useMemo(() => JSON.stringify(monitoringFilters), [monitoringFilters]); const monitoringRequestKey = useMemo(
const fullscreenHasSnapshot = fullscreenSnapshotKey === monitoringFilterKey; () => JSON.stringify({ ...monitoringFilters, sortBy, sortOrder }),
[monitoringFilters, sortBy, sortOrder],
);
const pageHasSnapshot = pageSnapshotKey === monitoringRequestKey;
const fullscreenHasSnapshot = fullscreenSnapshotKey === monitoringRequestKey;
const pageUpdatedAt = pageHasSnapshot ? dataUpdatedAt : null;
const detailVehicle = pageHasSnapshot && drillContext.level === 'vehicle' && drillContext.plate
? vehicles.find(vehicle => vehicle.plate === drillContext.plate) || null
: null;
const displayedRange = pageHasSnapshot
? effectiveRange
: { start: rangeStart, end: rangeEnd };
const rangeLabel = normalizeRangeLabel(displayedRange.start, displayedRange.end);
const isRangeMode = !!displayedRange.start && !!displayedRange.end && displayedRange.start !== displayedRange.end;
const averageDailyKm = pageHasSnapshot && rangeDailyTotals.length > 0
? rangeDailyTotals.reduce((sum, item) => sum + item.totalKm, 0) / rangeDailyTotals.length
: 0;
// 加载首页数据 // 加载首页数据
const loadFirstPage = useCallback((showPageLoading = true, force = false) => { const loadFirstPage = useCallback((showPageLoading = true, force = false) => {
const requestId = ++firstPageRequestRef.current;
const requestKey = monitoringRequestKey;
++loadMoreRequestRef.current;
setLoadingMore(false);
if (showPageLoading) setPageLoading(true); if (showPageLoading) setPageLoading(true);
setPageError(null); setPageError(null);
return fetchMonitoring({ return fetchMonitoring({
@@ -620,6 +635,7 @@ export default function MonitoringView() {
page: 1, page: 1,
force, force,
}).then(d => { }).then(d => {
if (requestId !== firstPageRequestRef.current) return;
setVehicles(d.vehicles); setVehicles(d.vehicles);
setStats(d.stats); setStats(d.stats);
setFilterOptions(d.filters); setFilterOptions(d.filters);
@@ -629,15 +645,17 @@ export default function MonitoringView() {
setPage(1); setPage(1);
setHasMore(d.page < d.totalPages); setHasMore(d.page < d.totalPages);
setDataUpdatedAt(d.updatedAt); setDataUpdatedAt(d.updatedAt);
setPageSnapshotKey(requestKey);
}).catch(error => { }).catch(error => {
if (requestId !== firstPageRequestRef.current) return;
setPageError({ setPageError({
message: error instanceof Error ? error.message : '里程数据加载失败,请稍后重试', message: error instanceof Error ? error.message : '里程数据加载失败,请稍后重试',
scope: 'first', scope: 'first',
}); });
}).finally(() => { }).finally(() => {
if (showPageLoading) setPageLoading(false); if (requestId === firstPageRequestRef.current) setPageLoading(false);
}); });
}, [monitoringFilters, rangeEnd, rangeStart, sortBy, sortOrder]); }, [monitoringFilters, monitoringRequestKey, rangeEnd, rangeStart, sortBy, sortOrder]);
const handleManualRefresh = useCallback(async () => { const handleManualRefresh = useCallback(async () => {
if (manualRefreshing) return; if (manualRefreshing) return;
@@ -653,6 +671,7 @@ export default function MonitoringView() {
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
if (loadingMore || !hasMore) return; if (loadingMore || !hasMore) return;
const nextPage = page + 1; const nextPage = page + 1;
const requestId = ++loadMoreRequestRef.current;
setLoadingMore(true); setLoadingMore(true);
setPageError(null); setPageError(null);
fetchMonitoring({ fetchMonitoring({
@@ -662,16 +681,20 @@ export default function MonitoringView() {
limit: PAGE_SIZE, limit: PAGE_SIZE,
page: nextPage, page: nextPage,
}).then(d => { }).then(d => {
if (requestId !== loadMoreRequestRef.current) return;
setVehicles(prev => [...prev, ...d.vehicles]); setVehicles(prev => [...prev, ...d.vehicles]);
setPage(nextPage); setPage(nextPage);
setHasMore(nextPage < d.totalPages); setHasMore(nextPage < d.totalPages);
setDataUpdatedAt(d.updatedAt); setDataUpdatedAt(d.updatedAt);
}).catch(error => { }).catch(error => {
if (requestId !== loadMoreRequestRef.current) return;
setPageError({ setPageError({
message: error instanceof Error ? error.message : '更多车辆加载失败,请稍后重试', message: error instanceof Error ? error.message : '更多车辆加载失败,请稍后重试',
scope: 'more', scope: 'more',
}); });
}).finally(() => setLoadingMore(false)); }).finally(() => {
if (requestId === loadMoreRequestRef.current) setLoadingMore(false);
});
}, [hasMore, loadingMore, monitoringFilters, page, sortBy, sortOrder]); }, [hasMore, loadingMore, monitoringFilters, page, sortBy, sortOrder]);
// 筛选/排序变化时重新加载 // 筛选/排序变化时重新加载
@@ -737,7 +760,7 @@ export default function MonitoringView() {
document.body.scrollTop = 0; document.body.scrollTop = 0;
}; };
const filteredVehicles = vehicles; const filteredVehicles = pageHasSnapshot ? vehicles : [];
const toggleFullscreen = () => { const toggleFullscreen = () => {
setIsFullscreen(!isFullscreen); setIsFullscreen(!isFullscreen);
@@ -760,9 +783,8 @@ export default function MonitoringView() {
setFullscreenVehicles(d.vehicles); setFullscreenVehicles(d.vehicles);
setFullscreenStats(d.stats); setFullscreenStats(d.stats);
setFilterOptions(d.filters); setFilterOptions(d.filters);
setDataUpdatedAt(d.updatedAt);
setFullscreenUpdatedAt(d.updatedAt); setFullscreenUpdatedAt(d.updatedAt);
setFullscreenSnapshotKey(monitoringFilterKey); setFullscreenSnapshotKey(monitoringRequestKey);
}).catch(error => { }).catch(error => {
if (cancelled) return; if (cancelled) return;
setFullscreenError(error instanceof Error ? error.message : '全屏监控加载失败,请稍后重试'); setFullscreenError(error instanceof Error ? error.message : '全屏监控加载失败,请稍后重试');
@@ -770,7 +792,7 @@ export default function MonitoringView() {
if (!cancelled) setFullscreenLoading(false); if (!cancelled) setFullscreenLoading(false);
}); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [fullscreenRefresh, isFullscreen, monitoringFilterKey, monitoringFilters, sortBy, sortOrder]); }, [fullscreenRefresh, isFullscreen, monitoringFilters, monitoringRequestKey, sortBy, sortOrder]);
// 全屏时禁止背景滚动 // 全屏时禁止背景滚动
useEffect(() => { useEffect(() => {
@@ -1143,9 +1165,9 @@ export default function MonitoringView() {
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" /> <span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
<span <span
className="truncate text-[9px] font-bold tracking-tight text-slate-400" className="truncate text-[9px] font-bold tracking-tight text-slate-400"
title={`OneOS 分钟级快照 · 数据截至 ${formatMileageSnapshotTime(dataUpdatedAt)}`} title={`OneOS 分钟级快照 · 数据截至 ${formatMileageSnapshotTime(pageUpdatedAt)}`}
> >
OneOS · {formatMileageSnapshotTime(dataUpdatedAt)} OneOS · {formatMileageSnapshotTime(pageUpdatedAt)}
</span> </span>
</div> </div>
</div> </div>
@@ -1153,7 +1175,7 @@ export default function MonitoringView() {
<div className="flex flex-shrink-0 items-center gap-1.5"> <div className="flex flex-shrink-0 items-center gap-1.5">
<span className="hidden text-[9px] font-bold tabular-nums text-slate-400 sm:inline"> <span className="hidden text-[9px] font-bold tabular-nums text-slate-400 sm:inline">
{formatMileageSnapshotTime(dataUpdatedAt)} {formatMileageSnapshotTime(pageUpdatedAt)}
</span> </span>
<button <button
type="button" type="button"
@@ -1508,30 +1530,73 @@ export default function MonitoringView() {
); );
})()} })()}
{pageError?.scope === 'first' && (
<div
role="alert"
className={`flex items-center justify-between gap-3 rounded-xl border px-3 py-2 ${
pageHasSnapshot
? 'border-amber-200 bg-amber-50 text-amber-700'
: 'border-rose-200 bg-rose-50 text-rose-700'
}`}
>
<div className="min-w-0">
<p className="truncate text-[10px] font-black" title={pageError.message}>
{pageHasSnapshot
? `刷新失败,仍展示截至 ${formatMileageSnapshotTime(pageUpdatedAt)} 的数据`
: '当前筛选数据暂不可用'}
</p>
{!pageHasSnapshot && <p className="mt-0.5 truncate text-[9px] opacity-70">{pageError.message}</p>}
</div>
<button
type="button"
onClick={() => pageHasSnapshot ? handleManualRefresh() : loadFirstPage()}
disabled={pageLoading || manualRefreshing}
className="inline-flex h-7 shrink-0 items-center gap-1 rounded-lg border border-current/20 bg-white/70 px-2 text-[9px] font-black disabled:opacity-50"
>
<RefreshCw size={11} className={pageLoading || manualRefreshing ? 'animate-spin' : ''} />
</button>
</div>
)}
{/* Sticky header: KPI + 清单标题 */} {/* Sticky header: KPI + 清单标题 */}
<div className="sticky top-[44px] z-20 bg-[var(--app-bg)] pt-1 pb-1 space-y-2"> <div className="sticky top-[44px] z-20 bg-[var(--app-bg)] pt-1 pb-1 space-y-2">
<div className={`grid grid-cols-4 gap-2 transition-opacity ${pageLoading ? 'opacity-60' : ''}`}> <div className={`grid grid-cols-4 gap-2 transition-opacity ${pageLoading && pageHasSnapshot ? 'opacity-60' : ''}`}>
<div className="relative col-span-2 flex min-h-[68px] flex-col justify-center overflow-hidden rounded-xl bg-slate-900 p-2.5 text-white"> <div className="relative col-span-2 flex min-h-[68px] flex-col justify-center overflow-hidden rounded-xl bg-slate-900 p-2.5 text-white">
<div className="text-[7px] font-bold text-slate-500 uppercase tracking-wider">{sortBy === 'total' ? '累计' : (isRangeMode ? '区间' : '当日')}</div> <div className="text-[7px] font-bold text-slate-500 uppercase tracking-wider">{sortBy === 'total' ? '累计' : (isRangeMode ? '区间' : '当日')}</div>
<div className="text-lg font-black tracking-tighter leading-tight flex items-baseline gap-1"> <div className="text-lg font-black tracking-tighter leading-tight flex items-baseline gap-1">
{pageLoading ? <div className="h-5 w-20 bg-slate-700 rounded animate-pulse"></div> : <>{Math.round(sortBy === 'total' ? stats.totalAll : stats.totalToday).toLocaleString()} <span className="text-[8px] text-slate-400">km</span></>} {pageLoading && !pageHasSnapshot
? <div className="h-5 w-20 bg-slate-700 rounded animate-pulse"></div>
: <>{pageHasSnapshot ? Math.round(sortBy === 'total' ? stats.totalAll : stats.totalToday).toLocaleString() : '—'} <span className="text-[8px] text-slate-400">km</span></>}
</div> </div>
<div className="mt-0.5 truncate text-[8px] font-bold text-slate-500">{rangeLabel}</div> <div className="mt-0.5 truncate text-[8px] font-bold text-slate-500">{rangeLabel}</div>
</div> </div>
<div className="flex min-h-[68px] flex-col justify-center rounded-xl border border-gray-100 bg-white p-2.5 shadow-sm"> <div className="flex min-h-[68px] flex-col justify-center rounded-xl border border-gray-100 bg-white p-2.5 shadow-sm">
<div className="text-[7px] font-bold text-slate-400 uppercase"></div> <div className="text-[7px] font-bold text-slate-400 uppercase"></div>
<div className="text-sm font-black text-slate-800 leading-tight">{pageLoading ? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div> : (stats.vehicleCount > 0 ? (sortBy === 'total' ? stats.totalAll : stats.totalToday) / stats.vehicleCount : 0).toFixed(0)}</div> <div className="text-sm font-black text-slate-800 leading-tight">
{pageLoading && !pageHasSnapshot
? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div>
: pageHasSnapshot ? (stats.vehicleCount > 0 ? (sortBy === 'total' ? stats.totalAll : stats.totalToday) / stats.vehicleCount : 0).toFixed(0) : '—'}
</div>
<div className="text-[7px] text-slate-400">km/</div> <div className="text-[7px] text-slate-400">km/</div>
</div> </div>
<div className="flex min-h-[68px] flex-col justify-center rounded-xl border border-gray-100 bg-white p-2.5 shadow-sm"> <div className="flex min-h-[68px] flex-col justify-center rounded-xl border border-gray-100 bg-white p-2.5 shadow-sm">
<div className="text-[7px] font-bold text-slate-400 uppercase"></div> <div className="text-[7px] font-bold text-slate-400 uppercase"></div>
<div className="text-sm font-black text-slate-800 leading-tight">{pageLoading ? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div> : stats.vehicleCount}</div> <div className="text-sm font-black text-slate-800 leading-tight">
{pageLoading && !pageHasSnapshot
? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div>
: pageHasSnapshot ? stats.vehicleCount : '—'}
</div>
<div className="text-[7px] text-slate-400"></div> <div className="text-[7px] text-slate-400"></div>
</div> </div>
</div> </div>
<div className="rounded-xl border border-slate-100 bg-white shadow-sm overflow-hidden"> <div className="rounded-xl border border-slate-100 bg-white shadow-sm overflow-hidden">
{pageLoading ? ( {pageLoading && !pageHasSnapshot ? (
<div className="h-[74px] bg-slate-50 animate-pulse" /> <div className="h-[74px] bg-slate-50 animate-pulse" />
) : !pageHasSnapshot ? (
<div className="flex h-[74px] items-center justify-center px-3 text-[10px] font-bold text-slate-400">
</div>
) : isRangeMode ? ( ) : isRangeMode ? (
<div className="grid grid-cols-[92px_minmax(0,1fr)_62px] items-center gap-2 px-2 py-2"> <div className="grid grid-cols-[92px_minmax(0,1fr)_62px] items-center gap-2 px-2 py-2">
<div className="min-w-0"> <div className="min-w-0">
@@ -1589,7 +1654,7 @@ export default function MonitoringView() {
<div className="flex items-center justify-between px-1"> <div className="flex items-center justify-between px-1">
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest"></span> <span className="text-[9px] font-black text-slate-400 uppercase tracking-widest"></span>
<span className="text-[9px] font-bold text-slate-400"> <span className="text-[9px] font-bold text-slate-400">
{filteredVehicles.length} / {total} {pageHasSnapshot ? `已加载 ${filteredVehicles.length} / 共 ${total}` : '数据不可用'}
</span> </span>
</div> </div>
<div className="hidden grid-cols-[minmax(190px,1.1fr)_minmax(160px,1fr)_130px_130px] items-center gap-3 rounded-lg border border-slate-200 bg-white px-4 py-2 text-[10px] font-bold text-slate-400 md:grid"> <div className="hidden grid-cols-[minmax(190px,1.1fr)_minmax(160px,1fr)_130px_130px] items-center gap-3 rounded-lg border border-slate-200 bg-white px-4 py-2 text-[10px] font-bold text-slate-400 md:grid">
@@ -1603,7 +1668,7 @@ export default function MonitoringView() {
{/* Vehicle List */} {/* Vehicle List */}
<div className="space-y-1.5"> <div className="space-y-1.5">
{pageLoading && ( {pageLoading && !pageHasSnapshot && (
<div className="space-y-1.5"> <div className="space-y-1.5">
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="bg-white px-3 py-3 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between animate-pulse"> <div key={i} className="bg-white px-3 py-3 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between animate-pulse">
@@ -1705,18 +1770,18 @@ export default function MonitoringView() {
})} })}
</div> </div>
{filteredVehicles.length === 0 && !loadingMore && !pageError && ( {pageHasSnapshot && filteredVehicles.length === 0 && !loadingMore && !pageError && (
<div className="py-10 text-center bg-white rounded-2xl border border-dashed border-slate-100"> <div className="py-10 text-center bg-white rounded-2xl border border-dashed border-slate-100">
<p className="text-xs font-bold text-slate-300"></p> <p className="text-xs font-bold text-slate-300"></p>
</div> </div>
)} )}
{pageError && ( {pageError?.scope === 'more' && pageHasSnapshot && (
<div className="rounded-xl border border-rose-100 bg-rose-50 px-3 py-3 text-center"> <div className="rounded-xl border border-rose-100 bg-rose-50 px-3 py-3 text-center">
<p className="text-[10px] font-bold text-rose-600">{pageError.message}</p> <p className="text-[10px] font-bold text-rose-600">{pageError.message}</p>
<button <button
type="button" type="button"
onClick={() => pageError.scope === 'more' ? loadMore() : loadFirstPage()} onClick={loadMore}
disabled={pageLoading || loadingMore} disabled={pageLoading || loadingMore}
className="mt-2 inline-flex h-8 items-center gap-1.5 rounded-lg border border-rose-200 bg-white px-3 text-[10px] font-black text-rose-700 transition-colors hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-60" className="mt-2 inline-flex h-8 items-center gap-1.5 rounded-lg border border-rose-200 bg-white px-3 text-[10px] font-black text-rose-700 transition-colors hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-60"
> >
@@ -1725,7 +1790,7 @@ export default function MonitoringView() {
</button> </button>
</div> </div>
)} )}
{hasMore && !pageError && filteredVehicles.length > 0 && ( {pageHasSnapshot && hasMore && !pageError && filteredVehicles.length > 0 && (
<div className="flex flex-col items-center gap-1.5 py-4"> <div className="flex flex-col items-center gap-1.5 py-4">
<button <button
type="button" type="button"
@@ -1741,7 +1806,7 @@ export default function MonitoringView() {
</span> </span>
</div> </div>
)} )}
{!hasMore && filteredVehicles.length > 0 && ( {pageHasSnapshot && !hasMore && filteredVehicles.length > 0 && (
<div className="py-4 text-center"> <div className="py-4 text-center">
<span className="text-[10px] font-bold text-slate-300"> {total} </span> <span className="text-[10px] font-bold text-slate-300"> {total} </span>
</div> </div>