feat: 实现故居续学弹窗功能
新增功能: - 统一续学逻辑:8个故居首页接入自动续学提示弹窗 - 完成判定:基于拼图碎片统一判断对话/体悟/内化是否完成 - 断点续学:对话回lastVisitedPlace、体悟回reflectionRoute、内化回internalization - 触发条件:会话内每故居仅提示一次 + 首页视频播完后 + store加载成功/空时 - 安全防护:组件卸载后不再setState、同步失败不弹窗避免用旧状态误导 新增组件: - ContinueLearningModal:纯CSS哑光米纸+朱红古籍风弹窗 - useResidenceContinue:封装触发逻辑+跳转+去重的共享hook 涉及文件: - src/config/progression.ts:getResidenceStageCompletion、resolveResidenceContinueTarget - src/hooks/useResidenceContinue.ts:统一续学hook - src/components/layout/alert/continue-learning-modal.tsx:弹窗组件 - src/pages/*/home.tsx:8个故居首页接入 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
49ecabbf3a
commit
73d02eb62d
13 files changed
+539
-500
No files matched your search
@@ -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
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
+31
-57
@@ -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') {
|
||||||
|
showYibanUnauthenticatedPrompt()
|
||||||
if (result.status !== 'success') {
|
} else if (status === 'error') {
|
||||||
useChenyunStore.setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
visitedExhibits: {},
|
|
||||||
isYinyuanSolved: false,
|
|
||||||
isMimianSolved: false,
|
|
||||||
lastVisitedPlace: null,
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (result.status === 'unauthenticated') {
|
|
||||||
showYibanUnauthenticatedPrompt()
|
|
||||||
} else if (result.status === 'error') {
|
|
||||||
showYibanSyncErrorPrompt()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = JSON.parse(result.content) as {
|
|
||||||
visitedExhibits?: Record<string, number[]>
|
|
||||||
isYinyuanSolved?: boolean
|
|
||||||
isMimianSolved?: boolean
|
|
||||||
lastVisitedPlace?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
useChenyunStore.setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
|
|
||||||
isYinyuanSolved: data.isYinyuanSolved ?? false,
|
|
||||||
isMimianSolved: data.isMimianSolved ?? false,
|
|
||||||
lastVisitedPlace: data.lastVisitedPlace ?? null,
|
|
||||||
}))
|
|
||||||
} catch (error) {
|
|
||||||
useChenyunStore.setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
visitedExhibits: {},
|
|
||||||
isYinyuanSolved: false,
|
|
||||||
isMimianSolved: false,
|
|
||||||
lastVisitedPlace: null,
|
|
||||||
}))
|
|
||||||
showYibanSyncErrorPrompt()
|
showYibanSyncErrorPrompt()
|
||||||
console.error('Failed to load Chenyun store', error)
|
}
|
||||||
|
|
||||||
|
if (!cancelled && (status === 'success' || status === 'empty')) {
|
||||||
|
setStoreReady(true)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
showYibanUnauthenticatedPrompt()
|
||||||
if (result.status !== 'success') {
|
} else if (status === 'error') {
|
||||||
useHuangyanpeiStore.setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
visitedExhibits: {},
|
|
||||||
lastVisitedPlace: null,
|
|
||||||
reflectionRoute: 'reflection',
|
|
||||||
shownReflectionCompletionGuides: {},
|
|
||||||
reflectionScene2Choice: null,
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (result.status === 'unauthenticated') {
|
|
||||||
showYibanUnauthenticatedPrompt()
|
|
||||||
} else if (result.status === 'error') {
|
|
||||||
showYibanSyncErrorPrompt()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = JSON.parse(result.content) as {
|
|
||||||
visitedExhibits?: Record<string, number[]>
|
|
||||||
lastVisitedPlace?: string | null
|
|
||||||
reflectionRoute?: string
|
|
||||||
shownReflectionCompletionGuides?: Record<string, boolean>
|
|
||||||
reflectionScene2Choice?: '铁工科' | '商科' | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const validReflectionRoute =
|
|
||||||
data.reflectionRoute &&
|
|
||||||
['reflection', 'reflection2', 'reflection3', 'reflection4'].includes(data.reflectionRoute)
|
|
||||||
? data.reflectionRoute
|
|
||||||
: 'reflection'
|
|
||||||
|
|
||||||
useHuangyanpeiStore.setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
visitedExhibits: Object.fromEntries(
|
|
||||||
Object.entries(data.visitedExhibits ?? {}).map(([place, exhibits]) => [
|
|
||||||
place,
|
|
||||||
new Set(Array.isArray(exhibits) ? exhibits : []),
|
|
||||||
])
|
|
||||||
),
|
|
||||||
lastVisitedPlace: data.lastVisitedPlace ?? null,
|
|
||||||
reflectionRoute: validReflectionRoute,
|
|
||||||
shownReflectionCompletionGuides: data.shownReflectionCompletionGuides ?? {},
|
|
||||||
reflectionScene2Choice: data.reflectionScene2Choice ?? null,
|
|
||||||
}))
|
|
||||||
} catch (error) {
|
|
||||||
useHuangyanpeiStore.setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
visitedExhibits: {},
|
|
||||||
lastVisitedPlace: null,
|
|
||||||
reflectionRoute: 'reflection',
|
|
||||||
shownReflectionCompletionGuides: {},
|
|
||||||
reflectionScene2Choice: null,
|
|
||||||
}))
|
|
||||||
showYibanSyncErrorPrompt()
|
showYibanSyncErrorPrompt()
|
||||||
console.error('Failed to load Huangyanpei store', error)
|
}
|
||||||
|
|
||||||
|
if (!cancelled && (status === 'success' || status === 'empty')) {
|
||||||
|
setStoreReady(true)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-60
@@ -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') {
|
||||||
|
showYibanUnauthenticatedPrompt()
|
||||||
if (result.status !== 'success') {
|
} else if (status === 'error') {
|
||||||
useLuxunStore.setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
visitedExhibits: {},
|
|
||||||
lastVisitedPlace: null,
|
|
||||||
reflectionRoute: 'reflection',
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (result.status === 'unauthenticated') {
|
|
||||||
showYibanUnauthenticatedPrompt()
|
|
||||||
} else if (result.status === 'error') {
|
|
||||||
showYibanSyncErrorPrompt()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = JSON.parse(result.content) as {
|
|
||||||
visitedExhibits?: Record<string, number[]>
|
|
||||||
lastVisitedPlace?: string | null
|
|
||||||
reflectionRoute?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const validReflectionRoute =
|
|
||||||
data.reflectionRoute &&
|
|
||||||
['reflection', 'reflection2', 'reflection3'].includes(data.reflectionRoute)
|
|
||||||
? data.reflectionRoute
|
|
||||||
: 'reflection'
|
|
||||||
|
|
||||||
useLuxunStore.setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
|
|
||||||
lastVisitedPlace: data.lastVisitedPlace ?? null,
|
|
||||||
reflectionRoute: validReflectionRoute,
|
|
||||||
}))
|
|
||||||
} catch (error) {
|
|
||||||
useLuxunStore.setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
visitedExhibits: {},
|
|
||||||
lastVisitedPlace: null,
|
|
||||||
reflectionRoute: 'reflection',
|
|
||||||
}))
|
|
||||||
showYibanSyncErrorPrompt()
|
showYibanSyncErrorPrompt()
|
||||||
console.error('Failed to load Luxun store', error)
|
}
|
||||||
|
|
||||||
|
if (!cancelled && (status === 'success' || status === 'empty')) {
|
||||||
|
setStoreReady(true)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
showYibanUnauthenticatedPrompt()
|
||||||
if (result.status !== 'success') {
|
} else if (status === 'error') {
|
||||||
useMaozedongStore.setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
visitedExhibits: {},
|
|
||||||
lastVisitedPlace: null,
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (result.status === 'unauthenticated') {
|
|
||||||
showYibanUnauthenticatedPrompt()
|
|
||||||
} else if (result.status === 'error') {
|
|
||||||
showYibanSyncErrorPrompt()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = JSON.parse(result.content) as {
|
|
||||||
visitedExhibits?: Record<string, number[]>
|
|
||||||
lastVisitedPlace?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
useMaozedongStore.setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
|
|
||||||
lastVisitedPlace: data.lastVisitedPlace ?? null,
|
|
||||||
}))
|
|
||||||
} catch (error) {
|
|
||||||
useMaozedongStore.setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
visitedExhibits: {},
|
|
||||||
lastVisitedPlace: null,
|
|
||||||
}))
|
|
||||||
showYibanSyncErrorPrompt()
|
showYibanSyncErrorPrompt()
|
||||||
console.error('Failed to load Maozedong store', error)
|
}
|
||||||
|
|
||||||
|
if (!cancelled && (status === 'success' || status === 'empty')) {
|
||||||
|
setStoreReady(true)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-49
@@ -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') {
|
||||||
|
showYibanUnauthenticatedPrompt()
|
||||||
if (result.status !== 'success') {
|
} else if (status === 'error') {
|
||||||
useNieerStore.setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
visitedExhibits: {},
|
|
||||||
lastVisitedPlace: null,
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (result.status === 'unauthenticated') {
|
|
||||||
showYibanUnauthenticatedPrompt()
|
|
||||||
} else if (result.status === 'error') {
|
|
||||||
showYibanSyncErrorPrompt()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = JSON.parse(result.content) as {
|
|
||||||
visitedExhibits?: Record<string, number[]>
|
|
||||||
lastVisitedPlace?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
useNieerStore.setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
|
|
||||||
lastVisitedPlace: data.lastVisitedPlace ?? null,
|
|
||||||
}))
|
|
||||||
} catch (error) {
|
|
||||||
useNieerStore.setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
visitedExhibits: {},
|
|
||||||
lastVisitedPlace: null,
|
|
||||||
}))
|
|
||||||
showYibanSyncErrorPrompt()
|
showYibanSyncErrorPrompt()
|
||||||
console.error('Failed to load Nieer store', error)
|
}
|
||||||
|
|
||||||
|
if (!cancelled && (status === 'success' || status === 'empty')) {
|
||||||
|
setStoreReady(true)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
showYibanUnauthenticatedPrompt()
|
||||||
if (result.status !== 'success') {
|
} else if (status === 'error') {
|
||||||
useRongdeshengStore.setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
visitedExhibits: {},
|
|
||||||
lastVisitedPlace: null,
|
|
||||||
reflectionCompleted: {},
|
|
||||||
reflectionVideoViewed: {},
|
|
||||||
reflectionFinalCompleted: false,
|
|
||||||
reflectionGuideShown: false,
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (result.status === 'unauthenticated') {
|
|
||||||
showYibanUnauthenticatedPrompt()
|
|
||||||
} else if (result.status === 'error') {
|
|
||||||
showYibanSyncErrorPrompt()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = JSON.parse(result.content) as {
|
|
||||||
visitedExhibits?: Record<string, number[]>
|
|
||||||
lastVisitedPlace?: string | null
|
|
||||||
reflectionCompleted?: Record<string, boolean>
|
|
||||||
reflectionVideoViewed?: Record<string, Record<string, boolean>>
|
|
||||||
reflectionFinalCompleted?: boolean
|
|
||||||
reflectionGuideShown?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
useRongdeshengStore.setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
|
|
||||||
lastVisitedPlace: data.lastVisitedPlace ?? null,
|
|
||||||
reflectionCompleted: data.reflectionCompleted ?? {},
|
|
||||||
reflectionVideoViewed: data.reflectionVideoViewed ?? {},
|
|
||||||
reflectionFinalCompleted: data.reflectionFinalCompleted ?? false,
|
|
||||||
reflectionGuideShown: data.reflectionGuideShown ?? false,
|
|
||||||
}))
|
|
||||||
} catch (error) {
|
|
||||||
useRongdeshengStore.setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
visitedExhibits: {},
|
|
||||||
lastVisitedPlace: null,
|
|
||||||
reflectionCompleted: {},
|
|
||||||
reflectionVideoViewed: {},
|
|
||||||
reflectionFinalCompleted: false,
|
|
||||||
reflectionGuideShown: false,
|
|
||||||
}))
|
|
||||||
showYibanSyncErrorPrompt()
|
showYibanSyncErrorPrompt()
|
||||||
console.error('Failed to load Rongdesheng store', error)
|
}
|
||||||
|
|
||||||
|
if (!cancelled && (status === 'success' || status === 'empty')) {
|
||||||
|
setStoreReady(true)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
showYibanUnauthenticatedPrompt()
|
||||||
if (result.status !== 'success') {
|
} else if (status === 'error') {
|
||||||
useSunzhongshanStore.setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
visitedExhibits: {},
|
|
||||||
lastVisitedPlace: null,
|
|
||||||
reflectionRoute: 'reflection',
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (result.status === 'unauthenticated') {
|
|
||||||
showYibanUnauthenticatedPrompt()
|
|
||||||
} else if (result.status === 'error') {
|
|
||||||
showYibanSyncErrorPrompt()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = JSON.parse(result.content) as {
|
|
||||||
visitedExhibits?: Record<string, number[]>
|
|
||||||
lastVisitedPlace?: string | null
|
|
||||||
reflectionRoute?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const validReflectionRoute =
|
|
||||||
data.reflectionRoute &&
|
|
||||||
['reflection', 'reflection1', 'reflection2', 'reflection3'].includes(data.reflectionRoute)
|
|
||||||
? data.reflectionRoute
|
|
||||||
: 'reflection'
|
|
||||||
|
|
||||||
useSunzhongshanStore.setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
|
|
||||||
lastVisitedPlace: data.lastVisitedPlace ?? null,
|
|
||||||
reflectionRoute: validReflectionRoute,
|
|
||||||
}))
|
|
||||||
} catch (error) {
|
|
||||||
useSunzhongshanStore.setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
visitedExhibits: {},
|
|
||||||
lastVisitedPlace: null,
|
|
||||||
reflectionRoute: 'reflection',
|
|
||||||
}))
|
|
||||||
showYibanSyncErrorPrompt()
|
showYibanSyncErrorPrompt()
|
||||||
console.error('Failed to load Sunzhongshan store', error)
|
}
|
||||||
|
|
||||||
|
if (!cancelled && (status === 'success' || status === 'empty')) {
|
||||||
|
setStoreReady(true)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
showYibanUnauthenticatedPrompt()
|
||||||
if (result.status !== 'success') {
|
} else if (status === 'error') {
|
||||||
useZhanglepingStore.setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
visitedExhibits: {},
|
|
||||||
lastVisitedPlace: null,
|
|
||||||
reflectionRoute: 'reflection',
|
|
||||||
visitedReflectionCursors: {},
|
|
||||||
drawArtworkDataUrl: '',
|
|
||||||
drawArtworkCaption: '',
|
|
||||||
hasDrawArtwork: false,
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (result.status === 'unauthenticated') {
|
|
||||||
showYibanUnauthenticatedPrompt()
|
|
||||||
} else if (result.status === 'error') {
|
|
||||||
showYibanSyncErrorPrompt()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = JSON.parse(result.content) as {
|
|
||||||
visitedExhibits?: Record<string, number[]>
|
|
||||||
lastVisitedPlace?: string | null
|
|
||||||
reflectionRoute?: string
|
|
||||||
visitedReflectionCursors?: Record<string, number[]>
|
|
||||||
drawArtworkDataUrl?: string
|
|
||||||
drawArtworkCaption?: string
|
|
||||||
hasDrawArtwork?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const validReflectionRoute =
|
|
||||||
data.reflectionRoute &&
|
|
||||||
[
|
|
||||||
'reflection',
|
|
||||||
'reflection1',
|
|
||||||
'reflection2',
|
|
||||||
'reflection3',
|
|
||||||
'reflection4',
|
|
||||||
'reflection5',
|
|
||||||
].includes(data.reflectionRoute)
|
|
||||||
? data.reflectionRoute
|
|
||||||
: 'reflection'
|
|
||||||
|
|
||||||
useZhanglepingStore.setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
|
|
||||||
lastVisitedPlace: data.lastVisitedPlace ?? null,
|
|
||||||
reflectionRoute: validReflectionRoute,
|
|
||||||
visitedReflectionCursors: deserializeVisitedCursors(data.visitedReflectionCursors),
|
|
||||||
drawArtworkDataUrl: data.drawArtworkDataUrl ?? '',
|
|
||||||
drawArtworkCaption: data.drawArtworkCaption ?? '',
|
|
||||||
hasDrawArtwork: data.hasDrawArtwork ?? false,
|
|
||||||
}))
|
|
||||||
} catch (error) {
|
|
||||||
useZhanglepingStore.setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
visitedExhibits: {},
|
|
||||||
lastVisitedPlace: null,
|
|
||||||
reflectionRoute: 'reflection',
|
|
||||||
visitedReflectionCursors: {},
|
|
||||||
drawArtworkDataUrl: '',
|
|
||||||
drawArtworkCaption: '',
|
|
||||||
hasDrawArtwork: false,
|
|
||||||
}))
|
|
||||||
showYibanSyncErrorPrompt()
|
showYibanSyncErrorPrompt()
|
||||||
console.error('Failed to load Zhangleping store', error)
|
}
|
||||||
|
|
||||||
|
if (!cancelled && (status === 'success' || status === 'empty')) {
|
||||||
|
setStoreReady(true)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user