- 更新展品图片(鲁迅/毛泽东/孙中山/黄炎培/聂耳/张乐平故居) - 新增点击查看图标、报纸素材 - 优化视频组件、拼图组件、展品组件 - 更新陈云音频资源 - 新增输入验证文档和工具函数 - 组件布局和动画优化 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
312 lines
9.4 KiB
TypeScript
312 lines
9.4 KiB
TypeScript
import { create } from 'zustand'
|
||
import { persist } from 'zustand/middleware'
|
||
import { STORE_NAMES } from './storeNames'
|
||
import {
|
||
setUserDefine,
|
||
getUserDefineResult,
|
||
type UserDefineFetchStatus,
|
||
} from '@/lib/utils/api-common'
|
||
|
||
export interface Clue {
|
||
id: string
|
||
name: string
|
||
collected: boolean
|
||
collectedAt?: number
|
||
}
|
||
|
||
interface SunzhongshanStore {
|
||
// 存储每个场景(place)的已访问展品ID集合
|
||
// 格式:{ place1: Set<1, 2>, place2: Set<1, 2>, ... }
|
||
visitedExhibits: Record<string, Set<number>>
|
||
|
||
// 体悟模块通用布尔状态(引导是否展示、场景是否完成等)
|
||
reflectionFlags: Record<string, boolean>
|
||
|
||
// 上次访问的场景(place1~place4)
|
||
lastVisitedPlace: string | null
|
||
setLastVisitedPlace: (place: string) => void
|
||
|
||
// 体悟模块当前路由(reflection、reflection1、reflection2、reflection3)
|
||
reflectionRoute: string
|
||
setReflectionRoute: (route: string) => void
|
||
|
||
// ========== 线索系统(原 clueStore,并入孙中山) ==========
|
||
clues: Clue[]
|
||
collectClue: (id: string, name: string) => void
|
||
isClueCollected: (id: string) => boolean
|
||
getSceneClues: (sceneId: string) => Clue[]
|
||
resetSceneClues: (sceneId: string) => void
|
||
|
||
// 标记展品为已访问
|
||
markExhibitVisited: (place: string, exhibitId: number) => void
|
||
|
||
// 检查展品是否已访问
|
||
isExhibitVisited: (place: string, exhibitId: number) => boolean
|
||
|
||
// 获取某个场景已访问的展品ID数组
|
||
getVisitedExhibits: (place: string) => number[]
|
||
|
||
// 重置某个场景的访问记录
|
||
resetPlace: (place: string) => void
|
||
|
||
// 重置所有访问记录
|
||
resetAll: () => void
|
||
|
||
// 设置体悟模块布尔状态
|
||
setReflectionFlag: (key: string, value: boolean) => void
|
||
|
||
// 获取体悟模块布尔状态
|
||
getReflectionFlag: (key: string) => boolean
|
||
|
||
completedVideos: Record<string, boolean>
|
||
markVideoCompleted: (videoKey: string) => void
|
||
}
|
||
|
||
// 将 Set 转换为数组以便序列化
|
||
const serializeVisitedExhibits = (
|
||
visitedExhibits: Record<string, Set<number>>
|
||
): Record<string, number[]> => {
|
||
const serialized: Record<string, number[]> = {}
|
||
for (const [place, exhibits] of Object.entries(visitedExhibits)) {
|
||
serialized[place] = Array.from(exhibits)
|
||
}
|
||
return serialized
|
||
}
|
||
|
||
// 将数组转换回 Set
|
||
const deserializeVisitedExhibits = (
|
||
serialized: Record<string, number[]> | undefined
|
||
): Record<string, Set<number>> => {
|
||
if (!serialized || typeof serialized !== 'object') {
|
||
return {}
|
||
}
|
||
const visitedExhibits: Record<string, Set<number>> = {}
|
||
for (const [place, exhibits] of Object.entries(serialized)) {
|
||
if (Array.isArray(exhibits)) {
|
||
visitedExhibits[place] = new Set(exhibits)
|
||
} else {
|
||
visitedExhibits[place] = new Set()
|
||
}
|
||
}
|
||
return visitedExhibits
|
||
}
|
||
|
||
const resolveReflectionRoute = (route: unknown): string =>
|
||
typeof route === 'string' &&
|
||
['reflection', 'reflection1', 'reflection2', 'reflection3'].includes(route)
|
||
? route
|
||
: 'reflection'
|
||
|
||
interface SunzhongshanPersistedState {
|
||
visitedExhibits?: Record<string, number[]>
|
||
reflectionFlags?: Record<string, boolean>
|
||
lastVisitedPlace?: string | null
|
||
reflectionRoute?: string
|
||
clues?: Clue[]
|
||
completedVideos?: Record<string, boolean>
|
||
}
|
||
|
||
export const useSunzhongshanStore = create<SunzhongshanStore>()(
|
||
persist(
|
||
(set, get) => ({
|
||
visitedExhibits: {},
|
||
reflectionFlags: {},
|
||
lastVisitedPlace: null,
|
||
reflectionRoute: 'reflection',
|
||
clues: [],
|
||
completedVideos: {},
|
||
|
||
markVideoCompleted: (videoKey: string) => {
|
||
set((state) => ({
|
||
completedVideos: { ...state.completedVideos, [videoKey]: true },
|
||
}))
|
||
},
|
||
|
||
markExhibitVisited: (place: string, exhibitId: number) => {
|
||
set((state) => {
|
||
const currentExhibits = state.visitedExhibits[place] || new Set<number>()
|
||
if (!currentExhibits.has(exhibitId)) {
|
||
const newExhibits = {
|
||
...state.visitedExhibits,
|
||
[place]: new Set([...currentExhibits, exhibitId]),
|
||
}
|
||
return { visitedExhibits: newExhibits }
|
||
}
|
||
return state
|
||
})
|
||
},
|
||
|
||
isExhibitVisited: (place: string, exhibitId: number) => {
|
||
const { visitedExhibits } = get()
|
||
return visitedExhibits[place]?.has(exhibitId) || false
|
||
},
|
||
|
||
getVisitedExhibits: (place: string) => {
|
||
const { visitedExhibits } = get()
|
||
return Array.from(visitedExhibits[place] || [])
|
||
},
|
||
|
||
resetPlace: (place: string) => {
|
||
set((state) => {
|
||
const newExhibits = { ...state.visitedExhibits }
|
||
delete newExhibits[place]
|
||
return { visitedExhibits: newExhibits }
|
||
})
|
||
},
|
||
|
||
resetAll: () => {
|
||
set({
|
||
visitedExhibits: {},
|
||
reflectionFlags: {},
|
||
lastVisitedPlace: null,
|
||
reflectionRoute: 'reflection',
|
||
clues: [],
|
||
completedVideos: {},
|
||
})
|
||
},
|
||
|
||
setReflectionFlag: (key: string, value: boolean) => {
|
||
set((state) => ({
|
||
reflectionFlags: {
|
||
...state.reflectionFlags,
|
||
[key]: value,
|
||
},
|
||
}))
|
||
},
|
||
|
||
getReflectionFlag: (key: string) => {
|
||
const { reflectionFlags } = get()
|
||
return !!reflectionFlags[key]
|
||
},
|
||
|
||
setLastVisitedPlace: (place: string) => set({ lastVisitedPlace: place }),
|
||
setReflectionRoute: (route: string) => set({ reflectionRoute: route }),
|
||
|
||
collectClue: (id: string, name: string) => {
|
||
const { clues } = get()
|
||
const existingClue = clues.find((c) => c.id === id)
|
||
|
||
if (!existingClue) {
|
||
set({
|
||
clues: [
|
||
...clues,
|
||
{
|
||
id,
|
||
name,
|
||
collected: true,
|
||
collectedAt: Date.now(),
|
||
},
|
||
],
|
||
})
|
||
} else if (!existingClue.collected) {
|
||
set({
|
||
clues: clues.map((c) =>
|
||
c.id === id ? { ...c, collected: true, collectedAt: Date.now() } : c
|
||
),
|
||
})
|
||
}
|
||
},
|
||
|
||
isClueCollected: (id: string) => {
|
||
const { clues } = get()
|
||
return clues.some((c) => c.id === id && c.collected)
|
||
},
|
||
|
||
getSceneClues: (sceneId: string) => {
|
||
const { clues } = get()
|
||
return clues.filter((c) => c.id.startsWith(sceneId))
|
||
},
|
||
|
||
resetSceneClues: (sceneId: string) => {
|
||
const { clues } = get()
|
||
set({
|
||
clues: clues.filter((c) => !c.id.startsWith(sceneId)),
|
||
})
|
||
},
|
||
}),
|
||
{
|
||
name: STORE_NAMES.SUNZHONGSHAN,
|
||
partialize: (state) => ({
|
||
visitedExhibits: serializeVisitedExhibits(state.visitedExhibits),
|
||
reflectionFlags: state.reflectionFlags,
|
||
lastVisitedPlace: state.lastVisitedPlace,
|
||
reflectionRoute: state.reflectionRoute,
|
||
clues: state.clues,
|
||
completedVideos: state.completedVideos,
|
||
}),
|
||
onRehydrateStorage: () => (state) => {
|
||
if (state) {
|
||
state.visitedExhibits = deserializeVisitedExhibits(state.visitedExhibits as any)
|
||
if (!state.reflectionFlags || typeof state.reflectionFlags !== 'object') {
|
||
state.reflectionFlags = {}
|
||
}
|
||
if (state.lastVisitedPlace === undefined) {
|
||
state.lastVisitedPlace = null
|
||
}
|
||
if (state.reflectionRoute === undefined) {
|
||
state.reflectionRoute = 'reflection'
|
||
}
|
||
if (!(state as any).clues || !Array.isArray((state as any).clues)) {
|
||
;(state as any).clues = []
|
||
}
|
||
if (!state.completedVideos || typeof state.completedVideos !== 'object') {
|
||
state.completedVideos = {}
|
||
}
|
||
}
|
||
},
|
||
}
|
||
)
|
||
)
|
||
|
||
// ========== 易班平台数据保存(对话模块) ==========
|
||
export const SUNZHONGSHAN_LESSON_ID = 'Sunzs_duihua'
|
||
export const SUNZHONGSHAN_KEY = 4
|
||
|
||
export const saveSunzhongshanStore = (): void => {
|
||
const state = useSunzhongshanStore.getState()
|
||
|
||
const payload = {
|
||
visitedExhibits: serializeVisitedExhibits(state.visitedExhibits),
|
||
reflectionFlags: state.reflectionFlags,
|
||
lastVisitedPlace: state.lastVisitedPlace,
|
||
reflectionRoute: state.reflectionRoute,
|
||
clues: state.clues,
|
||
completedVideos: state.completedVideos,
|
||
}
|
||
|
||
try {
|
||
setUserDefine(SUNZHONGSHAN_LESSON_ID, SUNZHONGSHAN_KEY, JSON.stringify(payload))
|
||
} catch (error) {
|
||
console.error('Failed to save Sunzhongshan store', error)
|
||
}
|
||
}
|
||
|
||
export const applySunzhongshanPersistedState = (payload: SunzhongshanPersistedState): void => {
|
||
useSunzhongshanStore.setState({
|
||
visitedExhibits: deserializeVisitedExhibits(payload.visitedExhibits),
|
||
reflectionFlags: payload.reflectionFlags ?? {},
|
||
lastVisitedPlace: payload.lastVisitedPlace ?? null,
|
||
reflectionRoute: resolveReflectionRoute(payload.reflectionRoute),
|
||
clues: Array.isArray(payload.clues) ? payload.clues : [],
|
||
completedVideos: payload.completedVideos ?? {},
|
||
})
|
||
}
|
||
|
||
export const loadSunzhongshanStore = async (): Promise<UserDefineFetchStatus> => {
|
||
try {
|
||
const result = await getUserDefineResult(SUNZHONGSHAN_LESSON_ID, SUNZHONGSHAN_KEY)
|
||
if (result.status !== 'success') {
|
||
if (result.status === 'empty') {
|
||
useSunzhongshanStore.getState().resetAll()
|
||
}
|
||
return result.status
|
||
}
|
||
|
||
applySunzhongshanPersistedState(JSON.parse(result.content) as SunzhongshanPersistedState)
|
||
return result.status
|
||
} catch (error) {
|
||
console.error('Failed to load Sunzhongshan store', error)
|
||
return 'error'
|
||
}
|
||
}
|