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
Binary file not shown.
|
After Width: | Height: | Size: 751 B |
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { puzzleDataConfig } from './puzzleData'
|
||||
import { PUZZLE_RESIDENCES } from './puzzleResidences'
|
||||
import {
|
||||
getMenuModuleProgress,
|
||||
isMenuModuleComplete,
|
||||
isMenuModuleStarted,
|
||||
type PuzzleProgress,
|
||||
} from './progression'
|
||||
|
||||
const moduleName = 'revolutionary'
|
||||
const moduleResidences = PUZZLE_RESIDENCES.filter((item) => item.moduleName === moduleName)
|
||||
const totalPieces = moduleResidences.reduce(
|
||||
(total, item) => total + Object.keys(puzzleDataConfig[item.residence] ?? {}).length,
|
||||
0
|
||||
)
|
||||
|
||||
describe('menu module progress', () => {
|
||||
it('distinguishes unexplored, in-progress, and complete modules', () => {
|
||||
const unexplored: PuzzleProgress = {}
|
||||
|
||||
expect(isMenuModuleStarted(moduleName, unexplored)).toBe(false)
|
||||
expect(isMenuModuleComplete(moduleName, unexplored)).toBe(false)
|
||||
expect(getMenuModuleProgress(moduleName, unexplored)).toBe(0)
|
||||
|
||||
const firstResidence = moduleResidences[0]!
|
||||
const firstPieceId = Number(Object.keys(puzzleDataConfig[firstResidence.residence] ?? {})[0])
|
||||
const inProgress: PuzzleProgress = {
|
||||
[firstResidence.residence]: new Set([firstPieceId]),
|
||||
}
|
||||
|
||||
expect(isMenuModuleStarted(moduleName, inProgress)).toBe(true)
|
||||
expect(isMenuModuleComplete(moduleName, inProgress)).toBe(false)
|
||||
expect(getMenuModuleProgress(moduleName, inProgress)).toBe(Math.round(100 / totalPieces))
|
||||
|
||||
const complete = Object.fromEntries(
|
||||
moduleResidences.map(({ residence }) => [
|
||||
residence,
|
||||
new Set(Object.keys(puzzleDataConfig[residence] ?? {}).map(Number)),
|
||||
])
|
||||
)
|
||||
|
||||
expect(isMenuModuleStarted(moduleName, complete)).toBe(true)
|
||||
expect(isMenuModuleComplete(moduleName, complete)).toBe(true)
|
||||
expect(getMenuModuleProgress(moduleName, complete)).toBe(100)
|
||||
})
|
||||
})
|
||||
+20
-11
@@ -1,3 +1,4 @@
|
||||
import { getStagePieceIds } from './puzzleStages'
|
||||
import { puzzleDataConfig } from './puzzleData'
|
||||
import { PUZZLE_RESIDENCES } from './puzzleResidences'
|
||||
|
||||
@@ -55,13 +56,21 @@ export const isMenuModuleComplete = (moduleName: string, puzzles: PuzzleProgress
|
||||
return residences.every((item) => isResidenceComplete(item.residence, puzzles))
|
||||
}
|
||||
|
||||
const getPieceIdsByRoute = (residence: string, routePrefix: string) => {
|
||||
const pieces = puzzleDataConfig[residence]
|
||||
if (!pieces) return []
|
||||
export const getMenuModuleProgress = (moduleName: string, puzzles: PuzzleProgress) => {
|
||||
const residences = PUZZLE_RESIDENCES.filter((item) => item.moduleName === moduleName)
|
||||
let totalPieces = 0
|
||||
let unlockedPieces = 0
|
||||
|
||||
return Object.entries(pieces)
|
||||
.filter(([, piece]) => piece.route.startsWith(routePrefix))
|
||||
.map(([pieceId]) => Number(pieceId))
|
||||
residences.forEach(({ residence }) => {
|
||||
const configuredPieceIds = getConfiguredPieceIds(residence)
|
||||
const residencePuzzles = puzzles[residence]
|
||||
|
||||
totalPieces += configuredPieceIds.length
|
||||
unlockedPieces += configuredPieceIds.filter((pieceId) => residencePuzzles?.has(pieceId)).length
|
||||
})
|
||||
|
||||
if (totalPieces === 0) return 0
|
||||
return Math.round((unlockedPieces / totalPieces) * 100)
|
||||
}
|
||||
|
||||
export const getResidenceStageUnlocks = (residence: string, puzzles: PuzzleProgress) => {
|
||||
@@ -71,10 +80,10 @@ export const getResidenceStageUnlocks = (residence: string, puzzles: PuzzleProgr
|
||||
dialogueUnlocked: residenceUnlocked,
|
||||
reflectionUnlocked:
|
||||
residenceUnlocked &&
|
||||
arePiecesUnlocked(residence, puzzles, getPieceIdsByRoute(residence, 'place')),
|
||||
arePiecesUnlocked(residence, puzzles, getStagePieceIds(residence, 'dialogue')),
|
||||
internalizationUnlocked:
|
||||
residenceUnlocked &&
|
||||
arePiecesUnlocked(residence, puzzles, getPieceIdsByRoute(residence, 'reflection')),
|
||||
arePiecesUnlocked(residence, puzzles, getStagePieceIds(residence, 'reflection')),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,17 +106,17 @@ export const getResidenceStageCompletion = (
|
||||
const dialogueComplete = arePiecesUnlocked(
|
||||
residence,
|
||||
puzzles,
|
||||
getPieceIdsByRoute(residence, 'place')
|
||||
getStagePieceIds(residence, 'dialogue')
|
||||
)
|
||||
const reflectionComplete = arePiecesUnlocked(
|
||||
residence,
|
||||
puzzles,
|
||||
getPieceIdsByRoute(residence, 'reflection')
|
||||
getStagePieceIds(residence, 'reflection')
|
||||
)
|
||||
const internalizationComplete = arePiecesUnlocked(
|
||||
residence,
|
||||
puzzles,
|
||||
getPieceIdsByRoute(residence, 'internalization')
|
||||
getStagePieceIds(residence, 'internalization')
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const sourceFiles = (directory: string): string[] =>
|
||||
readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name)
|
||||
return entry.isDirectory() ? sourceFiles(path) : /\.tsx?$/.test(path) ? [path] : []
|
||||
})
|
||||
describe('single collection reporting entry', () => {
|
||||
it('keeps every page free of independent course completion reports', () => {
|
||||
for (const path of sourceFiles('src/pages')) {
|
||||
expect(readFileSync(path, 'utf8'), path).not.toMatch(
|
||||
/commitCourseCheckpoint|setCourseTimeProgress|finishCourse/
|
||||
)
|
||||
}
|
||||
})
|
||||
it('waits for successful collection before starting the fly animation', () => {
|
||||
const source = readFileSync('src/components/layout/ResidenceLayout.tsx', 'utf8')
|
||||
const handler = source.slice(
|
||||
source.indexOf('const handleCollectPuzzlePiece'),
|
||||
source.indexOf('// 处理碎片动画触发')
|
||||
)
|
||||
expect(handler.indexOf('await unlockPuzzle')).toBeLessThan(handler.indexOf('if (!saved)'))
|
||||
expect(handler.indexOf('if (!saved)')).toBeLessThan(
|
||||
handler.indexOf('await waitForElementsAndAnimate')
|
||||
)
|
||||
expect(handler).toContain('collectionLockRef.current = true')
|
||||
expect(handler).toContain('view !== collectionViewRef.current')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getPuzzleCheckpoint, getStagePieceIds, type PuzzleStage } from './puzzleStages'
|
||||
|
||||
const cases: [string, number[][]][] = [
|
||||
['maozedong', [Array.from({ length: 12 }, (_, i) => i + 1), [13, 14], [15, 16]]],
|
||||
['sunzhongshan', [Array.from({ length: 11 }, (_, i) => i + 1), [12, 13, 14], [15, 16]]],
|
||||
['luxun', [Array.from({ length: 13 }, (_, i) => i + 1), [14, 15], [16]]],
|
||||
['huangyanpei', [Array.from({ length: 13 }, (_, i) => i + 1), [14, 15], [16]]],
|
||||
['nieer', [Array.from({ length: 13 }, (_, i) => i + 1), [14], [15, 16]]],
|
||||
['zhangleping', [Array.from({ length: 13 }, (_, i) => i + 1), [14], [15, 16]]],
|
||||
['rongdesheng', [Array.from({ length: 13 }, (_, i) => i + 1), [14], [15, 16]]],
|
||||
['chenyun', [Array.from({ length: 12 }, (_, i) => i + 1), [13, 14], [15, 16]]],
|
||||
]
|
||||
|
||||
describe('puzzle stage checkpoints', () => {
|
||||
it.each(cases)(
|
||||
'matches all configured stage IDs and course orders for %s',
|
||||
(residence, stages) => {
|
||||
const index = cases.findIndex(([id]) => id === residence)
|
||||
;(['dialogue', 'reflection', 'internalization'] as PuzzleStage[]).forEach((stage, offset) => {
|
||||
expect(getStagePieceIds(residence, stage)).toEqual(stages[offset])
|
||||
for (const piece of stages[offset]!) {
|
||||
expect(getPuzzleCheckpoint(residence, piece)).toEqual({
|
||||
stage,
|
||||
order: index * 3 + offset + 1,
|
||||
pieceIds: stages[offset],
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
it('rejects unknown residences and invalid pieces', () => {
|
||||
for (const piece of [0, 17, 1.5, NaN]) expect(getPuzzleCheckpoint('nieer', piece)).toBeNull()
|
||||
expect(getPuzzleCheckpoint('unknown', 14)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { puzzleDataConfig } from './puzzleData'
|
||||
|
||||
export const PUZZLE_STAGE_ROUTES = {
|
||||
dialogue: 'place',
|
||||
reflection: 'reflection',
|
||||
internalization: 'internalization',
|
||||
} as const
|
||||
export type PuzzleStage = keyof typeof PUZZLE_STAGE_ROUTES
|
||||
|
||||
const CHECKPOINT_ORDERS: Record<string, Record<PuzzleStage, number>> = {
|
||||
maozedong: { dialogue: 1, reflection: 2, internalization: 3 },
|
||||
sunzhongshan: { dialogue: 4, reflection: 5, internalization: 6 },
|
||||
luxun: { dialogue: 7, reflection: 8, internalization: 9 },
|
||||
huangyanpei: { dialogue: 10, reflection: 11, internalization: 12 },
|
||||
nieer: { dialogue: 13, reflection: 14, internalization: 15 },
|
||||
zhangleping: { dialogue: 16, reflection: 17, internalization: 18 },
|
||||
rongdesheng: { dialogue: 19, reflection: 20, internalization: 21 },
|
||||
chenyun: { dialogue: 22, reflection: 23, internalization: 24 },
|
||||
}
|
||||
|
||||
export const getStagePieceIds = (residence: string, stage: PuzzleStage): number[] =>
|
||||
Object.entries(puzzleDataConfig[residence] ?? {})
|
||||
.filter(([, piece]) => piece.route.startsWith(PUZZLE_STAGE_ROUTES[stage]))
|
||||
.map(([id]) => Number(id))
|
||||
|
||||
export const getPuzzleCheckpoint = (residence: string, pieceId: number) => {
|
||||
const orders = CHECKPOINT_ORDERS[residence]
|
||||
if (!orders || !Number.isInteger(pieceId)) return null
|
||||
for (const stage of Object.keys(PUZZLE_STAGE_ROUTES) as PuzzleStage[]) {
|
||||
const pieceIds = getStagePieceIds(residence, stage)
|
||||
if (pieceIds.includes(pieceId)) return { stage, order: orders[stage], pieceIds }
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -10,6 +10,14 @@
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'DingTalk-JinBuTi';
|
||||
src: url('/fonts/DingTalk-JinBuTi.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* *{ */
|
||||
/* margin: 0;
|
||||
padding: 0; */
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const state = { yibanConnectionStatus: 'unknown' }
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
state.yibanConnectionStatus = 'unknown'
|
||||
vi.stubGlobal('videoAjax', () => ({ setHost: vi.fn() }))
|
||||
vi.doMock('@/store/userStore', () => ({ useUserStore: { getState: () => state } }))
|
||||
})
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('explicit progress backend parsing', () => {
|
||||
it('does not unwrap a Yiban payload based on an unrelated state field during bootstrap', async () => {
|
||||
const { parseUserDefineContent } = await import('./api-common')
|
||||
const raw = { puzzles: { nieer: [14] }, state: { puzzles: {} } }
|
||||
expect(parseUserDefineContent(JSON.stringify(raw), { backend: 'yiban' })).toEqual(raw)
|
||||
})
|
||||
it('unwraps session persist payloads even while the connection is connected', async () => {
|
||||
const { parseUserDefineContent } = await import('./api-common')
|
||||
state.yibanConnectionStatus = 'connected'
|
||||
expect(
|
||||
parseUserDefineContent('{"state":{"puzzles":{"nieer":[14]}}}', { backend: 'session' })
|
||||
).toEqual({ puzzles: { nieer: [14] } })
|
||||
})
|
||||
it('keeps automatic parsing compatible with the connection backend', async () => {
|
||||
const { parseUserDefineContent } = await import('./api-common')
|
||||
expect(parseUserDefineContent('{"state":{"puzzles":{}}}')).toEqual({ puzzles: {} })
|
||||
state.yibanConnectionStatus = 'connected'
|
||||
expect(parseUserDefineContent('{"puzzles":{}}')).toEqual({ puzzles: {} })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const storage = new Map<string, string>()
|
||||
const sessionStorageMock: Storage = {
|
||||
get length() {
|
||||
return storage.size
|
||||
},
|
||||
clear: () => storage.clear(),
|
||||
getItem: (key) => storage.get(key) ?? null,
|
||||
key: (index) => [...storage.keys()][index] ?? null,
|
||||
removeItem: (key) => storage.delete(key),
|
||||
setItem: (key, value) => storage.set(key, value),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
storage.clear()
|
||||
vi.stubGlobal('sessionStorage', sessionStorageMock)
|
||||
vi.stubGlobal('videoAjax', () => ({
|
||||
setHost: vi.fn(),
|
||||
addUserDefine: vi.fn(),
|
||||
getUserDefineInfoByUser: vi.fn(),
|
||||
getUserDefineInfo: vi.fn(),
|
||||
finishCourse: vi.fn(),
|
||||
}))
|
||||
vi.doMock('@/store/userStore', () => ({
|
||||
useUserStore: {
|
||||
getState: () => ({
|
||||
yibanConnectionStatus: 'disconnected',
|
||||
courseId: null,
|
||||
}),
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
describe('non-Yiban writes', () => {
|
||||
it('stores user data in sessionStorage without entering the blocking coordinator', async () => {
|
||||
const { setUserDefine } = await import('./api-common')
|
||||
const { getSyncWriteState } = await import('./syncWriteCoordinator')
|
||||
|
||||
await expect(setUserDefine('lesson', 1, 'value', { critical: true })).resolves.toMatchObject({
|
||||
success: true,
|
||||
})
|
||||
expect(sessionStorage.getItem('hsgrl_lesson_1')).toBe('value')
|
||||
expect(getSyncWriteState().phase).toBe('idle')
|
||||
})
|
||||
|
||||
it('skips course progress without entering the blocking coordinator', async () => {
|
||||
const { setCourseTimeProgress } = await import('./api-common')
|
||||
const { getSyncWriteState } = await import('./syncWriteCoordinator')
|
||||
|
||||
await expect(setCourseTimeProgress(1)).resolves.toMatchObject({
|
||||
success: true,
|
||||
skipped: true,
|
||||
})
|
||||
expect(getSyncWriteState().phase).toBe('idle')
|
||||
})
|
||||
})
|
||||
+106
-22
@@ -1,4 +1,12 @@
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import {
|
||||
runCallbackWriteRound,
|
||||
type WriteResponse,
|
||||
type WriteRoundResult,
|
||||
} from './callbackWriteRetry'
|
||||
import { enqueueSyncWrite, type WritePriority, type WriteResult } from './syncWriteCoordinator'
|
||||
|
||||
export type { WriteResult } from './syncWriteCoordinator'
|
||||
|
||||
//超时时间
|
||||
const TIMEOUT_MS = 1000 * 6
|
||||
@@ -34,9 +42,17 @@ export interface UserDefineFetchResult {
|
||||
message?: string
|
||||
}
|
||||
|
||||
type VideoAjaxWriteCallback<T = unknown> = (
|
||||
successOrResponse: boolean | WriteResponse<T>,
|
||||
response?: WriteResponse<T>
|
||||
) => void
|
||||
|
||||
interface VideoAjax {
|
||||
setHost(host: string): void
|
||||
addUserDefine(param: UserDefineParam): void
|
||||
addUserDefine(
|
||||
param: UserDefineParam,
|
||||
callback: VideoAjaxWriteCallback<Record<string, unknown>>
|
||||
): void
|
||||
getUserDefineInfoByUser(
|
||||
param: UserDefineParam,
|
||||
callback: (data: VideoAjaxResponse | undefined) => void
|
||||
@@ -45,7 +61,10 @@ interface VideoAjax {
|
||||
param: UserDefineParam,
|
||||
callback: (data: VideoAjaxResponse | undefined) => void
|
||||
): void
|
||||
finishCourse(param: { order: number; co: string; process: number }): void
|
||||
finishCourse(
|
||||
param: { order: number; co: string; process: number },
|
||||
callback: VideoAjaxWriteCallback<number>
|
||||
): void
|
||||
}
|
||||
|
||||
const LESSON_PREFIX = 'hsgrl'
|
||||
@@ -56,6 +75,16 @@ export interface StorageOptions {
|
||||
backend?: StorageBackend
|
||||
}
|
||||
|
||||
export interface WriteOptions extends StorageOptions {
|
||||
critical?: boolean
|
||||
/** 内部关键事务使用:由外层协调器持有 loading 与重试状态。 */
|
||||
bypassQueue?: boolean
|
||||
/** 内部关键事务共享的剩余截止时间。 */
|
||||
deadlineMs?: number
|
||||
/** 由调用组件提供行内失败反馈时,关闭全局 Toast。 */
|
||||
silent?: boolean
|
||||
}
|
||||
|
||||
const getStorageKey = (lessonId: string | number, key: string | number) =>
|
||||
`${LESSON_PREFIX}_${lessonId}_${key}`
|
||||
|
||||
@@ -67,18 +96,44 @@ const resolveStorageBackend = (backend: StorageBackend = 'auto'): 'yiban' | 'ses
|
||||
const videoajax: VideoAjax = videoAjax()
|
||||
videoajax.setHost('/')
|
||||
|
||||
const getWriteResponse = <T>(
|
||||
successOrResponse: boolean | WriteResponse<T>,
|
||||
response?: WriteResponse<T>
|
||||
): WriteResponse<T> | undefined =>
|
||||
typeof successOrResponse === 'boolean' ? response : successOrResponse
|
||||
|
||||
const toWriteResult = <T>(result: WriteRoundResult<T>): WriteResult =>
|
||||
result.success
|
||||
? {
|
||||
success: true,
|
||||
code: result.code,
|
||||
attempts: result.attempts,
|
||||
message: result.response.msg,
|
||||
}
|
||||
: {
|
||||
success: false,
|
||||
code: result.code,
|
||||
attempts: result.attempts,
|
||||
message: result.message || '学习进度保存失败',
|
||||
}
|
||||
|
||||
// lessonId(课时唯一ID)
|
||||
export function setUserDefine(
|
||||
export async function setUserDefine(
|
||||
lessonId: string | number,
|
||||
key: string | number,
|
||||
value: string,
|
||||
options: StorageOptions = {}
|
||||
): void {
|
||||
options: WriteOptions = {}
|
||||
): Promise<WriteResult> {
|
||||
const backend = resolveStorageBackend(options.backend)
|
||||
|
||||
if (backend === 'session') {
|
||||
sessionStorage.setItem(getStorageKey(lessonId, key), value)
|
||||
return
|
||||
try {
|
||||
sessionStorage.setItem(getStorageKey(lessonId, key), value)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Failed to save user define to sessionStorage', error)
|
||||
return { success: false, message: '本地学习进度保存失败' }
|
||||
}
|
||||
}
|
||||
|
||||
const param: UserDefineParam = {
|
||||
@@ -86,7 +141,25 @@ export function setUserDefine(
|
||||
key,
|
||||
content: value,
|
||||
}
|
||||
videoajax.addUserDefine(param)
|
||||
const priority: WritePriority = options.critical ? 'critical' : 'background'
|
||||
const runRound = () =>
|
||||
runCallbackWriteRound<Record<string, unknown>>({
|
||||
deadlineMs: options.deadlineMs,
|
||||
invoke: (callback) => {
|
||||
videoajax.addUserDefine(param, (successOrResponse, response) => {
|
||||
callback(getWriteResponse(successOrResponse, response))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
if (options.bypassQueue) return toWriteResult(await runRound())
|
||||
return enqueueSyncWrite(
|
||||
`user-define:${param.lessonId}:${key}`,
|
||||
priority,
|
||||
runRound,
|
||||
undefined,
|
||||
!options.silent
|
||||
)
|
||||
}
|
||||
|
||||
// 内部:将回调形式封装为 Promise
|
||||
@@ -184,9 +257,9 @@ export async function getUserDefineResult(
|
||||
return getUserDefineResultAsyncInternal(lessonId, key, options)
|
||||
}
|
||||
|
||||
export function parseUserDefineContent<T>(content: string): T {
|
||||
export function parseUserDefineContent<T>(content: string, options: StorageOptions = {}): T {
|
||||
const raw = JSON.parse(content)
|
||||
if (useUserStore.getState().yibanConnectionStatus === 'connected') {
|
||||
if (resolveStorageBackend(options.backend) === 'yiban') {
|
||||
return raw as T
|
||||
}
|
||||
if (raw && typeof raw === 'object' && 'state' in raw) {
|
||||
@@ -196,28 +269,39 @@ export function parseUserDefineContent<T>(content: string): T {
|
||||
}
|
||||
|
||||
// 根据课时顺序号(所处课程的第几课时)更新对应易班平台上的每个环节进度(异步)
|
||||
export async function setCourseTimeProgress(order: number): Promise<boolean> {
|
||||
export async function setCourseTimeProgress(
|
||||
order: number,
|
||||
options: Pick<WriteOptions, 'bypassQueue' | 'deadlineMs'> = {}
|
||||
): Promise<WriteResult> {
|
||||
if (useUserStore.getState().yibanConnectionStatus !== 'connected') {
|
||||
console.info(`[yiban] skip finishCourse: disconnected, order=${order}`)
|
||||
return false
|
||||
return { success: true, skipped: true }
|
||||
}
|
||||
|
||||
const courseid = useUserStore.getState().courseId
|
||||
// var courseid = getCookie(lesson+"_course_id");//获取url链接的课程ID
|
||||
// const courseid = await getLtDefine('10000')
|
||||
|
||||
if (courseid === undefined || courseid === null || courseid === '') {
|
||||
console.info(`[yiban] skip finishCourse: missing courseId, order=${order}`)
|
||||
return false
|
||||
return { success: true, skipped: true }
|
||||
}
|
||||
|
||||
if (order <= 0) {
|
||||
console.info(`[yiban] skip finishCourse: invalid order=${order}`)
|
||||
return false
|
||||
if (!Number.isInteger(order) || order <= 0) {
|
||||
console.error(`[yiban] skip finishCourse: invalid order=${order}`)
|
||||
return { success: false, message: '无效的课时顺序号' }
|
||||
}
|
||||
|
||||
const param2 = { order, co: courseid, process: 100 }
|
||||
console.info('[yiban] finishCourse', param2)
|
||||
videoajax.finishCourse(param2)
|
||||
return true
|
||||
const param = { order, co: courseid, process: 100 }
|
||||
console.info('[yiban] finishCourse', param)
|
||||
const runRound = () =>
|
||||
runCallbackWriteRound<number>({
|
||||
deadlineMs: options.deadlineMs,
|
||||
invoke: (callback) => {
|
||||
videoajax.finishCourse(param, (successOrResponse, response) => {
|
||||
callback(getWriteResponse(successOrResponse, response))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
if (options.bypassQueue) return toWriteResult(await runRound())
|
||||
return enqueueSyncWrite(`course-progress:${courseid}:${order}`, 'critical', runRound)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { runCallbackWriteRound } from './callbackWriteRetry'
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('runCallbackWriteRound', () => {
|
||||
it('resolves on code 200 without depending on data.value', async () => {
|
||||
const invoke = vi.fn((callback) => callback({ code: 200, data: {} }))
|
||||
|
||||
const result = await runCallbackWriteRound({ invoke })
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(invoke).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retries an explicit non-200 response after 500ms', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
let attempts = 0
|
||||
const promise = runCallbackWriteRound({
|
||||
now: () => Date.now(),
|
||||
invoke: (callback) => {
|
||||
attempts += 1
|
||||
callback(attempts === 1 ? { code: 500, msg: '失败' } : { code: 200 })
|
||||
},
|
||||
})
|
||||
|
||||
expect(attempts).toBe(1)
|
||||
await vi.advanceTimersByTimeAsync(499)
|
||||
expect(attempts).toBe(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
|
||||
await expect(promise).resolves.toMatchObject({ success: true, attempts: 2 })
|
||||
})
|
||||
|
||||
it('does not issue another request when no callback arrives', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
const invoke = vi.fn()
|
||||
const promise = runCallbackWriteRound({ invoke, now: () => Date.now() })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
await expect(promise).resolves.toMatchObject({
|
||||
success: false,
|
||||
reason: 'deadline',
|
||||
attempts: 1,
|
||||
})
|
||||
expect(invoke).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('waits for the deadline when the callback has no code', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
const invoke = vi.fn((callback) => callback({ msg: 'invalid' }))
|
||||
const promise = runCallbackWriteRound({ invoke, now: () => Date.now() })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
await expect(promise).resolves.toMatchObject({
|
||||
success: false,
|
||||
reason: 'invalid-response',
|
||||
attempts: 1,
|
||||
})
|
||||
expect(invoke).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
export interface WriteResponse<T = unknown> {
|
||||
code?: number
|
||||
msg?: string
|
||||
data?: {
|
||||
value?: T
|
||||
}
|
||||
}
|
||||
|
||||
export type WriteFailureReason = 'deadline' | 'non-200' | 'invalid-response' | 'invoke-error'
|
||||
|
||||
export type WriteRoundResult<T = unknown> =
|
||||
| {
|
||||
success: true
|
||||
code: 200
|
||||
attempts: number
|
||||
elapsedMs: number
|
||||
response: WriteResponse<T>
|
||||
}
|
||||
| {
|
||||
success: false
|
||||
reason: WriteFailureReason
|
||||
attempts: number
|
||||
elapsedMs: number
|
||||
code?: number
|
||||
message?: string
|
||||
response?: WriteResponse<T>
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
export interface CallbackWriteRetryOptions<T> {
|
||||
invoke: (callback: (response: WriteResponse<T> | undefined) => void) => void
|
||||
deadlineMs?: number
|
||||
retryDelayMs?: number
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
const DEFAULT_DEADLINE_MS = 10_000
|
||||
const DEFAULT_RETRY_DELAY_MS = 500
|
||||
|
||||
export function runCallbackWriteRound<T>({
|
||||
invoke,
|
||||
deadlineMs = DEFAULT_DEADLINE_MS,
|
||||
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
|
||||
now = () => performance.now(),
|
||||
}: CallbackWriteRetryOptions<T>): Promise<WriteRoundResult<T>> {
|
||||
return new Promise((resolve) => {
|
||||
const startedAt = now()
|
||||
const deadline = startedAt + deadlineMs
|
||||
let attempts = 0
|
||||
let settled = false
|
||||
let attemptId = 0
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let lastFailure: Omit<
|
||||
Extract<WriteRoundResult<T>, { success: false }>,
|
||||
'elapsedMs' | 'attempts'
|
||||
> = {
|
||||
success: false,
|
||||
reason: 'deadline',
|
||||
}
|
||||
|
||||
const finish = (result: WriteRoundResult<T>) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(deadlineTimer)
|
||||
if (retryTimer !== undefined) clearTimeout(retryTimer)
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
const failAtDeadline = () => {
|
||||
finish({
|
||||
...lastFailure,
|
||||
success: false,
|
||||
attempts,
|
||||
elapsedMs: Math.max(0, now() - startedAt),
|
||||
})
|
||||
}
|
||||
|
||||
const deadlineTimer = setTimeout(failAtDeadline, deadlineMs)
|
||||
|
||||
const runAttempt = () => {
|
||||
if (settled) return
|
||||
if (now() >= deadline) {
|
||||
failAtDeadline()
|
||||
return
|
||||
}
|
||||
|
||||
attempts += 1
|
||||
const currentAttemptId = ++attemptId
|
||||
let callbackHandled = false
|
||||
|
||||
try {
|
||||
invoke((response) => {
|
||||
if (settled || callbackHandled || currentAttemptId !== attemptId) return
|
||||
callbackHandled = true
|
||||
|
||||
if (now() >= deadline) {
|
||||
failAtDeadline()
|
||||
return
|
||||
}
|
||||
|
||||
if (response?.code === 200) {
|
||||
finish({
|
||||
success: true,
|
||||
code: 200,
|
||||
attempts,
|
||||
elapsedMs: Math.max(0, now() - startedAt),
|
||||
response,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof response?.code !== 'number') {
|
||||
lastFailure = {
|
||||
success: false,
|
||||
reason: 'invalid-response',
|
||||
response,
|
||||
message: response?.msg,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lastFailure = {
|
||||
success: false,
|
||||
reason: 'non-200',
|
||||
code: response.code,
|
||||
message: response.msg,
|
||||
response,
|
||||
}
|
||||
|
||||
const remainingMs = deadline - now()
|
||||
if (remainingMs < retryDelayMs) return
|
||||
retryTimer = setTimeout(runAttempt, retryDelayMs)
|
||||
})
|
||||
} catch (error) {
|
||||
lastFailure = {
|
||||
success: false,
|
||||
reason: 'invoke-error',
|
||||
error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runAttempt()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { WriteOptions, WriteResult } from './api-common'
|
||||
import { MAOZEDONG_KEY, MAOZEDONG_LESSON_ID, saveMaozedongStore } from '@/store/maozedongStore'
|
||||
import {
|
||||
SUNZHONGSHAN_KEY,
|
||||
SUNZHONGSHAN_LESSON_ID,
|
||||
saveSunzhongshanStore,
|
||||
} from '@/store/sunzhongshanStore'
|
||||
import { LUXUN_KEY, LUXUN_LESSON_ID, saveLuxunStore } from '@/store/luxunStore'
|
||||
import {
|
||||
HUANGYANPEI_KEY,
|
||||
HUANGYANPEI_LESSON_ID,
|
||||
saveHuangyanpeiStore,
|
||||
} from '@/store/huangyanpeiStore'
|
||||
import { NIEER_KEY, NIEER_LESSON_ID, saveNieerStore } from '@/store/nieerStore'
|
||||
import {
|
||||
ZHANGLEPING_KEY,
|
||||
ZHANGLEPING_LESSON_ID,
|
||||
saveZhanglepingStore,
|
||||
} from '@/store/zhanglepingStore'
|
||||
import {
|
||||
RONGDESHENG_KEY,
|
||||
RONGDESHENG_LESSON_ID,
|
||||
saveRongdeshengStore,
|
||||
} from '@/store/rongdeshengStore'
|
||||
import { CHENYUN_KEY, CHENYUN_LESSON_ID, saveChenyunStore } from '@/store/chenyunStore'
|
||||
|
||||
type StoreSaver = (options?: WriteOptions) => Promise<WriteResult>
|
||||
|
||||
interface CheckpointStore {
|
||||
save: StoreSaver
|
||||
writeKey: string
|
||||
}
|
||||
|
||||
const getWriteKey = (lessonId: string, key: string | number) =>
|
||||
`user-define:hsgrl_${lessonId}:${key}`
|
||||
|
||||
const getCheckpointStore = (order: number): CheckpointStore | undefined => {
|
||||
if (order <= 3) {
|
||||
return {
|
||||
save: saveMaozedongStore,
|
||||
writeKey: getWriteKey(MAOZEDONG_LESSON_ID, MAOZEDONG_KEY),
|
||||
}
|
||||
}
|
||||
if (order <= 6) {
|
||||
return {
|
||||
save: saveSunzhongshanStore,
|
||||
writeKey: getWriteKey(SUNZHONGSHAN_LESSON_ID, SUNZHONGSHAN_KEY),
|
||||
}
|
||||
}
|
||||
if (order <= 9) {
|
||||
return { save: saveLuxunStore, writeKey: getWriteKey(LUXUN_LESSON_ID, LUXUN_KEY) }
|
||||
}
|
||||
if (order <= 12) {
|
||||
return {
|
||||
save: saveHuangyanpeiStore,
|
||||
writeKey: getWriteKey(HUANGYANPEI_LESSON_ID, HUANGYANPEI_KEY),
|
||||
}
|
||||
}
|
||||
if (order <= 15) {
|
||||
return { save: saveNieerStore, writeKey: getWriteKey(NIEER_LESSON_ID, NIEER_KEY) }
|
||||
}
|
||||
if (order <= 18) {
|
||||
return {
|
||||
save: saveZhanglepingStore,
|
||||
writeKey: getWriteKey(ZHANGLEPING_LESSON_ID, ZHANGLEPING_KEY),
|
||||
}
|
||||
}
|
||||
if (order <= 21) {
|
||||
return {
|
||||
save: saveRongdeshengStore,
|
||||
writeKey: getWriteKey(RONGDESHENG_LESSON_ID, RONGDESHENG_KEY),
|
||||
}
|
||||
}
|
||||
if (order <= 24) {
|
||||
return { save: saveChenyunStore, writeKey: getWriteKey(CHENYUN_LESSON_ID, CHENYUN_KEY) }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const saveCourseCheckpointState = async (order: number): Promise<WriteResult> => {
|
||||
const checkpointStore =
|
||||
Number.isInteger(order) && order > 0 ? getCheckpointStore(order) : undefined
|
||||
if (!checkpointStore) throw new Error(`Unknown course checkpoint order: ${order}`)
|
||||
return checkpointStore.save()
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PuzzleProgress } from '@/config/progression'
|
||||
import type { CollectionIdentity } from './puzzleCollection'
|
||||
import type { WriteResult } from './syncWriteCoordinator'
|
||||
|
||||
let storage: Map<string, string>
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
storage = new Map()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
})
|
||||
})
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
const deferred = <T>() => {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
const fixture = async (initial: PuzzleProgress = {}) => {
|
||||
const { createPuzzleCollector } = await import('./puzzleCollection')
|
||||
const coordinator = await import('./syncWriteCoordinator')
|
||||
const identity: CollectionIdentity = {
|
||||
uid: 'student',
|
||||
courseId: 'course',
|
||||
backend: 'yiban',
|
||||
revision: 0,
|
||||
loaded: true,
|
||||
}
|
||||
let puzzles = initial
|
||||
const report = vi
|
||||
.fn<(order: number) => Promise<WriteResult>>()
|
||||
.mockResolvedValue({ success: true })
|
||||
const save = vi.fn<() => Promise<WriteResult>>().mockResolvedValue({ success: true })
|
||||
const commit = vi.fn((next: PuzzleProgress) => {
|
||||
puzzles = next
|
||||
})
|
||||
const collector = createPuzzleCollector({
|
||||
getIdentity: () => ({ ...identity }),
|
||||
getPuzzles: () => puzzles,
|
||||
report,
|
||||
save,
|
||||
commit,
|
||||
})
|
||||
return { ...collector, coordinator, identity, report, save, commit, getPuzzles: () => puzzles }
|
||||
}
|
||||
const waitForFailure = async (f: Awaited<ReturnType<typeof fixture>>) => {
|
||||
await vi.waitFor(() => expect(f.coordinator.getSyncWriteState().phase).toBe('failed'))
|
||||
}
|
||||
|
||||
describe('puzzle collection transaction', () => {
|
||||
it('waits for report, then save, then local commit', async () => {
|
||||
const f = await fixture()
|
||||
const report = deferred<WriteResult>()
|
||||
const save = deferred<WriteResult>()
|
||||
f.report.mockReturnValue(report.promise)
|
||||
f.save.mockReturnValue(save.promise)
|
||||
const claim = f.collect('nieer', 14)
|
||||
await vi.waitFor(() =>
|
||||
expect(f.report).toHaveBeenCalledWith(14, expect.objectContaining({ bypassQueue: true }))
|
||||
)
|
||||
expect(f.save).not.toHaveBeenCalled()
|
||||
expect(f.commit).not.toHaveBeenCalled()
|
||||
report.resolve({ success: true })
|
||||
await vi.waitFor(() => expect(f.save).toHaveBeenCalledTimes(1))
|
||||
expect(f.commit).not.toHaveBeenCalled()
|
||||
save.resolve({ success: true })
|
||||
await expect(claim).resolves.toBe(true)
|
||||
expect(f.getPuzzles().nieer?.has(14)).toBe(true)
|
||||
expect(storage.size).toBe(0)
|
||||
})
|
||||
it('does not save on report failure, and retries report before save', async () => {
|
||||
const f = await fixture()
|
||||
f.report.mockResolvedValueOnce({ success: false })
|
||||
const claim = f.collect('nieer', 14)
|
||||
await waitForFailure(f)
|
||||
expect(f.save).not.toHaveBeenCalled()
|
||||
expect(f.commit).not.toHaveBeenCalled()
|
||||
f.coordinator.retryActiveSyncWrite()
|
||||
await expect(claim).resolves.toBe(true)
|
||||
expect(f.report).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
it('retries only save after an acknowledged report', async () => {
|
||||
const f = await fixture()
|
||||
f.save.mockResolvedValueOnce({ success: false })
|
||||
const claim = f.collect('nieer', 14)
|
||||
await waitForFailure(f)
|
||||
expect(f.commit).not.toHaveBeenCalled()
|
||||
expect(storage.size).toBe(1)
|
||||
f.coordinator.retryActiveSyncWrite()
|
||||
await expect(claim).resolves.toBe(true)
|
||||
expect(f.report).toHaveBeenCalledTimes(1)
|
||||
expect(f.save).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
it('recovers a report acknowledgement after a reload without reporting again', async () => {
|
||||
const first = await fixture()
|
||||
first.save.mockResolvedValue({ success: false })
|
||||
const abandoned = first.collect('nieer', 14)
|
||||
await waitForFailure(first)
|
||||
first.identity.revision++
|
||||
first.coordinator.retryActiveSyncWrite()
|
||||
await expect(abandoned).resolves.toBe(false)
|
||||
vi.resetModules()
|
||||
const next = await fixture()
|
||||
await expect(next.collect('nieer', 14)).resolves.toBe(true)
|
||||
expect(next.report).not.toHaveBeenCalled()
|
||||
expect(storage.size).toBe(0)
|
||||
})
|
||||
it('does not reuse another identity’s acknowledgement', async () => {
|
||||
const first = await fixture()
|
||||
first.save.mockResolvedValue({ success: false })
|
||||
const claim = first.collect('nieer', 14)
|
||||
await waitForFailure(first)
|
||||
first.identity.uid = 'another'
|
||||
first.coordinator.retryActiveSyncWrite()
|
||||
await claim
|
||||
const next = await fixture()
|
||||
next.identity.uid = 'another'
|
||||
await next.collect('nieer', 14)
|
||||
expect(next.report).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('deduplicates same-frame clicks', async () => {
|
||||
const f = await fixture()
|
||||
const first = f.collect('nieer', 14)
|
||||
const second = f.collect('nieer', 14)
|
||||
expect(first).toBe(second)
|
||||
await first
|
||||
expect(f.report).toHaveBeenCalledTimes(1)
|
||||
expect(f.save).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('serializes missing pieces and reports only when the actual last piece completes the set', async () => {
|
||||
const f = await fixture({ maozedong: new Set([14]) })
|
||||
await expect(f.collect('maozedong', 13)).resolves.toBe(true)
|
||||
expect(f.report).toHaveBeenCalledWith(2, expect.anything())
|
||||
const g = await fixture()
|
||||
await Promise.all([
|
||||
g.collect('maozedong', 14),
|
||||
g.collect('maozedong', 13),
|
||||
g.collect('nieer', 1),
|
||||
])
|
||||
expect(g.getPuzzles().maozedong).toEqual(new Set([14, 13]))
|
||||
expect(g.getPuzzles().nieer).toEqual(new Set([1]))
|
||||
expect(g.report).toHaveBeenCalledTimes(1)
|
||||
expect(g.report).toHaveBeenCalledWith(2, expect.anything())
|
||||
})
|
||||
it('does not backfill or report already-collected historical pieces', async () => {
|
||||
const f = await fixture({ nieer: new Set([14]) })
|
||||
await expect(f.collect('nieer', 14)).resolves.toBe(true)
|
||||
expect(f.report).not.toHaveBeenCalled()
|
||||
expect(f.save).not.toHaveBeenCalled()
|
||||
})
|
||||
it('supports local-only collection without course reporting', async () => {
|
||||
const f = await fixture()
|
||||
f.identity.backend = 'session'
|
||||
f.identity.courseId = null
|
||||
await expect(f.collect('nieer', 14)).resolves.toBe(true)
|
||||
expect(f.report).not.toHaveBeenCalled()
|
||||
expect(f.save).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ backend: 'session' })
|
||||
)
|
||||
})
|
||||
it('keeps ordinary and local claims uncollected on save failure', async () => {
|
||||
const f = await fixture()
|
||||
f.save.mockResolvedValue({ success: false })
|
||||
await expect(f.collect('nieer', 1)).resolves.toBe(false)
|
||||
f.identity.backend = 'session'
|
||||
await expect(f.collect('nieer', 14)).resolves.toBe(false)
|
||||
expect(f.commit).not.toHaveBeenCalled()
|
||||
})
|
||||
it.each([
|
||||
{ success: true, skipped: true },
|
||||
{ success: true, superseded: true },
|
||||
])('rejects an unconfirmed report result %j', async (result) => {
|
||||
const f = await fixture()
|
||||
f.report.mockResolvedValueOnce(result)
|
||||
const claim = f.collect('nieer', 14)
|
||||
await waitForFailure(f)
|
||||
expect(f.save).not.toHaveBeenCalled()
|
||||
f.coordinator.retryActiveSyncWrite()
|
||||
await claim
|
||||
})
|
||||
it('rejects a superseded save rather than publishing the piece', async () => {
|
||||
const f = await fixture()
|
||||
f.save.mockResolvedValueOnce({ success: true, superseded: true })
|
||||
await expect(f.collect('nieer', 1)).resolves.toBe(false)
|
||||
expect(f.commit).not.toHaveBeenCalled()
|
||||
})
|
||||
it('does not commit a stale identity after an outstanding report', async () => {
|
||||
const f = await fixture()
|
||||
const report = deferred<WriteResult>()
|
||||
f.report.mockReturnValue(report.promise)
|
||||
const claim = f.collect('nieer', 14)
|
||||
await vi.waitFor(() => expect(f.report).toHaveBeenCalledTimes(1))
|
||||
f.identity.uid = 'another'
|
||||
report.resolve({ success: true })
|
||||
await expect(claim).resolves.toBe(false)
|
||||
expect(f.save).not.toHaveBeenCalled()
|
||||
expect(f.commit).not.toHaveBeenCalled()
|
||||
})
|
||||
it('cancels after a reset/load while a save is outstanding', async () => {
|
||||
const f = await fixture()
|
||||
const save = deferred<WriteResult>()
|
||||
f.save.mockReturnValue(save.promise)
|
||||
const claim = f.collect('nieer', 1)
|
||||
await vi.waitFor(() => expect(f.save).toHaveBeenCalledTimes(1))
|
||||
f.identity.revision++
|
||||
save.resolve({ success: true })
|
||||
await expect(claim).resolves.toBe(false)
|
||||
expect(f.commit).not.toHaveBeenCalled()
|
||||
})
|
||||
it('handles exceptions and blocked storage without trapping the retry queue', async () => {
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: () => {
|
||||
throw new Error('blocked')
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error('blocked')
|
||||
},
|
||||
removeItem: () => {
|
||||
throw new Error('blocked')
|
||||
},
|
||||
})
|
||||
const f = await fixture()
|
||||
f.save.mockRejectedValueOnce(new Error('network exception'))
|
||||
const claim = f.collect('nieer', 14)
|
||||
await waitForFailure(f)
|
||||
f.coordinator.retryActiveSyncWrite()
|
||||
await expect(claim).resolves.toBe(true)
|
||||
expect(f.report).toHaveBeenCalledTimes(1)
|
||||
await expect(f.collect('nieer', 1)).resolves.toBe(true)
|
||||
})
|
||||
it('rejects invalid or not-yet-loaded claims without any writes', async () => {
|
||||
const f = await fixture()
|
||||
await expect(f.collect('unknown', 1)).resolves.toBe(false)
|
||||
await expect(f.collect('nieer', 17)).resolves.toBe(false)
|
||||
f.identity.loaded = false
|
||||
await expect(f.collect('nieer', 14)).resolves.toBe(false)
|
||||
expect(f.save).not.toHaveBeenCalled()
|
||||
expect(f.report).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
import { getPuzzleCheckpoint } from '@/config/puzzleStages'
|
||||
import type { PuzzleProgress } from '@/config/progression'
|
||||
import { enqueueSyncWrite, type WriteResult } from './syncWriteCoordinator'
|
||||
import type { WriteOptions } from './api-common'
|
||||
import type { WriteRoundResult } from './callbackWriteRetry'
|
||||
|
||||
export interface CollectionIdentity {
|
||||
uid: string | null
|
||||
courseId: string | null
|
||||
backend: 'yiban' | 'session'
|
||||
revision: number
|
||||
loaded: boolean
|
||||
}
|
||||
|
||||
interface CollectionDependencies {
|
||||
getIdentity: () => CollectionIdentity
|
||||
getPuzzles: () => PuzzleProgress
|
||||
save: (puzzles: PuzzleProgress, options: WriteOptions) => Promise<WriteResult>
|
||||
report: (order: number, options: WriteOptions) => Promise<WriteResult>
|
||||
commit: (puzzles: PuzzleProgress) => void
|
||||
}
|
||||
|
||||
const ACK_PREFIX = 'puzzle-collection-report:v1:'
|
||||
const scopeKey = (identity: CollectionIdentity) =>
|
||||
JSON.stringify([identity.uid, identity.courseId, identity.backend])
|
||||
const identityKey = (identity: CollectionIdentity) =>
|
||||
JSON.stringify([scopeKey(identity), identity.revision, identity.loaded])
|
||||
const toRound = (result: WriteResult): WriteRoundResult =>
|
||||
result.success && !result.skipped && !result.superseded
|
||||
? {
|
||||
success: true,
|
||||
code: 200,
|
||||
attempts: result.attempts ?? 1,
|
||||
elapsedMs: 0,
|
||||
response: { code: 200 },
|
||||
}
|
||||
: {
|
||||
success: false,
|
||||
reason: 'non-200',
|
||||
attempts: result.attempts ?? 1,
|
||||
elapsedMs: 0,
|
||||
message: result.message || '上报或碎片保存未成功,请重新尝试。',
|
||||
}
|
||||
|
||||
// All puzzle sets share one remote record, so serialize across residences as well as pieces.
|
||||
export const createPuzzleCollector = (dependencies: CollectionDependencies) => {
|
||||
let tail: Promise<unknown> = Promise.resolve()
|
||||
const pending = new Map<string, Promise<boolean>>()
|
||||
const acknowledged = new Set<string>()
|
||||
const ackKey = (
|
||||
identity: CollectionIdentity,
|
||||
residence: string,
|
||||
order: number,
|
||||
pieceId: number
|
||||
) => `${ACK_PREFIX}${encodeURIComponent(scopeKey(identity))}:${residence}:${order}:${pieceId}`
|
||||
const hasAck = (key: string) => {
|
||||
if (acknowledged.has(key)) return true
|
||||
try {
|
||||
if (localStorage.getItem(key) === '1') {
|
||||
acknowledged.add(key)
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// In-memory acknowledgement still supports retry when browser storage is unavailable.
|
||||
}
|
||||
return false
|
||||
}
|
||||
const recordAck = (key: string) => {
|
||||
acknowledged.add(key)
|
||||
try {
|
||||
localStorage.setItem(key, '1')
|
||||
} catch {
|
||||
/* Retry remains available in memory. */
|
||||
}
|
||||
}
|
||||
const clearAck = (key: string) => {
|
||||
acknowledged.delete(key)
|
||||
try {
|
||||
localStorage.removeItem(key)
|
||||
} catch {
|
||||
/* A saved piece never triggers another report. */
|
||||
}
|
||||
}
|
||||
|
||||
const collect = (residence: string, pieceId: number): Promise<boolean> => {
|
||||
const checkpoint = getPuzzleCheckpoint(residence, pieceId)
|
||||
const identity = dependencies.getIdentity()
|
||||
if (!checkpoint || !identity.loaded) return Promise.resolve(false)
|
||||
const capturedIdentity = identityKey(identity)
|
||||
const isCurrent = () => identityKey(dependencies.getIdentity()) === capturedIdentity
|
||||
const key = `${capturedIdentity}:${residence}:${pieceId}`
|
||||
const existing = pending.get(key)
|
||||
if (existing) return existing
|
||||
const acknowledgementKey = ackKey(identity, residence, checkpoint.order, pieceId)
|
||||
const run = async () => {
|
||||
if (!isCurrent()) return false
|
||||
const puzzles = dependencies.getPuzzles()
|
||||
const pieces = puzzles[residence] ?? new Set<number>()
|
||||
if (pieces.has(pieceId)) {
|
||||
clearAck(acknowledgementKey)
|
||||
return true
|
||||
}
|
||||
const candidate = { ...puzzles, [residence]: new Set([...pieces, pieceId]) }
|
||||
const isLastPiece = checkpoint.pieceIds.every((id) => candidate[residence]!.has(id))
|
||||
let committed = false
|
||||
const runRound = async (): Promise<WriteRoundResult> => {
|
||||
// Identity/load changes cancel the stale claim and release the coordinator, without writes.
|
||||
if (!isCurrent()) return toRound({ success: true })
|
||||
try {
|
||||
if (isLastPiece && identity.backend === 'yiban' && !hasAck(acknowledgementKey)) {
|
||||
const report = await dependencies.report(checkpoint.order, {
|
||||
bypassQueue: true,
|
||||
deadlineMs: 10_000,
|
||||
})
|
||||
if (!toRound(report).success) return toRound(report)
|
||||
recordAck(acknowledgementKey)
|
||||
}
|
||||
if (!isCurrent()) return toRound({ success: true })
|
||||
const saved = await dependencies.save(candidate, {
|
||||
backend: identity.backend,
|
||||
bypassQueue: true,
|
||||
silent: true,
|
||||
deadlineMs: 10_000,
|
||||
})
|
||||
if (!toRound(saved).success) return toRound(saved)
|
||||
if (!isCurrent()) return toRound({ success: true })
|
||||
dependencies.commit(candidate)
|
||||
committed = true
|
||||
clearAck(acknowledgementKey)
|
||||
return toRound(saved)
|
||||
} catch (error) {
|
||||
return toRound({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '碎片收下失败',
|
||||
})
|
||||
}
|
||||
}
|
||||
if (isLastPiece && identity.backend === 'yiban') {
|
||||
await enqueueSyncWrite(`puzzle-collection:${key}`, 'critical', runRound, [])
|
||||
} else {
|
||||
await runRound()
|
||||
}
|
||||
return committed
|
||||
}
|
||||
const request = tail
|
||||
.then(run)
|
||||
.catch((error) => {
|
||||
console.error('Failed to collect puzzle piece', error)
|
||||
return false
|
||||
})
|
||||
.finally(() => {
|
||||
pending.delete(key)
|
||||
})
|
||||
tail = request
|
||||
pending.set(key, request)
|
||||
return request
|
||||
}
|
||||
|
||||
const clearSavedAcknowledgements = () => {
|
||||
const identity = dependencies.getIdentity()
|
||||
for (const [residence, pieces] of Object.entries(dependencies.getPuzzles())) {
|
||||
for (const pieceId of pieces) {
|
||||
const checkpoint = getPuzzleCheckpoint(residence, pieceId)
|
||||
if (checkpoint) clearAck(ackKey(identity, residence, checkpoint.order, pieceId))
|
||||
}
|
||||
}
|
||||
}
|
||||
return { collect, clearSavedAcknowledgements }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WriteRoundResult } from './callbackWriteRetry'
|
||||
|
||||
beforeEach(() => vi.resetModules())
|
||||
const success = (): WriteRoundResult => ({
|
||||
success: true,
|
||||
code: 200,
|
||||
attempts: 1,
|
||||
elapsedMs: 0,
|
||||
response: { code: 200 },
|
||||
})
|
||||
describe('unexpected write exceptions', () => {
|
||||
it('releases the queue after a background runner throws', async () => {
|
||||
const { enqueueSyncWrite } = await import('./syncWriteCoordinator')
|
||||
await expect(
|
||||
enqueueSyncWrite('bad', 'background', async () => {
|
||||
throw new Error('broken')
|
||||
})
|
||||
).resolves.toMatchObject({ success: false, message: 'broken' })
|
||||
await expect(
|
||||
enqueueSyncWrite('next', 'background', async () => success())
|
||||
).resolves.toMatchObject({ success: true })
|
||||
})
|
||||
it('allows manual retry after a critical runner throws', async () => {
|
||||
const { enqueueSyncWrite, getSyncWriteState, retryActiveSyncWrite } =
|
||||
await import('./syncWriteCoordinator')
|
||||
const runner = vi.fn().mockRejectedValueOnce(new Error('broken')).mockResolvedValue(success())
|
||||
const request = enqueueSyncWrite('critical', 'critical', runner)
|
||||
await vi.waitFor(() => expect(getSyncWriteState().phase).toBe('failed'))
|
||||
retryActiveSyncWrite()
|
||||
await expect(request).resolves.toMatchObject({ success: true })
|
||||
expect(getSyncWriteState().phase).toBe('idle')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WriteRoundResult } from './callbackWriteRetry'
|
||||
|
||||
const success = (): WriteRoundResult => ({
|
||||
success: true,
|
||||
code: 200,
|
||||
attempts: 1,
|
||||
elapsedMs: 1,
|
||||
response: { code: 200 },
|
||||
})
|
||||
|
||||
const failure = (): WriteRoundResult => ({
|
||||
success: false,
|
||||
reason: 'deadline',
|
||||
attempts: 1,
|
||||
elapsedMs: 10_000,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
describe('syncWriteCoordinator', () => {
|
||||
it('runs a queued critical job before background jobs that have not started', async () => {
|
||||
const { enqueueSyncWrite } = await import('./syncWriteCoordinator')
|
||||
const order: string[] = []
|
||||
let releaseActive!: () => void
|
||||
const activeRound = new Promise<WriteRoundResult>((resolve) => {
|
||||
releaseActive = () => resolve(success())
|
||||
})
|
||||
|
||||
const first = enqueueSyncWrite('background-active', 'background', async () => {
|
||||
order.push('background-active')
|
||||
return activeRound
|
||||
})
|
||||
const second = enqueueSyncWrite('background-waiting', 'background', async () => {
|
||||
order.push('background-waiting')
|
||||
return success()
|
||||
})
|
||||
const critical = enqueueSyncWrite('critical', 'critical', async () => {
|
||||
order.push('critical')
|
||||
return success()
|
||||
})
|
||||
|
||||
expect(order).toEqual(['background-active'])
|
||||
releaseActive()
|
||||
await Promise.all([first, second, critical])
|
||||
|
||||
expect(order).toEqual(['background-active', 'critical', 'background-waiting'])
|
||||
})
|
||||
|
||||
it('keeps a critical promise pending until the user retries successfully', async () => {
|
||||
const { enqueueSyncWrite, getSyncWriteState, retryActiveSyncWrite } =
|
||||
await import('./syncWriteCoordinator')
|
||||
let rounds = 0
|
||||
const promise = enqueueSyncWrite('critical', 'critical', async () => {
|
||||
rounds += 1
|
||||
return rounds === 1 ? failure() : success()
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(getSyncWriteState().phase).toBe('failed'))
|
||||
let resolved = false
|
||||
void promise.then(() => {
|
||||
resolved = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(resolved).toBe(false)
|
||||
|
||||
retryActiveSyncWrite()
|
||||
await expect(promise).resolves.toMatchObject({ success: true })
|
||||
expect(rounds).toBe(2)
|
||||
})
|
||||
|
||||
it('supersedes an older queued background write with the same key', async () => {
|
||||
const { enqueueSyncWrite } = await import('./syncWriteCoordinator')
|
||||
let releaseActive!: () => void
|
||||
const activeRound = new Promise<WriteRoundResult>((resolve) => {
|
||||
releaseActive = () => resolve(success())
|
||||
})
|
||||
const staleRunner = vi.fn(async () => success())
|
||||
|
||||
const active = enqueueSyncWrite('other', 'background', () => activeRound)
|
||||
const stale = enqueueSyncWrite('same-key', 'background', staleRunner)
|
||||
const critical = enqueueSyncWrite('compound-checkpoint', 'critical', async () => success(), [
|
||||
'same-key',
|
||||
])
|
||||
|
||||
await expect(stale).resolves.toMatchObject({ success: true, superseded: true })
|
||||
expect(staleRunner).not.toHaveBeenCalled()
|
||||
releaseActive()
|
||||
await Promise.all([active, critical])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { WriteRoundResult } from './callbackWriteRetry'
|
||||
|
||||
export type WritePriority = 'background' | 'critical'
|
||||
|
||||
export interface WriteResult {
|
||||
success: boolean
|
||||
skipped?: boolean
|
||||
superseded?: boolean
|
||||
code?: number
|
||||
message?: string
|
||||
attempts?: number
|
||||
}
|
||||
|
||||
export interface SyncWriteState {
|
||||
phase: 'idle' | 'loading' | 'failed'
|
||||
message?: string
|
||||
retrying: boolean
|
||||
}
|
||||
|
||||
type RoundRunner = () => Promise<WriteRoundResult<unknown>>
|
||||
|
||||
interface WriteJob {
|
||||
id: number
|
||||
key: string
|
||||
supersedeKeys: string[]
|
||||
priority: WritePriority
|
||||
notifyOnFailure: boolean
|
||||
runRound: RoundRunner
|
||||
resolve: (result: WriteResult) => void
|
||||
}
|
||||
|
||||
const stateListeners = new Set<(state: SyncWriteState) => void>()
|
||||
const backgroundFailureListeners = new Set<(message: string) => void>()
|
||||
const queue: WriteJob[] = []
|
||||
let state: SyncWriteState = { phase: 'idle', retrying: false }
|
||||
let activeJob: WriteJob | null = null
|
||||
let nextJobId = 1
|
||||
let retryResolver: (() => void) | null = null
|
||||
|
||||
const emitState = (next: SyncWriteState) => {
|
||||
state = next
|
||||
stateListeners.forEach((listener) => listener(state))
|
||||
}
|
||||
|
||||
const hasCriticalWork = () =>
|
||||
activeJob?.priority === 'critical' || queue.some((job) => job.priority === 'critical')
|
||||
|
||||
const refreshLoadingState = () => {
|
||||
if (hasCriticalWork() && state.phase !== 'failed') {
|
||||
emitState({ phase: 'loading', retrying: false })
|
||||
} else if (!hasCriticalWork() && state.phase !== 'failed') {
|
||||
emitState({ phase: 'idle', retrying: false })
|
||||
}
|
||||
}
|
||||
|
||||
const waitForRetry = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
retryResolver = resolve
|
||||
})
|
||||
|
||||
const runWriteRound = async (job: WriteJob): Promise<WriteRoundResult> => {
|
||||
try {
|
||||
return await job.runRound()
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
reason: 'invoke-error',
|
||||
attempts: 1,
|
||||
elapsedMs: 0,
|
||||
message: error instanceof Error ? error.message : '学习进度保存失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const runCriticalJob = async (job: WriteJob): Promise<WriteResult> => {
|
||||
while (true) {
|
||||
emitState({ phase: 'loading', retrying: false })
|
||||
const result = await runWriteRound(job)
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
code: result.code,
|
||||
attempts: result.attempts,
|
||||
message: result.response.msg,
|
||||
}
|
||||
}
|
||||
|
||||
emitState({
|
||||
phase: 'failed',
|
||||
retrying: false,
|
||||
message: result.message || '学习进度保存失败,请检查网络后重新尝试。',
|
||||
})
|
||||
await waitForRetry()
|
||||
}
|
||||
}
|
||||
|
||||
const runBackgroundJob = async (job: WriteJob): Promise<WriteResult> => {
|
||||
const result = await runWriteRound(job)
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
code: result.code,
|
||||
attempts: result.attempts,
|
||||
message: result.response.msg,
|
||||
}
|
||||
}
|
||||
|
||||
const message = result.message || '学习进度保存失败,请检查网络后稍后重试。'
|
||||
const supersededByCritical = queue.some(
|
||||
(queued) => queued.priority === 'critical' && queued.supersedeKeys.includes(job.key)
|
||||
)
|
||||
if (!supersededByCritical && job.notifyOnFailure) {
|
||||
backgroundFailureListeners.forEach((listener) => listener(message))
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
code: result.code,
|
||||
attempts: result.attempts,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
const processQueue = async () => {
|
||||
if (activeJob || queue.length === 0) {
|
||||
refreshLoadingState()
|
||||
return
|
||||
}
|
||||
|
||||
activeJob = queue.shift() ?? null
|
||||
if (!activeJob) return
|
||||
refreshLoadingState()
|
||||
|
||||
const job = activeJob
|
||||
const result =
|
||||
job.priority === 'critical' ? await runCriticalJob(job) : await runBackgroundJob(job)
|
||||
job.resolve(result)
|
||||
activeJob = null
|
||||
retryResolver = null
|
||||
refreshLoadingState()
|
||||
queueMicrotask(() => void processQueue())
|
||||
}
|
||||
|
||||
export const enqueueSyncWrite = (
|
||||
key: string,
|
||||
priority: WritePriority,
|
||||
runRound: RoundRunner,
|
||||
supersedeKeys: string[] = [key],
|
||||
notifyOnFailure = true
|
||||
): Promise<WriteResult> =>
|
||||
new Promise((resolve) => {
|
||||
const job: WriteJob = {
|
||||
id: nextJobId++,
|
||||
key,
|
||||
supersedeKeys,
|
||||
priority,
|
||||
notifyOnFailure,
|
||||
runRound,
|
||||
resolve,
|
||||
}
|
||||
|
||||
if (priority === 'critical') {
|
||||
for (let index = queue.length - 1; index >= 0; index -= 1) {
|
||||
const queued = queue[index]
|
||||
if (queued?.priority === 'background' && supersedeKeys.includes(queued.key)) {
|
||||
queue.splice(index, 1)
|
||||
queued.resolve({ success: true, superseded: true })
|
||||
}
|
||||
}
|
||||
|
||||
const firstBackgroundIndex = queue.findIndex((queued) => queued.priority === 'background')
|
||||
if (firstBackgroundIndex === -1) queue.push(job)
|
||||
else queue.splice(firstBackgroundIndex, 0, job)
|
||||
emitState({ phase: 'loading', retrying: false })
|
||||
} else {
|
||||
for (let index = queue.length - 1; index >= 0; index -= 1) {
|
||||
const queued = queue[index]
|
||||
if (queued?.priority === 'background' && queued.key === key) {
|
||||
queue.splice(index, 1)
|
||||
queued.resolve({ success: true, superseded: true })
|
||||
}
|
||||
}
|
||||
queue.push(job)
|
||||
}
|
||||
|
||||
void processQueue()
|
||||
})
|
||||
|
||||
export const retryActiveSyncWrite = () => {
|
||||
if (state.phase !== 'failed' || !retryResolver) return
|
||||
const resolve = retryResolver
|
||||
retryResolver = null
|
||||
emitState({ phase: 'loading', retrying: true })
|
||||
resolve()
|
||||
}
|
||||
|
||||
export const subscribeSyncWriteState = (listener: (next: SyncWriteState) => void) => {
|
||||
stateListeners.add(listener)
|
||||
listener(state)
|
||||
return () => {
|
||||
stateListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
export const subscribeBackgroundWriteFailure = (listener: (message: string) => void) => {
|
||||
backgroundFailureListeners.add(listener)
|
||||
return () => {
|
||||
backgroundFailureListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
export const getSyncWriteState = () => state
|
||||
+33
-2
@@ -5,7 +5,11 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { DEFAULT_PUZZLE_RESIDENCE, PUZZLE_RESIDENCES } from '@/config/puzzleResidences'
|
||||
import { puzzleDataConfig } from '@/config/puzzleData'
|
||||
import { usePuzzleStore } from '@/store/puzzleStore'
|
||||
import { isMenuModuleComplete } from '@/config/progression'
|
||||
import {
|
||||
getMenuModuleProgress,
|
||||
isMenuModuleComplete,
|
||||
isMenuModuleStarted,
|
||||
} from '@/config/progression'
|
||||
|
||||
const Menu = () => {
|
||||
const navigate = useNavigate()
|
||||
@@ -93,6 +97,8 @@ const Menu = () => {
|
||||
<div className="absolute left-40 top-70 inline-grid grid-cols-2 gap-x-20 gap-y-10">
|
||||
{Category.map((item) => {
|
||||
const isComplete = isLoaded && isMenuModuleComplete(item.moduleName, puzzles)
|
||||
const isStarted = isLoaded && isMenuModuleStarted(item.moduleName, puzzles)
|
||||
const moduleProgress = isStarted ? getMenuModuleProgress(item.moduleName, puzzles) : 0
|
||||
const completeImageMap: Record<string, string> = {
|
||||
revolutionary: 'menu/革命完成',
|
||||
cultural: 'menu/文化完成',
|
||||
@@ -118,7 +124,32 @@ const Menu = () => {
|
||||
) : (
|
||||
<div className="mt-23">
|
||||
<Image className="absolute bottom-5" src={getStaticImage('menu/未看完')} />
|
||||
<Image src={getStaticImage('menu/未探索')} />
|
||||
{isStarted ? (
|
||||
<div
|
||||
className="relative h-[43px] w-[95px] bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: getBackgroundImage('menu/进度标签底'),
|
||||
backgroundSize: '100% 100%',
|
||||
}}
|
||||
role="img"
|
||||
aria-label={`探索进度 ${moduleProgress}%`}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-0 flex items-center justify-center text-[30px] leading-none text-white"
|
||||
style={{
|
||||
fontFamily: "'DingTalk-JinBuTi', sans-serif",
|
||||
fontWeight: 400,
|
||||
letterSpacing: '2px',
|
||||
transform: 'translateX(1px) scaleY(0.9)',
|
||||
}}
|
||||
>
|
||||
{moduleProgress}%
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Image src={getStaticImage('menu/未探索')} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { saveChenyunStore } from '@/store/chenyunStore'
|
||||
import { useMemo, useState, useRef, useEffect } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
@@ -15,7 +16,7 @@ import { SuccessAlert, DragQuizPanel } from '@/components/layout'
|
||||
import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/layout/quiz'
|
||||
import { GuideSteps, GuideStepsItem } from '@/components'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
||||
import { usePuzzleStore } from '@/store/puzzleStore'
|
||||
type QuizOption = {
|
||||
value: string
|
||||
label: string
|
||||
@@ -222,7 +223,14 @@ const ChenyunInternalization = () => {
|
||||
content=""
|
||||
onContinue={() => {
|
||||
setActiveSuccess(null)
|
||||
triggerPuzzleAnimation(16)
|
||||
void triggerPuzzleAnimation(16)
|
||||
.then(() => {
|
||||
if (!usePuzzleStore.getState().isPuzzleUnlocked(starName, 16)) return
|
||||
return saveChenyunStore()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to commit internalization checkpoint', error)
|
||||
})
|
||||
setDragQuizValues((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[item.id]
|
||||
@@ -496,7 +504,6 @@ const ChenyunInternalization = () => {
|
||||
animationSpeed={200}
|
||||
onContinue={() => {
|
||||
setShowStudyGuide(false)
|
||||
setCourseTimeProgress(24)
|
||||
navigate('/learning-map', { state: { completedResidence: 'chenyun' } })
|
||||
}}
|
||||
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_40.mp3')}
|
||||
|
||||
@@ -6,9 +6,8 @@ import ScaledPortal from '@/components/layout/ScaledPortal'
|
||||
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import type { VideoRef } from '@/components/video'
|
||||
import { useChenyunStore, saveChenyunStore } from '@/store/chenyunStore'
|
||||
import { saveChenyunStore, useChenyunStore } from '@/store/chenyunStore'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
||||
|
||||
// starName 固定为 chenyun
|
||||
const starName = 'chenyun'
|
||||
@@ -109,9 +108,8 @@ const ChenyunPlace9 = () => {
|
||||
className="absolute bottom-0 right-0"
|
||||
src={getStaticImage('chenyun/place9/离开场景')}
|
||||
button
|
||||
onClick={() => {
|
||||
setCourseTimeProgress(22)
|
||||
saveChenyunStore()
|
||||
onClick={async () => {
|
||||
await saveChenyunStore()
|
||||
navigate(`/${moduleName}/${starName}/home`)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -178,10 +178,10 @@ export const MimianBattle = ({
|
||||
}
|
||||
|
||||
// 处理单选题5提交
|
||||
const handleQuiz5Submit = (value: string, isCorrect: boolean) => {
|
||||
const handleQuiz5Submit = async (value: string, isCorrect: boolean) => {
|
||||
if (isCorrect) {
|
||||
// 标记米棉之战为已解决,并收起文件
|
||||
state.setIsMimianSolved(true)
|
||||
await state.setIsMimianSolved(true)
|
||||
onCollapseFile()
|
||||
|
||||
state.setShowMimianQuiz5(false)
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
* 管理米棉之战相关的所有状态
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { useChenyunStore, saveChenyunStore } from '@/store/chenyunStore'
|
||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
||||
import { saveChenyunStore, useChenyunStore } from '@/store/chenyunStore'
|
||||
|
||||
/**
|
||||
* 米棉之战状态管理 Hook
|
||||
@@ -15,10 +14,9 @@ export const useMimianState = () => {
|
||||
const { isMimianSolved, setMimianSolved } = useChenyunStore()
|
||||
|
||||
// 更新解决状态时,直接更新 store
|
||||
const handleSetIsMimianSolved = (solved: boolean) => {
|
||||
const handleSetIsMimianSolved = async (solved: boolean) => {
|
||||
setMimianSolved(solved)
|
||||
saveChenyunStore()
|
||||
setCourseTimeProgress(23)
|
||||
await saveChenyunStore()
|
||||
}
|
||||
|
||||
// 单选题1相关状态
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
|
||||
import { useEffect, useMemo, useState, useRef } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
@@ -16,7 +17,7 @@ import { SuccessAlert, DragQuizPanel } from '@/components/layout'
|
||||
import { MultipleChoiceQuizPanel } from '@/components/layout/quiz'
|
||||
import { GuideSteps, GuideStepsItem } from '@/components'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
||||
import { usePuzzleStore } from '@/store/puzzleStore'
|
||||
|
||||
type QuizOption = {
|
||||
value: string
|
||||
@@ -298,7 +299,14 @@ const HuangyanpeiInternalization = () => {
|
||||
return next
|
||||
})
|
||||
if (item.id === 'quiz-3') {
|
||||
triggerPuzzleAnimation(16)
|
||||
void triggerPuzzleAnimation(16)
|
||||
.then(() => {
|
||||
if (!usePuzzleStore.getState().isPuzzleUnlocked(starName, 16)) return
|
||||
return saveHuangyanpeiStore()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to commit internalization checkpoint', error)
|
||||
})
|
||||
}
|
||||
const cb = resumeCallback
|
||||
setResumeCallback(null)
|
||||
@@ -538,7 +546,6 @@ const HuangyanpeiInternalization = () => {
|
||||
animationSpeed={200}
|
||||
onContinue={() => {
|
||||
setShowStudyGuide(false)
|
||||
setCourseTimeProgress(12)
|
||||
navigate('/learning-map', { state: { completedResidence: 'huangyanpei' } })
|
||||
}}
|
||||
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_31.mp3')}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { EndingOverlay } from '@/components/overlay/EndingOverlay'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
import { useHuangyanpeiStore, saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
|
||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
||||
|
||||
const starName = 'huangyanpei'
|
||||
|
||||
@@ -105,9 +104,8 @@ const HuangyanpeiPlace6 = () => {
|
||||
className="absolute bottom-0 right-4"
|
||||
src={getStaticImage('components/place/离开场景')}
|
||||
button
|
||||
onClick={() => {
|
||||
saveHuangyanpeiStore()
|
||||
setCourseTimeProgress(10)
|
||||
onClick={async () => {
|
||||
await saveHuangyanpeiStore()
|
||||
navigate(`/${moduleName}/${starName}/home`)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -7,10 +7,8 @@ import { useNavigate, useParams } from 'react-router-dom'
|
||||
import ViseAssemblyDialog from './scene3/ViseAssemblyDialog'
|
||||
import type { VideoRef } from '@/components/video'
|
||||
import { QuizDialog } from './components'
|
||||
import { useHuangyanpeiStore } from '@/store/huangyanpeiStore'
|
||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
||||
import { saveHuangyanpeiStore, useHuangyanpeiStore } from '@/store/huangyanpeiStore'
|
||||
import { useCursorController } from '@/hooks/useCursorController'
|
||||
import { saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
|
||||
const starName = 'huangyanpei'
|
||||
@@ -242,10 +240,9 @@ const HuangyanpeiReflectionScene3 = () => {
|
||||
showSkipButton
|
||||
skipKey="huangyanpei_reflection_scene3_video"
|
||||
showReadonlyProgress
|
||||
onEnded={() => {
|
||||
onEnded={async () => {
|
||||
await saveHuangyanpeiStore()
|
||||
setShowVideo(false)
|
||||
saveHuangyanpeiStore()
|
||||
setCourseTimeProgress(11)
|
||||
navigate(`/${moduleName}/${starName}/home`)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -8,8 +8,7 @@ import TowelSalesDialog from './scene4/TowelSalesDialog'
|
||||
import type { VideoRef } from '@/components/video'
|
||||
import { QuizDialog } from './components'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
import { useHuangyanpeiStore, saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
|
||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
||||
import { saveHuangyanpeiStore, useHuangyanpeiStore } from '@/store/huangyanpeiStore'
|
||||
import { useCursorController } from '@/hooks/useCursorController'
|
||||
|
||||
const starName = 'huangyanpei'
|
||||
@@ -223,10 +222,9 @@ const HuangyanpeiReflectionScene4 = () => {
|
||||
skipKey="huangyanpei_reflection_scene4_video"
|
||||
showReadonlyProgress
|
||||
playsInline
|
||||
onEnded={() => {
|
||||
onEnded={async () => {
|
||||
await saveHuangyanpeiStore()
|
||||
setShowVideo(false)
|
||||
saveHuangyanpeiStore()
|
||||
setCourseTimeProgress(11)
|
||||
navigate(`/${moduleName}/${starName}/home`)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { saveLuxunStore } from '@/store/luxunStore'
|
||||
import { useEffect, useMemo, useState, useRef } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
@@ -15,7 +16,7 @@ import { SuccessAlert, DragQuizPanel } from '@/components/layout'
|
||||
import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/layout/quiz'
|
||||
import { GuideSteps, GuideStepsItem } from '@/components'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
||||
import { usePuzzleStore } from '@/store/puzzleStore'
|
||||
|
||||
type QuizOption = {
|
||||
value: string
|
||||
@@ -393,7 +394,14 @@ const LuxunInternalization = () => {
|
||||
setSuccessContent('')
|
||||
const cb = resumeCallback
|
||||
setResumeCallback(null)
|
||||
triggerPuzzleAnimation(16)
|
||||
void triggerPuzzleAnimation(16)
|
||||
.then(() => {
|
||||
if (!usePuzzleStore.getState().isPuzzleUnlocked(starName, 16)) return
|
||||
return saveLuxunStore()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to commit internalization checkpoint', error)
|
||||
})
|
||||
cb?.()
|
||||
}}
|
||||
/>
|
||||
@@ -538,7 +546,6 @@ const LuxunInternalization = () => {
|
||||
animationSpeed={200}
|
||||
onContinue={() => {
|
||||
setShowStudyGuide(false)
|
||||
setCourseTimeProgress(9)
|
||||
navigate('/learning-map', { state: { completedResidence: 'luxun' } })
|
||||
}}
|
||||
audioSrc={getPublicPath('audio/luxun/mp3_luxun_33_02_new.mp3')}
|
||||
|
||||
@@ -17,8 +17,7 @@ import { useState, useRef, useMemo } from 'react'
|
||||
import SuccessAlert from '@/components/layout/alert/success-alert'
|
||||
import { useCursorController } from '@/hooks/useCursorController'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
import { useLuxunStore, saveLuxunStore } from '@/store/luxunStore'
|
||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
||||
import { saveLuxunStore, useLuxunStore } from '@/store/luxunStore'
|
||||
|
||||
// starName 固定为 luxun
|
||||
const starName = 'luxun'
|
||||
@@ -431,13 +430,12 @@ const LuxunPlace5 = () => {
|
||||
{/* 视频结束后的结束语对话 */}
|
||||
<GuideSteps
|
||||
visible={endingGuideVisible}
|
||||
onVisibleChange={(visible) => {
|
||||
onVisibleChange={async (visible) => {
|
||||
setShowEndingVideo(false)
|
||||
setEndingGuideVisible(visible)
|
||||
if (!visible && moduleName) {
|
||||
// 对话结束后返回鲁迅首页
|
||||
saveLuxunStore()
|
||||
setCourseTimeProgress(7)
|
||||
await saveLuxunStore()
|
||||
navigate(`/${moduleName}/${starName}/home`)
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { saveLuxunStore } from '@/store/luxunStore'
|
||||
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
@@ -5,8 +6,6 @@ import { Image, GuideSteps, GuideStepsItem, StudyGuideModal, EndingOverlay } fro
|
||||
import { SingleChoiceQuizPanel } from '@/components/layout/quiz'
|
||||
import SuccessAlert from '@/components/layout/alert/success-alert'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
||||
import { saveLuxunStore } from '@/store/luxunStore'
|
||||
|
||||
const starName = 'luxun'
|
||||
|
||||
@@ -344,9 +343,8 @@ const LuxunReflectionScene3 = () => {
|
||||
className="absolute bottom-45 right-0"
|
||||
src={getStaticImage('components/place/离开场景')}
|
||||
button
|
||||
onClick={() => {
|
||||
saveLuxunStore()
|
||||
setCourseTimeProgress(8)
|
||||
onClick={async () => {
|
||||
await saveLuxunStore()
|
||||
navigate(`/${moduleName}/${starName}/home`)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { saveMaozedongStore } from '@/store/maozedongStore'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
@@ -15,7 +16,7 @@ import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
|
||||
import { GuideSteps, GuideStepsItem } from '@/components'
|
||||
import { BasketQuizPanel, DragQuizPanel, SuccessAlert } from '@/components/layout'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
||||
import { usePuzzleStore } from '@/store/puzzleStore'
|
||||
|
||||
const starName = 'maozedong'
|
||||
|
||||
@@ -291,7 +292,16 @@ const MaozedongInternalization = () => {
|
||||
|
||||
if (isCorrect) {
|
||||
setActiveSuccess(q.id)
|
||||
if (q.id === 'quiz-5') triggerPuzzleAnimation(16)
|
||||
if (q.id === 'quiz-5') {
|
||||
void triggerPuzzleAnimation(16)
|
||||
.then(() => {
|
||||
if (!usePuzzleStore.getState().isPuzzleUnlocked(starName, 16)) return
|
||||
return saveMaozedongStore()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to commit internalization checkpoint', error)
|
||||
})
|
||||
}
|
||||
if (q.id === 'quiz-2') triggerPuzzleAnimation(15)
|
||||
setResumeCallback(() => resume)
|
||||
return
|
||||
@@ -527,7 +537,6 @@ const MaozedongInternalization = () => {
|
||||
animationSpeed={200}
|
||||
onContinue={() => {
|
||||
setShowCompleted(false)
|
||||
setCourseTimeProgress(3)
|
||||
navigate('/learning-map', { state: { completedResidence: 'maozedong' } })
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -19,7 +19,6 @@ import SuccessAlert from '@/components/layout/alert/success-alert'
|
||||
import ScaledPortal from '@/components/layout/ScaledPortal'
|
||||
import { useMaozedongStore, saveMaozedongStore } from '@/store/maozedongStore'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
||||
|
||||
// starName 固定为 maozedong
|
||||
const starName = 'maozedong'
|
||||
@@ -152,10 +151,9 @@ const MaozedongPlace4 = () => {
|
||||
className="absolute right-0 bottom-0"
|
||||
src={getStaticImage('components/place/离开场景')}
|
||||
button
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
if (moduleName && starName) {
|
||||
saveMaozedongStore()
|
||||
setCourseTimeProgress(1)
|
||||
await saveMaozedongStore()
|
||||
navigate(`/${moduleName}/${starName}/home`)
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { saveMaozedongStore } from '@/store/maozedongStore'
|
||||
import {
|
||||
SingleChoiceQuizPanel,
|
||||
Video,
|
||||
@@ -10,10 +11,8 @@ import { SuccessAlert } from '@/components/layout'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
import { cn, getStaticImage } from '@/lib/utils'
|
||||
import { getPublicPath } from '@/lib/utils'
|
||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { saveMaozedongStore } from '@/store/maozedongStore'
|
||||
|
||||
// 光标组件 - 带旋转动画
|
||||
// Safari 兼容:使用 onTouchEnd 和 -webkit-tap-highlight-color
|
||||
@@ -488,10 +487,9 @@ const Home = () => {
|
||||
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_23.mp3')}
|
||||
animationSpeed={200}
|
||||
text="毛泽东在中共四大筹备期间的三次重要抉择,均锚定真实实践、扎根基层,为四大首次明确无产阶级领导权、工农联盟筑牢基础。虽因身体健康问题未参会,但其筹备工作与后续调研,如纽带般连接理论与实践,让党的革命理念在历史浪潮中,朝着正确方向坚定前行,成为早期共产党人探索革命道路的生动注脚,影响深远,照耀后世奋进征程。"
|
||||
onContinue={() => {
|
||||
onContinue={async () => {
|
||||
await saveMaozedongStore()
|
||||
setEndingVisible(false)
|
||||
saveMaozedongStore()
|
||||
setCourseTimeProgress(2)
|
||||
navigate(`/${moduleName}/maozedong/home`)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { saveNieerStore } from '@/store/nieerStore'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
@@ -14,7 +15,7 @@ import { GuideSteps, GuideStepsItem } from '@/components'
|
||||
import { SuccessAlert } from '@/components/layout'
|
||||
import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/layout/quiz'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
||||
import { usePuzzleStore } from '@/store/puzzleStore'
|
||||
|
||||
const starName = 'nieer'
|
||||
|
||||
@@ -241,7 +242,14 @@ const NieerInternalization = () => {
|
||||
triggerPuzzleAnimation(15)
|
||||
}
|
||||
if (item.id === 'quiz-5') {
|
||||
triggerPuzzleAnimation(16)
|
||||
void triggerPuzzleAnimation(16)
|
||||
.then(() => {
|
||||
if (!usePuzzleStore.getState().isPuzzleUnlocked(starName, 16)) return
|
||||
return saveNieerStore()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to commit internalization checkpoint', error)
|
||||
})
|
||||
}
|
||||
setSuccessContent('')
|
||||
setResumeCallback(() => resume)
|
||||
@@ -380,7 +388,6 @@ const NieerInternalization = () => {
|
||||
animationSpeed={200}
|
||||
onContinue={() => {
|
||||
setShowStudyGuide(false)
|
||||
setCourseTimeProgress(15)
|
||||
navigate('/learning-map', { state: { completedResidence: 'nieer' } })
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -16,7 +16,6 @@ import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useNieerStore, saveNieerStore } from '@/store/nieerStore'
|
||||
import { useCursorController } from '@/hooks/useCursorController'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
||||
|
||||
const NieerPlace6 = () => {
|
||||
const navigate = useNavigate()
|
||||
@@ -109,9 +108,8 @@ const NieerPlace6 = () => {
|
||||
className="absolute bottom-0 right-0"
|
||||
src={getStaticImage('components/place/离开场景')}
|
||||
button
|
||||
onClick={() => {
|
||||
saveNieerStore()
|
||||
setCourseTimeProgress(13)
|
||||
onClick={async () => {
|
||||
await saveNieerStore()
|
||||
navigate(`/${moduleName}/${starName}/home`)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
getUnlockVideoSrcForScene,
|
||||
getUnlockedByIdMap,
|
||||
} from './reflectionProgress'
|
||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
||||
|
||||
const starName = 'nieer'
|
||||
|
||||
@@ -343,10 +342,9 @@ const NieerReflection = () => {
|
||||
audioSrc={getPublicPath('audio/nieer/mp3_nieer_35.mp3')}
|
||||
animationSpeed={200}
|
||||
text={`恭喜你完成所有时空旅行!成功绘制“国歌精神图谱”,解锁6个跨越时空的国歌故事~国歌的故事还在继续,它的精神藏在每一次唱响、每一次坚守中。愿你带着这份精神力量,做国歌的传承者、爱国的践行者!`}
|
||||
onContinue={() => {
|
||||
onContinue={async () => {
|
||||
await saveNieerStore()
|
||||
setEndingVisible(false)
|
||||
// saveNieerStore()
|
||||
setCourseTimeProgress(14)
|
||||
navigate(`/${moduleName}/${starName}/home`)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { saveRongdeshengStore } from '@/store/rongdeshengStore'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
@@ -17,7 +18,7 @@ import { SuccessAlert } from '@/components/layout'
|
||||
import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/layout/quiz'
|
||||
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
||||
import { usePuzzleStore } from '@/store/puzzleStore'
|
||||
|
||||
const starName = 'rongdesheng'
|
||||
|
||||
@@ -312,7 +313,16 @@ const RongdeshengInternalization = () => {
|
||||
// 答题成功后触发碎片获取动画
|
||||
// quiz-2 -> 碎片15(坚韧不拔),quiz-5 -> 碎片16(社会责任担当)
|
||||
if (item.id === 'quiz-2') triggerPuzzleAnimation(15)
|
||||
if (item.id === 'quiz-5') triggerPuzzleAnimation(16)
|
||||
if (item.id === 'quiz-5') {
|
||||
void triggerPuzzleAnimation(16)
|
||||
.then(() => {
|
||||
if (!usePuzzleStore.getState().isPuzzleUnlocked(starName, 16)) return
|
||||
return saveRongdeshengStore()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to commit internalization checkpoint', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -508,7 +518,6 @@ const RongdeshengInternalization = () => {
|
||||
animationSpeed={200}
|
||||
onContinue={() => {
|
||||
setShowEnding(false)
|
||||
setCourseTimeProgress(21)
|
||||
navigate('/learning-map', { state: { completedResidence: 'rongdesheng' } })
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -17,7 +17,6 @@ import { useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { saveRongdeshengStore, useRongdeshengStore } from '@/store/rongdeshengStore'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
||||
|
||||
const starName = 'rongdesheng'
|
||||
const place = 'place4'
|
||||
@@ -165,10 +164,9 @@ const RongdeshengPlace4 = () => {
|
||||
className="absolute bottom-0 right-0"
|
||||
src={getStaticImage('components/place/离开场景')}
|
||||
button
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
setLastVisitedPlace('place4')
|
||||
saveRongdeshengStore()
|
||||
setCourseTimeProgress(19)
|
||||
await saveRongdeshengStore()
|
||||
navigate(`/${moduleName}/${starName}/home`)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -12,7 +12,6 @@ import { VideoBubbles } from './components/VideoBubbles'
|
||||
import { HINT_AUDIOS, HINT_TEXTS } from './constants'
|
||||
import { MODULES, getSceneTitle } from './modules.data'
|
||||
import type { ModuleId, ModuleState, VideoPlayingState } from './types'
|
||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
|
||||
const RongdeshengReflection = () => {
|
||||
@@ -382,11 +381,10 @@ const RongdeshengReflection = () => {
|
||||
visible={showFinalScene}
|
||||
finalVideoStage={finalVideoStage}
|
||||
onStart={() => setFinalVideoStage('playing')}
|
||||
onVideoEnded={() => {
|
||||
setCourseTimeProgress(20)
|
||||
setFinalVideoStage('ended')
|
||||
onVideoEnded={async () => {
|
||||
markReflectionFinalCompleted()
|
||||
saveRongdeshengStore()
|
||||
await saveRongdeshengStore()
|
||||
setFinalVideoStage('ended')
|
||||
}}
|
||||
onExit={() => {
|
||||
navigate(`/${moduleName}/${starName}/home`)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { saveSunzhongshanStore } from '@/store/sunzhongshanStore'
|
||||
import { useEffect, useMemo, useState, useRef } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
@@ -15,7 +16,7 @@ import { SuccessAlert, DragQuizPanel } from '@/components/layout'
|
||||
import { MultipleChoiceQuizPanel } from '@/components/layout/quiz'
|
||||
import { GuideSteps, GuideStepsItem } from '@/components'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
||||
import { usePuzzleStore } from '@/store/puzzleStore'
|
||||
|
||||
type QuizOption = {
|
||||
value: string
|
||||
@@ -407,7 +408,14 @@ const SunzhongshanInternalization = () => {
|
||||
if (item.id === 'quiz-2') {
|
||||
triggerPuzzleAnimation(15)
|
||||
} else if (item.id === 'quiz-5') {
|
||||
triggerPuzzleAnimation(16)
|
||||
void triggerPuzzleAnimation(16)
|
||||
.then(() => {
|
||||
if (!usePuzzleStore.getState().isPuzzleUnlocked(starName, 16)) return
|
||||
return saveSunzhongshanStore()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to commit internalization checkpoint', error)
|
||||
})
|
||||
}
|
||||
cb?.()
|
||||
}}
|
||||
@@ -545,7 +553,6 @@ const SunzhongshanInternalization = () => {
|
||||
animationSpeed={200}
|
||||
onContinue={() => {
|
||||
setShowStudyGuide(false)
|
||||
setCourseTimeProgress(6)
|
||||
navigate('/learning-map', { state: { completedResidence: 'sunzhongshan' } })
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -17,8 +17,7 @@ import { useCursorController } from '@/hooks/useCursorController'
|
||||
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
import { useSunzhongshanStore, saveSunzhongshanStore } from '@/store/sunzhongshanStore'
|
||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
||||
import { saveSunzhongshanStore, useSunzhongshanStore } from '@/store/sunzhongshanStore'
|
||||
|
||||
const starName = 'sunzhongshan'
|
||||
|
||||
@@ -134,9 +133,8 @@ const SunzhongshanPlace4 = () => {
|
||||
className="absolute bottom-0 right-0"
|
||||
src={getStaticImage('components/place/离开场景')}
|
||||
button
|
||||
onClick={() => {
|
||||
setCourseTimeProgress(4)
|
||||
saveSunzhongshanStore()
|
||||
onClick={async () => {
|
||||
await saveSunzhongshanStore()
|
||||
navigate(`/${moduleName}/${starName}`)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -15,8 +15,7 @@ import { SingleChoiceQuizPanel } from '@/components/layout/quiz'
|
||||
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
|
||||
import type { ClueHint } from '@/components/clue'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
import { useSunzhongshanStore, saveSunzhongshanStore } from '@/store/sunzhongshanStore'
|
||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
||||
import { saveSunzhongshanStore, useSunzhongshanStore } from '@/store/sunzhongshanStore'
|
||||
|
||||
const starName = 'sunzhongshan'
|
||||
const sceneId = `${starName}/reflection/scene3`
|
||||
@@ -277,15 +276,14 @@ const SunzhongshanReflectionScene3 = () => {
|
||||
}
|
||||
|
||||
// 处理完成提示关闭
|
||||
const handleCompleteGuideClose = () => {
|
||||
const handleCompleteGuideClose = async () => {
|
||||
setShowCompleteGuide(false)
|
||||
// 保存场景3完成状态
|
||||
setReflectionFlag(`${starName}-reflection-scene3-completed`, true)
|
||||
if (moduleName && starName) {
|
||||
const { setReflectionRoute } = useSunzhongshanStore.getState()
|
||||
setReflectionRoute('reflection3')
|
||||
saveSunzhongshanStore()
|
||||
setCourseTimeProgress(5)
|
||||
await saveSunzhongshanStore()
|
||||
navigate(`/${moduleName}/${starName}/home`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { saveZhanglepingStore } from '@/store/zhanglepingStore'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
@@ -14,7 +15,7 @@ import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/lay
|
||||
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
|
||||
import { GuideSteps, GuideStepsItem } from '@/components'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
||||
import { usePuzzleStore } from '@/store/puzzleStore'
|
||||
|
||||
const starName = 'zhangleping'
|
||||
|
||||
@@ -299,7 +300,14 @@ const ZhanglepingInternalization = () => {
|
||||
'先生善于捕捉核心细节展现人物特质,化繁为简的场景刻画让漫画更具感染力,这正是“以少胜多”艺术境界的体现。'
|
||||
)
|
||||
} else if (item.id === 'quiz-4') {
|
||||
triggerPuzzleAnimation(16)
|
||||
void triggerPuzzleAnimation(16)
|
||||
.then(() => {
|
||||
if (!usePuzzleStore.getState().isPuzzleUnlocked(starName, 16)) return
|
||||
return saveZhanglepingStore()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to commit internalization checkpoint', error)
|
||||
})
|
||||
setSuccessContent(
|
||||
'张乐平先生坚持艺术扎根生活,以简洁笔墨刻画时代,用作品传递温暖与正义,这些都是他的核心创作理念。'
|
||||
)
|
||||
@@ -495,7 +503,6 @@ const ZhanglepingInternalization = () => {
|
||||
animationSpeed={200}
|
||||
onContinue={() => {
|
||||
setShowStudyGuide(false)
|
||||
setCourseTimeProgress(18)
|
||||
navigate('/learning-map', { state: { completedResidence: 'zhangleping' } })
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -16,7 +16,6 @@ import Cursor from '@/components/cursor'
|
||||
import { useCursorController } from '@/hooks/useCursorController'
|
||||
import { saveZhanglepingStore, useZhanglepingStore } from '@/store/zhanglepingStore'
|
||||
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
|
||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
||||
|
||||
// starName 固定为 zhangleping
|
||||
const starName = 'zhangleping'
|
||||
@@ -131,10 +130,9 @@ const ZhanglepingPlace5 = () => {
|
||||
className="absolute right-0 bottom-0"
|
||||
src={getStaticImage('components/place/离开场景')}
|
||||
button
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
if (moduleName && starName) {
|
||||
saveZhanglepingStore()
|
||||
setCourseTimeProgress(16)
|
||||
await saveZhanglepingStore()
|
||||
navigate(`/${moduleName}/${starName}/home`)
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import { Image, StudyGuideModal, Video } from '@/components'
|
||||
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
|
||||
import { useZhanglepingStore, saveZhanglepingStore } from '@/store/zhanglepingStore'
|
||||
import { setCourseTimeProgress } from '@/lib/utils/api-common'
|
||||
|
||||
const starName = 'zhangleping'
|
||||
|
||||
@@ -93,15 +92,6 @@ const ZhanglepingReflection = () => {
|
||||
}
|
||||
|
||||
const allFragmentsCompleted = sceneConfig.every((scene) => isSceneCompleted(scene.id))
|
||||
|
||||
// 监听allFragmentsCompleted
|
||||
useEffect(() => {
|
||||
if (allFragmentsCompleted) {
|
||||
saveZhanglepingStore()
|
||||
setCourseTimeProgress(17)
|
||||
}
|
||||
}, [allFragmentsCompleted])
|
||||
|
||||
// 场景逐步解锁:1 默认解锁;N 需要 N-1 完成
|
||||
const isSceneUnlocked = (sceneId: SceneId) => {
|
||||
if (sceneId === 1) return true
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
getUserDefineResult,
|
||||
parseUserDefineContent,
|
||||
type UserDefineFetchStatus,
|
||||
type WriteOptions,
|
||||
type WriteResult,
|
||||
} from '@/lib/utils/api-common'
|
||||
|
||||
interface ChenyunStore {
|
||||
@@ -191,7 +193,7 @@ export const useChenyunStore = create<ChenyunStore>()(
|
||||
export const CHENYUN_LESSON_ID = 'Cheny_duihua'
|
||||
export const CHENYUN_KEY = 22
|
||||
|
||||
export const saveChenyunStore = (): void => {
|
||||
export const saveChenyunStore = (options: WriteOptions = {}): Promise<WriteResult> => {
|
||||
const state = useChenyunStore.getState()
|
||||
|
||||
const payload = {
|
||||
@@ -202,11 +204,7 @@ export const saveChenyunStore = (): void => {
|
||||
completedVideos: state.completedVideos,
|
||||
}
|
||||
|
||||
try {
|
||||
setUserDefine(CHENYUN_LESSON_ID, CHENYUN_KEY, JSON.stringify(payload))
|
||||
} catch (error) {
|
||||
console.error('Failed to save Chenyun store', error)
|
||||
}
|
||||
return setUserDefine(CHENYUN_LESSON_ID, CHENYUN_KEY, JSON.stringify(payload), options)
|
||||
}
|
||||
|
||||
export const applyChenyunPersistedState = (payload: ChenyunPersistedState): void => {
|
||||
@@ -219,7 +217,6 @@ export const applyChenyunPersistedState = (payload: ChenyunPersistedState): void
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
export const loadChenyunStore = async (): Promise<UserDefineFetchStatus> => {
|
||||
try {
|
||||
const result = await getUserDefineResult(CHENYUN_LESSON_ID, CHENYUN_KEY)
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
getUserDefineResult,
|
||||
parseUserDefineContent,
|
||||
type UserDefineFetchStatus,
|
||||
type WriteOptions,
|
||||
type WriteResult,
|
||||
} from '@/lib/utils/api-common'
|
||||
|
||||
interface HuangyanpeiStore {
|
||||
@@ -275,7 +277,7 @@ export const useHuangyanpeiStore = create<HuangyanpeiStore>()(
|
||||
export const HUANGYANPEI_LESSON_ID = 'Huangyp_duihua'
|
||||
export const HUANGYANPEI_KEY = 10
|
||||
|
||||
export const saveHuangyanpeiStore = (): void => {
|
||||
export const saveHuangyanpeiStore = (options: WriteOptions = {}): Promise<WriteResult> => {
|
||||
const state = useHuangyanpeiStore.getState()
|
||||
|
||||
const payload = {
|
||||
@@ -285,13 +287,10 @@ export const saveHuangyanpeiStore = (): void => {
|
||||
reflectionRoute: state.reflectionRoute,
|
||||
shownReflectionCompletionGuides: state.shownReflectionCompletionGuides,
|
||||
reflectionScene2Choice: state.reflectionScene2Choice,
|
||||
completedVideos: state.completedVideos,
|
||||
}
|
||||
|
||||
try {
|
||||
setUserDefine(HUANGYANPEI_LESSON_ID, HUANGYANPEI_KEY, JSON.stringify(payload))
|
||||
} catch (error) {
|
||||
console.error('Failed to save Huangyanpei store', error)
|
||||
}
|
||||
return setUserDefine(HUANGYANPEI_LESSON_ID, HUANGYANPEI_KEY, JSON.stringify(payload), options)
|
||||
}
|
||||
|
||||
export const applyHuangyanpeiPersistedState = (payload: HuangyanpeiPersistedState): void => {
|
||||
@@ -306,7 +305,6 @@ export const applyHuangyanpeiPersistedState = (payload: HuangyanpeiPersistedStat
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
export const loadHuangyanpeiStore = async (): Promise<UserDefineFetchStatus> => {
|
||||
try {
|
||||
const result = await getUserDefineResult(HUANGYANPEI_LESSON_ID, HUANGYANPEI_KEY)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { setUserDefine } = vi.hoisted(() => ({
|
||||
setUserDefine: vi.fn().mockResolvedValue({ success: true }),
|
||||
}))
|
||||
vi.mock('@/lib/utils/api-common', () => ({ setUserDefine }))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
setUserDefine.mockClear()
|
||||
const storage = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
})
|
||||
vi.stubGlobal('window', { localStorage })
|
||||
})
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
const image = 'data:image/jpeg;base64,YWJj'
|
||||
|
||||
describe('local-only artwork', () => {
|
||||
it('keeps Zhang Leping artwork locally and excludes even short data URLs from sync', async () => {
|
||||
const { useZhanglepingStore, saveZhanglepingStore, applyZhanglepingPersistedState } =
|
||||
await import('./zhanglepingStore')
|
||||
useZhanglepingStore.getState().setDrawArtwork(image, 'caption')
|
||||
await saveZhanglepingStore()
|
||||
const payload = JSON.parse(setUserDefine.mock.calls[0]![2])
|
||||
expect(payload).not.toHaveProperty('drawArtworkDataUrl')
|
||||
expect(payload.drawArtworkCaption).toBe('caption')
|
||||
expect(payload.hasDrawArtwork).toBe(true)
|
||||
applyZhanglepingPersistedState(payload)
|
||||
expect(useZhanglepingStore.getState().drawArtworkDataUrl).toBe(image)
|
||||
expect(JSON.parse(localStorage.getItem('zhangleping-storage')!).state.drawArtworkDataUrl).toBe(
|
||||
image
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps Nie Er previews locally while syncing other work fields', async () => {
|
||||
const { useNieerStore, saveNieerStore, applyNieerPersistedState } = await import('./nieerStore')
|
||||
useNieerStore.getState().setReflectionWork(6, { previewImageDataUrl: image, paradeId: '1' })
|
||||
await saveNieerStore()
|
||||
const payload = JSON.parse(setUserDefine.mock.calls[0]![2])
|
||||
expect(payload.reflectionWorks[6]).not.toHaveProperty('previewImageDataUrl')
|
||||
expect(payload.reflectionWorks[6].paradeId).toBe('1')
|
||||
expect(useNieerStore.getState().reflectionWorks[6]?.previewImageDataUrl).toBe(image)
|
||||
applyNieerPersistedState(payload)
|
||||
expect(useNieerStore.getState().reflectionWorks[6]?.previewImageDataUrl).toBe(image)
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem('nieer-storage')!).state.reflectionWorks[6]
|
||||
.previewImageDataUrl
|
||||
).toBe(image)
|
||||
})
|
||||
|
||||
it('does not restore legacy remote images over local images', async () => {
|
||||
const { useZhanglepingStore, applyZhanglepingPersistedState } =
|
||||
await import('./zhanglepingStore')
|
||||
const { useNieerStore, applyNieerPersistedState } = await import('./nieerStore')
|
||||
useZhanglepingStore.getState().setDrawArtwork(image, 'caption')
|
||||
useNieerStore.getState().setReflectionWork(6, { previewImageDataUrl: image })
|
||||
applyZhanglepingPersistedState({ drawArtworkDataUrl: 'remote' })
|
||||
applyNieerPersistedState({ reflectionWorks: { 6: { previewImageDataUrl: 'remote' } } })
|
||||
expect(useZhanglepingStore.getState().drawArtworkDataUrl).toBe(image)
|
||||
expect(useNieerStore.getState().reflectionWorks[6]?.previewImageDataUrl).toBe(image)
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
getUserDefineResult,
|
||||
parseUserDefineContent,
|
||||
type UserDefineFetchStatus,
|
||||
type WriteOptions,
|
||||
type WriteResult,
|
||||
} from '@/lib/utils/api-common'
|
||||
|
||||
interface LuxunStore {
|
||||
@@ -169,7 +171,7 @@ export const useLuxunStore = create<LuxunStore>()(
|
||||
export const LUXUN_LESSON_ID = 'Lux_duihua'
|
||||
export const LUXUN_KEY = 7
|
||||
|
||||
export const saveLuxunStore = (): void => {
|
||||
export const saveLuxunStore = (options: WriteOptions = {}): Promise<WriteResult> => {
|
||||
const state = useLuxunStore.getState()
|
||||
|
||||
const payload = {
|
||||
@@ -179,11 +181,7 @@ export const saveLuxunStore = (): void => {
|
||||
completedVideos: state.completedVideos,
|
||||
}
|
||||
|
||||
try {
|
||||
setUserDefine(LUXUN_LESSON_ID, LUXUN_KEY, JSON.stringify(payload))
|
||||
} catch (error) {
|
||||
console.error('Failed to save Luxun store', error)
|
||||
}
|
||||
return setUserDefine(LUXUN_LESSON_ID, LUXUN_KEY, JSON.stringify(payload), options)
|
||||
}
|
||||
|
||||
export const applyLuxunPersistedState = (payload: LuxunPersistedState): void => {
|
||||
@@ -195,7 +193,6 @@ export const applyLuxunPersistedState = (payload: LuxunPersistedState): void =>
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
export const loadLuxunStore = async (): Promise<UserDefineFetchStatus> => {
|
||||
try {
|
||||
const result = await getUserDefineResult(LUXUN_LESSON_ID, LUXUN_KEY)
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
getUserDefineResult,
|
||||
parseUserDefineContent,
|
||||
type UserDefineFetchStatus,
|
||||
type WriteOptions,
|
||||
type WriteResult,
|
||||
} from '@/lib/utils/api-common'
|
||||
|
||||
interface MaozedongStore {
|
||||
@@ -123,18 +125,14 @@ export const useMaozedongStore = create<MaozedongStore>()(
|
||||
export const MAOZEDONG_LESSON_ID = 'Maozd_duihua'
|
||||
export const MAOZEDONG_KEY = 1
|
||||
|
||||
export const saveMaozedongStore = (): void => {
|
||||
export const saveMaozedongStore = (options: WriteOptions = {}): Promise<WriteResult> => {
|
||||
const state = useMaozedongStore.getState()
|
||||
const payload = {
|
||||
visitedExhibits: serializeVisited(state.visitedExhibits),
|
||||
lastVisitedPlace: state.lastVisitedPlace,
|
||||
completedVideos: state.completedVideos,
|
||||
}
|
||||
try {
|
||||
setUserDefine(MAOZEDONG_LESSON_ID, MAOZEDONG_KEY, JSON.stringify(payload))
|
||||
} catch (error) {
|
||||
console.error('Failed to save Maozedong store', error)
|
||||
}
|
||||
return setUserDefine(MAOZEDONG_LESSON_ID, MAOZEDONG_KEY, JSON.stringify(payload), options)
|
||||
}
|
||||
|
||||
const applyMaozedongPersistedState = (payload: MaozedongPersistedState): void => {
|
||||
|
||||
+26
-9
@@ -6,6 +6,8 @@ import {
|
||||
getUserDefineResult,
|
||||
parseUserDefineContent,
|
||||
type UserDefineFetchStatus,
|
||||
type WriteOptions,
|
||||
type WriteResult,
|
||||
} from '@/lib/utils/api-common'
|
||||
|
||||
interface NieerStore {
|
||||
@@ -276,37 +278,52 @@ export const useNieerStore = create<NieerStore>()(
|
||||
export const NIEER_LESSON_ID = 'Nieer_duihua'
|
||||
export const NIEER_KEY = 5
|
||||
|
||||
export const saveNieerStore = (): void => {
|
||||
export const saveNieerStore = (options: WriteOptions = {}): Promise<WriteResult> => {
|
||||
const state = useNieerStore.getState()
|
||||
// 易班 GET 接口不支持 Base64,作品截图仅保存在本地 persist 中。
|
||||
const reflectionWorks = Object.fromEntries(
|
||||
Object.entries(state.reflectionWorks).map(([sceneId, work]) => {
|
||||
const remoteWork = { ...work }
|
||||
delete remoteWork.previewImageDataUrl
|
||||
return [sceneId, remoteWork]
|
||||
})
|
||||
)
|
||||
const payload = {
|
||||
visitedExhibits: serializeVisitedExhibits(state.visitedExhibits),
|
||||
visitedReflectionCursors: serializeVisitedExhibits(state.visitedReflectionCursors),
|
||||
lastVisitedPlace: state.lastVisitedPlace,
|
||||
reflectionWorks: state.reflectionWorks,
|
||||
reflectionWorks,
|
||||
hasSeenReflectionGuide: state.hasSeenReflectionGuide,
|
||||
hasSeenBoxHint: state.hasSeenBoxHint,
|
||||
completedVideos: state.completedVideos,
|
||||
}
|
||||
try {
|
||||
setUserDefine(NIEER_LESSON_ID, NIEER_KEY, JSON.stringify(payload))
|
||||
} catch (error) {
|
||||
console.error('Failed to save Nieer store', error)
|
||||
}
|
||||
return setUserDefine(NIEER_LESSON_ID, NIEER_KEY, JSON.stringify(payload), options)
|
||||
}
|
||||
|
||||
export const applyNieerPersistedState = (payload: NieerPersistedState): void => {
|
||||
const localWorks = useNieerStore.getState().reflectionWorks
|
||||
const reflectionWorks = Object.fromEntries(
|
||||
Object.entries(payload.reflectionWorks ?? {}).map(([sceneId, work]) => {
|
||||
const restoredWork = { ...work }
|
||||
delete restoredWork.previewImageDataUrl
|
||||
const localPreview = localWorks[Number(sceneId)]?.previewImageDataUrl
|
||||
if (typeof localPreview === 'string') {
|
||||
restoredWork.previewImageDataUrl = localPreview
|
||||
}
|
||||
return [sceneId, restoredWork]
|
||||
})
|
||||
)
|
||||
useNieerStore.setState({
|
||||
visitedExhibits: deserializeVisitedExhibits(payload.visitedExhibits),
|
||||
visitedReflectionCursors: deserializeVisitedExhibits(payload.visitedReflectionCursors),
|
||||
lastVisitedPlace: payload.lastVisitedPlace ?? null,
|
||||
reflectionWorks: payload.reflectionWorks ?? {},
|
||||
reflectionWorks,
|
||||
hasSeenReflectionGuide: payload.hasSeenReflectionGuide ?? false,
|
||||
hasSeenBoxHint: payload.hasSeenBoxHint ?? false,
|
||||
completedVideos: payload.completedVideos ?? {},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
export const loadNieerStore = async (): Promise<UserDefineFetchStatus> => {
|
||||
try {
|
||||
const result = await getUserDefineResult(NIEER_LESSON_ID, NIEER_KEY)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getResidenceStageCompletion } from '@/config/progression'
|
||||
import { getStagePieceIds } from '@/config/puzzleStages'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
save: vi.fn(),
|
||||
report: vi.fn(),
|
||||
fetch: vi.fn(),
|
||||
parse: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/utils/api-common', () => ({
|
||||
setUserDefine: mocks.save,
|
||||
setCourseTimeProgress: mocks.report,
|
||||
getUserDefineResult: mocks.fetch,
|
||||
parseUserDefineContent: mocks.parse,
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
mocks.save.mockReset().mockResolvedValue({ success: true })
|
||||
mocks.report.mockReset().mockResolvedValue({ success: true })
|
||||
mocks.fetch
|
||||
.mockReset()
|
||||
.mockResolvedValue({ status: 'success', content: '{"puzzles":{"nieer":[14]}}' })
|
||||
mocks.parse.mockReset().mockImplementation((content) => JSON.parse(content))
|
||||
const values = new Map<string, string>()
|
||||
const storage = {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
removeItem: (key: string) => values.delete(key),
|
||||
}
|
||||
vi.stubGlobal('localStorage', storage)
|
||||
vi.stubGlobal('window', { localStorage: storage })
|
||||
})
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
const fixture = async () => {
|
||||
const { useUserStore } = await import('./userStore')
|
||||
useUserStore.getState().setCourseInfo({ uid: 'student', courseId: 'course' })
|
||||
useUserStore.getState().setYibanConnectionStatus('connected')
|
||||
const module = await import('./puzzleStore')
|
||||
module.usePuzzleStore.getState().setLoaded(true)
|
||||
return { ...module, useUserStore }
|
||||
}
|
||||
|
||||
describe('puzzle store collection integration', () => {
|
||||
const residences = [
|
||||
'maozedong',
|
||||
'sunzhongshan',
|
||||
'luxun',
|
||||
'huangyanpei',
|
||||
'nieer',
|
||||
'zhangleping',
|
||||
'rongdesheng',
|
||||
'chenyun',
|
||||
]
|
||||
const scenarios = residences.flatMap((residence, index) =>
|
||||
(['dialogue', 'reflection', 'internalization'] as const).map((stage, offset) => ({
|
||||
residence,
|
||||
stage,
|
||||
order: index * 3 + offset + 1,
|
||||
}))
|
||||
)
|
||||
it.each(scenarios)(
|
||||
'reports $residence/$stage as order $order at the collection entry',
|
||||
async ({ residence, stage, order }) => {
|
||||
const f = await fixture()
|
||||
const required = getStagePieceIds(residence, stage)
|
||||
const missing = required[0]!
|
||||
f.usePuzzleStore.setState({ puzzles: { [residence]: new Set(required.slice(1)) } })
|
||||
await expect(f.usePuzzleStore.getState().unlockPuzzle(residence, missing)).resolves.toBe(true)
|
||||
expect(mocks.report).toHaveBeenCalledWith(order, expect.anything())
|
||||
expect(mocks.report.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.save.mock.invocationCallOrder[0]!
|
||||
)
|
||||
const payload = JSON.parse(mocks.save.mock.calls[0]![2])
|
||||
expect(new Set(payload.puzzles[residence])).toEqual(new Set(required))
|
||||
expect(
|
||||
getResidenceStageCompletion(residence, f.usePuzzleStore.getState().puzzles)[
|
||||
`${stage}Complete`
|
||||
]
|
||||
).toBe(true)
|
||||
}
|
||||
)
|
||||
it('does not publish progress until the remote save has succeeded', async () => {
|
||||
const f = await fixture()
|
||||
let resolveSave!: (result: { success: boolean }) => void
|
||||
mocks.save.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveSave = resolve
|
||||
})
|
||||
)
|
||||
const claim = f.usePuzzleStore.getState().unlockPuzzle('nieer', 14)
|
||||
await vi.waitFor(() => expect(mocks.save).toHaveBeenCalledTimes(1))
|
||||
expect(
|
||||
getResidenceStageCompletion('nieer', f.usePuzzleStore.getState().puzzles).reflectionComplete
|
||||
).toBe(false)
|
||||
resolveSave({ success: true })
|
||||
await claim
|
||||
expect(
|
||||
getResidenceStageCompletion('nieer', f.usePuzzleStore.getState().puzzles).reflectionComplete
|
||||
).toBe(true)
|
||||
})
|
||||
it('uses session storage without reporting when courseId is missing', async () => {
|
||||
const f = await fixture()
|
||||
f.useUserStore.getState().resetCourseInfo()
|
||||
f.useUserStore.getState().setYibanConnectionStatus('connected')
|
||||
await f.usePuzzleStore.getState().unlockPuzzle('nieer', 14)
|
||||
expect(mocks.report).not.toHaveBeenCalled()
|
||||
expect(mocks.save).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({ backend: 'session' })
|
||||
)
|
||||
})
|
||||
it('loads historical pieces with the explicit backend and never backfills reports', async () => {
|
||||
const f = await fixture()
|
||||
await f.loadPuzzleStore({ backend: 'yiban' })
|
||||
expect(mocks.parse).toHaveBeenCalledWith('{"puzzles":{"nieer":[14]}}', { backend: 'yiban' })
|
||||
expect(mocks.report).not.toHaveBeenCalled()
|
||||
expect(mocks.save).not.toHaveBeenCalled()
|
||||
await f.usePuzzleStore.getState().unlockPuzzle('nieer', 14)
|
||||
expect(mocks.report).not.toHaveBeenCalled()
|
||||
expect(mocks.save).not.toHaveBeenCalled()
|
||||
})
|
||||
it('cancels an outstanding save after reset without restoring the old pieces', async () => {
|
||||
const f = await fixture()
|
||||
let resolveSave!: (result: { success: boolean }) => void
|
||||
mocks.save.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveSave = resolve
|
||||
})
|
||||
)
|
||||
const claim = f.usePuzzleStore.getState().unlockPuzzle('nieer', 1)
|
||||
await vi.waitFor(() => expect(mocks.save).toHaveBeenCalledTimes(1))
|
||||
f.usePuzzleStore.getState().resetAll()
|
||||
resolveSave({ success: true })
|
||||
await expect(claim).resolves.toBe(false)
|
||||
expect(f.usePuzzleStore.getState().puzzles).toEqual({})
|
||||
})
|
||||
})
|
||||
+42
-22
@@ -3,12 +3,20 @@ import { persist } from 'zustand/middleware'
|
||||
import { STORE_NAMES } from './storeNames'
|
||||
import {
|
||||
setUserDefine,
|
||||
setCourseTimeProgress,
|
||||
getUserDefineResult,
|
||||
parseUserDefineContent,
|
||||
type StorageOptions,
|
||||
type UserDefineFetchStatus,
|
||||
type WriteOptions,
|
||||
type WriteResult,
|
||||
} from '@/lib/utils/api-common'
|
||||
|
||||
import { useUserStore } from './userStore'
|
||||
import { createPuzzleCollector } from '@/lib/utils/puzzleCollection'
|
||||
|
||||
let collectionRevision = 0
|
||||
|
||||
interface PuzzleStore {
|
||||
// 每个故居的已解锁碎片集合,格式:{ residence: Set<pieceId> }
|
||||
puzzles: Record<string, Set<number>>
|
||||
@@ -17,7 +25,7 @@ interface PuzzleStore {
|
||||
setLoaded: (isLoaded: boolean) => void
|
||||
|
||||
// 解锁碎片
|
||||
unlockPuzzle: (residence: string, pieceId: number) => void
|
||||
unlockPuzzle: (residence: string, pieceId: number) => Promise<boolean>
|
||||
|
||||
// 检查碎片是否解锁
|
||||
isPuzzleUnlocked: (residence: string, pieceId: number) => boolean
|
||||
@@ -92,22 +100,14 @@ export const usePuzzleStore = create<PuzzleStore>()(
|
||||
puzzles: {},
|
||||
isLoaded: false,
|
||||
|
||||
setLoaded: (isLoaded: boolean) => set({ isLoaded }),
|
||||
|
||||
unlockPuzzle: (residence: string, pieceId: number) => {
|
||||
const { puzzles } = get()
|
||||
// 确保 puzzles 格式正确
|
||||
const fixedPuzzles = ensurePuzzlesAreSets(puzzles)
|
||||
const residencePuzzles = fixedPuzzles[residence] || new Set<number>()
|
||||
|
||||
if (!residencePuzzles.has(pieceId)) {
|
||||
const newPuzzles = { ...fixedPuzzles }
|
||||
newPuzzles[residence] = new Set([...residencePuzzles, pieceId])
|
||||
savePuzzleStore(newPuzzles)
|
||||
set({ puzzles: newPuzzles })
|
||||
}
|
||||
setLoaded: (isLoaded: boolean) => {
|
||||
if (get().isLoaded !== isLoaded) collectionRevision += 1
|
||||
set({ isLoaded })
|
||||
if (isLoaded) puzzleCollector.clearSavedAcknowledgements()
|
||||
},
|
||||
|
||||
unlockPuzzle: (residence, pieceId) => puzzleCollector.collect(residence, pieceId),
|
||||
|
||||
isPuzzleUnlocked: (residence: string, pieceId: number) => {
|
||||
const { puzzles } = get()
|
||||
// 确保 puzzles 格式正确
|
||||
@@ -142,6 +142,7 @@ export const usePuzzleStore = create<PuzzleStore>()(
|
||||
},
|
||||
|
||||
resetPuzzles: (residence: string) => {
|
||||
collectionRevision += 1
|
||||
const { puzzles } = get()
|
||||
// 确保 puzzles 格式正确
|
||||
const fixedPuzzles = ensurePuzzlesAreSets(puzzles)
|
||||
@@ -151,10 +152,12 @@ export const usePuzzleStore = create<PuzzleStore>()(
|
||||
},
|
||||
|
||||
resetAll: () => {
|
||||
collectionRevision += 1
|
||||
set({ puzzles: {} })
|
||||
},
|
||||
|
||||
applyPersistedState: (payload: PuzzlePersistedState) => {
|
||||
collectionRevision += 1
|
||||
set({ puzzles: deserializePuzzles(payload.puzzles) })
|
||||
},
|
||||
}),
|
||||
@@ -182,20 +185,36 @@ export const usePuzzleStore = create<PuzzleStore>()(
|
||||
)
|
||||
)
|
||||
|
||||
const puzzleCollector = createPuzzleCollector({
|
||||
getIdentity: () => {
|
||||
const { uid, courseId, yibanConnectionStatus } = useUserStore.getState()
|
||||
return {
|
||||
uid,
|
||||
courseId,
|
||||
backend: yibanConnectionStatus === 'connected' && courseId ? 'yiban' : 'session',
|
||||
revision: collectionRevision,
|
||||
loaded: usePuzzleStore.getState().isLoaded,
|
||||
}
|
||||
},
|
||||
getPuzzles: () => ensurePuzzlesAreSets(usePuzzleStore.getState().puzzles),
|
||||
save: (puzzles, options) => savePuzzleStore(puzzles, options),
|
||||
report: (order, options) => setCourseTimeProgress(order, options),
|
||||
commit: (puzzles) => usePuzzleStore.setState({ puzzles }),
|
||||
})
|
||||
|
||||
// ========== 易班平台数据保存 ==========
|
||||
export const PUZZLE_LESSON_ID = 'Cheny_neihua'
|
||||
export const PUZZLE_KEY = 24
|
||||
|
||||
export const savePuzzleStore = (puzzles: Record<string, Set<number>>): void => {
|
||||
export const savePuzzleStore = (
|
||||
puzzles: Record<string, Set<number>>,
|
||||
options: WriteOptions = {}
|
||||
): Promise<WriteResult> => {
|
||||
const payload = {
|
||||
puzzles: serializePuzzles(puzzles),
|
||||
}
|
||||
|
||||
try {
|
||||
setUserDefine(PUZZLE_LESSON_ID, PUZZLE_KEY, JSON.stringify(payload))
|
||||
} catch (error) {
|
||||
console.error('Failed to save Chenyun store', error)
|
||||
}
|
||||
return setUserDefine(PUZZLE_LESSON_ID, PUZZLE_KEY, JSON.stringify(payload), options)
|
||||
}
|
||||
|
||||
export const loadPuzzleStore = async (
|
||||
@@ -210,8 +229,9 @@ export const loadPuzzleStore = async (
|
||||
return result.status
|
||||
}
|
||||
|
||||
const payload = parseUserDefineContent<PuzzlePersistedState>(result.content)
|
||||
const payload = parseUserDefineContent<PuzzlePersistedState>(result.content, options)
|
||||
usePuzzleStore.getState().applyPersistedState(payload)
|
||||
puzzleCollector.clearSavedAcknowledgements()
|
||||
return result.status
|
||||
} catch (error) {
|
||||
console.error('Failed to load puzzle store', error)
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
getUserDefineResult,
|
||||
parseUserDefineContent,
|
||||
type UserDefineFetchStatus,
|
||||
type WriteOptions,
|
||||
type WriteResult,
|
||||
} from '@/lib/utils/api-common'
|
||||
|
||||
interface RongdeshengStore {
|
||||
@@ -222,7 +224,7 @@ export const useRongdeshengStore = create<RongdeshengStore>()(
|
||||
export const RONGDESHENG_LESSON_ID = 'Rongds_duihua'
|
||||
export const RONGDESHENG_KEY = 19
|
||||
|
||||
export const saveRongdeshengStore = (): void => {
|
||||
export const saveRongdeshengStore = (options: WriteOptions = {}): Promise<WriteResult> => {
|
||||
const state = useRongdeshengStore.getState()
|
||||
const payload = {
|
||||
visitedExhibits: serializeVisitedExhibits(state.visitedExhibits),
|
||||
@@ -233,11 +235,7 @@ export const saveRongdeshengStore = (): void => {
|
||||
reflectionGuideShown: state.reflectionGuideShown,
|
||||
completedVideos: state.completedVideos,
|
||||
}
|
||||
try {
|
||||
setUserDefine(RONGDESHENG_LESSON_ID, RONGDESHENG_KEY, JSON.stringify(payload))
|
||||
} catch (error) {
|
||||
console.error('Failed to save Rongdesheng store', error)
|
||||
}
|
||||
return setUserDefine(RONGDESHENG_LESSON_ID, RONGDESHENG_KEY, JSON.stringify(payload), options)
|
||||
}
|
||||
|
||||
export const applyRongdeshengPersistedState = (payload: RongdeshengPersistedState): void => {
|
||||
@@ -252,7 +250,6 @@ export const applyRongdeshengPersistedState = (payload: RongdeshengPersistedStat
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
export const loadRongdeshengStore = async (): Promise<UserDefineFetchStatus> => {
|
||||
try {
|
||||
const result = await getUserDefineResult(RONGDESHENG_LESSON_ID, RONGDESHENG_KEY)
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
getUserDefineResult,
|
||||
parseUserDefineContent,
|
||||
type UserDefineFetchStatus,
|
||||
type WriteOptions,
|
||||
type WriteResult,
|
||||
} from '@/lib/utils/api-common'
|
||||
|
||||
export interface Clue {
|
||||
@@ -263,7 +265,7 @@ export const useSunzhongshanStore = create<SunzhongshanStore>()(
|
||||
export const SUNZHONGSHAN_LESSON_ID = 'Sunzs_duihua'
|
||||
export const SUNZHONGSHAN_KEY = 4
|
||||
|
||||
export const saveSunzhongshanStore = (): void => {
|
||||
export const saveSunzhongshanStore = (options: WriteOptions = {}): Promise<WriteResult> => {
|
||||
const state = useSunzhongshanStore.getState()
|
||||
|
||||
const payload = {
|
||||
@@ -275,11 +277,7 @@ export const saveSunzhongshanStore = (): void => {
|
||||
completedVideos: state.completedVideos,
|
||||
}
|
||||
|
||||
try {
|
||||
setUserDefine(SUNZHONGSHAN_LESSON_ID, SUNZHONGSHAN_KEY, JSON.stringify(payload))
|
||||
} catch (error) {
|
||||
console.error('Failed to save Sunzhongshan store', error)
|
||||
}
|
||||
return setUserDefine(SUNZHONGSHAN_LESSON_ID, SUNZHONGSHAN_KEY, JSON.stringify(payload), options)
|
||||
}
|
||||
|
||||
export const applySunzhongshanPersistedState = (payload: SunzhongshanPersistedState): void => {
|
||||
@@ -293,7 +291,6 @@ export const applySunzhongshanPersistedState = (payload: SunzhongshanPersistedSt
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
export const loadSunzhongshanStore = async (): Promise<UserDefineFetchStatus> => {
|
||||
try {
|
||||
const result = await getUserDefineResult(SUNZHONGSHAN_LESSON_ID, SUNZHONGSHAN_KEY)
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
getUserDefineResult,
|
||||
parseUserDefineContent,
|
||||
type UserDefineFetchStatus,
|
||||
type WriteOptions,
|
||||
type WriteResult,
|
||||
} from '@/lib/utils/api-common'
|
||||
|
||||
interface ZhanglepingStore {
|
||||
@@ -238,33 +240,24 @@ export const useZhanglepingStore = create<ZhanglepingStore>()(
|
||||
// ========== 易班平台数据保存(体悟模块) ==========
|
||||
export const ZHANGLEPING_LESSON_ID = 'Zhanglp_tiwu'
|
||||
export const ZHANGLEPING_KEY = 16
|
||||
const REMOTE_MAX_DATA_URL_LENGTH = 1200
|
||||
|
||||
export const saveZhanglepingStore = (): void => {
|
||||
export const saveZhanglepingStore = (options: WriteOptions = {}): Promise<WriteResult> => {
|
||||
const state = useZhanglepingStore.getState()
|
||||
|
||||
// 易班接口在当前环境是 GET 参数传输,完整 base64 图会导致 URL 超长请求失败。
|
||||
// 因此远端只保存轻量摘要;完整画作仍保留在本地 persist(localStorage)中。
|
||||
const remoteArtworkDataUrl =
|
||||
state.drawArtworkDataUrl.length <= REMOTE_MAX_DATA_URL_LENGTH ? state.drawArtworkDataUrl : ''
|
||||
// 易班 GET 接口不支持 Base64,画作图片仅保存在本地 persist 中。
|
||||
|
||||
const payload = {
|
||||
visitedExhibits: serializeVisited(state.visitedExhibits),
|
||||
lastVisitedPlace: state.lastVisitedPlace,
|
||||
reflectionRoute: state.reflectionRoute,
|
||||
visitedReflectionCursors: serializeVisited(state.visitedReflectionCursors),
|
||||
drawArtworkDataUrl: remoteArtworkDataUrl,
|
||||
drawArtworkCaption: state.drawArtworkCaption,
|
||||
hasDrawArtwork: state.hasDrawArtwork,
|
||||
hasSeenStudyGuide: state.hasSeenStudyGuide,
|
||||
completedVideos: state.completedVideos,
|
||||
}
|
||||
|
||||
try {
|
||||
setUserDefine(ZHANGLEPING_LESSON_ID, ZHANGLEPING_KEY, JSON.stringify(payload))
|
||||
} catch (error) {
|
||||
console.error('Failed to save Zhangleping store', error)
|
||||
}
|
||||
return setUserDefine(ZHANGLEPING_LESSON_ID, ZHANGLEPING_KEY, JSON.stringify(payload), options)
|
||||
}
|
||||
|
||||
export const applyZhanglepingPersistedState = (payload: ZhanglepingPersistedState): void => {
|
||||
@@ -273,7 +266,6 @@ export const applyZhanglepingPersistedState = (payload: ZhanglepingPersistedStat
|
||||
lastVisitedPlace: payload.lastVisitedPlace ?? null,
|
||||
reflectionRoute: resolveReflectionRoute(payload.reflectionRoute),
|
||||
visitedReflectionCursors: deserializeVisited(payload.visitedReflectionCursors),
|
||||
drawArtworkDataUrl: payload.drawArtworkDataUrl ?? '',
|
||||
drawArtworkCaption: payload.drawArtworkCaption ?? '',
|
||||
hasDrawArtwork: payload.hasDrawArtwork ?? false,
|
||||
hasSeenStudyGuide: payload.hasSeenStudyGuide ?? false,
|
||||
@@ -281,7 +273,6 @@ export const applyZhanglepingPersistedState = (payload: ZhanglepingPersistedStat
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
export const loadZhanglepingStore = async (): Promise<UserDefineFetchStatus> => {
|
||||
try {
|
||||
const result = await getUserDefineResult(ZHANGLEPING_LESSON_ID, ZHANGLEPING_KEY)
|
||||
|
||||
Reference in new issue
Block a user