feat: 实现故居续学弹窗功能

新增功能:
- 统一续学逻辑:8个故居首页接入自动续学提示弹窗
- 完成判定:基于拼图碎片统一判断对话/体悟/内化是否完成
- 断点续学:对话回lastVisitedPlace、体悟回reflectionRoute、内化回internalization
- 触发条件:会话内每故居仅提示一次 + 首页视频播完后 + store加载成功/空时
- 安全防护:组件卸载后不再setState、同步失败不弹窗避免用旧状态误导

新增组件:
- ContinueLearningModal:纯CSS哑光米纸+朱红古籍风弹窗
- useResidenceContinue:封装触发逻辑+跳转+去重的共享hook

涉及文件:
- src/config/progression.ts:getResidenceStageCompletion、resolveResidenceContinueTarget
- src/hooks/useResidenceContinue.ts:统一续学hook
- src/components/layout/alert/continue-learning-modal.tsx:弹窗组件
- src/pages/*/home.tsx:8个故居首页接入

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 4.8 committed 2026-06-21 21:44:27 +08:00
1 parent 49ecabbf3a
commit 73d02eb62d
13 files changed
+515 -476

No files matched your search

@@ -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 (
<div className="fixed inset-0 z-10000 flex items-center justify-center">
{/* 遮罩 */}
<div className="absolute inset-0 bg-[#1f0e06]/65" onClick={onCancel} />
{/* 哑光米纸卡片 */}
<div
className="relative z-10 w-[920px] rounded-[28px] border-[3px] border-[#bfa05c] bg-gradient-to-b from-[#e4d4b2] to-[#d6c198] px-20 py-14 shadow-[0_24px_70px_rgba(20,8,4,0.5)]"
onClick={swallow}
>
{/* 内层细金线描边 */}
<div className="pointer-events-none absolute inset-[10px] rounded-[20px] border border-[#bfa05c]/55" />
<div className="relative flex flex-col items-center">
{/* 标题 */}
<div className="text-5xl font-bold tracking-[10px] text-[#a8281c]">{title}</div>
{/* 菱形分隔线 */}
<div className="mt-6 flex items-center gap-4">
<span className="h-[2px] w-24 bg-gradient-to-r from-transparent to-[#c0392b]/70" />
<span className="h-3 w-3 rotate-45 bg-[#c0392b]" />
<span className="h-[2px] w-24 bg-gradient-to-l from-transparent to-[#c0392b]/70" />
</div>
{/* 正文 */}
<p className="mt-10 max-w-[720px] text-center text-4xl font-medium leading-relaxed tracking-wide text-[#5c3a1e]">
{message}
</p>
{/* 按钮组 */}
<div className="mt-12 flex items-center gap-12">
{/* 取消(次要:米底描边) */}
<button
type="button"
className="cursor-pointer rounded-full border-2 border-[#bfa05c] bg-[#ecdcb6] px-14 py-3.5 text-3xl font-bold tracking-[4px] text-[#8a5326] transition-all duration-200 hover:bg-[#e2d0a8] active:scale-95"
onMouseDown={swallow}
onClick={(e) => {
swallow(e)
onCancel?.()
}}
>
{cancelText}
</button>
{/* 继续学习(主要:朱红渐变 + 金边) */}
<button
type="button"
className="cursor-pointer rounded-full border-2 border-[#e8c06a] bg-gradient-to-b from-[#d4452f] to-[#a8281c] px-14 py-3.5 text-3xl font-bold tracking-[4px] text-[#fdf1da] shadow-[0_8px_20px_rgba(150,40,25,0.45)] transition-all duration-200 hover:brightness-110 active:scale-95"
onMouseDown={swallow}
onClick={(e) => {
swallow(e)
onContinue?.()
}}
>
{continueText}
</button>
</div>
</div>
</div>
</div>
)
}
export default ContinueLearningModal
+2
View File
@@ -9,6 +9,8 @@ export { default as SuccessAlert } from './alert/success-alert'
export type { SuccessAlertProps } from './alert/success-alert' export type { SuccessAlertProps } from './alert/success-alert'
export { default as ErrorAlert } from './alert/error-alert' export { default as ErrorAlert } from './alert/error-alert'
export type { ErrorAlertProps } 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 { DragQuizPanel, BasketQuizPanel } from './quiz'
export type { DragQuizPanelProps } from './quiz' export type { DragQuizPanelProps } from './quiz'
// export type { // export type {
+75
View File
@@ -77,3 +77,78 @@ export const getResidenceStageUnlocks = (residence: string, puzzles: PuzzleProgr
arePiecesUnlocked(residence, puzzles, getPieceIdsByRoute(residence, 'reflection')), 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
}
+5
View File
@@ -3,6 +3,11 @@ export { usePageScale } from './usePageScale'
export { useTouch } from './useTouch' export { useTouch } from './useTouch'
export { useZoomAndDrag } from './useZoomAndDrag' export { useZoomAndDrag } from './useZoomAndDrag'
export { useCursorController } from './useCursorController' export { useCursorController } from './useCursorController'
export { useResidenceContinue } from './useResidenceContinue'
export type { UseTouchOptions, UseTouchReturn, TouchEventData, SwipeDirection } from './useTouch' export type { UseTouchOptions, UseTouchReturn, TouchEventData, SwipeDirection } from './useTouch'
export type { UseZoomAndDragOptions, UseZoomAndDragReturn } from './useZoomAndDrag' export type { UseZoomAndDragOptions, UseZoomAndDragReturn } from './useZoomAndDrag'
export type { CursorControllerConfig } from './useCursorController' export type { CursorControllerConfig } from './useCursorController'
export type {
UseResidenceContinueParams,
UseResidenceContinueResult,
} from './useResidenceContinue'
+102
View File
@@ -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<string>()
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<string | null>(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 }
}
+28 -54
View File
@@ -1,23 +1,13 @@
import { useEffect } from 'react' import { useEffect, useState } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils' import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components' import { Image } from '@/components'
import { ContinueLearningModal } from '@/components/layout'
import { useResidenceContinue } from '@/hooks'
import { usePuzzleStore } from '@/store/puzzleStore' import { usePuzzleStore } from '@/store/puzzleStore'
import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression' import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression'
import { getUserDefineResult } from '@/lib/utils/api-common'
import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components' import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components'
import { useChenyunStore, CHENYUN_LESSON_ID, CHENYUN_KEY } from '@/store/chenyunStore' import { useChenyunStore, loadChenyunStore } 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
}
const Chenyun = () => { const Chenyun = () => {
const navigate = useNavigate() const navigate = useNavigate()
@@ -32,9 +22,16 @@ const Chenyun = () => {
const puzzles = usePuzzleStore((state) => state.puzzles) const puzzles = usePuzzleStore((state) => state.puzzles)
const isLoaded = usePuzzleStore((state) => state.isLoaded) const isLoaded = usePuzzleStore((state) => state.isLoaded)
const [storeReady, setStoreReady] = useState(false)
const { dialogueUnlocked, reflectionUnlocked, internalizationUnlocked } = const { dialogueUnlocked, reflectionUnlocked, internalizationUnlocked } =
getResidenceStageUnlocks(starName, puzzles) getResidenceStageUnlocks(starName, puzzles)
const continuePrompt = useResidenceContinue({
residence: starName,
storeReady,
lastVisitedPlace,
})
useEffect(() => { useEffect(() => {
if (isLoaded && !isResidenceUnlocked(starName, puzzles)) { if (isLoaded && !isResidenceUnlocked(starName, puzzles)) {
navigate('/menu', { replace: true }) navigate('/menu', { replace: true })
@@ -42,53 +39,24 @@ const Chenyun = () => {
}, [isLoaded, navigate, puzzles, starName]) }, [isLoaded, navigate, puzzles, starName])
useEffect(() => { useEffect(() => {
let cancelled = false
;(async () => { ;(async () => {
try { const status = await loadChenyunStore()
const result = await getUserDefineResult(CHENYUN_LESSON_ID, CHENYUN_KEY) if (status === 'unauthenticated') {
if (result.status !== 'success') {
useChenyunStore.setState((prev) => ({
...prev,
visitedExhibits: {},
isYinyuanSolved: false,
isMimianSolved: false,
lastVisitedPlace: null,
}))
if (result.status === 'unauthenticated') {
showYibanUnauthenticatedPrompt() showYibanUnauthenticatedPrompt()
} else if (result.status === 'error') { } else if (status === 'error') {
showYibanSyncErrorPrompt() showYibanSyncErrorPrompt()
} }
return
}
const data = JSON.parse(result.content) as { if (!cancelled && (status === 'success' || status === 'empty')) {
visitedExhibits?: Record<string, number[]> setStoreReady(true)
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,
}))
showYibanSyncErrorPrompt()
console.error('Failed to load Chenyun store', error)
} }
})() })()
return () => {
cancelled = true
}
}, []) }, [])
if (!moduleName) { if (!moduleName) {
@@ -143,6 +111,12 @@ const Chenyun = () => {
}} }}
/> />
</div> </div>
<ContinueLearningModal
visible={continuePrompt.visible}
onContinue={continuePrompt.onContinue}
onCancel={continuePrompt.onCancel}
/>
</div> </div>
) )
} }
+29 -62
View File
@@ -1,16 +1,13 @@
import { useEffect } from 'react' import { useEffect, useState } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils' import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components' import { Image } from '@/components'
import { ContinueLearningModal } from '@/components/layout'
import { useResidenceContinue } from '@/hooks'
import { usePuzzleStore } from '@/store/puzzleStore' import { usePuzzleStore } from '@/store/puzzleStore'
import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression' import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression'
import { getUserDefineResult } from '@/lib/utils/api-common'
import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components' import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components'
import { import { useHuangyanpeiStore, loadHuangyanpeiStore } from '@/store/huangyanpeiStore'
useHuangyanpeiStore,
HUANGYANPEI_LESSON_ID,
HUANGYANPEI_KEY,
} from '@/store/huangyanpeiStore'
const Huangyanpei = () => { const Huangyanpei = () => {
const navigate = useNavigate() const navigate = useNavigate()
@@ -26,9 +23,17 @@ const Huangyanpei = () => {
const puzzles = usePuzzleStore((state) => state.puzzles) const puzzles = usePuzzleStore((state) => state.puzzles)
const isLoaded = usePuzzleStore((state) => state.isLoaded) const isLoaded = usePuzzleStore((state) => state.isLoaded)
const [storeReady, setStoreReady] = useState(false)
const { dialogueUnlocked, reflectionUnlocked, internalizationUnlocked } = const { dialogueUnlocked, reflectionUnlocked, internalizationUnlocked } =
getResidenceStageUnlocks(starName, puzzles) getResidenceStageUnlocks(starName, puzzles)
const continuePrompt = useResidenceContinue({
residence: starName,
storeReady,
lastVisitedPlace,
reflectionRoute,
})
useEffect(() => { useEffect(() => {
if (isLoaded && !isResidenceUnlocked(starName, puzzles)) { if (isLoaded && !isResidenceUnlocked(starName, puzzles)) {
navigate('/menu', { replace: true }) navigate('/menu', { replace: true })
@@ -36,68 +41,24 @@ const Huangyanpei = () => {
}, [isLoaded, navigate, puzzles, starName]) }, [isLoaded, navigate, puzzles, starName])
useEffect(() => { useEffect(() => {
let cancelled = false
;(async () => { ;(async () => {
try { const status = await loadHuangyanpeiStore()
const result = await getUserDefineResult(HUANGYANPEI_LESSON_ID, HUANGYANPEI_KEY) if (status === 'unauthenticated') {
if (result.status !== 'success') {
useHuangyanpeiStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
reflectionRoute: 'reflection',
shownReflectionCompletionGuides: {},
reflectionScene2Choice: null,
}))
if (result.status === 'unauthenticated') {
showYibanUnauthenticatedPrompt() showYibanUnauthenticatedPrompt()
} else if (result.status === 'error') { } else if (status === 'error') {
showYibanSyncErrorPrompt() showYibanSyncErrorPrompt()
} }
return
}
const data = JSON.parse(result.content) as { if (!cancelled && (status === 'success' || status === 'empty')) {
visitedExhibits?: Record<string, number[]> setStoreReady(true)
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,
}))
showYibanSyncErrorPrompt()
console.error('Failed to load Huangyanpei store', error)
} }
})() })()
return () => {
cancelled = true
}
}, []) }, [])
if (!moduleName) { if (!moduleName) {
@@ -152,6 +113,12 @@ const Huangyanpei = () => {
}} }}
/> />
</div> </div>
<ContinueLearningModal
visible={continuePrompt.visible}
onContinue={continuePrompt.onContinue}
onCancel={continuePrompt.onCancel}
/>
</div> </div>
) )
} }
+30 -57
View File
@@ -1,23 +1,13 @@
import { useEffect } from 'react' import { useEffect, useState } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils' import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components' import { Image } from '@/components'
import { ContinueLearningModal } from '@/components/layout'
import { useResidenceContinue } from '@/hooks'
import { usePuzzleStore } from '@/store/puzzleStore' import { usePuzzleStore } from '@/store/puzzleStore'
import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression' import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression'
import { getUserDefineResult } from '@/lib/utils/api-common'
import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components' import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components'
import { useLuxunStore, LUXUN_LESSON_ID, LUXUN_KEY } from '@/store/luxunStore' import { useLuxunStore, loadLuxunStore } 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
}
const Luxun = () => { const Luxun = () => {
const navigate = useNavigate() const navigate = useNavigate()
@@ -33,9 +23,17 @@ const Luxun = () => {
const puzzles = usePuzzleStore((state) => state.puzzles) const puzzles = usePuzzleStore((state) => state.puzzles)
const isLoaded = usePuzzleStore((state) => state.isLoaded) const isLoaded = usePuzzleStore((state) => state.isLoaded)
const [storeReady, setStoreReady] = useState(false)
const { dialogueUnlocked, reflectionUnlocked, internalizationUnlocked } = const { dialogueUnlocked, reflectionUnlocked, internalizationUnlocked } =
getResidenceStageUnlocks(starName, puzzles) getResidenceStageUnlocks(starName, puzzles)
const continuePrompt = useResidenceContinue({
residence: starName,
storeReady,
lastVisitedPlace,
reflectionRoute,
})
useEffect(() => { useEffect(() => {
if (isLoaded && !isResidenceUnlocked(starName, puzzles)) { if (isLoaded && !isResidenceUnlocked(starName, puzzles)) {
navigate('/menu', { replace: true }) navigate('/menu', { replace: true })
@@ -43,55 +41,24 @@ const Luxun = () => {
}, [isLoaded, navigate, puzzles, starName]) }, [isLoaded, navigate, puzzles, starName])
useEffect(() => { useEffect(() => {
let cancelled = false
;(async () => { ;(async () => {
try { const status = await loadLuxunStore()
const result = await getUserDefineResult(LUXUN_LESSON_ID, LUXUN_KEY) if (status === 'unauthenticated') {
if (result.status !== 'success') {
useLuxunStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
reflectionRoute: 'reflection',
}))
if (result.status === 'unauthenticated') {
showYibanUnauthenticatedPrompt() showYibanUnauthenticatedPrompt()
} else if (result.status === 'error') { } else if (status === 'error') {
showYibanSyncErrorPrompt() showYibanSyncErrorPrompt()
} }
return
}
const data = JSON.parse(result.content) as { if (!cancelled && (status === 'success' || status === 'empty')) {
visitedExhibits?: Record<string, number[]> setStoreReady(true)
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',
}))
showYibanSyncErrorPrompt()
console.error('Failed to load Luxun store', error)
} }
})() })()
return () => {
cancelled = true
}
}, []) }, [])
if (!moduleName) { if (!moduleName) {
@@ -130,7 +97,7 @@ const Luxun = () => {
src={getStaticImage('star/二-解锁')} src={getStaticImage('star/二-解锁')}
onClick={() => { onClick={() => {
if (!reflectionUnlocked) return if (!reflectionUnlocked) return
navigate(`/${moduleName}/${starName}/reflection`) navigate(`/${moduleName}/${starName}/${reflectionRoute || 'reflection'}`)
}} }}
/> />
{/* 内化 */} {/* 内化 */}
@@ -146,6 +113,12 @@ const Luxun = () => {
}} }}
/> />
</div> </div>
<ContinueLearningModal
visible={continuePrompt.visible}
onContinue={continuePrompt.onContinue}
onCancel={continuePrompt.onCancel}
/>
</div> </div>
) )
} }
+28 -46
View File
@@ -1,23 +1,13 @@
import { useEffect } from 'react' import { useEffect, useState } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils' import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components' import { Image } from '@/components'
import { ContinueLearningModal } from '@/components/layout'
import { useResidenceContinue } from '@/hooks'
import { usePuzzleStore } from '@/store/puzzleStore' import { usePuzzleStore } from '@/store/puzzleStore'
import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression' import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression'
import { getUserDefineResult } from '@/lib/utils/api-common'
import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components' import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components'
import { MAOZEDONG_KEY, MAOZEDONG_LESSON_ID, useMaozedongStore } from '@/store/maozedongStore' import { useMaozedongStore, loadMaozedongStore } 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
}
const Maozedong = () => { const Maozedong = () => {
const navigate = useNavigate() const navigate = useNavigate()
@@ -31,9 +21,16 @@ const Maozedong = () => {
const lastVisitedPlace = useMaozedongStore((state) => state.lastVisitedPlace) const lastVisitedPlace = useMaozedongStore((state) => state.lastVisitedPlace)
const puzzles = usePuzzleStore((state) => state.puzzles) const puzzles = usePuzzleStore((state) => state.puzzles)
const isLoaded = usePuzzleStore((state) => state.isLoaded) const isLoaded = usePuzzleStore((state) => state.isLoaded)
const [storeReady, setStoreReady] = useState(false)
const { dialogueUnlocked, reflectionUnlocked, internalizationUnlocked } = const { dialogueUnlocked, reflectionUnlocked, internalizationUnlocked } =
getResidenceStageUnlocks(starName, puzzles) getResidenceStageUnlocks(starName, puzzles)
const continuePrompt = useResidenceContinue({
residence: starName,
storeReady,
lastVisitedPlace,
})
useEffect(() => { useEffect(() => {
if (isLoaded && !isResidenceUnlocked(starName, puzzles)) { if (isLoaded && !isResidenceUnlocked(starName, puzzles)) {
navigate('/menu', { replace: true }) navigate('/menu', { replace: true })
@@ -41,45 +38,24 @@ const Maozedong = () => {
}, [isLoaded, navigate, puzzles, starName]) }, [isLoaded, navigate, puzzles, starName])
useEffect(() => { useEffect(() => {
let cancelled = false
;(async () => { ;(async () => {
try { const status = await loadMaozedongStore()
const result = await getUserDefineResult(MAOZEDONG_LESSON_ID, MAOZEDONG_KEY) if (status === 'unauthenticated') {
if (result.status !== 'success') {
useMaozedongStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
}))
if (result.status === 'unauthenticated') {
showYibanUnauthenticatedPrompt() showYibanUnauthenticatedPrompt()
} else if (result.status === 'error') { } else if (status === 'error') {
showYibanSyncErrorPrompt() showYibanSyncErrorPrompt()
} }
return
}
const data = JSON.parse(result.content) as { if (!cancelled && (status === 'success' || status === 'empty')) {
visitedExhibits?: Record<string, number[]> setStoreReady(true)
lastVisitedPlace?: string | null
}
useMaozedongStore.setState((prev) => ({
...prev,
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
lastVisitedPlace: data.lastVisitedPlace ?? null,
}))
} catch (error) {
useMaozedongStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
}))
showYibanSyncErrorPrompt()
console.error('Failed to load Maozedong store', error)
} }
})() })()
return () => {
cancelled = true
}
}, []) }, [])
if (!moduleName) return null if (!moduleName) return null
@@ -129,6 +105,12 @@ const Maozedong = () => {
}} }}
/> />
</div> </div>
<ContinueLearningModal
visible={continuePrompt.visible}
onContinue={continuePrompt.onContinue}
onCancel={continuePrompt.onCancel}
/>
</div> </div>
) )
} }
+28 -46
View File
@@ -1,23 +1,13 @@
import { useEffect } from 'react' import { useEffect, useState } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils' import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components' import { Image } from '@/components'
import { ContinueLearningModal } from '@/components/layout'
import { useResidenceContinue } from '@/hooks'
import { usePuzzleStore } from '@/store/puzzleStore' import { usePuzzleStore } from '@/store/puzzleStore'
import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression' import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression'
import { getUserDefineResult } from '@/lib/utils/api-common'
import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components' import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components'
import { NIEER_KEY, NIEER_LESSON_ID, useNieerStore } from '@/store/nieerStore' import { useNieerStore, loadNieerStore } 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
}
const NieerHome = () => { const NieerHome = () => {
const navigate = useNavigate() const navigate = useNavigate()
@@ -29,9 +19,16 @@ const NieerHome = () => {
const puzzles = usePuzzleStore((state) => state.puzzles) const puzzles = usePuzzleStore((state) => state.puzzles)
const isLoaded = usePuzzleStore((state) => state.isLoaded) const isLoaded = usePuzzleStore((state) => state.isLoaded)
const [storeReady, setStoreReady] = useState(false)
const { dialogueUnlocked, reflectionUnlocked, internalizationUnlocked } = const { dialogueUnlocked, reflectionUnlocked, internalizationUnlocked } =
getResidenceStageUnlocks(starName, puzzles) getResidenceStageUnlocks(starName, puzzles)
const continuePrompt = useResidenceContinue({
residence: starName,
storeReady,
lastVisitedPlace,
})
useEffect(() => { useEffect(() => {
if (isLoaded && !isResidenceUnlocked(starName, puzzles)) { if (isLoaded && !isResidenceUnlocked(starName, puzzles)) {
navigate('/menu', { replace: true }) navigate('/menu', { replace: true })
@@ -39,45 +36,24 @@ const NieerHome = () => {
}, [isLoaded, navigate, puzzles, starName]) }, [isLoaded, navigate, puzzles, starName])
useEffect(() => { useEffect(() => {
let cancelled = false
;(async () => { ;(async () => {
try { const status = await loadNieerStore()
const result = await getUserDefineResult(NIEER_LESSON_ID, NIEER_KEY) if (status === 'unauthenticated') {
if (result.status !== 'success') {
useNieerStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
}))
if (result.status === 'unauthenticated') {
showYibanUnauthenticatedPrompt() showYibanUnauthenticatedPrompt()
} else if (result.status === 'error') { } else if (status === 'error') {
showYibanSyncErrorPrompt() showYibanSyncErrorPrompt()
} }
return
}
const data = JSON.parse(result.content) as { if (!cancelled && (status === 'success' || status === 'empty')) {
visitedExhibits?: Record<string, number[]> setStoreReady(true)
lastVisitedPlace?: string | null
}
useNieerStore.setState((prev) => ({
...prev,
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
lastVisitedPlace: data.lastVisitedPlace ?? null,
}))
} catch (error) {
useNieerStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
}))
showYibanSyncErrorPrompt()
console.error('Failed to load Nieer store', error)
} }
})() })()
return () => {
cancelled = true
}
}, []) }, [])
if (!moduleName) return null if (!moduleName) return null
@@ -129,6 +105,12 @@ const NieerHome = () => {
}} }}
/> />
</div> </div>
<ContinueLearningModal
visible={continuePrompt.visible}
onContinue={continuePrompt.onContinue}
onCancel={continuePrompt.onCancel}
/>
</div> </div>
) )
} }
+28 -66
View File
@@ -1,27 +1,13 @@
import { useEffect } from 'react' import { useEffect, useState } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils' import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { Image } from '@/components' import { Image } from '@/components'
import { ContinueLearningModal } from '@/components/layout'
import { useResidenceContinue } from '@/hooks'
import { usePuzzleStore } from '@/store/puzzleStore' import { usePuzzleStore } from '@/store/puzzleStore'
import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression' import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { getUserDefineResult } from '@/lib/utils/api-common'
import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components' import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components'
import { import { useRongdeshengStore, loadRongdeshengStore } from '@/store/rongdeshengStore'
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
}
const RongdeshengHome = () => { const RongdeshengHome = () => {
const navigate = useNavigate() const navigate = useNavigate()
@@ -33,9 +19,16 @@ const RongdeshengHome = () => {
const puzzles = usePuzzleStore((state) => state.puzzles) const puzzles = usePuzzleStore((state) => state.puzzles)
const isLoaded = usePuzzleStore((state) => state.isLoaded) const isLoaded = usePuzzleStore((state) => state.isLoaded)
const [storeReady, setStoreReady] = useState(false)
const { dialogueUnlocked, reflectionUnlocked, internalizationUnlocked } = const { dialogueUnlocked, reflectionUnlocked, internalizationUnlocked } =
getResidenceStageUnlocks(starName, puzzles) getResidenceStageUnlocks(starName, puzzles)
const continuePrompt = useResidenceContinue({
residence: starName,
storeReady,
lastVisitedPlace,
})
useEffect(() => { useEffect(() => {
if (isLoaded && !isResidenceUnlocked(starName, puzzles)) { if (isLoaded && !isResidenceUnlocked(starName, puzzles)) {
navigate('/menu', { replace: true }) navigate('/menu', { replace: true })
@@ -43,61 +36,24 @@ const RongdeshengHome = () => {
}, [isLoaded, navigate, puzzles, starName]) }, [isLoaded, navigate, puzzles, starName])
useEffect(() => { useEffect(() => {
let cancelled = false
;(async () => { ;(async () => {
try { const status = await loadRongdeshengStore()
const result = await getUserDefineResult(RONGDESHENG_LESSON_ID, RONGDESHENG_KEY) if (status === 'unauthenticated') {
if (result.status !== 'success') {
useRongdeshengStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
reflectionCompleted: {},
reflectionVideoViewed: {},
reflectionFinalCompleted: false,
reflectionGuideShown: false,
}))
if (result.status === 'unauthenticated') {
showYibanUnauthenticatedPrompt() showYibanUnauthenticatedPrompt()
} else if (result.status === 'error') { } else if (status === 'error') {
showYibanSyncErrorPrompt() showYibanSyncErrorPrompt()
} }
return
}
const data = JSON.parse(result.content) as { if (!cancelled && (status === 'success' || status === 'empty')) {
visitedExhibits?: Record<string, number[]> setStoreReady(true)
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,
}))
showYibanSyncErrorPrompt()
console.error('Failed to load Rongdesheng store', error)
} }
})() })()
return () => {
cancelled = true
}
}, []) }, [])
if (!moduleName) return null if (!moduleName) return null
@@ -152,6 +108,12 @@ const RongdeshengHome = () => {
}} }}
/> />
</div> </div>
<ContinueLearningModal
visible={continuePrompt.visible}
onContinue={continuePrompt.onContinue}
onCancel={continuePrompt.onCancel}
/>
</div> </div>
) )
} }
+29 -60
View File
@@ -1,27 +1,13 @@
import { useEffect } from 'react' import { useEffect, useState } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils' import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components' import { Image } from '@/components'
import { ContinueLearningModal } from '@/components/layout'
import { useResidenceContinue } from '@/hooks'
import { usePuzzleStore } from '@/store/puzzleStore' import { usePuzzleStore } from '@/store/puzzleStore'
import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression' import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression'
import { getUserDefineResult } from '@/lib/utils/api-common'
import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components' import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components'
import { import { useSunzhongshanStore, loadSunzhongshanStore } from '@/store/sunzhongshanStore'
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
}
const Sunzhongshan = () => { const Sunzhongshan = () => {
const navigate = useNavigate() const navigate = useNavigate()
@@ -37,9 +23,17 @@ const Sunzhongshan = () => {
const puzzles = usePuzzleStore((state) => state.puzzles) const puzzles = usePuzzleStore((state) => state.puzzles)
const isLoaded = usePuzzleStore((state) => state.isLoaded) const isLoaded = usePuzzleStore((state) => state.isLoaded)
const [storeReady, setStoreReady] = useState(false)
const { dialogueUnlocked, reflectionUnlocked, internalizationUnlocked } = const { dialogueUnlocked, reflectionUnlocked, internalizationUnlocked } =
getResidenceStageUnlocks(starName, puzzles) getResidenceStageUnlocks(starName, puzzles)
const continuePrompt = useResidenceContinue({
residence: starName,
storeReady,
lastVisitedPlace,
reflectionRoute,
})
useEffect(() => { useEffect(() => {
if (isLoaded && !isResidenceUnlocked(starName, puzzles)) { if (isLoaded && !isResidenceUnlocked(starName, puzzles)) {
navigate('/menu', { replace: true }) navigate('/menu', { replace: true })
@@ -47,55 +41,24 @@ const Sunzhongshan = () => {
}, [isLoaded, navigate, puzzles, starName]) }, [isLoaded, navigate, puzzles, starName])
useEffect(() => { useEffect(() => {
let cancelled = false
;(async () => { ;(async () => {
try { const status = await loadSunzhongshanStore()
const result = await getUserDefineResult(SUNZHONGSHAN_LESSON_ID, SUNZHONGSHAN_KEY) if (status === 'unauthenticated') {
if (result.status !== 'success') {
useSunzhongshanStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
reflectionRoute: 'reflection',
}))
if (result.status === 'unauthenticated') {
showYibanUnauthenticatedPrompt() showYibanUnauthenticatedPrompt()
} else if (result.status === 'error') { } else if (status === 'error') {
showYibanSyncErrorPrompt() showYibanSyncErrorPrompt()
} }
return
}
const data = JSON.parse(result.content) as { if (!cancelled && (status === 'success' || status === 'empty')) {
visitedExhibits?: Record<string, number[]> setStoreReady(true)
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',
}))
showYibanSyncErrorPrompt()
console.error('Failed to load Sunzhongshan store', error)
} }
})() })()
return () => {
cancelled = true
}
}, []) }, [])
if (!moduleName) { if (!moduleName) {
@@ -150,6 +113,12 @@ const Sunzhongshan = () => {
}} }}
/> />
</div> </div>
<ContinueLearningModal
visible={continuePrompt.visible}
onContinue={continuePrompt.onContinue}
onCancel={continuePrompt.onCancel}
/>
</div> </div>
) )
} }
+28 -85
View File
@@ -1,29 +1,13 @@
import { useEffect } from 'react' import { useEffect, useState } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils' import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components' import { Image } from '@/components'
import { ContinueLearningModal } from '@/components/layout'
import { useResidenceContinue } from '@/hooks'
import { usePuzzleStore } from '@/store/puzzleStore' import { usePuzzleStore } from '@/store/puzzleStore'
import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression' import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression'
import { getUserDefineResult } from '@/lib/utils/api-common'
import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components' import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components'
import { import { useZhanglepingStore, loadZhanglepingStore } from '@/store/zhanglepingStore'
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
const ZhanglepingHome = () => { const ZhanglepingHome = () => {
const navigate = useNavigate() const navigate = useNavigate()
@@ -37,9 +21,16 @@ const ZhanglepingHome = () => {
const lastVisitedPlace = useZhanglepingStore((state) => state.lastVisitedPlace) const lastVisitedPlace = useZhanglepingStore((state) => state.lastVisitedPlace)
const puzzles = usePuzzleStore((state) => state.puzzles) const puzzles = usePuzzleStore((state) => state.puzzles)
const isLoaded = usePuzzleStore((state) => state.isLoaded) const isLoaded = usePuzzleStore((state) => state.isLoaded)
const [storeReady, setStoreReady] = useState(false)
const { dialogueUnlocked, reflectionUnlocked, internalizationUnlocked } = const { dialogueUnlocked, reflectionUnlocked, internalizationUnlocked } =
getResidenceStageUnlocks(starName, puzzles) getResidenceStageUnlocks(starName, puzzles)
const continuePrompt = useResidenceContinue({
residence: starName,
storeReady,
lastVisitedPlace,
})
useEffect(() => { useEffect(() => {
if (isLoaded && !isResidenceUnlocked(starName, puzzles)) { if (isLoaded && !isResidenceUnlocked(starName, puzzles)) {
navigate('/menu', { replace: true }) navigate('/menu', { replace: true })
@@ -47,78 +38,24 @@ const ZhanglepingHome = () => {
}, [isLoaded, navigate, puzzles, starName]) }, [isLoaded, navigate, puzzles, starName])
useEffect(() => { useEffect(() => {
let cancelled = false
;(async () => { ;(async () => {
try { const status = await loadZhanglepingStore()
const result = await getUserDefineResult(ZHANGLEPING_LESSON_ID, ZHANGLEPING_KEY) if (status === 'unauthenticated') {
if (result.status !== 'success') {
useZhanglepingStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
reflectionRoute: 'reflection',
visitedReflectionCursors: {},
drawArtworkDataUrl: '',
drawArtworkCaption: '',
hasDrawArtwork: false,
}))
if (result.status === 'unauthenticated') {
showYibanUnauthenticatedPrompt() showYibanUnauthenticatedPrompt()
} else if (result.status === 'error') { } else if (status === 'error') {
showYibanSyncErrorPrompt() showYibanSyncErrorPrompt()
} }
return
}
const data = JSON.parse(result.content) as { if (!cancelled && (status === 'success' || status === 'empty')) {
visitedExhibits?: Record<string, number[]> setStoreReady(true)
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,
}))
showYibanSyncErrorPrompt()
console.error('Failed to load Zhangleping store', error)
} }
})() })()
return () => {
cancelled = true
}
}, []) }, [])
if (!moduleName) return null if (!moduleName) return null
@@ -170,6 +107,12 @@ const ZhanglepingHome = () => {
}} }}
/> />
</div> </div>
<ContinueLearningModal
visible={continuePrompt.visible}
onContinue={continuePrompt.onContinue}
onCancel={continuePrompt.onCancel}
/>
</div> </div>
) )
} }