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

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

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

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 751 B

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