feat: 实现故居续学弹窗功能
新增功能: - 统一续学逻辑:8个故居首页接入自动续学提示弹窗 - 完成判定:基于拼图碎片统一判断对话/体悟/内化是否完成 - 断点续学:对话回lastVisitedPlace、体悟回reflectionRoute、内化回internalization - 触发条件:会话内每故居仅提示一次 + 首页视频播完后 + store加载成功/空时 - 安全防护:组件卸载后不再setState、同步失败不弹窗避免用旧状态误导 新增组件: - ContinueLearningModal:纯CSS哑光米纸+朱红古籍风弹窗 - useResidenceContinue:封装触发逻辑+跳转+去重的共享hook 涉及文件: - src/config/progression.ts:getResidenceStageCompletion、resolveResidenceContinueTarget - src/hooks/useResidenceContinue.ts:统一续学hook - src/components/layout/alert/continue-learning-modal.tsx:弹窗组件 - src/pages/*/home.tsx:8个故居首页接入 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
49ecabbf3a
commit
73d02eb62d
13 files changed
+539
-500
No files matched your search
@@ -1,27 +1,13 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
|
||||
import { Image } from '@/components'
|
||||
import { ContinueLearningModal } from '@/components/layout'
|
||||
import { useResidenceContinue } from '@/hooks'
|
||||
import { usePuzzleStore } from '@/store/puzzleStore'
|
||||
import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { getUserDefineResult } from '@/lib/utils/api-common'
|
||||
import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components'
|
||||
import {
|
||||
RONGDESHENG_KEY,
|
||||
RONGDESHENG_LESSON_ID,
|
||||
useRongdeshengStore,
|
||||
} from '@/store/rongdeshengStore'
|
||||
|
||||
const deserializeVisitedExhibits = (
|
||||
data: Record<string, number[]> | undefined
|
||||
): Record<string, Set<number>> => {
|
||||
if (!data || typeof data !== 'object') return {}
|
||||
const visitedExhibits: Record<string, Set<number>> = {}
|
||||
for (const [place, exhibits] of Object.entries(data)) {
|
||||
visitedExhibits[place] = new Set(Array.isArray(exhibits) ? exhibits : [])
|
||||
}
|
||||
return visitedExhibits
|
||||
}
|
||||
import { useRongdeshengStore, loadRongdeshengStore } from '@/store/rongdeshengStore'
|
||||
|
||||
const RongdeshengHome = () => {
|
||||
const navigate = useNavigate()
|
||||
@@ -33,9 +19,16 @@ const RongdeshengHome = () => {
|
||||
|
||||
const puzzles = usePuzzleStore((state) => state.puzzles)
|
||||
const isLoaded = usePuzzleStore((state) => state.isLoaded)
|
||||
const [storeReady, setStoreReady] = useState(false)
|
||||
const { dialogueUnlocked, reflectionUnlocked, internalizationUnlocked } =
|
||||
getResidenceStageUnlocks(starName, puzzles)
|
||||
|
||||
const continuePrompt = useResidenceContinue({
|
||||
residence: starName,
|
||||
storeReady,
|
||||
lastVisitedPlace,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoaded && !isResidenceUnlocked(starName, puzzles)) {
|
||||
navigate('/menu', { replace: true })
|
||||
@@ -43,61 +36,24 @@ const RongdeshengHome = () => {
|
||||
}, [isLoaded, navigate, puzzles, starName])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
const result = await getUserDefineResult(RONGDESHENG_LESSON_ID, RONGDESHENG_KEY)
|
||||
|
||||
if (result.status !== 'success') {
|
||||
useRongdeshengStore.setState((prev) => ({
|
||||
...prev,
|
||||
visitedExhibits: {},
|
||||
lastVisitedPlace: null,
|
||||
reflectionCompleted: {},
|
||||
reflectionVideoViewed: {},
|
||||
reflectionFinalCompleted: false,
|
||||
reflectionGuideShown: false,
|
||||
}))
|
||||
|
||||
if (result.status === 'unauthenticated') {
|
||||
showYibanUnauthenticatedPrompt()
|
||||
} else if (result.status === 'error') {
|
||||
showYibanSyncErrorPrompt()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const data = JSON.parse(result.content) as {
|
||||
visitedExhibits?: Record<string, number[]>
|
||||
lastVisitedPlace?: string | null
|
||||
reflectionCompleted?: Record<string, boolean>
|
||||
reflectionVideoViewed?: Record<string, Record<string, boolean>>
|
||||
reflectionFinalCompleted?: boolean
|
||||
reflectionGuideShown?: boolean
|
||||
}
|
||||
|
||||
useRongdeshengStore.setState((prev) => ({
|
||||
...prev,
|
||||
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
|
||||
lastVisitedPlace: data.lastVisitedPlace ?? null,
|
||||
reflectionCompleted: data.reflectionCompleted ?? {},
|
||||
reflectionVideoViewed: data.reflectionVideoViewed ?? {},
|
||||
reflectionFinalCompleted: data.reflectionFinalCompleted ?? false,
|
||||
reflectionGuideShown: data.reflectionGuideShown ?? false,
|
||||
}))
|
||||
} catch (error) {
|
||||
useRongdeshengStore.setState((prev) => ({
|
||||
...prev,
|
||||
visitedExhibits: {},
|
||||
lastVisitedPlace: null,
|
||||
reflectionCompleted: {},
|
||||
reflectionVideoViewed: {},
|
||||
reflectionFinalCompleted: false,
|
||||
reflectionGuideShown: false,
|
||||
}))
|
||||
const status = await loadRongdeshengStore()
|
||||
if (status === 'unauthenticated') {
|
||||
showYibanUnauthenticatedPrompt()
|
||||
} else if (status === 'error') {
|
||||
showYibanSyncErrorPrompt()
|
||||
console.error('Failed to load Rongdesheng store', error)
|
||||
}
|
||||
|
||||
if (!cancelled && (status === 'success' || status === 'empty')) {
|
||||
setStoreReady(true)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!moduleName) return null
|
||||
@@ -152,6 +108,12 @@ const RongdeshengHome = () => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ContinueLearningModal
|
||||
visible={continuePrompt.visible}
|
||||
onContinue={continuePrompt.onContinue}
|
||||
onCancel={continuePrompt.onCancel}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user