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”等伪业务结果。
- 里程考核车辆日期侧栏仅展示带目标和日期标识的成功快照;切换日期失败时保留并标注上一成功日期,首次失败不再回退为当前日数据或伪造“0 辆”。
- 里程实时监控的首页、分页、全屏和导出已共用同一组筛选参数;全屏首次故障不再显示零值,刷新故障会保留并标注上一成功快照,导出成功或失败均提供可见结果和重试入口。
- 里程实时监控主列表快照已绑定完整筛选与排序口径;新口径请求失败时隐藏旧 KPI、趋势和车辆,原口径刷新失败时保留并标注旧快照,快速筛选与分页的过期响应不会覆盖当前结果。
- 已完成电能数据截至时间和实际趋势月展示。
- 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。
- 已完成氢能结构化故障状态与重试体验。
+97 -32
View File
@@ -499,14 +499,14 @@ export default function MonitoringView() {
const [loadingMore, setLoadingMore] = useState(false);
const [pageLoading, setPageLoading] = useState(true);
const [pageError, setPageError] = useState<{ message: string; scope: 'first' | 'more' } | null>(null);
const [pageSnapshotKey, setPageSnapshotKey] = useState<string | null>(null);
const [manualRefreshing, setManualRefreshing] = useState(false);
const [dataUpdatedAt, setDataUpdatedAt] = useState<string | null>(null);
const [showBackToTop, setShowBackToTop] = useState(false);
const [relativeNow, setRelativeNow] = useState(() => Date.now());
const firstPageRequestRef = useRef(0);
const loadMoreRequestRef = useRef(0);
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 url = buildMileageDrillUrl(window.location, next);
@@ -553,11 +553,6 @@ export default function MonitoringView() {
const departments = filterOptions.departments;
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 range = getRangePreset(preset);
setRangeStart(range.start);
@@ -605,11 +600,31 @@ export default function MonitoringView() {
searchTerm,
sourcePriority,
]);
const monitoringFilterKey = useMemo(() => JSON.stringify(monitoringFilters), [monitoringFilters]);
const fullscreenHasSnapshot = fullscreenSnapshotKey === monitoringFilterKey;
const monitoringRequestKey = useMemo(
() => 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 requestId = ++firstPageRequestRef.current;
const requestKey = monitoringRequestKey;
++loadMoreRequestRef.current;
setLoadingMore(false);
if (showPageLoading) setPageLoading(true);
setPageError(null);
return fetchMonitoring({
@@ -620,6 +635,7 @@ export default function MonitoringView() {
page: 1,
force,
}).then(d => {
if (requestId !== firstPageRequestRef.current) return;
setVehicles(d.vehicles);
setStats(d.stats);
setFilterOptions(d.filters);
@@ -629,15 +645,17 @@ export default function MonitoringView() {
setPage(1);
setHasMore(d.page < d.totalPages);
setDataUpdatedAt(d.updatedAt);
setPageSnapshotKey(requestKey);
}).catch(error => {
if (requestId !== firstPageRequestRef.current) return;
setPageError({
message: error instanceof Error ? error.message : '里程数据加载失败,请稍后重试',
scope: 'first',
});
}).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 () => {
if (manualRefreshing) return;
@@ -653,6 +671,7 @@ export default function MonitoringView() {
const loadMore = useCallback(() => {
if (loadingMore || !hasMore) return;
const nextPage = page + 1;
const requestId = ++loadMoreRequestRef.current;
setLoadingMore(true);
setPageError(null);
fetchMonitoring({
@@ -662,16 +681,20 @@ export default function MonitoringView() {
limit: PAGE_SIZE,
page: nextPage,
}).then(d => {
if (requestId !== loadMoreRequestRef.current) return;
setVehicles(prev => [...prev, ...d.vehicles]);
setPage(nextPage);
setHasMore(nextPage < d.totalPages);
setDataUpdatedAt(d.updatedAt);
}).catch(error => {
if (requestId !== loadMoreRequestRef.current) return;
setPageError({
message: error instanceof Error ? error.message : '更多车辆加载失败,请稍后重试',
scope: 'more',
});
}).finally(() => setLoadingMore(false));
}).finally(() => {
if (requestId === loadMoreRequestRef.current) setLoadingMore(false);
});
}, [hasMore, loadingMore, monitoringFilters, page, sortBy, sortOrder]);
// 筛选/排序变化时重新加载
@@ -737,7 +760,7 @@ export default function MonitoringView() {
document.body.scrollTop = 0;
};
const filteredVehicles = vehicles;
const filteredVehicles = pageHasSnapshot ? vehicles : [];
const toggleFullscreen = () => {
setIsFullscreen(!isFullscreen);
@@ -760,9 +783,8 @@ export default function MonitoringView() {
setFullscreenVehicles(d.vehicles);
setFullscreenStats(d.stats);
setFilterOptions(d.filters);
setDataUpdatedAt(d.updatedAt);
setFullscreenUpdatedAt(d.updatedAt);
setFullscreenSnapshotKey(monitoringFilterKey);
setFullscreenSnapshotKey(monitoringRequestKey);
}).catch(error => {
if (cancelled) return;
setFullscreenError(error instanceof Error ? error.message : '全屏监控加载失败,请稍后重试');
@@ -770,7 +792,7 @@ export default function MonitoringView() {
if (!cancelled) setFullscreenLoading(false);
});
return () => { cancelled = true; };
}, [fullscreenRefresh, isFullscreen, monitoringFilterKey, monitoringFilters, sortBy, sortOrder]);
}, [fullscreenRefresh, isFullscreen, monitoringFilters, monitoringRequestKey, sortBy, sortOrder]);
// 全屏时禁止背景滚动
useEffect(() => {
@@ -1143,9 +1165,9 @@ export default function MonitoringView() {
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
<span
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>
</div>
</div>
@@ -1153,7 +1175,7 @@ export default function MonitoringView() {
<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">
{formatMileageSnapshotTime(dataUpdatedAt)}
{formatMileageSnapshotTime(pageUpdatedAt)}
</span>
<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 + 清单标题 */}
<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="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">
{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 className="mt-0.5 truncate text-[8px] font-bold text-slate-500">{rangeLabel}</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="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>
<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-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>
</div>
<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" />
) : !pageHasSnapshot ? (
<div className="flex h-[74px] items-center justify-center px-3 text-[10px] font-bold text-slate-400">
</div>
) : isRangeMode ? (
<div className="grid grid-cols-[92px_minmax(0,1fr)_62px] items-center gap-2 px-2 py-2">
<div className="min-w-0">
@@ -1589,7 +1654,7 @@ export default function MonitoringView() {
<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-bold text-slate-400">
{filteredVehicles.length} / {total}
{pageHasSnapshot ? `已加载 ${filteredVehicles.length} / 共 ${total}` : '数据不可用'}
</span>
</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">
@@ -1603,7 +1668,7 @@ export default function MonitoringView() {
{/* Vehicle List */}
<div className="space-y-1.5">
{pageLoading && (
{pageLoading && !pageHasSnapshot && (
<div className="space-y-1.5">
{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">
@@ -1705,18 +1770,18 @@ export default function MonitoringView() {
})}
</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">
<p className="text-xs font-bold text-slate-300"></p>
</div>
)}
{pageError && (
{pageError?.scope === 'more' && pageHasSnapshot && (
<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>
<button
type="button"
onClick={() => pageError.scope === 'more' ? loadMore() : loadFirstPage()}
onClick={loadMore}
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"
>
@@ -1725,7 +1790,7 @@ export default function MonitoringView() {
</button>
</div>
)}
{hasMore && !pageError && filteredVehicles.length > 0 && (
{pageHasSnapshot && hasMore && !pageError && filteredVehicles.length > 0 && (
<div className="flex flex-col items-center gap-1.5 py-4">
<button
type="button"
@@ -1741,7 +1806,7 @@ export default function MonitoringView() {
</span>
</div>
)}
{!hasMore && filteredVehicles.length > 0 && (
{pageHasSnapshot && !hasMore && filteredVehicles.length > 0 && (
<div className="py-4 text-center">
<span className="text-[10px] font-bold text-slate-300"> {total} </span>
</div>