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:
zhongyidianandClaude Opus 4.8 committed 2026-06-21 21:44:27 +08:00
1 parent 49ecabbf3a
commit 73d02eb62d
13 files changed
+539 -500

No files matched your search

+31 -69
View File
@@ -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>
)
}