diff --git a/AGENTS.md b/AGENTS.md index 76270a7..9d24f43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **36-character-flower** (2527 symbols, 4819 relationships, 217 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **36-character-flower** (2566 symbols, 4898 relationships, 220 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/CLAUDE.md b/CLAUDE.md index 76270a7..9d24f43 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **36-character-flower** (2527 symbols, 4819 relationships, 217 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **36-character-flower** (2566 symbols, 4898 relationships, 220 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/src/components/app-boot-resource-gate.tsx b/src/components/app-boot-resource-gate.tsx new file mode 100644 index 0000000..6d2fc88 --- /dev/null +++ b/src/components/app-boot-resource-gate.tsx @@ -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 + +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((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 ( +
+
+
+
+ +
+ {particleLayout.map((particle) => ( + + ))} +
+ +
+
+
+
+
+ + {progress}% + +
+ +
+
+
+ +
+
+ 资源加载中 +
+
+ 正在同步字花图鉴与游戏界面 +
+
+
+
+ ) +} + +export function AppBootResourceGate({ children }: PropsWithChildren) { + const { isReady, progress } = useBootResourceLoader() + + if (!isReady) { + return + } + + return children +} diff --git a/src/components/ui/data-loading-indicator.tsx b/src/components/ui/data-loading-indicator.tsx new file mode 100644 index 0000000..34db1cb --- /dev/null +++ b/src/components/ui/data-loading-indicator.tsx @@ -0,0 +1,53 @@ +import type { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +interface DataLoadingIndicatorProps { + className?: string + compact?: boolean + label?: ReactNode +} + +export function DataLoadingIndicator({ + className, + compact = false, + label, +}: DataLoadingIndicatorProps) { + return ( +
+
+ + + +
+ + {label ? ( +
+ {label} +
+ ) : null} +
+ ) +} diff --git a/src/features/game/components/desktop/desktop-game-history.tsx b/src/features/game/components/desktop/desktop-game-history.tsx index e43acbf..8ae57e1 100644 --- a/src/features/game/components/desktop/desktop-game-history.tsx +++ b/src/features/game/components/desktop/desktop-game-history.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next' import historyBg from '@/assets/system/history-bg.png' import { SmartBackground } from '@/components/smart-background.tsx' import { SmartImage } from '@/components/smart-image' +import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator' import { useGameHistoryVm } from '@/features/game/hooks/use-game-history-vm.ts' import { FLOWER_IMAGE_BY_ID } from '@/features/game/shared' @@ -17,12 +18,18 @@ function HistoryRewardNumber({ const label = String(number).padStart(2, '0') if (!image?.rewardUrl) { - return {label} + return ( + + {label} + + ) } return (
{t('gameDesktop.history.title')} @@ -87,13 +94,10 @@ export function DesktopGameHistory() { } > {isInitialLoading ? ( -
- {loadingText} -
+ ) : isEmpty ? (
-