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
+31
-57
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user