feat(app): 添加应用启动资源预加载功能

- 实现了 AppBootResourceGate 组件用于管理应用启动时的资源加载
- 集成了图片和字体资源的预加载机制
- 添加了启动时的加载进度指示器和动画效果
- 创建了 DataLoadingIndicator 组件用于显示数据加载状态
- 在多个组件中替换原有的加载提示为新的 DataLoadingIndicator
- 更新了样式文件以支持新的加载动画和视觉效果
This commit is contained in:
JiaJun
2026-05-29 17:43:47 +08:00
parent f388aabfda
commit 54410aaac5
12 changed files with 516 additions and 69 deletions

View File

@@ -0,0 +1,159 @@
import { type PropsWithChildren, useEffect, useMemo, useState } from 'react'
const bootImageModules = import.meta.glob(
'../assets/**/*.{jpg,jpeg,png,svg,webp}',
{
eager: true,
import: 'default',
},
) as Record<string, string>
const BOOT_MIN_VISIBLE_MS = 900
const particleLayout = Array.from({ length: 36 }, (_, index) => ({
delayMs: (index % 9) * 180,
durationMs: 3200 + (index % 7) * 260,
left: `${(index * 17 + 9) % 100}%`,
size: 2 + (index % 4),
top: `${(index * 29 + 13) % 100}%`,
}))
function preloadImage(url: string) {
return new Promise<void>((resolve) => {
const image = new Image()
image.onload = () => resolve()
image.onerror = () => resolve()
image.src = url
})
}
function waitForFonts() {
if (typeof document === 'undefined' || !('fonts' in document)) {
return Promise.resolve()
}
return document.fonts.ready.then(() => undefined)
}
function useBootResourceLoader() {
const imageUrls = useMemo(
() => Array.from(new Set(Object.values(bootImageModules))).filter(Boolean),
[],
)
const [progress, setProgress] = useState(0)
const [isReady, setIsReady] = useState(false)
useEffect(() => {
let cancelled = false
const startedAt = performance.now()
const totalSteps = imageUrls.length + 1
let completedSteps = 0
const syncProgress = () => {
completedSteps += 1
if (!cancelled) {
setProgress(
Math.min(100, Math.round((completedSteps / totalSteps) * 100)),
)
}
}
const waitForMinimumDuration = async () => {
const elapsedMs = performance.now() - startedAt
const remainingMs = Math.max(0, BOOT_MIN_VISIBLE_MS - elapsedMs)
if (remainingMs <= 0) {
return
}
await new Promise((resolve) => window.setTimeout(resolve, remainingMs))
}
void Promise.all([
waitForFonts().finally(syncProgress),
...imageUrls.map((url) => preloadImage(url).finally(syncProgress)),
])
.then(waitForMinimumDuration)
.then(() => {
if (!cancelled) {
setProgress(100)
setIsReady(true)
}
})
return () => {
cancelled = true
}
}, [imageUrls])
return { isReady, progress }
}
function AppLoadingOverlay({ progress }: { progress: number }) {
return (
<div
role="status"
aria-live="polite"
aria-label="Loading"
className="fixed inset-0 z-[9999] flex items-center justify-center overflow-hidden bg-[#020913] text-[#D5FBFF]"
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_38%,rgba(35,221,255,0.18),transparent_30%),linear-gradient(180deg,#061827_0%,#020913_54%,#01050C_100%)]" />
<div className="boot-grid absolute inset-0 opacity-50" />
<div className="boot-scanline absolute inset-0 opacity-35" />
<div className="pointer-events-none absolute inset-0">
{particleLayout.map((particle) => (
<span
key={`${particle.left}-${particle.top}`}
className="boot-particle absolute rounded-full bg-[#71FFF7] shadow-[0_0_12px_rgba(113,255,247,0.88)]"
style={{
animationDelay: `${particle.delayMs}ms`,
animationDuration: `${particle.durationMs}ms`,
height: `calc(var(--design-unit) * ${particle.size})`,
left: particle.left,
top: particle.top,
width: `calc(var(--design-unit) * ${particle.size})`,
}}
/>
))}
</div>
<div className="relative flex w-[min(calc(var(--design-unit)*720),86vw)] flex-col items-center gap-design-24 px-design-24">
<div className="relative flex h-design-190 w-design-190 min-h-32 min-w-32 items-center justify-center md:h-design-260 md:w-design-260">
<div className="boot-ring absolute inset-0 rounded-full border border-[rgba(105,255,250,0.38)]" />
<div className="boot-ring boot-ring-delay absolute inset-[calc(var(--design-unit)*18)] rounded-full border border-[rgba(255,224,117,0.34)] md:inset-[calc(var(--design-unit)*24)]" />
<div className="absolute h-[64%] w-[64%] rounded-full border border-[rgba(113,255,247,0.26)] bg-[radial-gradient(circle,rgba(18,95,113,0.68),rgba(2,13,22,0.96))] shadow-[inset_0_0_calc(var(--design-unit)*32)_rgba(113,255,247,0.18),0_0_calc(var(--design-unit)*52)_rgba(65,232,255,0.22)]" />
<span className="relative text-design-34 font-bold leading-none text-[#FEE889] [text-shadow:0_0_calc(var(--design-unit)*16)_rgba(254,232,137,0.46)] md:text-design-50 md:[text-shadow:0_0_calc(var(--design-unit)*22)_rgba(254,232,137,0.56)]">
{progress}%
</span>
</div>
<div className="w-full overflow-hidden rounded-[calc(var(--design-unit)*10)] border border-[rgba(113,255,247,0.38)] bg-[rgba(4,20,31,0.82)] p-design-2 shadow-[0_0_calc(var(--design-unit)*22)_rgba(65,232,255,0.16),inset_0_0_calc(var(--design-unit)*10)_rgba(113,255,247,0.1)]">
<div
className="h-design-10 rounded-[calc(var(--design-unit)*8)] bg-[linear-gradient(90deg,#34E7FF,#7BFFF6,#FEE889)] shadow-[0_0_calc(var(--design-unit)*16)_rgba(113,255,247,0.58)] transition-[width] duration-300 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
<div className="flex flex-col items-center gap-design-8 text-center">
<div className="text-design-24 font-bold text-[#E8FFFF] [text-shadow:0_0_calc(var(--design-unit)*14)_rgba(113,255,247,0.38)]">
</div>
<div className="text-design-14 text-[rgba(181,242,247,0.72)]">
</div>
</div>
</div>
</div>
)
}
export function AppBootResourceGate({ children }: PropsWithChildren) {
const { isReady, progress } = useBootResourceLoader()
if (!isReady) {
return <AppLoadingOverlay progress={progress} />
}
return children
}