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 = () => { }} /> + + ) }