diff --git a/docs/bi-refactor-roadmap.md b/docs/bi-refactor-roadmap.md
index 50e88cb..b9f02d5 100644
--- a/docs/bi-refactor-roadmap.md
+++ b/docs/bi-refactor-roadmap.md
@@ -173,6 +173,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
- 里程考核目标接口发布车辆台账 `MAX(update_time)`,统计页使用真实日期和时间展示数据截至点,不再用页面访问日或考核结束日代替快照时间。
- 里程考核接口区分目标声明台数与当前有效车辆数;两者不一致时页面明确标注缺少或超配数量,并说明完成率、缺口和归因的实际计算范围。
- 里程考核统计主数据已区分加载、真实空数据、首次请求故障和保留旧数据的刷新故障,并提供统一重试入口,接口失败不再显示为零值或空页面。
+- 里程考核归因车辆与 7 天趋势已提供独立加载、故障、空数据和重试状态;子请求失败时停止展示“0 台未达标”和“最新日变化 +0”等伪业务结果。
- 已完成电能数据截至时间和实际趋势月展示。
- 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。
- 已完成氢能结构化故障状态与重试体验。
diff --git a/src/modules/mileage/MileageAttributionView.tsx b/src/modules/mileage/MileageAttributionView.tsx
index 2bf0e2c..37ba331 100644
--- a/src/modules/mileage/MileageAttributionView.tsx
+++ b/src/modules/mileage/MileageAttributionView.tsx
@@ -5,17 +5,20 @@ import {
reconcileMileageAttribution,
type AttributionDimension,
} from './attribution';
+import { ErrorState } from '../../components/ui/surface';
interface Props {
vehicles: TargetVehicle[];
dimension: AttributionDimension;
selectedValue?: string;
loading?: boolean;
+ error?: string | null;
targetMileagePerVehicle: number;
expectedShortfallMileage?: number;
assessmentLabel?: string;
onDimensionChange: (dimension: AttributionDimension) => void;
onSelect: (value: string) => void;
+ onRetry?: () => void;
}
function fmtKm(value: number): string {
@@ -27,11 +30,13 @@ export default function MileageAttributionView({
dimension,
selectedValue,
loading = false,
+ error,
targetMileagePerVehicle,
expectedShortfallMileage,
assessmentLabel,
onDimensionChange,
onSelect,
+ onRetry,
}: Props) {
const groups = buildMileageAttribution(vehicles, dimension, targetMileagePerVehicle);
const totalShortfall = groups.reduce((sum, group) => sum + group.shortfallMileage, 0);
@@ -49,9 +54,14 @@ export default function MileageAttributionView({
考核缺口归因
- {assessmentLabel ? `${assessmentLabel} · ` : ''}{totalBehind} 台未达标,累计缺口 {fmtKm(totalShortfall)} km
+ {assessmentLabel ? `${assessmentLabel} · ` : ''}
+ {loading
+ ? '正在加载归因车辆'
+ : error
+ ? '归因数据暂不可用'
+ : `${totalBehind} 台未达标,累计缺口 ${fmtKm(totalShortfall)} km`}
- {!loading && reconciliation && (
+ {!loading && !error && reconciliation && (
正在计算归因...
+ ) : error ? (
+
) : groups.length === 0 ? (
暂无可归因车辆
) : (
diff --git a/src/modules/mileage/StatisticsView.tsx b/src/modules/mileage/StatisticsView.tsx
index c43f351..2cd84b5 100644
--- a/src/modules/mileage/StatisticsView.tsx
+++ b/src/modules/mileage/StatisticsView.tsx
@@ -65,11 +65,11 @@ function shortTargetName(name: string): string {
return `${count}台${desc}`;
}
-function getRequestErrorMessage(error: unknown): string {
+function getRequestErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message && error.message !== 'Failed to fetch') {
return error.message;
}
- return '无法获取考核目标数据,请检查服务连接后重试。';
+ return fallback;
}
export default function StatisticsView() {
@@ -81,7 +81,12 @@ export default function StatisticsView() {
const [targetsError, setTargetsError] = useState(null);
const [targetsRequestVersion, setTargetsRequestVersion] = useState(0);
const [trendData, setTrendData] = useState([]);
+ const [trendLoading, setTrendLoading] = useState(false);
+ const [trendError, setTrendError] = useState(null);
+ const [trendRequestVersion, setTrendRequestVersion] = useState(0);
const [targetVehiclesMap, setTargetVehiclesMap] = useState>({});
+ const [targetVehicleLoadingMap, setTargetVehicleLoadingMap] = useState>({});
+ const [targetVehicleErrorMap, setTargetVehicleErrorMap] = useState>({});
const [selectedTargetId, setSelectedTargetId] = useState(drillContext.targetId || null);
const [attributionDimension, setAttributionDimension] = useState(() => (
drillContext.level === 'breakdown' && drillContext.dimension === 'customer' ? 'customer' : 'department'
@@ -139,12 +144,28 @@ export default function StatisticsView() {
setTargetsRequestVersion(version => version + 1);
}, []);
+ const retryTrend = useCallback(() => {
+ setTrendRequestVersion(version => version + 1);
+ }, []);
+
const loadCurrentTargetVehicles = useCallback((targetId: number) => {
if (targetVehicleRequestsRef.current.has(targetId)) return;
targetVehicleRequestsRef.current.add(targetId);
+ setTargetVehicleLoadingMap(prev => ({ ...prev, [targetId]: true }));
+ setTargetVehicleErrorMap(prev => {
+ const next = { ...prev };
+ delete next[targetId];
+ return next;
+ });
fetchTargetVehicles(targetId).then(vehicles => {
setTargetVehiclesMap(prev => ({ ...prev, [targetId]: vehicles }));
- }).catch(() => {}).finally(() => {
+ }).catch(error => {
+ setTargetVehicleErrorMap(prev => ({
+ ...prev,
+ [targetId]: getRequestErrorMessage(error, '无法获取考核车辆数据,请稍后重试。'),
+ }));
+ }).finally(() => {
+ setTargetVehicleLoadingMap(prev => ({ ...prev, [targetId]: false }));
targetVehicleRequestsRef.current.delete(targetId);
});
}, []);
@@ -267,7 +288,9 @@ export default function StatisticsView() {
setAssessmentYearMap({});
}
}).catch(error => {
- if (!cancelled) setTargetsError(getRequestErrorMessage(error));
+ if (!cancelled) {
+ setTargetsError(getRequestErrorMessage(error, '无法获取考核目标数据,请检查服务连接后重试。'));
+ }
}).finally(() => {
if (!cancelled) setTargetsLoading(false);
});
@@ -278,13 +301,33 @@ export default function StatisticsView() {
// Load trend when selectedTargetId changes
useEffect(() => {
- if (selectedTargetId === null) return;
+ if (selectedTargetId === null) {
+ setTrendData([]);
+ setTrendError(null);
+ setTrendLoading(false);
+ return;
+ }
+ let cancelled = false;
setExpandedTargetId(selectedTargetId);
if (!targetVehiclesMap[selectedTargetId]) {
loadCurrentTargetVehicles(selectedTargetId);
}
- fetchTrend(selectedTargetId).then(setTrendData).catch(() => setTrendData([]));
- }, [selectedTargetId]);
+ setTrendLoading(true);
+ setTrendError(null);
+ fetchTrend(selectedTargetId).then(data => {
+ if (!cancelled) setTrendData(data);
+ }).catch(error => {
+ if (!cancelled) {
+ setTrendData([]);
+ setTrendError(getRequestErrorMessage(error, '无法获取里程趋势,请稍后重试。'));
+ }
+ }).finally(() => {
+ if (!cancelled) setTrendLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [selectedTargetId, trendRequestVersion]);
// Re-fetch target vehicles when viewAllDate changes
useEffect(() => {
@@ -421,8 +464,8 @@ export default function StatisticsView() {
最新日变化
-
= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
- {trendDelta >= 0 ? '+' : ''}{fmtKm(trendDelta)}
+
= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
+ {trendLoading ? '加载中' : trendError ? '暂不可用' : `${trendDelta >= 0 ? '+' : ''}${fmtKm(trendDelta)}`}
达标率 {fmtPercent(selectedQualifiedRate)}
@@ -448,7 +491,9 @@ export default function StatisticsView() {
vehicles={selectedTargetVehicles}
dimension={attributionDimension}
selectedValue={selectedBreakdownValue}
- loading={!targetVehiclesMap[selectedTarget.id]}
+ loading={targetVehicleLoadingMap[selectedTarget.id]
+ || (!targetVehiclesMap[selectedTarget.id] && !targetVehicleErrorMap[selectedTarget.id])}
+ error={targetVehicleErrorMap[selectedTarget.id]}
targetMileagePerVehicle={selectedTarget.annualMileagePerVehicle * (selectedAssessmentYear || 1)}
expectedShortfallMileage={selectedAssessment?.remaining}
assessmentLabel={selectedAssessment?.label}
@@ -457,6 +502,7 @@ export default function StatisticsView() {
if (drillContext.level === 'breakdown') closeVehiclePanel();
}}
onSelect={openAttributionGroup}
+ onRetry={() => loadCurrentTargetVehicles(selectedTarget.id)}
/>
)}
@@ -521,6 +567,13 @@ export default function StatisticsView() {
+ {trendLoading ? (
+
+ ) : trendError ? (
+
+ ) : trendData.length === 0 ? (
+
+ ) : (
{chartType === 'bar' ? (
@@ -563,6 +616,7 @@ export default function StatisticsView() {
)}
+ )}