fix: make mileage fullscreen failures visible

This commit is contained in:
kkfluous
2026-08-07 18:23:50 +08:00
parent 6bf88a25e3
commit f8e926876c
2 changed files with 162 additions and 75 deletions
+1
View File
@@ -176,6 +176,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
- 里程考核统计主数据已区分加载、真实空数据、首次请求故障和保留旧数据的刷新故障,并提供统一重试入口,接口失败不再显示为零值或空页面。
- 里程考核归因车辆与 7 天趋势已提供独立加载、故障、空数据和重试状态;子请求失败时停止展示“0 台未达标”和“最新日变化 +0”等伪业务结果。
- 里程考核车辆日期侧栏仅展示带目标和日期标识的成功快照;切换日期失败时保留并标注上一成功日期,首次失败不再回退为当前日数据或伪造“0 辆”。
- 里程实时监控的首页、分页、全屏和导出已共用同一组筛选参数;全屏首次故障不再显示零值,刷新故障会保留并标注上一成功快照,导出成功或失败均提供可见结果和重试入口。
- 已完成电能数据截至时间和实际趋势月展示。
- 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。
- 已完成氢能结构化故障状态与重试体验。
+154 -68
View File
@@ -31,6 +31,7 @@ const HIGH_MILEAGE_ALERT_TARGETS = new Set([
]);
const HIGH_MILEAGE_ALERT_KM = 800;
const ALL_MILEAGE_SOURCE_GROUPS: MileageSourceGroup[] = ['instrument', 'gps'];
type MonitoringRequestParams = NonNullable<Parameters<typeof fetchMonitoring>[0]>;
const MILEAGE_SOURCE_META = {
instrument: { label: '仪表数据', protocol: null },
@@ -463,6 +464,9 @@ export default function MonitoringView() {
const [fullscreenStats, setFullscreenStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
const [fullscreenRefresh, setFullscreenRefresh] = useState(0);
const [fullscreenLoading, setFullscreenLoading] = useState(false);
const [fullscreenError, setFullscreenError] = useState<string | null>(null);
const [fullscreenSnapshotKey, setFullscreenSnapshotKey] = useState<string | null>(null);
const [fullscreenUpdatedAt, setFullscreenUpdatedAt] = useState<string | null>(null);
// New filters from image
const [filterPlates, setFilterPlates] = useState<string[]>(() => (
@@ -479,6 +483,7 @@ export default function MonitoringView() {
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
const [exporting, setExporting] = useState(false);
const [exportStatus, setExportStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
const [rangeStart, setRangeStart] = useState(() => drillContext.startDate || defaultMileageDate());
const [rangeEnd, setRangeEnd] = useState(() => drillContext.endDate || defaultMileageDate());
const [sourcePriority, setSourcePriority] = useState<MileageSourceGroup[]>(drillContext.sourcePriority);
@@ -565,15 +570,7 @@ export default function MonitoringView() {
return inAlertTarget && Math.max(0, v.dailyKm || 0) >= HIGH_MILEAGE_ALERT_KM;
}, [filterTargetNames]);
// 加载首页数据
const loadFirstPage = useCallback((showPageLoading = true, force = false) => {
if (showPageLoading) setPageLoading(true);
setPageError(null);
return fetchMonitoring({
sortBy,
sortOrder,
limit: PAGE_SIZE,
page: 1,
const monitoringFilters = useMemo<MonitoringRequestParams>(() => ({
search: searchTerm || undefined,
dept: filterDept !== 'All' ? filterDept : undefined,
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
@@ -590,6 +587,37 @@ export default function MonitoringView() {
endDate: rangeEnd || undefined,
brands: filterBrands.length > 0 ? filterBrands : undefined,
sourcePriority,
}), [
appliedMileageRange.max,
appliedMileageRange.min,
filterBrands,
filterCustomer,
filterDept,
filterEntity,
filterPlatePrefix,
filterPlates,
filterProject,
filterRegion,
filterRentStatus,
filterTargetNames,
rangeEnd,
rangeStart,
searchTerm,
sourcePriority,
]);
const monitoringFilterKey = useMemo(() => JSON.stringify(monitoringFilters), [monitoringFilters]);
const fullscreenHasSnapshot = fullscreenSnapshotKey === monitoringFilterKey;
// 加载首页数据
const loadFirstPage = useCallback((showPageLoading = true, force = false) => {
if (showPageLoading) setPageLoading(true);
setPageError(null);
return fetchMonitoring({
...monitoringFilters,
sortBy,
sortOrder,
limit: PAGE_SIZE,
page: 1,
force,
}).then(d => {
setVehicles(d.vehicles);
@@ -609,7 +637,7 @@ export default function MonitoringView() {
}).finally(() => {
if (showPageLoading) setPageLoading(false);
});
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands, sourcePriority]);
}, [monitoringFilters, rangeEnd, rangeStart, sortBy, sortOrder]);
const handleManualRefresh = useCallback(async () => {
if (manualRefreshing) return;
@@ -628,26 +656,11 @@ export default function MonitoringView() {
setLoadingMore(true);
setPageError(null);
fetchMonitoring({
...monitoringFilters,
sortBy,
sortOrder,
limit: PAGE_SIZE,
page: nextPage,
search: searchTerm || undefined,
dept: filterDept !== 'All' ? filterDept : undefined,
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
project: filterProject !== 'All' ? filterProject : undefined,
entity: filterEntity !== 'All' ? filterEntity : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
region: filterRegion !== 'All' ? filterRegion : undefined,
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
mileageMin: appliedMileageRange.min || undefined,
mileageMax: appliedMileageRange.max || undefined,
startDate: rangeStart || undefined,
endDate: rangeEnd || undefined,
brands: filterBrands.length > 0 ? filterBrands : undefined,
sourcePriority,
}).then(d => {
setVehicles(prev => [...prev, ...d.vehicles]);
setPage(nextPage);
@@ -659,7 +672,7 @@ export default function MonitoringView() {
scope: 'more',
});
}).finally(() => setLoadingMore(false));
}, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, page, loadingMore, hasMore, filterBrands, sourcePriority]);
}, [hasMore, loadingMore, monitoringFilters, page, sortBy, sortOrder]);
// 筛选/排序变化时重新加载
useEffect(() => {
@@ -678,36 +691,27 @@ export default function MonitoringView() {
const handleDownload = useCallback(async () => {
if (exporting) return;
setExporting(true);
setExportStatus(null);
try {
const d = await fetchMonitoring({
...monitoringFilters,
sortBy,
sortOrder,
limit: 9999,
page: 1,
search: searchTerm || undefined,
dept: filterDept !== 'All' ? filterDept : undefined,
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
project: filterProject !== 'All' ? filterProject : undefined,
entity: filterEntity !== 'All' ? filterEntity : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
region: filterRegion !== 'All' ? filterRegion : undefined,
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
mileageMin: appliedMileageRange.min || undefined,
mileageMax: appliedMileageRange.max || undefined,
startDate: rangeStart || undefined,
endDate: rangeEnd || undefined,
brands: filterBrands.length > 0 ? filterBrands : undefined,
sourcePriority,
});
exportMileageXlsx(d.vehicles, { startDate: d.dateRange?.start || rangeStart, endDate: d.dateRange?.end || rangeEnd, sortBy });
setExportStatus({ type: 'success', message: `已导出 ${d.vehicles.length} 辆车辆` });
} catch (err) {
console.error('export failed', err);
setExportStatus({
type: 'error',
message: err instanceof Error ? err.message : '导出失败,请稍后重试',
});
} finally {
setExporting(false);
}
}, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands, sourcePriority]);
}, [exporting, monitoringFilters, rangeEnd, rangeStart, sortBy, sortOrder]);
// 每分钟自动刷新
useEffect(() => {
@@ -742,31 +746,31 @@ export default function MonitoringView() {
// 全屏时加载全部数据(无分页),筛选变化时重新加载
useEffect(() => {
if (!isFullscreen) return;
let cancelled = false;
setFullscreenLoading(true);
setFullscreenError(null);
fetchMonitoring({
...monitoringFilters,
sortBy,
sortOrder,
limit: 9999,
page: 1,
search: searchTerm || undefined,
dept: filterDept !== 'All' ? filterDept : undefined,
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
region: filterRegion !== 'All' ? filterRegion : undefined,
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
startDate: rangeStart || undefined,
endDate: rangeEnd || undefined,
brands: filterBrands.length > 0 ? filterBrands : undefined,
sourcePriority,
}).then(d => {
if (cancelled) return;
setFullscreenVehicles(d.vehicles);
setFullscreenStats(d.stats);
setFilterOptions(d.filters);
setDataUpdatedAt(d.updatedAt);
}).catch(() => {}).finally(() => setFullscreenLoading(false));
}, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, rangeStart, rangeEnd, fullscreenRefresh, filterBrands, sourcePriority]);
setFullscreenUpdatedAt(d.updatedAt);
setFullscreenSnapshotKey(monitoringFilterKey);
}).catch(error => {
if (cancelled) return;
setFullscreenError(error instanceof Error ? error.message : '全屏监控加载失败,请稍后重试');
}).finally(() => {
if (!cancelled) setFullscreenLoading(false);
});
return () => { cancelled = true; };
}, [fullscreenRefresh, isFullscreen, monitoringFilterKey, monitoringFilters, sortBy, sortOrder]);
// 全屏时禁止背景滚动
useEffect(() => {
@@ -804,6 +808,9 @@ export default function MonitoringView() {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
role="dialog"
aria-modal="true"
aria-label="里程全屏监控"
className="fixed z-[100] bg-slate-950 flex flex-col overflow-hidden"
style={
forceLandscape
@@ -827,13 +834,13 @@ export default function MonitoringView() {
<h2 className="text-white font-bold text-xs"></h2>
</div>
<div className="flex items-center gap-3 text-[10px]">
<span className="text-slate-500"> <span className="text-white font-black">{Math.round(fullscreenStats.totalToday).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-500"> <span className="text-white font-black">{fullscreenHasSnapshot ? Math.round(fullscreenStats.totalToday).toLocaleString() : '—'}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-700">|</span>
<span className="text-slate-500"> <span className="text-white font-black">{Math.round(fullscreenStats.totalAll).toLocaleString()}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-500"> <span className="text-white font-black">{fullscreenHasSnapshot ? Math.round(fullscreenStats.totalAll).toLocaleString() : '—'}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-700">|</span>
<span className="text-slate-500"> <span className="text-white font-black">{fullscreenStats.vehicleCount}</span> </span>
<span className="text-slate-500"> <span className="text-white font-black">{fullscreenHasSnapshot ? fullscreenStats.vehicleCount : '—'}</span> </span>
<span className="text-slate-700">|</span>
<span className="text-slate-500"> <span className="text-white font-black">{(fullscreenStats.vehicleCount > 0 ? (sortBy === 'total' ? fullscreenStats.totalAll : fullscreenStats.totalToday) / fullscreenStats.vehicleCount : 0).toFixed(0)}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-500"> <span className="text-white font-black">{fullscreenHasSnapshot ? (fullscreenStats.vehicleCount > 0 ? (sortBy === 'total' ? fullscreenStats.totalAll : fullscreenStats.totalToday) / fullscreenStats.vehicleCount : 0).toFixed(0) : '—'}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-700">|</span>
<span className="text-slate-500">
<span className="text-white font-black">
@@ -845,11 +852,14 @@ export default function MonitoringView() {
<div className="flex items-center gap-2">
<button
onClick={() => { setFullscreenRefresh(n => n + 1); }}
disabled={fullscreenLoading}
aria-label={fullscreenLoading ? '正在刷新全屏监控' : '刷新全屏监控'}
title={fullscreenLoading ? '刷新中' : '刷新全屏监控'}
className={`p-1.5 text-slate-500 hover:text-blue-400 transition-colors ${fullscreenLoading ? 'animate-spin' : ''}`}
>
<RotateCcw size={13} />
</button>
<button onClick={toggleFullscreen} className="p-1.5 text-slate-500 hover:text-white transition-colors">
<button onClick={toggleFullscreen} aria-label="退出全屏监控" title="退出全屏" className="p-1.5 text-slate-500 hover:text-white transition-colors">
<Minimize2 size={14} />
</button>
</div>
@@ -879,9 +889,24 @@ export default function MonitoringView() {
{/* Table Area */}
<div className="flex-1 overflow-hidden flex flex-col">
{fullscreenError && fullscreenHasSnapshot && (
<div role="alert" className="flex flex-shrink-0 items-center justify-between gap-3 border-b border-amber-500/20 bg-amber-500/10 px-3 py-1.5 text-[10px] font-bold text-amber-300">
<span className="truncate" title={fullscreenError}>
{formatMileageSnapshotTime(fullscreenUpdatedAt)}
</span>
<button
type="button"
onClick={() => setFullscreenRefresh(value => value + 1)}
disabled={fullscreenLoading}
className="shrink-0 text-amber-200 underline underline-offset-2 disabled:opacity-50"
>
</button>
</div>
)}
<div className="flex-1 overflow-auto relative">
{fullscreenLoading && (
{fullscreenLoading && fullscreenHasSnapshot && (
<div className="absolute inset-0 bg-slate-950/60 z-20 flex items-center justify-center">
<div className="flex items-center gap-2 text-slate-400 text-xs font-bold">
<RotateCcw size={14} className="animate-spin" />
@@ -889,7 +914,29 @@ export default function MonitoringView() {
</div>
</div>
)}
<table className="w-full text-left border-collapse">
{!fullscreenHasSnapshot && (
<div className="flex min-h-full items-center justify-center px-6 py-20">
{fullscreenLoading ? (
<div className="flex items-center gap-2 text-xs font-bold text-slate-400" role="status">
<RotateCcw size={14} className="animate-spin" />
...
</div>
) : (
<div className="max-w-md text-center" role="alert">
<p className="text-sm font-bold text-rose-300"></p>
<p className="mt-1 text-[10px] text-slate-500">{fullscreenError || '未取得当前筛选条件下的数据'}</p>
<button
type="button"
onClick={() => setFullscreenRefresh(value => value + 1)}
className="mt-3 rounded border border-slate-700 px-3 py-1.5 text-[10px] font-bold text-slate-300 hover:border-blue-500 hover:text-blue-300"
>
</button>
</div>
)}
</div>
)}
{fullscreenHasSnapshot && <table className="w-full text-left border-collapse">
<thead className="sticky top-0 bg-slate-900 z-10">
<tr className="border-b border-slate-800/60">
<th className="px-3 py-2 text-[10px] font-bold text-slate-500 uppercase w-12 text-center"></th>
@@ -1017,6 +1064,13 @@ export default function MonitoringView() {
</tr>
</thead>
<tbody className="divide-y divide-slate-800/30">
{fullscreenVehicles.length === 0 && (
<tr>
<td colSpan={8} className="px-3 py-20 text-center text-xs font-bold text-slate-500">
</td>
</tr>
)}
{fullscreenVehicles.map((v) => {
const highMileageAlert = isHighMileageAlert(v);
const statisticTime = vehicleStatisticTime(v, relativeNow);
@@ -1058,7 +1112,7 @@ export default function MonitoringView() {
);
})}
</tbody>
</table>
</table>}
</div>
</div>
</motion.div>
@@ -1072,10 +1126,16 @@ export default function MonitoringView() {
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="truncate text-lg font-black leading-none text-slate-900"></h1>
<button onClick={toggleFullscreen} className="p-1 text-slate-300 transition-colors hover:text-blue-600" title="全屏视图">
<button onClick={toggleFullscreen} aria-label="打开全屏监控" className="p-1 text-slate-300 transition-colors hover:text-blue-600" title="全屏视图">
<Maximize2 size={14} />
</button>
<button onClick={handleDownload} disabled={exporting} className="p-1 text-slate-300 transition-colors hover:text-blue-600 disabled:text-slate-200" title="下载当前筛选结果">
<button
onClick={handleDownload}
disabled={exporting}
aria-label={exporting ? '正在导出当前筛选结果' : '导出当前筛选结果'}
className="p-1 text-slate-300 transition-colors hover:text-blue-600 disabled:text-slate-200"
title={exporting ? '导出中' : '下载当前筛选结果'}
>
{exporting ? <RotateCcw size={14} className="animate-spin" /> : <Download size={14} />}
</button>
</div>
@@ -1120,6 +1180,32 @@ export default function MonitoringView() {
</div>
</div>
{exportStatus && (
<div
role={exportStatus.type === 'error' ? 'alert' : 'status'}
className={`flex items-center justify-between gap-3 border-t pt-2 text-[10px] font-bold ${
exportStatus.type === 'error'
? 'border-rose-100 text-rose-600'
: 'border-emerald-100 text-emerald-600'
}`}
>
<span className="flex min-w-0 items-center gap-1.5">
{exportStatus.type === 'success' && <Check size={12} className="shrink-0" />}
<span className="truncate" title={exportStatus.message}>{exportStatus.message}</span>
</span>
{exportStatus.type === 'error' && (
<button
type="button"
onClick={handleDownload}
disabled={exporting}
className="shrink-0 underline underline-offset-2 disabled:opacity-50"
>
</button>
)}
</div>
)}
<SourcePriorityControl priority={sourcePriority} onChange={setSourcePriority} />
<div className="flex items-center gap-2">