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() { )} + )}