diff --git a/src/components/layout/alert/continue-learning-modal.tsx b/src/components/layout/alert/continue-learning-modal.tsx
new file mode 100644
index 0000000..ba28a54
--- /dev/null
+++ b/src/components/layout/alert/continue-learning-modal.tsx
@@ -0,0 +1,103 @@
+import type { MouseEvent } from 'react'
+
+export interface ContinueLearningModalProps {
+ /** 是否可见 */
+ visible?: boolean
+ /** 标题 */
+ title?: string
+ /** 提示正文 */
+ message?: string
+ /** 继续按钮文案 */
+ continueText?: string
+ /** 取消按钮文案 */
+ cancelText?: string
+ /** 点击「继续学习」回调 */
+ onContinue?: () => void
+ /** 点击「取消」或遮罩回调 */
+ onCancel?: () => void
+}
+
+/**
+ * ContinueLearningModal - 「未完成,是否继续学习」二选一确认弹窗
+ * 纯 CSS 实现,暖纸 + 朱红 + 金线古籍风,不依赖图片素材。
+ */
+const ContinueLearningModal = ({
+ visible = true,
+ title = '温馨提示',
+ message = '你还没有完成本故居的学习,是否继续上次的进度?',
+ continueText = '继续学习',
+ cancelText = '取消',
+ onContinue,
+ onCancel,
+}: ContinueLearningModalProps) => {
+ if (!visible) return null
+
+ const swallow = (e: MouseEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ }
+
+ return (
+
+ {/* 遮罩 */}
+
+
+ {/* 哑光米纸卡片 */}
+
+ {/* 内层细金线描边 */}
+
+
+
+ {/* 标题 */}
+
{title}
+
+ {/* 菱形分隔线 */}
+
+
+
+
+
+
+ {/* 正文 */}
+
+ {message}
+
+
+ {/* 按钮组 */}
+
+ {/* 取消(次要:米底描边) */}
+
+
+ {/* 继续学习(主要:朱红渐变 + 金边) */}
+
+
+
+
+
+ )
+}
+
+export default ContinueLearningModal
diff --git a/src/components/layout/index.ts b/src/components/layout/index.ts
index 7bd287e..b7d1635 100644
--- a/src/components/layout/index.ts
+++ b/src/components/layout/index.ts
@@ -9,6 +9,8 @@ export { default as SuccessAlert } from './alert/success-alert'
export type { SuccessAlertProps } from './alert/success-alert'
export { default as ErrorAlert } from './alert/error-alert'
export type { ErrorAlertProps } from './alert/error-alert'
+export { default as ContinueLearningModal } from './alert/continue-learning-modal'
+export type { ContinueLearningModalProps } from './alert/continue-learning-modal'
export { DragQuizPanel, BasketQuizPanel } from './quiz'
export type { DragQuizPanelProps } from './quiz'
// export type {
diff --git a/src/config/progression.ts b/src/config/progression.ts
index ba95b58..a3bf01d 100644
--- a/src/config/progression.ts
+++ b/src/config/progression.ts
@@ -77,3 +77,78 @@ export const getResidenceStageUnlocks = (residence: string, puzzles: PuzzleProgr
arePiecesUnlocked(residence, puzzles, getPieceIdsByRoute(residence, 'reflection')),
}
}
+
+export interface ResidenceStageCompletion {
+ dialogueComplete: boolean
+ reflectionComplete: boolean
+ internalizationComplete: boolean
+ residenceComplete: boolean
+}
+
+/**
+ * 基于拼图碎片统一判定每个故居三阶段是否完成。
+ * 每个故居 16 块碎片天然按 route 分为 place* / reflection* / internalization 三段,
+ * 收齐对应碎片即视为该阶段完成。
+ */
+export const getResidenceStageCompletion = (
+ residence: string,
+ puzzles: PuzzleProgress
+): ResidenceStageCompletion => {
+ const dialogueComplete = arePiecesUnlocked(
+ residence,
+ puzzles,
+ getPieceIdsByRoute(residence, 'place')
+ )
+ const reflectionComplete = arePiecesUnlocked(
+ residence,
+ puzzles,
+ getPieceIdsByRoute(residence, 'reflection')
+ )
+ const internalizationComplete = arePiecesUnlocked(
+ residence,
+ puzzles,
+ getPieceIdsByRoute(residence, 'internalization')
+ )
+
+ return {
+ dialogueComplete,
+ reflectionComplete,
+ internalizationComplete,
+ residenceComplete: dialogueComplete && reflectionComplete && internalizationComplete,
+ }
+}
+
+export interface ResidenceContinueInput {
+ completion: ResidenceStageCompletion
+ /** 对话模块断点(place1~placeN),各故居 store 均有 */
+ lastVisitedPlace?: string | null
+ /** 体悟模块断点路由,仅部分多场景故居(孙中山/鲁迅/黄炎培)有 */
+ reflectionRoute?: string | null
+}
+
+/**
+ * 计算「继续学习」应跳转到的目标路由——取第一个未完成的阶段。
+ * - 对话未完成 → lastVisitedPlace || 'place1'
+ * - 体悟未完成 → reflectionRoute || 'reflection'
+ * - 内化未完成 → 'internalization'
+ * - 全部完成 → null(无需续学)
+ *
+ * 这些路由都是各故居 home/场景现有逻辑已经在用的跳转目标,
+ * 单场景体悟(毛泽东/陈云/聂耳/荣德生)统一落到 'reflection' 由页面自管进度。
+ */
+export const resolveResidenceContinueTarget = ({
+ completion,
+ lastVisitedPlace,
+ reflectionRoute,
+}: ResidenceContinueInput): string | null => {
+ if (!completion.dialogueComplete) {
+ return lastVisitedPlace || 'place1'
+ }
+ if (!completion.reflectionComplete) {
+ return reflectionRoute || 'reflection'
+ }
+ if (!completion.internalizationComplete) {
+ return 'internalization'
+ }
+ return null
+}
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index d7962e3..c4addc8 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -3,6 +3,11 @@ export { usePageScale } from './usePageScale'
export { useTouch } from './useTouch'
export { useZoomAndDrag } from './useZoomAndDrag'
export { useCursorController } from './useCursorController'
+export { useResidenceContinue } from './useResidenceContinue'
export type { UseTouchOptions, UseTouchReturn, TouchEventData, SwipeDirection } from './useTouch'
export type { UseZoomAndDragOptions, UseZoomAndDragReturn } from './useZoomAndDrag'
export type { CursorControllerConfig } from './useCursorController'
+export type {
+ UseResidenceContinueParams,
+ UseResidenceContinueResult,
+} from './useResidenceContinue'
diff --git a/src/hooks/useResidenceContinue.ts b/src/hooks/useResidenceContinue.ts
new file mode 100644
index 0000000..7fab6f3
--- /dev/null
+++ b/src/hooks/useResidenceContinue.ts
@@ -0,0 +1,102 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { usePuzzleStore } from '@/store/puzzleStore'
+import {
+ getResidenceStageCompletion,
+ isResidenceStarted,
+ isResidenceUnlocked,
+ resolveResidenceContinueTarget,
+} from '@/config/progression'
+import { useVideoContextSafe } from '@/contexts/VideoContext'
+
+/**
+ * 「会话内每个故居只自动弹一次」的去重标记。
+ * 模块级内存集合:在 SPA 内跨路由导航保持,整页刷新后重置(刷新视为一次新访问)。
+ */
+const promptedResidences = new Set()
+
+export interface UseResidenceContinueParams {
+ /** 故居标识(同时也是路由里的 starName),如 'maozedong' */
+ residence: string
+ /** 本故居 store 是否已加载完成(loadXxxStore 完成后置 true),用于读取最新断点 */
+ storeReady: boolean
+ /** 对话模块断点(place1~placeN) */
+ lastVisitedPlace?: string | null
+ /** 体悟模块断点路由,仅孙中山/鲁迅/黄炎培有 */
+ reflectionRoute?: string | null
+}
+
+export interface UseResidenceContinueResult {
+ /** 是否显示「继续学习」弹窗 */
+ visible: boolean
+ /** 点击「继续学习」:跳转到第一个未完成阶段的续学位置 */
+ onContinue: () => void
+ /** 点击「取消」:仅关闭弹窗,停留在首页 */
+ onCancel: () => void
+}
+
+/**
+ * 故居首页「未完成 → 是否继续学习」自动弹窗逻辑。
+ *
+ * 触发条件(全部满足):
+ * 1. 全局拼图已加载 && 本故居 store 已加载
+ * 2. 首页地图视频已结束(内容可见)
+ * 3. 本会话内该故居尚未自动弹过
+ * 4. 故居已解锁 && 已开始 && 未全部完成
+ * 5. 能解析出有效的续学目标
+ *
+ * 弹一次后即标记,之后再回到 home 不再自动弹(由首页的三个阶段按钮继续)。
+ */
+export function useResidenceContinue({
+ residence,
+ storeReady,
+ lastVisitedPlace,
+ reflectionRoute,
+}: UseResidenceContinueParams): UseResidenceContinueResult {
+ const navigate = useNavigate()
+ const { moduleName } = useParams<{ moduleName: string }>()
+ const video = useVideoContextSafe()
+ const contentVisible = video ? video.isContentVisible : true
+
+ const puzzles = usePuzzleStore((state) => state.puzzles)
+ const isLoaded = usePuzzleStore((state) => state.isLoaded)
+
+ const [visible, setVisible] = useState(false)
+ const targetRef = useRef(null)
+
+ useEffect(() => {
+ if (!isLoaded || !storeReady || !contentVisible) return
+ if (promptedResidences.has(residence)) return
+ if (!isResidenceUnlocked(residence, puzzles)) return
+ // 没开始过的新故居不打扰,让学生自己点「一」正常开始
+ if (!isResidenceStarted(residence, puzzles)) return
+
+ const completion = getResidenceStageCompletion(residence, puzzles)
+ if (completion.residenceComplete) return
+
+ const target = resolveResidenceContinueTarget({
+ completion,
+ lastVisitedPlace,
+ reflectionRoute,
+ })
+ if (!target) return
+
+ targetRef.current = target
+ promptedResidences.add(residence)
+ setVisible(true)
+ }, [isLoaded, storeReady, contentVisible, residence, puzzles, lastVisitedPlace, reflectionRoute])
+
+ const onContinue = useCallback(() => {
+ setVisible(false)
+ const target = targetRef.current
+ if (moduleName && target) {
+ navigate(`/${moduleName}/${residence}/${target}`)
+ }
+ }, [moduleName, navigate, residence])
+
+ const onCancel = useCallback(() => {
+ setVisible(false)
+ }, [])
+
+ return { visible, onContinue, onCancel }
+}
diff --git a/src/pages/chenyun/home.tsx b/src/pages/chenyun/home.tsx
index a334a1d..7c49e8f 100644
--- a/src/pages/chenyun/home.tsx
+++ b/src/pages/chenyun/home.tsx
@@ -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 | undefined
-): Record> => {
- if (!data || typeof data !== 'object') return {}
- const visitedExhibits: Record> = {}
- 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
- 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 = () => {
}}
/>
+
+
)
}
diff --git a/src/pages/huangyanpei/home.tsx b/src/pages/huangyanpei/home.tsx
index 4011943..e09aa1f 100644
--- a/src/pages/huangyanpei/home.tsx
+++ b/src/pages/huangyanpei/home.tsx
@@ -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
- lastVisitedPlace?: string | null
- reflectionRoute?: string
- shownReflectionCompletionGuides?: Record
- 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 = () => {
}}
/>
+
+
)
}
diff --git a/src/pages/luxun/home.tsx b/src/pages/luxun/home.tsx
index d672e0e..60927af 100644
--- a/src/pages/luxun/home.tsx
+++ b/src/pages/luxun/home.tsx
@@ -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 | undefined
-): Record> => {
- if (!data || typeof data !== 'object') return {}
- const visitedExhibits: Record> = {}
- 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
- 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 = () => {
}}
/>
+
+
)
}
diff --git a/src/pages/maozedong/home.tsx b/src/pages/maozedong/home.tsx
index da3b055..b27f645 100644
--- a/src/pages/maozedong/home.tsx
+++ b/src/pages/maozedong/home.tsx
@@ -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 | undefined
-): Record> => {
- if (!data || typeof data !== 'object') return {}
- const visitedExhibits: Record> = {}
- 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
- 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 = () => {
}}
/>
+
+
)
}
diff --git a/src/pages/nieer/home.tsx b/src/pages/nieer/home.tsx
index 3ed6b25..7fdb658 100644
--- a/src/pages/nieer/home.tsx
+++ b/src/pages/nieer/home.tsx
@@ -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 | undefined
-): Record> => {
- if (!data || typeof data !== 'object') return {}
- const visitedExhibits: Record> = {}
- 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
- 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 = () => {
}}
/>
+
+
)
}
diff --git a/src/pages/rongdesheng/home.tsx b/src/pages/rongdesheng/home.tsx
index 2b75170..0f3ad04 100644
--- a/src/pages/rongdesheng/home.tsx
+++ b/src/pages/rongdesheng/home.tsx
@@ -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 | undefined
-): Record> => {
- if (!data || typeof data !== 'object') return {}
- const visitedExhibits: Record> = {}
- 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
- lastVisitedPlace?: string | null
- reflectionCompleted?: Record
- reflectionVideoViewed?: Record>
- 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 = () => {
}}
/>
+
+
)
}
diff --git a/src/pages/sunzhongshan/home.tsx b/src/pages/sunzhongshan/home.tsx
index 0651b54..c68eb82 100644
--- a/src/pages/sunzhongshan/home.tsx
+++ b/src/pages/sunzhongshan/home.tsx
@@ -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 | undefined
-): Record> => {
- if (!data || typeof data !== 'object') return {}
- const visitedExhibits: Record> = {}
- 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
- 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 = () => {
}}
/>
+
+
)
}
diff --git a/src/pages/zhangleping/home.tsx b/src/pages/zhangleping/home.tsx
index be458e8..0b95e1d 100644
--- a/src/pages/zhangleping/home.tsx
+++ b/src/pages/zhangleping/home.tsx
@@ -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 | undefined
-): Record> => {
- if (!data || typeof data !== 'object') return {}
- const visitedExhibits: Record> = {}
- 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
- lastVisitedPlace?: string | null
- reflectionRoute?: string
- visitedReflectionCursors?: Record
- 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 = () => {
}}
/>
+
+
)
}