fix: surface mileage child request failures
This commit is contained in:
@@ -173,6 +173,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
|
|||||||
- 里程考核目标接口发布车辆台账 `MAX(update_time)`,统计页使用真实日期和时间展示数据截至点,不再用页面访问日或考核结束日代替快照时间。
|
- 里程考核目标接口发布车辆台账 `MAX(update_time)`,统计页使用真实日期和时间展示数据截至点,不再用页面访问日或考核结束日代替快照时间。
|
||||||
- 里程考核接口区分目标声明台数与当前有效车辆数;两者不一致时页面明确标注缺少或超配数量,并说明完成率、缺口和归因的实际计算范围。
|
- 里程考核接口区分目标声明台数与当前有效车辆数;两者不一致时页面明确标注缺少或超配数量,并说明完成率、缺口和归因的实际计算范围。
|
||||||
- 里程考核统计主数据已区分加载、真实空数据、首次请求故障和保留旧数据的刷新故障,并提供统一重试入口,接口失败不再显示为零值或空页面。
|
- 里程考核统计主数据已区分加载、真实空数据、首次请求故障和保留旧数据的刷新故障,并提供统一重试入口,接口失败不再显示为零值或空页面。
|
||||||
|
- 里程考核归因车辆与 7 天趋势已提供独立加载、故障、空数据和重试状态;子请求失败时停止展示“0 台未达标”和“最新日变化 +0”等伪业务结果。
|
||||||
- 已完成电能数据截至时间和实际趋势月展示。
|
- 已完成电能数据截至时间和实际趋势月展示。
|
||||||
- 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。
|
- 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。
|
||||||
- 已完成氢能结构化故障状态与重试体验。
|
- 已完成氢能结构化故障状态与重试体验。
|
||||||
|
|||||||
@@ -5,17 +5,20 @@ import {
|
|||||||
reconcileMileageAttribution,
|
reconcileMileageAttribution,
|
||||||
type AttributionDimension,
|
type AttributionDimension,
|
||||||
} from './attribution';
|
} from './attribution';
|
||||||
|
import { ErrorState } from '../../components/ui/surface';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
vehicles: TargetVehicle[];
|
vehicles: TargetVehicle[];
|
||||||
dimension: AttributionDimension;
|
dimension: AttributionDimension;
|
||||||
selectedValue?: string;
|
selectedValue?: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
error?: string | null;
|
||||||
targetMileagePerVehicle: number;
|
targetMileagePerVehicle: number;
|
||||||
expectedShortfallMileage?: number;
|
expectedShortfallMileage?: number;
|
||||||
assessmentLabel?: string;
|
assessmentLabel?: string;
|
||||||
onDimensionChange: (dimension: AttributionDimension) => void;
|
onDimensionChange: (dimension: AttributionDimension) => void;
|
||||||
onSelect: (value: string) => void;
|
onSelect: (value: string) => void;
|
||||||
|
onRetry?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtKm(value: number): string {
|
function fmtKm(value: number): string {
|
||||||
@@ -27,11 +30,13 @@ export default function MileageAttributionView({
|
|||||||
dimension,
|
dimension,
|
||||||
selectedValue,
|
selectedValue,
|
||||||
loading = false,
|
loading = false,
|
||||||
|
error,
|
||||||
targetMileagePerVehicle,
|
targetMileagePerVehicle,
|
||||||
expectedShortfallMileage,
|
expectedShortfallMileage,
|
||||||
assessmentLabel,
|
assessmentLabel,
|
||||||
onDimensionChange,
|
onDimensionChange,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onRetry,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const groups = buildMileageAttribution(vehicles, dimension, targetMileagePerVehicle);
|
const groups = buildMileageAttribution(vehicles, dimension, targetMileagePerVehicle);
|
||||||
const totalShortfall = groups.reduce((sum, group) => sum + group.shortfallMileage, 0);
|
const totalShortfall = groups.reduce((sum, group) => sum + group.shortfallMileage, 0);
|
||||||
@@ -49,9 +54,14 @@ export default function MileageAttributionView({
|
|||||||
<h2 className="text-sm font-black text-slate-800">考核缺口归因</h2>
|
<h2 className="text-sm font-black text-slate-800">考核缺口归因</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-[10px] font-bold text-slate-400">
|
<p className="mt-1 text-[10px] font-bold text-slate-400">
|
||||||
{assessmentLabel ? `${assessmentLabel} · ` : ''}{totalBehind} 台未达标,累计缺口 {fmtKm(totalShortfall)} km
|
{assessmentLabel ? `${assessmentLabel} · ` : ''}
|
||||||
|
{loading
|
||||||
|
? '正在加载归因车辆'
|
||||||
|
: error
|
||||||
|
? '归因数据暂不可用'
|
||||||
|
: `${totalBehind} 台未达标,累计缺口 ${fmtKm(totalShortfall)} km`}
|
||||||
</p>
|
</p>
|
||||||
{!loading && reconciliation && (
|
{!loading && !error && reconciliation && (
|
||||||
<p
|
<p
|
||||||
role={reconciliation.matches ? 'status' : 'alert'}
|
role={reconciliation.matches ? 'status' : 'alert'}
|
||||||
className={`mt-1 text-[10px] font-black ${reconciliation.matches ? 'text-emerald-600' : 'text-rose-600'}`}
|
className={`mt-1 text-[10px] font-black ${reconciliation.matches ? 'text-emerald-600' : 'text-rose-600'}`}
|
||||||
@@ -84,6 +94,8 @@ export default function MileageAttributionView({
|
|||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="px-4 py-8 text-center text-xs font-bold text-slate-400">正在计算归因...</div>
|
<div className="px-4 py-8 text-center text-xs font-bold text-slate-400">正在计算归因...</div>
|
||||||
|
) : error ? (
|
||||||
|
<ErrorState message={error} onRetry={onRetry} variant="inline" />
|
||||||
) : groups.length === 0 ? (
|
) : groups.length === 0 ? (
|
||||||
<div className="px-4 py-8 text-center text-xs font-bold text-slate-400">暂无可归因车辆</div>
|
<div className="px-4 py-8 text-center text-xs font-bold text-slate-400">暂无可归因车辆</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -65,11 +65,11 @@ function shortTargetName(name: string): string {
|
|||||||
return `${count}台${desc}`;
|
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') {
|
if (error instanceof Error && error.message && error.message !== 'Failed to fetch') {
|
||||||
return error.message;
|
return error.message;
|
||||||
}
|
}
|
||||||
return '无法获取考核目标数据,请检查服务连接后重试。';
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StatisticsView() {
|
export default function StatisticsView() {
|
||||||
@@ -81,7 +81,12 @@ export default function StatisticsView() {
|
|||||||
const [targetsError, setTargetsError] = useState<string | null>(null);
|
const [targetsError, setTargetsError] = useState<string | null>(null);
|
||||||
const [targetsRequestVersion, setTargetsRequestVersion] = useState(0);
|
const [targetsRequestVersion, setTargetsRequestVersion] = useState(0);
|
||||||
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
||||||
|
const [trendLoading, setTrendLoading] = useState(false);
|
||||||
|
const [trendError, setTrendError] = useState<string | null>(null);
|
||||||
|
const [trendRequestVersion, setTrendRequestVersion] = useState(0);
|
||||||
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
|
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
|
||||||
|
const [targetVehicleLoadingMap, setTargetVehicleLoadingMap] = useState<Record<number, boolean>>({});
|
||||||
|
const [targetVehicleErrorMap, setTargetVehicleErrorMap] = useState<Record<number, string>>({});
|
||||||
const [selectedTargetId, setSelectedTargetId] = useState<number | null>(drillContext.targetId || null);
|
const [selectedTargetId, setSelectedTargetId] = useState<number | null>(drillContext.targetId || null);
|
||||||
const [attributionDimension, setAttributionDimension] = useState<AttributionDimension>(() => (
|
const [attributionDimension, setAttributionDimension] = useState<AttributionDimension>(() => (
|
||||||
drillContext.level === 'breakdown' && drillContext.dimension === 'customer' ? 'customer' : 'department'
|
drillContext.level === 'breakdown' && drillContext.dimension === 'customer' ? 'customer' : 'department'
|
||||||
@@ -139,12 +144,28 @@ export default function StatisticsView() {
|
|||||||
setTargetsRequestVersion(version => version + 1);
|
setTargetsRequestVersion(version => version + 1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const retryTrend = useCallback(() => {
|
||||||
|
setTrendRequestVersion(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);
|
||||||
|
setTargetVehicleLoadingMap(prev => ({ ...prev, [targetId]: true }));
|
||||||
|
setTargetVehicleErrorMap(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[targetId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
fetchTargetVehicles(targetId).then(vehicles => {
|
fetchTargetVehicles(targetId).then(vehicles => {
|
||||||
setTargetVehiclesMap(prev => ({ ...prev, [targetId]: 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);
|
targetVehicleRequestsRef.current.delete(targetId);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -267,7 +288,9 @@ export default function StatisticsView() {
|
|||||||
setAssessmentYearMap({});
|
setAssessmentYearMap({});
|
||||||
}
|
}
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
if (!cancelled) setTargetsError(getRequestErrorMessage(error));
|
if (!cancelled) {
|
||||||
|
setTargetsError(getRequestErrorMessage(error, '无法获取考核目标数据,请检查服务连接后重试。'));
|
||||||
|
}
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
if (!cancelled) setTargetsLoading(false);
|
if (!cancelled) setTargetsLoading(false);
|
||||||
});
|
});
|
||||||
@@ -278,13 +301,33 @@ export default function StatisticsView() {
|
|||||||
|
|
||||||
// Load trend when selectedTargetId changes
|
// Load trend when selectedTargetId changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedTargetId === null) return;
|
if (selectedTargetId === null) {
|
||||||
|
setTrendData([]);
|
||||||
|
setTrendError(null);
|
||||||
|
setTrendLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
setExpandedTargetId(selectedTargetId);
|
setExpandedTargetId(selectedTargetId);
|
||||||
if (!targetVehiclesMap[selectedTargetId]) {
|
if (!targetVehiclesMap[selectedTargetId]) {
|
||||||
loadCurrentTargetVehicles(selectedTargetId);
|
loadCurrentTargetVehicles(selectedTargetId);
|
||||||
}
|
}
|
||||||
fetchTrend(selectedTargetId).then(setTrendData).catch(() => setTrendData([]));
|
setTrendLoading(true);
|
||||||
}, [selectedTargetId]);
|
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
|
// Re-fetch target vehicles when viewAllDate changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -421,8 +464,8 @@ export default function StatisticsView() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||||
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">最新日变化</div>
|
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">最新日变化</div>
|
||||||
<div className={`mt-1 text-lg font-black ${trendDelta >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
|
<div className={`mt-1 text-lg font-black ${trendLoading || trendError ? 'text-slate-500' : trendDelta >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||||||
{trendDelta >= 0 ? '+' : ''}{fmtKm(trendDelta)}
|
{trendLoading ? '加载中' : trendError ? '暂不可用' : `${trendDelta >= 0 ? '+' : ''}${fmtKm(trendDelta)}`}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-[10px] font-bold text-slate-400">达标率 {fmtPercent(selectedQualifiedRate)}</div>
|
<div className="mt-1 text-[10px] font-bold text-slate-400">达标率 {fmtPercent(selectedQualifiedRate)}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -448,7 +491,9 @@ export default function StatisticsView() {
|
|||||||
vehicles={selectedTargetVehicles}
|
vehicles={selectedTargetVehicles}
|
||||||
dimension={attributionDimension}
|
dimension={attributionDimension}
|
||||||
selectedValue={selectedBreakdownValue}
|
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)}
|
targetMileagePerVehicle={selectedTarget.annualMileagePerVehicle * (selectedAssessmentYear || 1)}
|
||||||
expectedShortfallMileage={selectedAssessment?.remaining}
|
expectedShortfallMileage={selectedAssessment?.remaining}
|
||||||
assessmentLabel={selectedAssessment?.label}
|
assessmentLabel={selectedAssessment?.label}
|
||||||
@@ -457,6 +502,7 @@ export default function StatisticsView() {
|
|||||||
if (drillContext.level === 'breakdown') closeVehiclePanel();
|
if (drillContext.level === 'breakdown') closeVehiclePanel();
|
||||||
}}
|
}}
|
||||||
onSelect={openAttributionGroup}
|
onSelect={openAttributionGroup}
|
||||||
|
onRetry={() => loadCurrentTargetVehicles(selectedTarget.id)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -521,6 +567,13 @@ export default function StatisticsView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-[280px] w-full min-w-0">
|
<div className="h-[280px] w-full min-w-0">
|
||||||
|
{trendLoading ? (
|
||||||
|
<LoadingState label="正在加载里程趋势" variant="inline" />
|
||||||
|
) : trendError ? (
|
||||||
|
<ErrorState message={trendError} onRetry={retryTrend} variant="inline" />
|
||||||
|
) : trendData.length === 0 ? (
|
||||||
|
<EmptyState title="暂无趋势数据" description="当前考核目标在最近 7 天没有可用里程" variant="inline" />
|
||||||
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={280} minWidth={0}>
|
<ResponsiveContainer width="100%" height={280} minWidth={0}>
|
||||||
{chartType === 'bar' ? (
|
{chartType === 'bar' ? (
|
||||||
<BarChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
|
<BarChart data={trendData} margin={{ top: 20, right: 10, left: 0, bottom: 0 }}>
|
||||||
@@ -563,6 +616,7 @@ export default function StatisticsView() {
|
|||||||
</AreaChart>
|
</AreaChart>
|
||||||
)}
|
)}
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user