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 -57
View File
@@ -1,23 +1,13 @@
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
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 { getUserDefineResult } from '@/lib/utils/api-common'
import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components'
import { useChenyunStore, CHENYUN_LESSON_ID, CHENYUN_KEY } from '@/store/chenyunStore'
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 { useChenyunStore, loadChenyunStore } from '@/store/chenyunStore'
const Chenyun = () => {
const navigate = useNavigate()
@@ -32,9 +22,16 @@ const Chenyun = () => {
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 })
@@ -42,53 +39,24 @@ const Chenyun = () => {
}, [isLoaded, navigate, puzzles, starName])
useEffect(() => {
let cancelled = false
;(async () => {
try {
const result = await getUserDefineResult(CHENYUN_LESSON_ID, CHENYUN_KEY)
if (result.status !== 'success') {
useChenyunStore.setState((prev) => ({
...prev,
visitedExhibits: {},
isYinyuanSolved: false,
isMimianSolved: false,
lastVisitedPlace: null,
}))
if (result.status === 'unauthenticated') {
showYibanUnauthenticatedPrompt()
} else if (result.status === 'error') {
showYibanSyncErrorPrompt()
}
return
}
const data = JSON.parse(result.content) as {
visitedExhibits?: Record<string, number[]>
isYinyuanSolved?: boolean
isMimianSolved?: boolean
lastVisitedPlace?: string
}
useChenyunStore.setState((prev) => ({
...prev,
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
isYinyuanSolved: data.isYinyuanSolved ?? false,
isMimianSolved: data.isMimianSolved ?? false,
lastVisitedPlace: data.lastVisitedPlace ?? null,
}))
} catch (error) {
useChenyunStore.setState((prev) => ({
...prev,
visitedExhibits: {},
isYinyuanSolved: false,
isMimianSolved: false,
lastVisitedPlace: null,
}))
const status = await loadChenyunStore()
if (status === 'unauthenticated') {
showYibanUnauthenticatedPrompt()
} else if (status === 'error') {
showYibanSyncErrorPrompt()
console.error('Failed to load Chenyun store', error)
}
if (!cancelled && (status === 'success' || status === 'empty')) {
setStoreReady(true)
}
})()
return () => {
cancelled = true
}
}, [])
if (!moduleName) {
@@ -143,6 +111,12 @@ const Chenyun = () => {
}}
/>
</div>
<ContinueLearningModal
visible={continuePrompt.visible}
onContinue={continuePrompt.onContinue}
onCancel={continuePrompt.onCancel}
/>
</div>
)
}
+32 -65
View File
@@ -1,16 +1,13 @@
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
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 { getUserDefineResult } from '@/lib/utils/api-common'
import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components'
import {
useHuangyanpeiStore,
HUANGYANPEI_LESSON_ID,
HUANGYANPEI_KEY,
} from '@/store/huangyanpeiStore'
import { useHuangyanpeiStore, loadHuangyanpeiStore } from '@/store/huangyanpeiStore'
const Huangyanpei = () => {
const navigate = useNavigate()
@@ -26,9 +23,17 @@ const Huangyanpei = () => {
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,
reflectionRoute,
})
useEffect(() => {
if (isLoaded && !isResidenceUnlocked(starName, puzzles)) {
navigate('/menu', { replace: true })
@@ -36,68 +41,24 @@ const Huangyanpei = () => {
}, [isLoaded, navigate, puzzles, starName])
useEffect(() => {
let cancelled = false
;(async () => {
try {
const result = await getUserDefineResult(HUANGYANPEI_LESSON_ID, HUANGYANPEI_KEY)
if (result.status !== 'success') {
useHuangyanpeiStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
reflectionRoute: 'reflection',
shownReflectionCompletionGuides: {},
reflectionScene2Choice: null,
}))
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
reflectionRoute?: string
shownReflectionCompletionGuides?: Record<string, boolean>
reflectionScene2Choice?: '铁工科' | '商科' | null
}
const validReflectionRoute =
data.reflectionRoute &&
['reflection', 'reflection2', 'reflection3', 'reflection4'].includes(data.reflectionRoute)
? data.reflectionRoute
: 'reflection'
useHuangyanpeiStore.setState((prev) => ({
...prev,
visitedExhibits: Object.fromEntries(
Object.entries(data.visitedExhibits ?? {}).map(([place, exhibits]) => [
place,
new Set(Array.isArray(exhibits) ? exhibits : []),
])
),
lastVisitedPlace: data.lastVisitedPlace ?? null,
reflectionRoute: validReflectionRoute,
shownReflectionCompletionGuides: data.shownReflectionCompletionGuides ?? {},
reflectionScene2Choice: data.reflectionScene2Choice ?? null,
}))
} catch (error) {
useHuangyanpeiStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
reflectionRoute: 'reflection',
shownReflectionCompletionGuides: {},
reflectionScene2Choice: null,
}))
const status = await loadHuangyanpeiStore()
if (status === 'unauthenticated') {
showYibanUnauthenticatedPrompt()
} else if (status === 'error') {
showYibanSyncErrorPrompt()
console.error('Failed to load Huangyanpei store', error)
}
if (!cancelled && (status === 'success' || status === 'empty')) {
setStoreReady(true)
}
})()
return () => {
cancelled = true
}
}, [])
if (!moduleName) {
@@ -152,6 +113,12 @@ const Huangyanpei = () => {
}}
/>
</div>
<ContinueLearningModal
visible={continuePrompt.visible}
onContinue={continuePrompt.onContinue}
onCancel={continuePrompt.onCancel}
/>
</div>
)
}
+33 -60
View File
@@ -1,23 +1,13 @@
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
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 { getUserDefineResult } from '@/lib/utils/api-common'
import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components'
import { useLuxunStore, LUXUN_LESSON_ID, LUXUN_KEY } from '@/store/luxunStore'
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 { useLuxunStore, loadLuxunStore } from '@/store/luxunStore'
const Luxun = () => {
const navigate = useNavigate()
@@ -33,9 +23,17 @@ const Luxun = () => {
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,
reflectionRoute,
})
useEffect(() => {
if (isLoaded && !isResidenceUnlocked(starName, puzzles)) {
navigate('/menu', { replace: true })
@@ -43,55 +41,24 @@ const Luxun = () => {
}, [isLoaded, navigate, puzzles, starName])
useEffect(() => {
let cancelled = false
;(async () => {
try {
const result = await getUserDefineResult(LUXUN_LESSON_ID, LUXUN_KEY)
if (result.status !== 'success') {
useLuxunStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
reflectionRoute: 'reflection',
}))
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
reflectionRoute?: string
}
const validReflectionRoute =
data.reflectionRoute &&
['reflection', 'reflection2', 'reflection3'].includes(data.reflectionRoute)
? data.reflectionRoute
: 'reflection'
useLuxunStore.setState((prev) => ({
...prev,
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
lastVisitedPlace: data.lastVisitedPlace ?? null,
reflectionRoute: validReflectionRoute,
}))
} catch (error) {
useLuxunStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
reflectionRoute: 'reflection',
}))
const status = await loadLuxunStore()
if (status === 'unauthenticated') {
showYibanUnauthenticatedPrompt()
} else if (status === 'error') {
showYibanSyncErrorPrompt()
console.error('Failed to load Luxun store', error)
}
if (!cancelled && (status === 'success' || status === 'empty')) {
setStoreReady(true)
}
})()
return () => {
cancelled = true
}
}, [])
if (!moduleName) {
@@ -130,7 +97,7 @@ const Luxun = () => {
src={getStaticImage('star/二-解锁')}
onClick={() => {
if (!reflectionUnlocked) return
navigate(`/${moduleName}/${starName}/reflection`)
navigate(`/${moduleName}/${starName}/${reflectionRoute || 'reflection'}`)
}}
/>
{/* 内化 */}
@@ -146,6 +113,12 @@ const Luxun = () => {
}}
/>
</div>
<ContinueLearningModal
visible={continuePrompt.visible}
onContinue={continuePrompt.onContinue}
onCancel={continuePrompt.onCancel}
/>
</div>
)
}
+31 -49
View File
@@ -1,23 +1,13 @@
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
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 { getUserDefineResult } from '@/lib/utils/api-common'
import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components'
import { MAOZEDONG_KEY, MAOZEDONG_LESSON_ID, useMaozedongStore } from '@/store/maozedongStore'
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 { useMaozedongStore, loadMaozedongStore } from '@/store/maozedongStore'
const Maozedong = () => {
const navigate = useNavigate()
@@ -31,9 +21,16 @@ const Maozedong = () => {
const lastVisitedPlace = useMaozedongStore((state) => state.lastVisitedPlace)
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 })
@@ -41,45 +38,24 @@ const Maozedong = () => {
}, [isLoaded, navigate, puzzles, starName])
useEffect(() => {
let cancelled = false
;(async () => {
try {
const result = await getUserDefineResult(MAOZEDONG_LESSON_ID, MAOZEDONG_KEY)
if (result.status !== 'success') {
useMaozedongStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
}))
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
}
useMaozedongStore.setState((prev) => ({
...prev,
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
lastVisitedPlace: data.lastVisitedPlace ?? null,
}))
} catch (error) {
useMaozedongStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
}))
const status = await loadMaozedongStore()
if (status === 'unauthenticated') {
showYibanUnauthenticatedPrompt()
} else if (status === 'error') {
showYibanSyncErrorPrompt()
console.error('Failed to load Maozedong store', error)
}
if (!cancelled && (status === 'success' || status === 'empty')) {
setStoreReady(true)
}
})()
return () => {
cancelled = true
}
}, [])
if (!moduleName) return null
@@ -129,6 +105,12 @@ const Maozedong = () => {
}}
/>
</div>
<ContinueLearningModal
visible={continuePrompt.visible}
onContinue={continuePrompt.onContinue}
onCancel={continuePrompt.onCancel}
/>
</div>
)
}
+31 -49
View File
@@ -1,23 +1,13 @@
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
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 { getUserDefineResult } from '@/lib/utils/api-common'
import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components'
import { NIEER_KEY, NIEER_LESSON_ID, useNieerStore } from '@/store/nieerStore'
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 { useNieerStore, loadNieerStore } from '@/store/nieerStore'
const NieerHome = () => {
const navigate = useNavigate()
@@ -29,9 +19,16 @@ const NieerHome = () => {
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 })
@@ -39,45 +36,24 @@ const NieerHome = () => {
}, [isLoaded, navigate, puzzles, starName])
useEffect(() => {
let cancelled = false
;(async () => {
try {
const result = await getUserDefineResult(NIEER_LESSON_ID, NIEER_KEY)
if (result.status !== 'success') {
useNieerStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
}))
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
}
useNieerStore.setState((prev) => ({
...prev,
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
lastVisitedPlace: data.lastVisitedPlace ?? null,
}))
} catch (error) {
useNieerStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
}))
const status = await loadNieerStore()
if (status === 'unauthenticated') {
showYibanUnauthenticatedPrompt()
} else if (status === 'error') {
showYibanSyncErrorPrompt()
console.error('Failed to load Nieer store', error)
}
if (!cancelled && (status === 'success' || status === 'empty')) {
setStoreReady(true)
}
})()
return () => {
cancelled = true
}
}, [])
if (!moduleName) return null
@@ -129,6 +105,12 @@ const NieerHome = () => {
}}
/>
</div>
<ContinueLearningModal
visible={continuePrompt.visible}
onContinue={continuePrompt.onContinue}
onCancel={continuePrompt.onCancel}
/>
</div>
)
}
+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>
)
}
+32 -63
View File
@@ -1,27 +1,13 @@
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
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 { getUserDefineResult } from '@/lib/utils/api-common'
import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components'
import {
useSunzhongshanStore,
SUNZHONGSHAN_LESSON_ID,
SUNZHONGSHAN_KEY,
} from '@/store/sunzhongshanStore'
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 { useSunzhongshanStore, loadSunzhongshanStore } from '@/store/sunzhongshanStore'
const Sunzhongshan = () => {
const navigate = useNavigate()
@@ -37,9 +23,17 @@ const Sunzhongshan = () => {
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,
reflectionRoute,
})
useEffect(() => {
if (isLoaded && !isResidenceUnlocked(starName, puzzles)) {
navigate('/menu', { replace: true })
@@ -47,55 +41,24 @@ const Sunzhongshan = () => {
}, [isLoaded, navigate, puzzles, starName])
useEffect(() => {
let cancelled = false
;(async () => {
try {
const result = await getUserDefineResult(SUNZHONGSHAN_LESSON_ID, SUNZHONGSHAN_KEY)
if (result.status !== 'success') {
useSunzhongshanStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
reflectionRoute: 'reflection',
}))
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
reflectionRoute?: string
}
const validReflectionRoute =
data.reflectionRoute &&
['reflection', 'reflection1', 'reflection2', 'reflection3'].includes(data.reflectionRoute)
? data.reflectionRoute
: 'reflection'
useSunzhongshanStore.setState((prev) => ({
...prev,
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
lastVisitedPlace: data.lastVisitedPlace ?? null,
reflectionRoute: validReflectionRoute,
}))
} catch (error) {
useSunzhongshanStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
reflectionRoute: 'reflection',
}))
const status = await loadSunzhongshanStore()
if (status === 'unauthenticated') {
showYibanUnauthenticatedPrompt()
} else if (status === 'error') {
showYibanSyncErrorPrompt()
console.error('Failed to load Sunzhongshan store', error)
}
if (!cancelled && (status === 'success' || status === 'empty')) {
setStoreReady(true)
}
})()
return () => {
cancelled = true
}
}, [])
if (!moduleName) {
@@ -150,6 +113,12 @@ const Sunzhongshan = () => {
}}
/>
</div>
<ContinueLearningModal
visible={continuePrompt.visible}
onContinue={continuePrompt.onContinue}
onCancel={continuePrompt.onCancel}
/>
</div>
)
}
+31 -88
View File
@@ -1,29 +1,13 @@
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
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 { getUserDefineResult } from '@/lib/utils/api-common'
import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components'
import {
ZHANGLEPING_KEY,
ZHANGLEPING_LESSON_ID,
useZhanglepingStore,
} from '@/store/zhanglepingStore'
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
}
const deserializeVisitedCursors = deserializeVisitedExhibits
import { useZhanglepingStore, loadZhanglepingStore } from '@/store/zhanglepingStore'
const ZhanglepingHome = () => {
const navigate = useNavigate()
@@ -37,9 +21,16 @@ const ZhanglepingHome = () => {
const lastVisitedPlace = useZhanglepingStore((state) => state.lastVisitedPlace)
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 })
@@ -47,78 +38,24 @@ const ZhanglepingHome = () => {
}, [isLoaded, navigate, puzzles, starName])
useEffect(() => {
let cancelled = false
;(async () => {
try {
const result = await getUserDefineResult(ZHANGLEPING_LESSON_ID, ZHANGLEPING_KEY)
if (result.status !== 'success') {
useZhanglepingStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
reflectionRoute: 'reflection',
visitedReflectionCursors: {},
drawArtworkDataUrl: '',
drawArtworkCaption: '',
hasDrawArtwork: 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
reflectionRoute?: string
visitedReflectionCursors?: Record<string, number[]>
drawArtworkDataUrl?: string
drawArtworkCaption?: string
hasDrawArtwork?: boolean
}
const validReflectionRoute =
data.reflectionRoute &&
[
'reflection',
'reflection1',
'reflection2',
'reflection3',
'reflection4',
'reflection5',
].includes(data.reflectionRoute)
? data.reflectionRoute
: 'reflection'
useZhanglepingStore.setState((prev) => ({
...prev,
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
lastVisitedPlace: data.lastVisitedPlace ?? null,
reflectionRoute: validReflectionRoute,
visitedReflectionCursors: deserializeVisitedCursors(data.visitedReflectionCursors),
drawArtworkDataUrl: data.drawArtworkDataUrl ?? '',
drawArtworkCaption: data.drawArtworkCaption ?? '',
hasDrawArtwork: data.hasDrawArtwork ?? false,
}))
} catch (error) {
useZhanglepingStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
reflectionRoute: 'reflection',
visitedReflectionCursors: {},
drawArtworkDataUrl: '',
drawArtworkCaption: '',
hasDrawArtwork: false,
}))
const status = await loadZhanglepingStore()
if (status === 'unauthenticated') {
showYibanUnauthenticatedPrompt()
} else if (status === 'error') {
showYibanSyncErrorPrompt()
console.error('Failed to load Zhangleping store', error)
}
if (!cancelled && (status === 'success' || status === 'empty')) {
setStoreReady(true)
}
})()
return () => {
cancelled = true
}
}, [])
if (!moduleName) return null
@@ -170,6 +107,12 @@ const ZhanglepingHome = () => {
}}
/>
</div>
<ContinueLearningModal
visible={continuePrompt.visible}
onContinue={continuePrompt.onContinue}
onCancel={continuePrompt.onCancel}
/>
</div>
)
}