fix: 完善学习进度同步与拼图收集流程
统一阶段拼图配置和进度上报,增加同步写入协调与失败重试,优化收集确认及同步反馈,并展示菜单探索进度。补充相关回归测试与字体素材。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
6491b0c918
commit
9ab67b1bd3
63 files changed
+2141
-360
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user