feat: 添加断点续读功能,优化用户体验

- 在 chenyunStore 中引入 lastVisitedPlace 和 setLastVisitedPlace 方法,支持记录用户上次访问的场景。
- 在各个场景组件中使用 useEffect 设置 lastVisitedPlace,确保用户在返回时能继续上次访问的地点。
- 更新相关组件,确保在导航时能够正确使用 lastVisitedPlace,提升用户交互体验。
- 在 puzzleStore 中添加保存用户定义的功能,增强数据持久性。
This commit is contained in:
zhongyidian committed 2026-03-06 16:33:44 +08:00
1 parent 76c67b492e
commit b1d55b9464
17 files changed
+147 -54

No files matched your search

+25
View File
@@ -4,6 +4,13 @@ import router from './router'
import { ScaleProvider, useScale } from './contexts/ScaleContext'
import { RotateHint } from './components/RotateHint'
import { useUserStore } from './store'
import { getUserDefine } from '@/lib/utils/api-common'
import {
usePuzzleStore,
deserializePuzzles,
PUZZLE_LESSON_ID,
PUZZLE_KEY,
} from '@/store/puzzleStore'
/**
* 应用内容组件
@@ -26,6 +33,24 @@ function AppContent() {
}
}, [setCourseInfo])
useEffect(() => {
;(async () => {
try {
const raw = await getUserDefine(PUZZLE_LESSON_ID, PUZZLE_KEY)
if (!raw) return
const data = JSON.parse(raw) as { puzzles?: Record<string, number[]> }
if (data.puzzles) {
usePuzzleStore.setState((prev) => ({
...prev,
puzzles: deserializePuzzles(data.puzzles),
}))
}
} catch (e) {
console.error('Failed to load puzzle store', e)
}
})()
}, [])
return (
<>
<div className="relative w-screen h-screen overflow-hidden">
+2 -2
View File
@@ -277,7 +277,7 @@ const ResidenceLayoutContent = () => {
{hasPuzzleFeature && (
<div
ref={puzzleBoxRef}
className="absolute left-4 bottom-4 z-[1] cursor-pointer"
className="absolute left-4 bottom-4 z-1 cursor-pointer"
onClick={(e) => {
e.stopPropagation()
if (residence) {
@@ -336,7 +336,7 @@ const ResidenceLayoutContent = () => {
{isAnimationPlaying &&
createPortal(
<div
className="fixed inset-0 z-[100000] pointer-events-auto"
className="fixed inset-0 z-100000 pointer-events-auto"
style={{ touchAction: 'none' }}
onClick={(e) => e.stopPropagation()}
onTouchStart={(e) => e.preventDefault()}
+7 -3
View File
@@ -28,19 +28,21 @@ const Chenyun = () => {
// starName 固定为 chenyun
const starName = 'chenyun'
const { lastVisitedPlace } = useChenyunStore()
useEffect(() => {
;(async () => {
console.log('1233')
try {
const raw = await getUserDefine(CHENYUN_LESSON_ID, CHENYUN_KEY)
console.log(raw)
if (!raw) return
const data = JSON.parse(raw) as {
visitedExhibits?: Record<string, number[]>
isYinyuanSolved?: boolean
isMimianSolved?: boolean
lastVisitedPlace?: string | null
}
console.log(data)
useChenyunStore.setState((prev) => ({
...prev,
@@ -49,6 +51,8 @@ const Chenyun = () => {
data.isYinyuanSolved !== undefined ? data.isYinyuanSolved : prev.isYinyuanSolved,
isMimianSolved:
data.isMimianSolved !== undefined ? data.isMimianSolved : prev.isMimianSolved,
lastVisitedPlace:
data.lastVisitedPlace !== undefined ? data.lastVisitedPlace : prev.lastVisitedPlace,
}))
} catch (error) {
console.error('Failed to load Chenyun store', error)
@@ -79,7 +83,7 @@ const Chenyun = () => {
<Image
button
src={getStaticImage('star/一-解锁')}
onClick={() => navigate(`/${moduleName}/${starName}/place1`)}
onClick={() => navigate(`/${moduleName}/${starName}/${lastVisitedPlace ?? 'place1'}`)}
/>
{/* 体悟 */}
<Image
+15 -11
View File
@@ -9,7 +9,7 @@ import {
} from '@/components'
import { useNavigate, useParams } from 'react-router-dom'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useState } from 'react'
import { useState, useEffect } from 'react'
import Cursor from '@/components/cursor'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { useChenyunStore, saveChenyunStore } from '@/store/chenyunStore'
@@ -48,7 +48,11 @@ const ChenyunPlace1 = () => {
// 使用持久化的访问状态
const place = 'place1'
const { markExhibitVisited, getVisitedExhibits } = useChenyunStore()
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useChenyunStore()
useEffect(() => {
setLastVisitedPlace(place)
}, [setLastVisitedPlace])
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
@@ -251,22 +255,22 @@ const ChenyunPlace1 = () => {
const LearningIntroduction = ({ handleStartLearning }: { handleStartLearning: () => void }) => {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="relative w-full h-full bg-gradient-to-b from-[#f5f1e8] via-[#ede8dc] to-[#e8e0d1] shadow-[0_20px_60px_rgba(0,0,0,0.4)] border-4 border-[#d4c5a9] overflow-hidden">
<div className="relative w-full h-full bg-linear-to-b from-[#f5f1e8] via-[#ede8dc] to-[#e8e0d1] shadow-[0_20px_60px_rgba(0,0,0,0.4)] border-4 border-[#d4c5a9] overflow-hidden">
{/* 内容层 */}
<div className="relative h-full w-full p-16 pb-20 overflow-hidden flex flex-col">
{/* 装饰性顶部线条 */}
<div className="absolute top-0 left-0 right-0 h-2 bg-gradient-to-r from-transparent via-[#c9b99b] to-transparent"></div>
<div className="absolute top-0 left-0 right-0 h-2 bg-linear-to-r from-transparent via-[#c9b99b] to-transparent"></div>
{/* 标题 */}
<div className="text-center mb-8 flex-shrink-0">
<div className="text-center mb-8 shrink-0">
<h1 className="text-6xl font-bold text-[#5c4a37] mb-4 tracking-wide">
走进故居-对话模块学习说明
</h1>
<div className="w-32 h-1 bg-gradient-to-r from-transparent via-[#8b7355] to-transparent mx-auto"></div>
<div className="w-32 h-1 bg-linear-to-r from-transparent via-[#8b7355] to-transparent mx-auto"></div>
</div>
{/* 介绍文字 */}
<div className="mb-8 space-y-4 flex-shrink-0">
<div className="mb-8 space-y-4 shrink-0">
<p className="text-3xl text-[#6b5d47] leading-relaxed text-center font-medium">
同学你好,欢迎来到走进故居-对话学习模块!
</p>
@@ -312,7 +316,7 @@ const LearningIntroduction = ({ handleStartLearning }: { handleStartLearning: ()
key={index}
className="flex items-start gap-6 bg-[rgba(255,255,255,0.4)] rounded-xl p-6 border-l-4 border-[#8b7355] mb-4"
>
<span className="flex-shrink-0 w-12 h-12 bg-gradient-to-br from-[#8b7355] to-[#6b5d47] text-white text-3xl font-bold rounded-full flex items-center justify-center shadow-md">
<span className="shrink-0 w-12 h-12 bg-linear-to-br from-[#8b7355] to-[#6b5d47] text-white text-3xl font-bold rounded-full flex items-center justify-center shadow-md">
{index + 1}
</span>
<p className="text-2xl text-[#5c4a37] leading-relaxed flex-1 pt-1">{feature}</p>
@@ -322,13 +326,13 @@ const LearningIntroduction = ({ handleStartLearning }: { handleStartLearning: ()
</div>
{/* 开始学习按钮 */}
<div className="flex justify-center flex-shrink-0">
<div className="flex justify-center shrink-0">
<button
onClick={handleStartLearning}
className="relative cursor-pointer px-20 py-5 bg-gradient-to-r from-[#8b7355] to-[#6b5d47] text-white text-4xl font-bold rounded-2xl shadow-[0_8px_20px_rgba(139,115,85,0.4)] hover:shadow-[0_12px_30px_rgba(139,115,85,0.6)] hover:from-[#9b8365] hover:to-[#7b6d57] transition-all duration-300 active:scale-95 border-2 border-[#a08f75]"
className="relative cursor-pointer px-20 py-5 bg-linear-to-r from-[#8b7355] to-[#6b5d47] text-white text-4xl font-bold rounded-2xl shadow-[0_8px_20px_rgba(139,115,85,0.4)] hover:shadow-[0_12px_30px_rgba(139,115,85,0.6)] hover:from-[#9b8365] hover:to-[#7b6d57] transition-all duration-300 active:scale-95 border-2 border-[#a08f75]"
>
<span className="relative z-10">开始学习</span>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white to-transparent opacity-0 hover:opacity-20 rounded-2xl transition-opacity duration-300"></div>
<div className="absolute inset-0 bg-linear-to-r from-transparent via-white to-transparent opacity-0 hover:opacity-20 rounded-2xl transition-opacity duration-300"></div>
</button>
</div>
</div>
+6 -2
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { ExhibitOverlay, Title, Image, Cursor, GuideSteps, GuideStepsItem } from '@/components'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
@@ -30,7 +30,11 @@ const ChenyunPlace2 = () => {
// 使用持久化的访问状态
const place = 'place2'
const { markExhibitVisited, getVisitedExhibits } = useChenyunStore()
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useChenyunStore()
useEffect(() => {
setLastVisitedPlace(place)
}, [setLastVisitedPlace])
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
+6 -2
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import {
ExhibitOverlay,
Title,
@@ -35,7 +35,11 @@ const ChenyunPlace3 = () => {
// 使用持久化的访问状态
const place = 'place3'
const { markExhibitVisited, getVisitedExhibits } = useChenyunStore()
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useChenyunStore()
useEffect(() => {
setLastVisitedPlace(place)
}, [setLastVisitedPlace])
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
+6 -2
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import {
ExhibitOverlay,
Title,
@@ -37,7 +37,11 @@ const ChenyunPlace4 = () => {
// 使用持久化的访问状态
const place = 'place4'
const { markExhibitVisited, getVisitedExhibits } = useChenyunStore()
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useChenyunStore()
useEffect(() => {
setLastVisitedPlace(place)
}, [setLastVisitedPlace])
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
+6 -2
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import {
ExhibitOverlay,
Title,
@@ -45,7 +45,11 @@ const ChenyunPlace5 = () => {
// 使用持久化的访问状态
const place = 'place5'
const { markExhibitVisited, getVisitedExhibits } = useChenyunStore()
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useChenyunStore()
useEffect(() => {
setLastVisitedPlace(place)
}, [setLastVisitedPlace])
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
+6 -2
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { ExhibitOverlay, Title, Image, Cursor, GuideSteps, GuideStepsItem } from '@/components'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
@@ -25,7 +25,11 @@ const ChenyunPlace6 = () => {
// 使用持久化的访问状态
const place = 'place6'
const { isExhibitVisited, markExhibitVisited, getVisitedExhibits } = useChenyunStore()
const { isExhibitVisited, markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useChenyunStore()
useEffect(() => {
setLastVisitedPlace(place)
}, [setLastVisitedPlace])
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
+6 -2
View File
@@ -1,4 +1,4 @@
import { useState, useRef } from 'react'
import { useState, useRef, useEffect } from 'react'
import {
ExhibitOverlay,
Title,
@@ -61,7 +61,11 @@ const ChenyunPlace7 = () => {
// 使用持久化的访问状态
const place = 'place7'
const { isExhibitVisited, markExhibitVisited, getVisitedExhibits } = useChenyunStore()
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useChenyunStore()
useEffect(() => {
setLastVisitedPlace(place)
}, [setLastVisitedPlace])
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
+6 -2
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import {
ExhibitOverlay,
Title,
@@ -38,7 +38,11 @@ const ChenyunPlace8 = () => {
// 使用持久化的访问状态
const place = 'place8'
const { markExhibitVisited, getVisitedExhibits } = useChenyunStore()
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useChenyunStore()
useEffect(() => {
setLastVisitedPlace(place)
}, [setLastVisitedPlace])
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
+11 -19
View File
@@ -1,13 +1,5 @@
import { useState, useRef } from 'react'
import {
ExhibitOverlay,
Title,
Image,
Cursor,
Video,
GuideSteps,
GuideStepsItem,
} from '@/components'
import { useState, useRef, useEffect } from 'react'
import { Title, Image, Cursor, Video, GuideSteps, GuideStepsItem } from '@/components'
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import type { VideoRef } from '@/components/video'
@@ -17,10 +9,6 @@ import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
// starName 固定为 chenyun
const starName = 'chenyun'
// 展品 ID 与资源路径映射
const exhibitSrcMap: Record<1, string> = {
1: 'chenyun/place9/场景9展品1',
}
const ChenyunPlace9 = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{
@@ -30,11 +18,15 @@ const ChenyunPlace9 = () => {
// 获取碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const [activeExhibitId, setActiveExhibitId] = useState<1 | null>(null)
// const [activeExhibitId, setActiveExhibitId] = useState<1 | null>(null)
// 使用持久化的访问状态
const place = 'place9'
const { markExhibitVisited, getVisitedExhibits } = useChenyunStore()
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useChenyunStore()
useEffect(() => {
setLastVisitedPlace(place)
}, [setLastVisitedPlace])
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
@@ -124,7 +116,7 @@ const ChenyunPlace9 = () => {
{/* 展品1视频播放(全屏) */}
{videoVisible && (
<div className="fixed inset-0 z-[9999] bg-black">
<div className="fixed inset-0 z-9999 bg-black">
<Video
ref={videoRef}
src={getPublicPath('video/chenyun/chenyun_14.mp4')}
@@ -172,7 +164,7 @@ const ChenyunPlace9 = () => {
</GuideSteps>
{/* 遮罩层 */}
<ExhibitOverlay
{/* <ExhibitOverlay
visible={activeExhibitId !== null}
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onClose={() => {
@@ -181,7 +173,7 @@ const ChenyunPlace9 = () => {
}
setActiveExhibitId(null)
}}
/>
/> */}
</div>
)
}
+6 -2
View File
@@ -35,7 +35,9 @@ const ChenyunReflection = () => {
// ========== 顶层状态管理 ==========
/** 当前展开的文件 */
const [expandedFile, setExpandedFile] = useState<FileType | null>(null)
const [expandedFile, setExpandedFile] = useState<FileType | null>(
() => (useChenyunStore.getState().isYinyuanSolved ? 'mimian' : null)
)
/** 当前播放的视频 */
const [playingVideo, setPlayingVideo] = useState<FileType | null>(null)
@@ -62,7 +64,9 @@ const ChenyunReflection = () => {
const [showClueOverlay, setShowClueOverlay] = useState(false)
/** 欢迎语弹窗(使用 StudyGuideModal 展示入口引导) */
const [showWelcomeGuide, setShowWelcomeGuide] = useState(true)
const [showWelcomeGuide, setShowWelcomeGuide] = useState(
() => !useChenyunStore.getState().isYinyuanSolved
)
/** 第一幕银元之战 - 学习引导弹窗 */
const [showYinyuanGuide, setShowYinyuanGuide] = useState(false)
@@ -44,7 +44,9 @@ export const useYinyuanState = () => {
// 记录是否已经显示过这些提示(避免重复显示)
const [hasShownGuide1, setHasShownGuide1] = useState(false)
const [hasShownGuide2, setHasShownGuide2] = useState(false)
const [hasShownGuide2, setHasShownGuide2] = useState(
() => useChenyunStore.getState().isYinyuanSolved
)
const [hasShownGuide3, setHasShownGuide3] = useState(false)
return {
+14
View File
@@ -23,6 +23,11 @@ interface ChenyunStore {
// 重置所有访问记录
resetAll: () => void
// ========== 断点续读 ==========
// 上次访问的场景(place1~place9)
lastVisitedPlace: string | null
setLastVisitedPlace: (place: string) => void
// ========== 体悟模块状态 ==========
// 银元之战是否已解决
isYinyuanSolved: boolean
@@ -105,6 +110,10 @@ export const useChenyunStore = create<ChenyunStore>()(
set({ visitedExhibits: {} })
},
// ========== 断点续读 ==========
lastVisitedPlace: null,
setLastVisitedPlace: (place: string) => set({ lastVisitedPlace: place }),
// ========== 体悟模块状态 ==========
isYinyuanSolved: false,
setYinyuanSolved: (solved: boolean) => {
@@ -122,6 +131,7 @@ export const useChenyunStore = create<ChenyunStore>()(
visitedExhibits: serializeVisitedExhibits(state.visitedExhibits),
isYinyuanSolved: state.isYinyuanSolved,
isMimianSolved: state.isMimianSolved,
lastVisitedPlace: state.lastVisitedPlace,
}),
onRehydrateStorage: () => (state) => {
if (state) {
@@ -134,6 +144,9 @@ export const useChenyunStore = create<ChenyunStore>()(
if (state.isMimianSolved === undefined) {
state.isMimianSolved = false
}
if (state.lastVisitedPlace === undefined) {
state.lastVisitedPlace = null
}
}
},
}
@@ -151,6 +164,7 @@ export const saveChenyunStore = (): void => {
visitedExhibits: serializeVisitedExhibits(state.visitedExhibits),
isYinyuanSolved: state.isYinyuanSolved,
isMimianSolved: state.isMimianSolved,
lastVisitedPlace: state.lastVisitedPlace,
}
try {
+19 -1
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { STORE_NAMES } from './storeNames'
import { setUserDefine } from '@/lib/utils/api-common'
interface PuzzleStore {
// 每个故居的已解锁碎片集合,格式:{ residence: Set<pieceId> }
@@ -35,7 +36,7 @@ const serializePuzzles = (puzzles: Record<string, Set<number>>) => {
}
// 将数组转换回 Set
const deserializePuzzles = (serialized: Record<string, number[]> | undefined) => {
export const deserializePuzzles = (serialized: Record<string, number[]> | undefined) => {
if (!serialized || typeof serialized !== 'object') {
return {}
}
@@ -83,6 +84,7 @@ export const usePuzzleStore = create<PuzzleStore>()(
if (!residencePuzzles.has(pieceId)) {
const newPuzzles = { ...fixedPuzzles }
newPuzzles[residence] = new Set([...residencePuzzles, pieceId])
savePuzzleStore(newPuzzles)
set({ puzzles: newPuzzles })
}
},
@@ -156,3 +158,19 @@ export const usePuzzleStore = create<PuzzleStore>()(
}
)
)
// ========== 易班平台数据保存 ==========
export const PUZZLE_LESSON_ID = 'Cheny_neihua'
export const PUZZLE_KEY = 24
export const savePuzzleStore = (puzzles: Record<string, Set<number>>): void => {
const payload = {
puzzles: serializePuzzles(puzzles),
}
try {
setUserDefine(PUZZLE_LESSON_ID, PUZZLE_KEY, JSON.stringify(payload))
} catch (error) {
console.error('Failed to save Chenyun store', error)
}
}