fix: 完善学习进度同步与拼图收集流程

统一阶段拼图配置和进度上报,增加同步写入协调与失败重试,优化收集确认及同步反馈,并展示菜单探索进度。补充相关回归测试与字体素材。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 5 committed 2026-10-12 09:29:21 +08:00
1 parent 6491b0c918
commit 9ab67b1bd3
63 files changed
+2141 -360

No files matched your search

+70 -55
View File
@@ -1,81 +1,96 @@
import { useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import {
SYNC_LOADING_EVENT,
SYNC_LOADING_HIDE_EVENT,
type SyncLoadingOptions,
} from './syncLoadingApi'
getSyncWriteState,
retryActiveSyncWrite,
subscribeBackgroundWriteFailure,
subscribeSyncWriteState,
type SyncWriteState,
} from '@/lib/utils/syncWriteCoordinator'
import { showSyncErrorPrompt } from './SyncErrorPrompt'
interface SyncLoadingHostProps {
visible?: boolean
text?: string
}
export const SyncLoadingHost = ({
visible: controlledVisible,
text: controlledText,
}: SyncLoadingHostProps) => {
const [text, setText] = useState('正在同步学习进度...')
const [visible, setVisible] = useState(false)
const actualVisible = controlledVisible ?? visible
const actualText = controlledText ?? text
export const SyncLoadingHost = ({ visible = false, text }: SyncLoadingHostProps) => {
const [writeState, setWriteState] = useState<SyncWriteState>(getSyncWriteState())
useEffect(() => {
const handleShow = (event: Event) => {
const customEvent = event as CustomEvent<SyncLoadingOptions>
setText(customEvent.detail.text ?? '正在同步学习进度...')
setVisible(true)
}
useEffect(() => subscribeSyncWriteState(setWriteState), [])
const handleHide = () => {
setVisible(false)
}
useEffect(
() =>
subscribeBackgroundWriteFailure((message) => {
showSyncErrorPrompt({ title: '同步失败', message })
}),
[]
)
window.addEventListener(SYNC_LOADING_EVENT, handleShow)
window.addEventListener(SYNC_LOADING_HIDE_EVENT, handleHide)
return () => {
window.removeEventListener(SYNC_LOADING_EVENT, handleShow)
window.removeEventListener(SYNC_LOADING_HIDE_EVENT, handleHide)
}
}, [])
const writeVisible = writeState.phase !== 'idle'
const actualVisible = visible || writeVisible
const showFailure = !visible && writeState.phase === 'failed'
const actualText = text ?? '正在同步学习进度...'
return (
<AnimatePresence>
{actualVisible && (
<motion.div
key="sync-loading"
className="fixed inset-0 z-10000 flex items-center justify-center bg-black/60"
className="fixed inset-0 z-200000 flex items-center justify-center bg-black/60"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<motion.div
className="flex flex-col items-center justify-center gap-8"
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.24, ease: 'easeOut' }}
>
<svg className="h-20 w-20 animate-spin" viewBox="0 0 24 24" fill="none">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="#f4e4bd"
strokeWidth="3"
/>
<path
className="opacity-90"
fill="#f4e4bd"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<div className="text-3xl font-semibold tracking-[6px] text-[#f4e4bd] drop-shadow-[0_2px_8px_rgba(0,0,0,0.7)]">
{actualText}
</div>
</motion.div>
{showFailure ? (
<motion.div
className="flex w-[760px] flex-col items-center gap-10 rounded-[24px] border-2 border-[#e1bd78] bg-[#2a1608]/95 px-16 py-14 text-center shadow-2xl"
initial={{ opacity: 0, y: 12, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.24, ease: 'easeOut' }}
>
<div className="text-4xl font-bold tracking-[6px] text-[#f4e4bd]">同步失败</div>
<div className="text-2xl font-semibold leading-relaxed tracking-wider text-[#f4e4bd]">
{writeState.message ?? '学习进度保存失败,请检查网络后重新尝试。'}
</div>
<button
type="button"
className="rounded-full border-2 border-[#f4e4bd] bg-[#a66a35] px-14 py-4 text-2xl font-bold tracking-[4px] text-white transition hover:brightness-110 active:scale-95 disabled:cursor-not-allowed disabled:opacity-60"
disabled={writeState.retrying}
onClick={retryActiveSyncWrite}
>
重新尝试
</button>
</motion.div>
) : (
<motion.div
className="flex flex-col items-center justify-center gap-8"
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.24, ease: 'easeOut' }}
>
<svg className="h-20 w-20 animate-spin" viewBox="0 0 24 24" fill="none">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="#f4e4bd"
strokeWidth="3"
/>
<path
className="opacity-90"
fill="#f4e4bd"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<div className="text-3xl font-semibold tracking-[6px] text-[#f4e4bd] drop-shadow-[0_2px_8px_rgba(0,0,0,0.7)]">
{actualText}
</div>
</motion.div>
)}
</motion.div>
)}
</AnimatePresence>
+86 -51
View File
@@ -54,8 +54,11 @@ const ResidenceLayoutContent = () => {
const [isFlying, setIsFlying] = useState(false) // 标记是否正在飞行
const [isAwaitingCollect, setIsAwaitingCollect] = useState(false)
const [isCollectConfirming, setIsCollectConfirming] = useState(false)
const [collectError, setCollectError] = useState<string | null>(null)
const puzzlePieceRef = useRef<HTMLDivElement>(null)
const animationInProgressRef = useRef<boolean>(false) // 防止重复执行动画
const collectionLockRef = useRef(false)
const collectionViewRef = useRef(0)
const animationCompletionRef = useRef<(() => void) | null>(null)
const animationCompletionCallbackRef = useRef<(() => void) | null>(null)
@@ -70,6 +73,14 @@ const ResidenceLayoutContent = () => {
return ''
}, [location.pathname])
useEffect(() => {
collectionViewRef.current += 1
collectionLockRef.current = false
return () => {
collectionViewRef.current += 1
}
}, [location.pathname])
// 判断当前路由是否是 place1(讲解视频,首次不可跳过)
const isPlace1Route = useMemo(() => /place1/.test(location.pathname), [location.pathname])
@@ -120,80 +131,72 @@ const ResidenceLayoutContent = () => {
setIsFlying(false)
setIsAwaitingCollect(false)
setIsCollectConfirming(false)
setCollectError(null)
collectionLockRef.current = false
animationInProgressRef.current = false
resolveAnimation()
}, [resolveAnimation])
// 执行飞行动画
const executeFlyAnimation = useCallback(
(pieceId: number) => {
if (!residence || !puzzleBoxRef.current || !puzzlePieceRef.current) {
return false
const executeFlyAnimation = useCallback(() => {
if (!residence || !puzzleBoxRef.current || !puzzlePieceRef.current) {
return false
}
// 使用 requestAnimationFrame 确保元素已完全渲染
requestAnimationFrame(() => {
if (!puzzlePieceRef.current || !residence) {
return
}
// 使用 requestAnimationFrame 确保元素已完全渲染
requestAnimationFrame(() => {
if (!puzzlePieceRef.current || !residence) {
return
}
flyToAnimation({
fromRef: puzzlePieceRef,
toRef: puzzleBoxRef,
targetPosition: 'center',
duration: ANIMATION_CONFIG.FLY_DURATION,
scale: ANIMATION_CONFIG.SCALE,
opacity: ANIMATION_CONFIG.OPACITY,
ease: [0.22, 0.72, 0.16, 1],
onComplete: () => {
if (residence) {
unlockPuzzle(residence, pieceId)
}
cleanupAnimation()
},
})
requestAnimationFrame(() => {
setIsFlying(true)
})
flyToAnimation({
fromRef: puzzlePieceRef,
toRef: puzzleBoxRef,
targetPosition: 'center',
duration: ANIMATION_CONFIG.FLY_DURATION,
scale: ANIMATION_CONFIG.SCALE,
opacity: ANIMATION_CONFIG.OPACITY,
ease: [0.22, 0.72, 0.16, 1],
onComplete: cleanupAnimation,
})
return true
},
[
ANIMATION_CONFIG.FLY_DURATION,
ANIMATION_CONFIG.OPACITY,
ANIMATION_CONFIG.SCALE,
cleanupAnimation,
puzzleBoxRef,
residence,
unlockPuzzle,
]
)
requestAnimationFrame(() => {
setIsFlying(true)
})
})
return true
}, [
ANIMATION_CONFIG.FLY_DURATION,
ANIMATION_CONFIG.OPACITY,
ANIMATION_CONFIG.SCALE,
cleanupAnimation,
puzzleBoxRef,
residence,
])
// 等待元素准备就绪并执行动画
const waitForElementsAndAnimate = useCallback(
async (pieceId: number, retryCount = 0): Promise<void> => {
async (retryCount = 0): Promise<void> => {
if (!residence) {
cleanupAnimation()
return
}
// 检查元素是否已准备好
// 检查元素是否已准备就绪
if (puzzleBoxRef.current && puzzlePieceRef.current) {
const success = executeFlyAnimation(pieceId)
const success = executeFlyAnimation()
if (success) return
}
// 如果元素未准备好且未超过最大重试次数,则重试
if (retryCount < ANIMATION_CONFIG.MAX_RETRIES) {
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.RETRY_DELAY))
return waitForElementsAndAnimate(pieceId, retryCount + 1)
return waitForElementsAndAnimate(retryCount + 1)
}
// 超过最大重试次数,直接解锁并清理
console.warn('Puzzle animation: 元素未准备好,直接解锁')
unlockPuzzle(residence, pieceId)
// 保存已经成功,动画元素缺失时只需结束收集流程
console.warn('Puzzle animation: 元素未准备好,跳过飞行动画')
cleanupAnimation()
},
[
@@ -203,7 +206,6 @@ const ResidenceLayoutContent = () => {
executeFlyAnimation,
puzzleBoxRef,
residence,
unlockPuzzle,
]
)
@@ -213,17 +215,48 @@ const ResidenceLayoutContent = () => {
}, [puzzlePieceId, residence])
const handleCollectPuzzlePiece = useCallback(async () => {
if (!puzzlePieceId || isCollectConfirming || isFlying) return
if (
!puzzlePieceId ||
!residence ||
isCollectConfirming ||
isFlying ||
collectionLockRef.current
)
return
collectionLockRef.current = true
const view = collectionViewRef.current
setCollectError(null)
setIsCollectConfirming(true)
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.COLLECT_CONFIRM_DURATION))
if (view !== collectionViewRef.current) return
let saved = false
try {
saved = await unlockPuzzle(residence, puzzlePieceId)
} catch (error) {
console.error('Failed to collect puzzle piece', error)
}
if (view !== collectionViewRef.current) return
collectionLockRef.current = false
if (!saved) {
setCollectError('收下失败,请检查网络后重新尝试')
setIsCollectConfirming(false)
return
}
setIsAwaitingCollect(false)
await waitForElementsAndAnimate(puzzlePieceId)
setIsCollectConfirming(false)
await waitForElementsAndAnimate()
}, [
ANIMATION_CONFIG.COLLECT_CONFIRM_DURATION,
isCollectConfirming,
isFlying,
puzzlePieceId,
residence,
unlockPuzzle,
waitForElementsAndAnimate,
])
@@ -259,6 +292,7 @@ const ResidenceLayoutContent = () => {
// 开始动画流程:禁用所有操作
setIsAnimationPlaying(true)
setCollectError(null)
setPuzzlePieceId(pieceId)
setShowPuzzlePiece(true)
@@ -444,6 +478,7 @@ const ResidenceLayoutContent = () => {
hidden={isFlying}
awaitingCollect={isAwaitingCollect}
confirming={isCollectConfirming}
collectError={collectError}
puzzlePieceTitle={currentPuzzlePieceData?.title ?? null}
onCollect={handleCollectPuzzlePiece}
/>
+18 -5
View File
@@ -11,8 +11,9 @@ interface PuzzlePiecePortalProps {
hidden?: boolean
awaitingCollect?: boolean
confirming?: boolean
collectError?: string | null
puzzlePieceTitle?: string | null
onCollect?: () => void
onCollect?: () => void | Promise<void>
}
const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
@@ -23,6 +24,7 @@ const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
hidden = false,
awaitingCollect = false,
confirming = false,
collectError,
puzzlePieceTitle,
onCollect,
},
@@ -153,8 +155,8 @@ const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
<motion.div
className="absolute left-0 right-0 top-[clamp(218px,25vw,276px)] flex flex-col items-center gap-3 pointer-events-none"
initial={{ opacity: 0, y: 10 }}
animate={confirming ? { opacity: 0, y: 6 } : { opacity: 1, y: 0 }}
transition={{ duration: confirming ? 0.16 : 0.36, ease: [0.22, 1, 0.36, 1] }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.36, ease: [0.22, 1, 0.36, 1] }}
style={{ willChange: 'transform, opacity' }}
>
{puzzlePieceTitle && (
@@ -165,12 +167,23 @@ const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
</div>
)}
{collectError && (
<div className="rounded-full bg-[#6f211c]/90 px-5 py-2 text-base font-semibold text-[#ffe7c2] shadow-lg">
{collectError}
</div>
)}
<FlipActionButton
className="pointer-events-auto border border-[#ffe6a6]/55 bg-linear-to-b from-[#bd8b50] to-[#84512b] px-4 py-1.5 text-base shadow-[0_3px_10px_rgba(0,0,0,0.2)] hover:brightness-105 active:scale-[0.97]"
className="pointer-events-auto border border-[#ffe6a6]/55 bg-linear-to-b from-[#bd8b50] to-[#84512b] px-4 py-1.5 text-base shadow-[0_3px_10px_rgba(0,0,0,0.2)] hover:brightness-105 active:scale-[0.97] disabled:cursor-wait disabled:opacity-85"
disabled={confirming}
onClick={onCollect}
>
收下
<span className="flex items-center justify-center gap-2">
{confirming && (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-[#fff4d0]/40 border-t-[#fff4d0]" />
)}
{confirming ? '正在收下...' : collectError ? '重新收下' : '收下'}
</span>
</FlipActionButton>
</motion.div>
)}