fix: distinguish mileage statistics failures
This commit is contained in:
@@ -172,6 +172,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
|
|||||||
- 里程年度归因使用考核台账 `current_mileage` 快照,按车辆目标封顶后加总完成量与缺口,并自动核对归因合计和目标汇总;带日期的 OneOS 里程只用于车辆实时明细。
|
- 里程年度归因使用考核台账 `current_mileage` 快照,按车辆目标封顶后加总完成量与缺口,并自动核对归因合计和目标汇总;带日期的 OneOS 里程只用于车辆实时明细。
|
||||||
- 里程考核目标接口发布车辆台账 `MAX(update_time)`,统计页使用真实日期和时间展示数据截至点,不再用页面访问日或考核结束日代替快照时间。
|
- 里程考核目标接口发布车辆台账 `MAX(update_time)`,统计页使用真实日期和时间展示数据截至点,不再用页面访问日或考核结束日代替快照时间。
|
||||||
- 里程考核接口区分目标声明台数与当前有效车辆数;两者不一致时页面明确标注缺少或超配数量,并说明完成率、缺口和归因的实际计算范围。
|
- 里程考核接口区分目标声明台数与当前有效车辆数;两者不一致时页面明确标注缺少或超配数量,并说明完成率、缺口和归因的实际计算范围。
|
||||||
|
- 里程考核统计主数据已区分加载、真实空数据、首次请求故障和保留旧数据的刷新故障,并提供统一重试入口,接口失败不再显示为零值或空页面。
|
||||||
- 已完成电能数据截至时间和实际趋势月展示。
|
- 已完成电能数据截至时间和实际趋势月展示。
|
||||||
- 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。
|
- 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。
|
||||||
- 已完成氢能结构化故障状态与重试体验。
|
- 已完成氢能结构化故障状态与重试体验。
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint } from './types';
|
import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint } from './types';
|
||||||
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
|
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
|
||||||
import Blur from '../../components/Blur';
|
import Blur from '../../components/Blur';
|
||||||
|
import { EmptyState, ErrorState, LoadingState } from '../../components/ui/surface';
|
||||||
import { weightedCompletionRate } from '../../shared/analytics/metrics';
|
import { weightedCompletionRate } from '../../shared/analytics/metrics';
|
||||||
import { formatMileageSnapshotDateTime } from './data-freshness';
|
import { formatMileageSnapshotDateTime } from './data-freshness';
|
||||||
import { assessVehicleCoverage } from './assessment-coverage';
|
import { assessVehicleCoverage } from './assessment-coverage';
|
||||||
@@ -64,11 +65,21 @@ function shortTargetName(name: string): string {
|
|||||||
return `${count}台${desc}`;
|
return `${count}台${desc}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRequestErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error && error.message && error.message !== 'Failed to fetch') {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return '无法获取考核目标数据,请检查服务连接后重试。';
|
||||||
|
}
|
||||||
|
|
||||||
export default function StatisticsView() {
|
export default function StatisticsView() {
|
||||||
const [drillContext, setDrillContext] = useState<MileageDrillContext>(() => (
|
const [drillContext, setDrillContext] = useState<MileageDrillContext>(() => (
|
||||||
parseMileageDrillContext(window.location.search)
|
parseMileageDrillContext(window.location.search)
|
||||||
));
|
));
|
||||||
const [targets, setTargets] = useState<TargetSummary[]>([]);
|
const [targets, setTargets] = useState<TargetSummary[]>([]);
|
||||||
|
const [targetsLoading, setTargetsLoading] = useState(true);
|
||||||
|
const [targetsError, setTargetsError] = useState<string | null>(null);
|
||||||
|
const [targetsRequestVersion, setTargetsRequestVersion] = useState(0);
|
||||||
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
||||||
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
|
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
|
||||||
const [selectedTargetId, setSelectedTargetId] = useState<number | null>(drillContext.targetId || null);
|
const [selectedTargetId, setSelectedTargetId] = useState<number | null>(drillContext.targetId || null);
|
||||||
@@ -124,6 +135,10 @@ export default function StatisticsView() {
|
|||||||
setDrillContext(next);
|
setDrillContext(next);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const retryTargets = useCallback(() => {
|
||||||
|
setTargetsRequestVersion(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);
|
||||||
@@ -215,9 +230,13 @@ export default function StatisticsView() {
|
|||||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||||
}, [selectedAssessmentYear, selectedTargetId, viewAllDate, viewAllTargetId]);
|
}, [selectedAssessmentYear, selectedTargetId, viewAllDate, viewAllTargetId]);
|
||||||
|
|
||||||
// Load targets on mount
|
// Load targets on mount and through the shared retry path.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setTargetsLoading(true);
|
||||||
|
setTargetsError(null);
|
||||||
fetchTargets().then(data => {
|
fetchTargets().then(data => {
|
||||||
|
if (cancelled) return;
|
||||||
const initialContext = initialDrillContextRef.current;
|
const initialContext = initialDrillContextRef.current;
|
||||||
const focused = data.find(item => item.targetName.includes('羚牛136')) || data[0];
|
const focused = data.find(item => item.targetName.includes('羚牛136')) || data[0];
|
||||||
const ordered = focused
|
const ordered = focused
|
||||||
@@ -242,9 +261,20 @@ export default function StatisticsView() {
|
|||||||
setDrillContext(normalizedContext);
|
setDrillContext(normalizedContext);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
setSelectedTargetId(null);
|
||||||
|
setExpandedTargetId(null);
|
||||||
|
setAssessmentYearMap({});
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(error => {
|
||||||
}, []);
|
if (!cancelled) setTargetsError(getRequestErrorMessage(error));
|
||||||
|
}).finally(() => {
|
||||||
|
if (!cancelled) setTargetsLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [targetsRequestVersion]);
|
||||||
|
|
||||||
// Load trend when selectedTargetId changes
|
// Load trend when selectedTargetId changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -307,6 +337,30 @@ export default function StatisticsView() {
|
|||||||
))
|
))
|
||||||
), [viewAllSearch, viewAllVehicles]);
|
), [viewAllSearch, viewAllVehicles]);
|
||||||
|
|
||||||
|
if (targetsLoading && targets.length === 0) {
|
||||||
|
return <LoadingState label="正在加载考核统计" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetsError && targets.length === 0) {
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
title="考核统计加载失败"
|
||||||
|
message={targetsError}
|
||||||
|
onRetry={retryTargets}
|
||||||
|
retrying={targetsLoading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetsLoading && targets.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="暂无考核目标"
|
||||||
|
description="当前权限范围内没有有效考核目标"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 pb-2 landscape:pb-4 landscape:h-full landscape:overflow-hidden landscape:flex landscape:flex-col flex-none landscape:flex-1 [overflow-anchor:none]" style={{ overflowX: 'clip' }}>
|
<div className="space-y-2 pb-2 landscape:pb-4 landscape:h-full landscape:overflow-hidden landscape:flex landscape:flex-col flex-none landscape:flex-1 [overflow-anchor:none]" style={{ overflowX: 'clip' }}>
|
||||||
{/* Project Selector */}
|
{/* Project Selector */}
|
||||||
@@ -326,6 +380,16 @@ export default function StatisticsView() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{targetsError && (
|
||||||
|
<ErrorState
|
||||||
|
title="考核统计刷新失败"
|
||||||
|
message={`${targetsError} 当前仍展示上一次成功加载的数据。`}
|
||||||
|
onRetry={retryTargets}
|
||||||
|
retrying={targetsLoading}
|
||||||
|
variant="inline"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{selectedTarget && (
|
{selectedTarget && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 8 }}
|
initial={{ opacity: 0, y: 8 }}
|
||||||
@@ -752,10 +816,14 @@ export default function StatisticsView() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => { fetchTargets().then(data => { setTargets(data); }).catch(() => {}); }}
|
type="button"
|
||||||
|
onClick={retryTargets}
|
||||||
|
disabled={targetsLoading}
|
||||||
|
title="刷新考核统计"
|
||||||
|
aria-label="刷新考核统计"
|
||||||
className="p-1.5 text-slate-500 hover:text-blue-400 transition-colors"
|
className="p-1.5 text-slate-500 hover:text-blue-400 transition-colors"
|
||||||
>
|
>
|
||||||
<RotateCcw size={13} />
|
<RotateCcw size={13} className={targetsLoading ? 'animate-spin' : ''} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsTableFullscreen(false)}
|
onClick={() => setIsTableFullscreen(false)}
|
||||||
@@ -766,6 +834,15 @@ export default function StatisticsView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{targetsError && (
|
||||||
|
<div role="alert" className="flex items-center justify-between gap-3 border-b border-rose-900/50 bg-rose-950/60 px-3 py-2 text-[10px] font-bold text-rose-200">
|
||||||
|
<span>{targetsError} 当前仍展示上一次成功加载的数据。</span>
|
||||||
|
<button type="button" onClick={retryTargets} disabled={targetsLoading} className="shrink-0 text-rose-100 underline disabled:opacity-60">
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Table Area */}
|
{/* Table Area */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<table className="w-full text-left border-collapse">
|
<table className="w-full text-left border-collapse">
|
||||||
|
|||||||
Reference in New Issue
Block a user