feat(mileage): add report loading overlay
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
kkfluous
2026-08-12 23:17:06 +08:00
parent 7fce6b8474
commit d610e4b841
+62 -20
View File
@@ -41,7 +41,6 @@ import {
import Blur from '../../components/Blur'; import Blur from '../../components/Blur';
import { import {
ErrorState, ErrorState,
LoadingState,
MetricTile, MetricTile,
SurfaceCard, SurfaceCard,
} from '../../components/ui/surface'; } from '../../components/ui/surface';
@@ -57,6 +56,7 @@ type VehicleSortKey = 'dailyMileage' | 'sevenDayMileage' | 'completionRate';
type SortDirection = 'asc' | 'desc'; type SortDirection = 'asc' | 'desc';
const VEHICLE_PAGE_SIZE = 20; const VEHICLE_PAGE_SIZE = 20;
const MIN_REPORT_LOADING_MS = 280;
const VEHICLE_FILTERS: { id: VehicleFilter; label: string }[] = [ const VEHICLE_FILTERS: { id: VehicleFilter; label: string }[] = [
{ id: 'ALL', label: '全部' }, { id: 'ALL', label: '全部' },
@@ -209,6 +209,48 @@ function ReportDatePicker({
); );
} }
function DailyReportLoadingOverlay({ reportDate }: { reportDate: string }) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.16, ease: 'easeOut' }}
role="status"
aria-live="polite"
className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-950/20 p-5 backdrop-blur-[2px]"
>
<motion.div
initial={{ opacity: 0, y: 12, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.98 }}
transition={{ duration: 0.22, ease: 'easeOut' }}
className="w-full max-w-[260px] overflow-hidden rounded-xl border border-slate-100 bg-white p-5 text-center shadow-2xl"
>
<div className="relative mx-auto flex h-12 w-12 items-center justify-center">
<motion.span
className="absolute inset-0 rounded-full border-2 border-blue-100"
animate={{ scale: [0.86, 1.15], opacity: [0.9, 0] }}
transition={{ duration: 1.2, repeat: Infinity, ease: 'easeOut' }}
/>
<span className="relative flex h-10 w-10 items-center justify-center rounded-full bg-blue-50 text-blue-600">
<RefreshCw size={19} className="animate-spin" />
</span>
</div>
<div className="mt-3 text-sm font-black text-slate-900"></div>
<div className="mt-1 text-[11px] font-bold text-slate-400"> {fmtDate(reportDate)} </div>
<div className="mt-4 h-1 overflow-hidden rounded-full bg-slate-100">
<motion.div
className="h-full w-1/2 rounded-full bg-blue-500"
animate={{ x: ['-100%', '220%'] }}
transition={{ duration: 1.15, repeat: Infinity, ease: 'easeInOut' }}
/>
</div>
</motion.div>
</motion.div>
);
}
function updateReportDateInUrl(date: string): void { function updateReportDateInUrl(date: string): void {
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.set('mileageReportDate', date); url.searchParams.set('mileageReportDate', date);
@@ -831,7 +873,7 @@ function roundMileageForDisplay(value: number): number {
} }
export default function DailyReportView() { export default function DailyReportView() {
const maxDate = shanghaiYmd(); const maxDate = shanghaiYmd(-1);
const [selectedDate, setSelectedDate] = useState(initialReportDate); const [selectedDate, setSelectedDate] = useState(initialReportDate);
const [report, setReport] = useState<DailyMileageReport | null>(null); const [report, setReport] = useState<DailyMileageReport | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -841,6 +883,8 @@ export default function DailyReportView() {
useEffect(() => { useEffect(() => {
let active = true; let active = true;
let finishTimer: number | undefined;
const loadingStartedAt = performance.now();
setLoading(true); setLoading(true);
setError(''); setError('');
updateReportDateInUrl(selectedDate); updateReportDateInUrl(selectedDate);
@@ -855,8 +899,17 @@ export default function DailyReportView() {
if (!active) return; if (!active) return;
setError(cause instanceof Error ? cause.message : '日报加载失败'); setError(cause instanceof Error ? cause.message : '日报加载失败');
}) })
.finally(() => { if (active) setLoading(false); }); .finally(() => {
return () => { active = false; }; if (!active) return;
const delay = Math.max(0, MIN_REPORT_LOADING_MS - (performance.now() - loadingStartedAt));
finishTimer = window.setTimeout(() => {
if (active) setLoading(false);
}, delay);
});
return () => {
active = false;
if (finishTimer != null) window.clearTimeout(finishTimer);
};
}, [selectedDate]); }, [selectedDate]);
const highMileageRate = report && report.totals.operatingCount > 0 const highMileageRate = report && report.totals.operatingCount > 0
@@ -871,7 +924,7 @@ export default function DailyReportView() {
note => note.message !== '日行驶总里程延续人工汇报口径,包含库存车辆产生的里程。', note => note.message !== '日行驶总里程延续人工汇报口径,包含库存车辆产生的里程。',
) || []; ) || [];
if (loading && !report) return <LoadingState label="正在生成里程日报" />; if (loading && !report) return <DailyReportLoadingOverlay reportDate={selectedDate} />;
if (!report) return <ErrorState message={error || '日报接口未返回有效数据'} />; if (!report) return <ErrorState message={error || '日报接口未返回有效数据'} />;
const trendGroup = trendTargetId == null ? null : report.groups.find(group => group.targetId === trendTargetId) || null; const trendGroup = trendTargetId == null ? null : report.groups.find(group => group.targetId === trendTargetId) || null;
@@ -881,7 +934,7 @@ export default function DailyReportView() {
const trendColor = trendGroup ? '#0f766e' : '#2563eb'; const trendColor = trendGroup ? '#0f766e' : '#2563eb';
return ( return (
<div className="space-y-3 pb-5"> <div className="relative space-y-3 pb-5" aria-busy={loading}>
<section className="rounded-xl border border-slate-200 bg-white px-4 py-3 shadow-sm"> <section className="rounded-xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0"> <div className="min-w-0">
@@ -917,15 +970,6 @@ export default function DailyReportView() {
<ChevronLeft size={16} /> <ChevronLeft size={16} />
</button> </button>
<ReportDatePicker value={selectedDate} maxDate={maxDate} onChange={setSelectedDate} /> <ReportDatePicker value={selectedDate} maxDate={maxDate} onChange={setSelectedDate} />
<button
type="button"
onClick={() => setSelectedDate(maxDate)}
disabled={selectedDate === maxDate}
className="flex h-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 bg-white px-2.5 text-[11px] font-black text-blue-600 hover:border-blue-200 hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-35"
title="回到今天"
>
</button>
<button <button
type="button" type="button"
onClick={() => setSelectedDate(dateShift(selectedDate, 1))} onClick={() => setSelectedDate(dateShift(selectedDate, 1))}
@@ -1139,11 +1183,9 @@ export default function DailyReportView() {
))} ))}
</SurfaceCard> </SurfaceCard>
{loading ? ( <AnimatePresence initial={false}>
<div className="fixed bottom-5 right-5 z-50 flex items-center gap-2 rounded-lg bg-slate-900 px-3 py-2 text-xs font-black text-white shadow-xl"> {loading ? <DailyReportLoadingOverlay reportDate={selectedDate} /> : null}
<RefreshCw size={14} className="animate-spin" /> </AnimatePresence>
</div>
) : null}
{error ? <ErrorState message={error} /> : null} {error ? <ErrorState message={error} /> : null}
</div> </div>
); );