(
})
}
}
- }, [isOpen, focusedIndex])
+ }, [focusedIndex, isOpen])
+
+ const displayText = selectedOption ? selectedOption.label : placeholder
return (
<>
- {/* 隐藏的测量元素(用于 fitContent 模式) */}
- {fitContent && (
-
- )}
-
- {/* 触发器 */}
{
if (typeof ref === 'function') {
@@ -274,11 +239,13 @@ const Select = forwardRef(
triggerRef.current = node
}}
className={cn(
- 'inline-flex justify-start w-full h-[36px] mx-2 bg-transparent border-b-2 border-dashed text-[26px] outline-none cursor-pointer',
+ 'inline-flex h-[36px] items-center justify-center border-b-2 border-dashed bg-transparent align-baseline outline-none',
+ 'mx-2 cursor-pointer whitespace-nowrap px-2 text-center text-[#4e402e]',
error ? 'border-red-500' : 'border-[#4e402e]',
- disabled && 'opacity-50 cursor-not-allowed',
+ disabled && 'cursor-not-allowed opacity-50',
className
)}
+ style={fontSize ? { fontSize: `${fontSize}px` } : undefined}
onClick={handleOpen}
role="combobox"
aria-expanded={isOpen}
@@ -286,29 +253,26 @@ const Select = forwardRef(
aria-disabled={disabled}
tabIndex={disabled ? -1 : 0}
>
- {/* */}
- {selectedOption ? selectedOption.label : placeholder}
- {/* */}
- {/*
- ▼
- */}
+ {displayText}
+
- {/* 下拉菜单 - 使用 Portal 渲染到 body */}
{isOpen &&
dropdownPosition &&
createPortal(
0 ? dropdownPosition.width : 'auto'}px`,
+ width: `${dropdownPosition.width}px`,
minWidth: '80px',
- zIndex: zIndex,
+ zIndex,
}}
role="listbox"
>
@@ -322,10 +286,10 @@ const Select = forwardRef(
key={option.value}
data-option-index={index}
className={cn(
- 'px-3 py-2 cursor-pointer text-[1vw] text-[#4e402e] transition-colors text-center whitespace-nowrap',
+ 'cursor-pointer whitespace-nowrap px-3 py-2 text-center text-[1vw] text-[#4e402e] transition-colors',
isSelected && 'bg-[#4e402e]/10 font-medium',
isFocused && !isSelected && 'bg-gray-100',
- isDisabled && 'opacity-50 cursor-not-allowed pointer-events-none'
+ isDisabled && 'pointer-events-none cursor-not-allowed opacity-50'
)}
onClick={() => !isDisabled && handleSelect(option.value)}
role="option"
diff --git a/src/components/select/select.types.ts b/src/components/select/select.types.ts
index 7b079a6..8a86b67 100644
--- a/src/components/select/select.types.ts
+++ b/src/components/select/select.types.ts
@@ -34,4 +34,6 @@ export interface SelectProps {
minWidth?: number
/** 下拉菜单最大宽度(当 fitContent 为 true 时生效) */
maxWidth?: number
+ /** 字体大小(单位:px) */
+ fontSize?: number
}
diff --git a/src/components/video/video.tsx b/src/components/video/video.tsx
index a9d01e6..b48fa50 100644
--- a/src/components/video/video.tsx
+++ b/src/components/video/video.tsx
@@ -70,10 +70,13 @@ const Video = forwardRef(
const [triggeredOnceIds, setTriggeredOnceIds] = useState>(new Set())
// 当前激活的锚点 ID
const [activeAnchorId, setActiveAnchorId] = useState(null)
+ const activeAnchorIdRef = useRef(null)
// 已隐藏的锚点 ID(afterResume: 'hide')
const [hiddenAnchorIds, setHiddenAnchorIds] = useState>(new Set())
// 已移除的锚点 ID(afterResume: 'remove')
const [removedAnchorIds, setRemovedAnchorIds] = useState>(new Set())
+ // 已完成的锚点 ID(用户已交互并恢复播放,用于阻止进度条跳过)
+ const [completedAnchorIds, setCompletedAnchorIds] = useState>(new Set())
const isHandlingAnchor = useRef(false)
const [isPlaying, setIsPlaying] = useState(false)
const [isDragging, setIsDragging] = useState(false)
@@ -91,7 +94,61 @@ const Video = forwardRef(
},
}))
- // 初始化视频信息
+ // ─── Helper 函数 ───
+
+ /** 强制暂停视频(含 rAF 兜底,Safari 兼容) */
+ const forcePause = useCallback((video: HTMLVideoElement) => {
+ video.pause()
+ requestAnimationFrame(() => {
+ if (!video.paused) {
+ video.pause()
+ }
+ })
+ }, [])
+
+ /** 锚点是否可被触发(用于 timeUpdate / seek 触发) */
+ const isAnchorTriggerable = useCallback(
+ (anchor: VideoAnchor): boolean => {
+ if (anchor.enabled === false) return false
+ if (removedAnchorIds.has(anchor.id)) return false
+ if (anchor.once && triggeredOnceIds.has(anchor.id)) return false
+ if (hiddenAnchorIds.has(anchor.id)) return false
+ if (completedAnchorIds.has(anchor.id)) return false
+ if (activeAnchorId === anchor.id) return false
+ return true
+ },
+ [removedAnchorIds, triggeredOnceIds, hiddenAnchorIds, completedAnchorIds, activeAnchorId]
+ )
+
+ /** 锚点是否未完成(用于 seek 拦截) */
+ const isAnchorUncompleted = useCallback(
+ (anchor: VideoAnchor): boolean => {
+ if (anchor.enabled === false) return false
+ if (removedAnchorIds.has(anchor.id)) return false
+ if (completedAnchorIds.has(anchor.id)) return false
+ return true
+ },
+ [removedAnchorIds, completedAnchorIds]
+ )
+
+ /** 触发锚点:暂停 + 设置状态 + 回调 */
+ const triggerAnchor = useCallback(
+ (anchor: VideoAnchor, video?: HTMLVideoElement | null) => {
+ if (video) {
+ forcePause(video)
+ }
+ isHandlingAnchor.current = true
+ if (anchor.once) {
+ setTriggeredOnceIds((prev) => new Set(prev).add(anchor.id))
+ }
+ setActiveAnchorId(anchor.id)
+ anchor.onTrigger?.()
+ },
+ [forcePause]
+ )
+
+ // ─── 初始化与生命周期 ───
+
useEffect(() => {
const video = videoRef.current
if (!video) return
@@ -107,7 +164,6 @@ const Video = forwardRef(
setIsVideoReady(true)
}
- // 如果视频已经有尺寸,立即设置为ready
if (video.videoWidth > 0 && video.videoHeight > 0) {
setIsVideoReady(true)
}
@@ -120,6 +176,18 @@ const Video = forwardRef(
}
}, [])
+ // 保持 activeAnchorIdRef 与 state 同步(解决闭包过期问题)
+ useEffect(() => {
+ activeAnchorIdRef.current = activeAnchorId
+ }, [activeAnchorId])
+
+ // 锚点激活时强制暂停视频(兜底保护)
+ useEffect(() => {
+ if (activeAnchorId && videoRef.current && !videoRef.current.paused) {
+ videoRef.current.pause()
+ }
+ }, [activeAnchorId])
+
// 全屏状态监听
useEffect(() => {
const handleFullscreenChange = () => {
@@ -166,6 +234,9 @@ const Video = forwardRef(
return () => {
const afterResume = anchor.afterResume ?? 'hide'
+ // 标记锚点为已完成(防止进度条跳过)
+ setCompletedAnchorIds((prev) => new Set(prev).add(anchor.id))
+
if (afterResume === 'hide') {
setHiddenAnchorIds((prev) => new Set(prev).add(anchor.id))
setActiveAnchorId(null)
@@ -180,238 +251,140 @@ const Video = forwardRef(
}
}, [])
+ // ─── 锚点触发与 seek 逻辑 ───
+
const handleTimeUpdate = (e: React.SyntheticEvent): void => {
- if (onTimeUpdate) {
- onTimeUpdate(e)
- }
+ onTimeUpdate?.(e)
const video = e.currentTarget
- const currentTime = video.currentTime
- setCurrentTime(currentTime)
+ const time = video.currentTime
+ setCurrentTime(time)
if (isHandlingAnchor.current) return
for (const anchor of anchors) {
- // 检查是否启用
- if (anchor.enabled === false) continue
- // 检查是否已移除
- if (removedAnchorIds.has(anchor.id)) continue
- // 检查 once 逻辑
- if (anchor.once && triggeredOnceIds.has(anchor.id)) continue
- // 检查是否已隐藏(隐藏的锚点不再触发)
- if (hiddenAnchorIds.has(anchor.id)) continue
- // keep 模式下,如果当前锚点已激活,跳过(避免重复触发暂停)
- if (activeAnchorId === anchor.id) continue
-
- const timeDiff = currentTime - anchor.time
-
+ if (!isAnchorTriggerable(anchor)) continue
+ const timeDiff = time - anchor.time
if (timeDiff >= 0 && timeDiff < 0.5) {
- console.log(`Anchor triggered: ${anchor.id} at ${anchor.time}s`)
-
- // 暂停视频 - Safari 兼容:确保暂停生效
- video.pause()
- // 双重保险:延迟再次确认暂停
- requestAnimationFrame(() => {
- if (!video.paused) {
- video.pause()
- }
- })
- isHandlingAnchor.current = true
-
- // 标记为已触发(用于 once 逻辑)
- if (anchor.once) {
- setTriggeredOnceIds((prev) => new Set(prev).add(anchor.id))
- }
-
- // 设置当前激活的锚点
- setActiveAnchorId(anchor.id)
-
- // 触发回调(如埋点)
- anchor.onTrigger?.()
-
+ triggerAnchor(anchor, video)
break
}
}
}
- // 检查并触发锚点的辅助函数
+ // 检查并触发锚点(用于 seek 后检测)
const checkAndTriggerAnchors = useCallback(
(targetTime: number) => {
if (!videoRef.current || isHandlingAnchor.current) return
-
for (const anchor of anchors) {
- // 检查是否启用
- if (anchor.enabled === false) continue
- // 检查是否已移除
- if (removedAnchorIds.has(anchor.id)) continue
- // 检查 once 逻辑
- if (anchor.once && triggeredOnceIds.has(anchor.id)) continue
- // 检查是否已隐藏(隐藏的锚点不再触发)
- if (hiddenAnchorIds.has(anchor.id)) continue
- // keep 模式下,如果当前锚点已激活,跳过(避免重复触发暂停)
- if (activeAnchorId === anchor.id) continue
-
- // 检查是否跳过了锚点时间(允许0.5秒的容差)
+ if (!isAnchorTriggerable(anchor)) continue
const timeDiff = targetTime - anchor.time
if (timeDiff >= 0 && timeDiff < 0.5) {
- console.log(
- `Anchor triggered: ${anchor.id} at ${anchor.time}s (seeked to ${targetTime}s)`
- )
-
- // 立即暂停视频(多次确认确保暂停生效)
- if (videoRef.current) {
- videoRef.current.pause()
- // 同步检查并再次暂停(Safari 兼容)
- if (!videoRef.current.paused) {
- videoRef.current.pause()
- }
- // 双重保险:延迟再次确认暂停
- requestAnimationFrame(() => {
- if (videoRef.current && !videoRef.current.paused) {
- videoRef.current.pause()
- }
- })
- // 三重保险:再延迟一次确认
- setTimeout(() => {
- if (videoRef.current && !videoRef.current.paused) {
- videoRef.current.pause()
- }
- }, 50)
- }
- isHandlingAnchor.current = true
-
- // 标记为已触发(用于 once 逻辑)
- if (anchor.once) {
- setTriggeredOnceIds((prev) => new Set(prev).add(anchor.id))
- }
-
- // 设置当前激活的锚点
- setActiveAnchorId(anchor.id)
-
- // 触发回调(如埋点)
- anchor.onTrigger?.()
-
+ triggerAnchor(anchor, videoRef.current)
break
}
}
},
- [anchors, removedAnchorIds, triggeredOnceIds, hiddenAnchorIds, activeAnchorId]
+ [anchors, isAnchorTriggerable, triggerAnchor]
)
- // 查找目标时间之前最近的未触发锚点
- const findNearestAnchorBeforeTime = useCallback(
- (targetTime: number): VideoAnchor | null => {
- let nearestAnchor: VideoAnchor | null = null
- let nearestTime = -1
-
+ // 查找范围内第一个未完成锚点
+ const findFirstUncompletedAnchorInRange = useCallback(
+ (fromTime: number, toTime: number): VideoAnchor | null => {
+ let firstAnchor: VideoAnchor | null = null
+ let firstTime = Infinity
for (const anchor of anchors) {
- // 检查是否启用
- if (anchor.enabled === false) continue
- // 检查是否已移除
- if (removedAnchorIds.has(anchor.id)) continue
- // 检查 once 逻辑
- if (anchor.once && triggeredOnceIds.has(anchor.id)) continue
- // 检查是否已隐藏(隐藏的锚点不再触发)
- if (hiddenAnchorIds.has(anchor.id)) continue
-
- // 锚点时间应该在目标时间之前或等于目标时间
- if (anchor.time <= targetTime && anchor.time > nearestTime) {
- nearestAnchor = anchor
- nearestTime = anchor.time
+ if (!isAnchorUncompleted(anchor)) continue
+ if (anchor.time > fromTime && anchor.time <= toTime && anchor.time < firstTime) {
+ firstAnchor = anchor
+ firstTime = anchor.time
}
}
-
- return nearestAnchor
+ return firstAnchor
},
- [anchors, removedAnchorIds, triggeredOnceIds, hiddenAnchorIds]
+ [anchors, isAnchorUncompleted]
+ )
+
+ // 带锚点拦截的 seek
+ const seekWithAnchorGuard = useCallback(
+ (targetTime: number) => {
+ if (!videoRef.current) return
+ const firstAnchor = findFirstUncompletedAnchorInRange(currentTime, targetTime)
+ const finalTime = firstAnchor ? firstAnchor.time : targetTime
+ if (firstAnchor) {
+ shouldStopAtAnchorRef.current = { time: finalTime, anchorId: firstAnchor.id }
+ videoRef.current.pause()
+ } else {
+ shouldStopAtAnchorRef.current = null
+ }
+ videoRef.current.currentTime = finalTime
+ },
+ [currentTime, findFirstUncompletedAnchorInRange]
)
- // 开始跳转时,如果应该停在锚点处,立即暂停
const handleSeeking = (): void => {
if (!videoRef.current) return
- // 如果设置了应该停在锚点处,立即暂停
if (shouldStopAtAnchorRef.current) {
- videoRef.current.pause()
- // 同步检查并再次暂停(Safari 兼容)
- if (!videoRef.current.paused) {
- videoRef.current.pause()
- }
+ forcePause(videoRef.current)
}
}
- // Seek 时重置隐藏状态(非 once 的锚点可以重新触发),并检查是否跳过了锚点
const handleSeeked = (): void => {
if (!videoRef.current) return
- const currentTime = videoRef.current.currentTime
+ const time = videoRef.current.currentTime
// 如果设置了应该停在锚点处,强制暂停并触发锚点
if (shouldStopAtAnchorRef.current) {
const { time: anchorTime, anchorId } = shouldStopAtAnchorRef.current
- // 检查当前时间是否在锚点附近(允许0.1秒容差)
- if (Math.abs(currentTime - anchorTime) < 0.1) {
- // 强制暂停(多次确认)
- videoRef.current.pause()
- if (!videoRef.current.paused) {
- videoRef.current.pause()
- }
- requestAnimationFrame(() => {
- if (videoRef.current && !videoRef.current.paused) {
- videoRef.current.pause()
- }
- })
-
- // 触发锚点逻辑
+ if (Math.abs(time - anchorTime) < 0.1) {
+ forcePause(videoRef.current)
if (!isHandlingAnchor.current) {
const anchor = anchors.find((a) => a.id === anchorId)
if (anchor) {
- isHandlingAnchor.current = true
-
- // 标记为已触发(用于 once 逻辑)
- if (anchor.once) {
- setTriggeredOnceIds((prev) => new Set(prev).add(anchor.id))
- }
-
- // 设置当前激活的锚点
- setActiveAnchorId(anchor.id)
-
- // 触发回调(如埋点)
- anchor.onTrigger?.()
+ triggerAnchor(anchor)
}
}
-
- // 清除标记
shouldStopAtAnchorRef.current = null
return
}
- // 如果时间不匹配,清除标记
shouldStopAtAnchorRef.current = null
}
- // 清除当前时间之后的隐藏状态(允许重新触发)
+ // 清除当前时间之后的隐藏/完成状态(回退后允许重新触发)
setHiddenAnchorIds((prev) => {
const next = new Set(prev)
for (const id of next) {
const anchor = anchors.find((a) => a.id === id)
- if (anchor && anchor.time > currentTime) {
+ if (anchor && anchor.time > time) {
next.delete(id)
}
}
return next
})
- // 重置锚点处理锁(如果当前没有激活的锚点)
- if (!activeAnchorId) {
+ setCompletedAnchorIds((prev) => {
+ const next = new Set(prev)
+ for (const id of next) {
+ const anchor = anchors.find((a) => a.id === id)
+ if (anchor && anchor.time > time) {
+ next.delete(id)
+ }
+ }
+ return next
+ })
+
+ // 使用 ref 避免闭包过期
+ if (!activeAnchorIdRef.current) {
isHandlingAnchor.current = false
}
- // 检查是否跳过了锚点时间,如果是则触发锚点
- checkAndTriggerAnchors(currentTime)
+ checkAndTriggerAnchors(time)
}
- // 获取当前激活的锚点
+ // ─── 事件处理 ───
+
const activeAnchor = activeAnchorId ? anchors.find((a) => a.id === activeAnchorId) : null
- // 统一处理 onEnded:更新播放状态并透传给外部
const handleEnded: React.VideoHTMLAttributes['onEnded'] = (e) => {
setIsPlaying(false)
props.onEnded?.(e)
@@ -430,55 +403,27 @@ const Video = forwardRef(
const handleSkipClick = (): void => {
if (!videoRef.current) return
- const currentTime = videoRef.current.currentTime
+ const time = videoRef.current.currentTime
const videoDuration = videoRef.current.duration
- // 检查是否有未触发的锚点
- let hasUntriggeredAnchor = false
- for (const anchor of anchors) {
- // 检查是否启用
- if (anchor.enabled === false) continue
- // 检查是否已移除
- if (removedAnchorIds.has(anchor.id)) continue
- // 检查 once 逻辑
- if (anchor.once && triggeredOnceIds.has(anchor.id)) continue
- // 检查是否已隐藏
- if (hiddenAnchorIds.has(anchor.id)) continue
- // 检查锚点时间是否在当前时间和视频结束之间
- if (anchor.time >= currentTime && anchor.time <= videoDuration) {
- hasUntriggeredAnchor = true
- break
- }
+ // 找到第一个可触发的锚点
+ const nextAnchor = anchors.find((anchor) => {
+ if (!isAnchorTriggerable(anchor)) return false
+ return anchor.time >= time && anchor.time <= videoDuration
+ })
+
+ if (nextAnchor) {
+ videoRef.current.currentTime = nextAnchor.time
+ setTimeout(() => {
+ checkAndTriggerAnchors(nextAnchor.time)
+ }, 100)
+ return
}
- // 如果有未触发的锚点,跳转到第一个未触发的锚点时间并触发
- if (hasUntriggeredAnchor) {
- // 找到第一个未触发的锚点
- const nextAnchor = anchors.find((anchor) => {
- if (anchor.enabled === false) return false
- if (removedAnchorIds.has(anchor.id)) return false
- if (anchor.once && triggeredOnceIds.has(anchor.id)) return false
- if (hiddenAnchorIds.has(anchor.id)) return false
- return anchor.time >= currentTime && anchor.time <= videoDuration
- })
-
- if (nextAnchor) {
- // 跳转到锚点时间
- videoRef.current.currentTime = nextAnchor.time
- // 触发锚点(通过 checkAndTriggerAnchors)
- setTimeout(() => {
- checkAndTriggerAnchors(nextAnchor.time)
- }, 100)
- return
- }
- }
-
- // 如果没有未触发的锚点,执行原来的跳过逻辑
+ // 没有未触发的锚点,执行跳过逻辑
videoRef.current.pause()
onSkip?.()
- // 触发与自然结束相同的逻辑
if (props.onEnded) {
- // 调用时不关心事件内容,保持类型兼容
;(props.onEnded as (e: any) => void)({} as any)
}
}
@@ -495,68 +440,27 @@ const Video = forwardRef(
resetControlsTimer()
}
- // 进度条点击/拖拽
+ // 进度条点击
const handleProgressClick = (e: React.MouseEvent) => {
- // 如果正在拖拽,不处理点击事件
if (isDragging) return
+ if (isHandlingAnchor.current) return
+ if (shouldStopAtAnchorRef.current) return
if (!videoRef.current || !progressRef.current) return
const rect = progressRef.current.getBoundingClientRect()
const percent = (e.clientX - rect.left) / rect.width
- const targetTime = percent * duration
-
- // 检查目标时间之前是否有未触发的锚点
- const nearestAnchor = findNearestAnchorBeforeTime(targetTime)
- // 如果找到锚点,且目标时间跳过了锚点(目标时间 > 锚点时间),则停在锚点处
- // 但只有当目标时间在锚点之后超过0.1秒时才停在锚点,避免点击锚点本身时也被拦截
- const shouldStopAtAnchor =
- nearestAnchor && targetTime > nearestAnchor.time && targetTime - nearestAnchor.time > 0.1
- const finalTime = shouldStopAtAnchor ? nearestAnchor!.time : targetTime
-
- // 如果会停在锚点处,设置标记(用于 onSeeking 和 onSeeked 事件)
- if (shouldStopAtAnchor && nearestAnchor) {
- shouldStopAtAnchorRef.current = {
- time: finalTime,
- anchorId: nearestAnchor.id,
- }
- // 立即暂停视频
- videoRef.current.pause()
- } else {
- shouldStopAtAnchorRef.current = null
- }
-
- videoRef.current.currentTime = finalTime
+ seekWithAnchorGuard(percent * duration)
resetControlsTimer()
}
+ // 进度条拖拽开始
const handleProgressMouseDown = (e: React.MouseEvent) => {
e.preventDefault()
+ if (isHandlingAnchor.current) return
setIsDragging(true)
if (!videoRef.current || !progressRef.current) return
const rect = progressRef.current.getBoundingClientRect()
const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
- const targetTime = percent * duration
-
- // 检查目标时间之前是否有未触发的锚点
- const nearestAnchor = findNearestAnchorBeforeTime(targetTime)
- // 如果找到锚点,且目标时间跳过了锚点(目标时间 > 锚点时间),则停在锚点处
- // 但只有当目标时间在锚点之后超过0.1秒时才停在锚点,避免点击锚点本身时也被拦截
- const shouldStopAtAnchor =
- nearestAnchor && targetTime > nearestAnchor.time && targetTime - nearestAnchor.time > 0.1
- const finalTime = shouldStopAtAnchor ? nearestAnchor!.time : targetTime
-
- // 如果会停在锚点处,设置标记(用于 onSeeking 和 onSeeked 事件)
- if (shouldStopAtAnchor && nearestAnchor) {
- shouldStopAtAnchorRef.current = {
- time: finalTime,
- anchorId: nearestAnchor.id,
- }
- // 立即暂停视频
- videoRef.current.pause()
- } else {
- shouldStopAtAnchorRef.current = null
- }
-
- videoRef.current.currentTime = finalTime
+ seekWithAnchorGuard(percent * duration)
}
// 全局鼠标移动和释放事件处理拖拽
@@ -564,37 +468,15 @@ const Video = forwardRef(
if (!isDragging) return
const handleMouseMove = (e: MouseEvent) => {
+ if (isHandlingAnchor.current) return
if (!videoRef.current || !progressRef.current) return
const rect = progressRef.current.getBoundingClientRect()
const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
- const targetTime = percent * duration
-
- // 检查目标时间之前是否有未触发的锚点
- const nearestAnchor = findNearestAnchorBeforeTime(targetTime)
- // 如果找到锚点,且目标时间跳过了锚点(目标时间 > 锚点时间),则停在锚点处
- // 但只有当目标时间在锚点之后超过0.1秒时才停在锚点,避免点击锚点本身时也被拦截
- const shouldStopAtAnchor =
- nearestAnchor && targetTime > nearestAnchor.time && targetTime - nearestAnchor.time > 0.1
- const finalTime = shouldStopAtAnchor ? nearestAnchor!.time : targetTime
-
- // 如果会停在锚点处,设置标记(用于 onSeeking 和 onSeeked 事件)
- if (shouldStopAtAnchor && nearestAnchor) {
- shouldStopAtAnchorRef.current = {
- time: finalTime,
- anchorId: nearestAnchor.id,
- }
- // 立即暂停视频
- videoRef.current.pause()
- } else {
- shouldStopAtAnchorRef.current = null
- }
-
- videoRef.current.currentTime = finalTime
+ seekWithAnchorGuard(percent * duration)
}
const handleMouseUp = () => {
setIsDragging(false)
- // 拖拽结束后,handleSeeked 会自动处理锚点触发
}
document.addEventListener('mousemove', handleMouseMove)
@@ -604,7 +486,7 @@ const Video = forwardRef(
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
- }, [isDragging, duration, checkAndTriggerAnchors, findNearestAnchorBeforeTime])
+ }, [isDragging, duration, seekWithAnchorGuard])
// 音量控制
const toggleMute = () => {
@@ -822,9 +704,9 @@ const Video = forwardRef(
)}
- {/* 锚点渲染层 - Safari 兼容:直接使用 pointer-events-auto */}
+ {/* 锚点渲染层 */}
{activeAnchor && (
-
+
{activeAnchor.render({
resume: createResume(activeAnchor),
unmute,
diff --git a/src/config/puzzleData.ts b/src/config/puzzleData.ts
index 760997f..02e2b66 100644
--- a/src/config/puzzleData.ts
+++ b/src/config/puzzleData.ts
@@ -377,6 +377,88 @@ export const puzzleDataConfig: Record
> =
route: 'internalization',
},
},
+ maozedong: {
+ 1: {
+ title: '毛泽东与陈独秀合影',
+ detailImage: 'maozedong/details/1',
+ route: 'place1',
+ },
+ 2: {
+ title: '中共一大会址',
+ detailImage: 'maozedong/details/2',
+ route: 'place1',
+ },
+ 3: {
+ title: '中共二大旧址',
+ detailImage: 'maozedong/details/3',
+ route: 'place1',
+ },
+ 4: {
+ title: '《向导》周刊三篇文章',
+ detailImage: 'maozedong/details/4',
+ route: 'place1',
+ },
+ 5: {
+ title: '中共三大会议召开',
+ detailImage: 'maozedong/details/5',
+ route: 'place1',
+ },
+ 6: {
+ title: '《孙中山手书名单》',
+ detailImage: 'maozedong/details/6',
+ route: 'place1',
+ },
+ 7: {
+ title: '《孙文越飞联合宣言》',
+ detailImage: 'maozedong/details/7',
+ route: 'place1',
+ },
+ 8: {
+ title: '毛泽东签发的两份中共四大通知',
+ detailImage: 'maozedong/details/8',
+ route: 'place1',
+ },
+ 9: {
+ title: '中共四大立碑处',
+ detailImage: 'maozedong/details/9',
+ route: 'place1',
+ },
+ 10: {
+ title: '毛泽东中国社会分析',
+ detailImage: 'maozedong/details/10',
+ route: 'place1',
+ },
+ 11: {
+ title: '湖南农民运动考察报告',
+ detailImage: 'maozedong/details/11',
+ route: 'place1',
+ },
+ 12: {
+ title: '少一人的全家福',
+ detailImage: 'maozedong/details/12',
+ route: 'place1',
+ },
+ 13: {
+ title: '筹备方向的定夺',
+ detailImage: 'maozedong/details/13',
+ route: 'place1',
+ },
+ 14: {
+ title: '基层声音的汇聚',
+ detailImage: 'maozedong/details/14',
+ route: 'place1',
+ },
+ 15: {
+ title: '毛泽东思想的确立',
+ detailImage: 'maozedong/details/15',
+ route: 'place1',
+ },
+ 16: {
+ title: '毛泽东思想活的灵魂',
+ detailImage: 'maozedong/details/16',
+ route: 'place1',
+ },
+ },
// 后续可以添加其他故居的数据
// sunzhongshan: { ... },
}
diff --git a/src/hooks/useAudioManager.ts b/src/hooks/useAudioManager.ts
index e13cdca..5dd5271 100644
--- a/src/hooks/useAudioManager.ts
+++ b/src/hooks/useAudioManager.ts
@@ -93,16 +93,34 @@ export function useAudioManager(options?: UseAudioManagerOptions) {
}
audio.currentTime = 0
- // 捕获浏览器因策略或中断导致的 play() 拒绝,避免控制台 Unhandled promise
- audio.play().catch(() => {
- // 可以在这里按需上报或忽略
- })
- emit({
- key,
- currentSrc: src,
- isPlaying: true,
- })
+ // 捕获浏览器因策略或中断导致的 play() 拒绝,避免控制台 Unhandled promise
+ // 只有在 play() 成功后才更新 isPlaying,避免误判导致“以为在播但实际没播”的情况。
+ const playPromise = audio.play()
+ if (playPromise && typeof (playPromise as Promise).then === 'function') {
+ playPromise
+ .then(() => {
+ emit({
+ key,
+ currentSrc: src,
+ isPlaying: true,
+ })
+ })
+ .catch(() => {
+ emit({
+ key,
+ currentSrc: src,
+ isPlaying: false,
+ })
+ })
+ } else {
+ // 理论上现代浏览器 audio.play() 返回 Promise,但这里做保护
+ emit({
+ key,
+ currentSrc: src,
+ isPlaying: true,
+ })
+ }
},
[resolvedMode, resolvedChannelId]
)
diff --git a/src/lib/utils/api-common.ts b/src/lib/utils/api-common.ts
index e48dc28..d6e5fe8 100644
--- a/src/lib/utils/api-common.ts
+++ b/src/lib/utils/api-common.ts
@@ -1,3 +1,5 @@
+import { useUserStore } from '@/store/userStore'
+
// 声明外部依赖(由运行环境提供)
declare const $: any
declare const iscallyb: boolean
@@ -73,6 +75,11 @@ const LESSON_PREFIX = 'hsgrl'
const videoajax: VideoAjax = videoAjax()
videoajax.setHost('/')
+function normalizeLessonId(lessonId: string | number): string {
+ const raw = String(lessonId)
+ return raw.startsWith(`${LESSON_PREFIX}_`) ? raw : `${LESSON_PREFIX}_${raw}`
+}
+
// 设置我的自定义数据(所有的自定义数据都设置在第一个课时ID上,以key来区分不同的类型)
function setLtDefine(key: string | number, value: string): void {
if (iscallyb) {
@@ -131,7 +138,7 @@ export function setUserDefine(
value: string
): void {
const param: UserDefineParam = {
- lessonId: `${LESSON_PREFIX}_${lessonId}`,
+ lessonId: normalizeLessonId(lessonId),
key,
content: value,
}
@@ -144,7 +151,7 @@ function getUserDefineAsyncInternal(
key: string | number
): Promise {
const param: UserDefineParam = {
- lessonId: `${LESSON_PREFIX}_${lessonId}`,
+ lessonId: normalizeLessonId(lessonId),
order: key,
}
@@ -179,7 +186,7 @@ function getUsersDefineAsyncInternal(
key: string | number
): Promise {
const param: UserDefineParam = {
- lessonId: `${LESSON_PREFIX}_${lessonId}`,
+ lessonId: normalizeLessonId(lessonId),
order: key,
}
@@ -221,8 +228,9 @@ export async function getUsersDefine(
// 根据课时顺序号(所处课程的第几课时)更新对应易班平台上的每个环节进度(异步)
export async function setCourseTimeProgress(order: number): Promise {
+ const courseid = useUserStore.getState().courseId
// var courseid = getCookie(lesson+"_course_id");//获取url链接的课程ID
- const courseid = await getLtDefine('10000')
+ // const courseid = await getLtDefine('10000')
console.info(`courseid:${courseid} order:${order}`)
if (iscallyb && courseid !== undefined && courseid !== null && courseid !== '') {
if (order > 0) {
diff --git a/src/main.tsx b/src/main.tsx
index 82d3e87..715d78b 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -2,7 +2,7 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
-import { useChenyunStore, useHuangyanpeiStore, usePuzzleStore, useClueStore } from './store'
+import { useChenyunStore, useHuangyanpeiStore, usePuzzleStore, useSunzhongshanStore } from './store'
// 在开发模式的移动端启用 vConsole(需先安装 vconsole 依赖)
if (import.meta.env.DEV && typeof window !== 'undefined') {
@@ -42,7 +42,7 @@ const setupGlobalKeyboardShortcuts = () => {
useChenyunStore.getState().resetAll()
useHuangyanpeiStore.getState().resetAll()
usePuzzleStore.getState().resetAll()
- useClueStore.getState().resetAll()
+ useSunzhongshanStore.getState().resetAll()
// 显示成功消息
alert('所有缓存已重置!页面将刷新。')
diff --git a/src/pages/chenyun/home.tsx b/src/pages/chenyun/home.tsx
index 3b5a1ad..d9d9561 100644
--- a/src/pages/chenyun/home.tsx
+++ b/src/pages/chenyun/home.tsx
@@ -3,10 +3,7 @@ import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components'
import { getUserDefine } from '@/lib/utils/api-common'
-import { useChenyunStore } from '@/store/chenyunStore'
-
-const CHENYUN_LESSON_ID = 'Cheny_duihua'
-const CHENYUN_KEY = 22
+import { useChenyunStore, CHENYUN_LESSON_ID, CHENYUN_KEY } from '@/store/chenyunStore'
const deserializeVisitedExhibits = (
data: Record | undefined
@@ -28,7 +25,7 @@ const Chenyun = () => {
// starName 固定为 chenyun
const starName = 'chenyun'
- const { lastVisitedPlace } = useChenyunStore()
+ const lastVisitedPlace = useChenyunStore((state) => state.lastVisitedPlace)
useEffect(() => {
;(async () => {
@@ -40,9 +37,8 @@ const Chenyun = () => {
visitedExhibits?: Record
isYinyuanSolved?: boolean
isMimianSolved?: boolean
- lastVisitedPlace?: string | null
+ lastVisitedPlace?: string
}
- console.log(data)
useChenyunStore.setState((prev) => ({
...prev,
@@ -83,7 +79,7 @@ const Chenyun = () => {
navigate(`/${moduleName}/${starName}/${lastVisitedPlace ?? 'place1'}`)}
+ onClick={() => navigate(`/${moduleName}/${starName}/${lastVisitedPlace || 'place1'}`)}
/>
{/* 体悟 */}
{
const [showVideo, setShowVideo] = useState(false)
const [postGuideVisible, setPostGuideVisible] = useState(false)
const [showLetter, setShowLetter] = useState(false)
- const [showLetterVideo, setShowLetterVideo] = useState(false)
const [showStudyGuide, setShowStudyGuide] = useState(false)
const videoRef = useRef(null)
- const letterVideoRef = useRef(null)
- const audioRef = useRef(null)
useEffect(() => {
setVisible(true)
}, [])
- // 组件卸载时清理背景音乐
- useEffect(() => {
- const audio = audioRef.current
- return () => {
- if (audio) {
- audio.pause()
- audio.currentTime = 0
- }
- }
- }, [])
-
// 视频加载完成后自动播放
const handleVideoCanPlay = () => {
if (showVideo && videoRef.current) {
@@ -177,14 +164,6 @@ const ChenyunInternalization = () => {
}
}
- // 信件视频加载完成后自动播放
- const handleLetterVideoCanPlay = () => {
- if (showLetterVideo && letterVideoRef.current) {
- letterVideoRef.current.play().catch((error) => {
- console.warn('信件视频播放失败:', error)
- })
- }
- }
const anchors: VideoAnchor[] = useMemo(
() =>
QUIZ_ANCHORS.map((item) => {
@@ -243,7 +222,7 @@ const ChenyunInternalization = () => {
{
setActiveSuccess(null)
triggerPuzzleAnimation(16)
@@ -357,9 +336,6 @@ const ChenyunInternalization = () => {
onClick={() => navigate(`/${moduleName}/${starName}/home`)}
/>
- {/* 内化结束后的背景音乐 */}
-
-
{showVideo && (
)
diff --git a/src/pages/chenyun/place/place2.tsx b/src/pages/chenyun/place/place2.tsx
index acdd24d..4a33c3c 100644
--- a/src/pages/chenyun/place/place2.tsx
+++ b/src/pages/chenyun/place/place2.tsx
@@ -32,10 +32,6 @@ const ChenyunPlace2 = () => {
const place = 'place2'
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useChenyunStore()
- useEffect(() => {
- setLastVisitedPlace(place)
- }, [setLastVisitedPlace])
-
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
const visited = new Set(visitedExhibits)
@@ -75,9 +71,7 @@ const ChenyunPlace2 = () => {
const progressCount = visited.size
const total = 2
const completed = progressCount >= total
- const subtitle = completed
- ? '探索陈云就读过的学校(已完成)'
- : `探索陈云就读过的学校(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
@@ -130,6 +124,7 @@ const ChenyunPlace2 = () => {
src={getStaticImage('chenyun/place2/继续前进2')}
button
onClick={() => {
+ setLastVisitedPlace('place3')
saveChenyunStore()
navigate(`/${moduleName}/${starName}/place3`)
}}
diff --git a/src/pages/chenyun/place/place3.tsx b/src/pages/chenyun/place/place3.tsx
index 8129fe2..6f8fb13 100644
--- a/src/pages/chenyun/place/place3.tsx
+++ b/src/pages/chenyun/place/place3.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react'
+import { useState } from 'react'
import {
ExhibitOverlay,
Title,
@@ -37,10 +37,6 @@ const ChenyunPlace3 = () => {
const place = 'place3'
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useChenyunStore()
- useEffect(() => {
- setLastVisitedPlace(place)
- }, [setLastVisitedPlace])
-
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
const visited = new Set(visitedExhibits)
@@ -75,9 +71,7 @@ const ChenyunPlace3 = () => {
const progressCount = visited.size
const total = 2
const completed = progressCount >= total
- const subtitle = completed
- ? '探索该阶段相关资料(已完成)'
- : `探索该阶段相关资料(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
@@ -130,6 +124,7 @@ const ChenyunPlace3 = () => {
src={getStaticImage('chenyun/place3/继续前进3')}
button
onClick={() => {
+ setLastVisitedPlace('place4')
saveChenyunStore()
navigate(`/${moduleName}/${starName}/place4`)
}}
@@ -138,7 +133,7 @@ const ChenyunPlace3 = () => {
{/* 展品2视频播放遮罩(全屏) */}
{videoVisible && (
-
+
{
const place = 'place4'
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useChenyunStore()
- useEffect(() => {
- setLastVisitedPlace(place)
- }, [setLastVisitedPlace])
-
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
const visited = new Set(visitedExhibits)
@@ -88,9 +84,7 @@ const ChenyunPlace4 = () => {
const progressCount = visited.size
const total = 2
const completed = progressCount >= total
- const subtitle = completed
- ? '探索该阶段相关资料(已完成)'
- : `探索该阶段相关资料(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
@@ -143,6 +137,7 @@ const ChenyunPlace4 = () => {
src={getStaticImage('chenyun/place3/继续前进3')}
button
onClick={() => {
+ setLastVisitedPlace('place5')
saveChenyunStore()
navigate(`/${moduleName}/${starName}/place5`)
}}
diff --git a/src/pages/chenyun/place/place5.tsx b/src/pages/chenyun/place/place5.tsx
index dee2871..fe8edb2 100644
--- a/src/pages/chenyun/place/place5.tsx
+++ b/src/pages/chenyun/place/place5.tsx
@@ -47,10 +47,6 @@ const ChenyunPlace5 = () => {
const place = 'place5'
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useChenyunStore()
- useEffect(() => {
- setLastVisitedPlace(place)
- }, [setLastVisitedPlace])
-
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
const visited = new Set(visitedExhibits)
@@ -83,9 +79,7 @@ const ChenyunPlace5 = () => {
const progressCount = visited.size
const total = 3
const completed = progressCount >= total
- const subtitle = completed
- ? '探索该阶段相关资料(已完成)'
- : `探索该阶段相关资料(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
@@ -146,6 +140,7 @@ const ChenyunPlace5 = () => {
src={getStaticImage('chenyun/place5/继续前进4')}
button
onClick={() => {
+ setLastVisitedPlace('place6')
saveChenyunStore()
navigate(`/${moduleName}/${starName}/place6`)
}}
diff --git a/src/pages/chenyun/place/place6.tsx b/src/pages/chenyun/place/place6.tsx
index 76f8cc5..6c56822 100644
--- a/src/pages/chenyun/place/place6.tsx
+++ b/src/pages/chenyun/place/place6.tsx
@@ -25,11 +25,7 @@ const ChenyunPlace6 = () => {
// 使用持久化的访问状态
const place = 'place6'
- const { isExhibitVisited, markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useChenyunStore()
-
- useEffect(() => {
- setLastVisitedPlace(place)
- }, [setLastVisitedPlace])
+ const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useChenyunStore()
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
@@ -51,9 +47,7 @@ const ChenyunPlace6 = () => {
const progressCount = visited.size
const total = 1
const completed = progressCount >= total
- const subtitle = completed
- ? '探索该阶段相关资料(已完成)'
- : `探索该阶段相关资料(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
@@ -98,6 +92,7 @@ const ChenyunPlace6 = () => {
src={getStaticImage('chenyun/place6/继续前进5')}
button
onClick={() => {
+ setLastVisitedPlace('place7')
saveChenyunStore()
navigate(`/${moduleName}/${starName}/place7`)
}}
diff --git a/src/pages/chenyun/place/place7.tsx b/src/pages/chenyun/place/place7.tsx
index 8194854..e5ae301 100644
--- a/src/pages/chenyun/place/place7.tsx
+++ b/src/pages/chenyun/place/place7.tsx
@@ -63,10 +63,6 @@ const ChenyunPlace7 = () => {
const place = 'place7'
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useChenyunStore()
- useEffect(() => {
- setLastVisitedPlace(place)
- }, [setLastVisitedPlace])
-
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
const visited = new Set(visitedExhibits)
@@ -178,9 +174,7 @@ const ChenyunPlace7 = () => {
const progressCount = visited.size
const total = 1
const completed = progressCount >= total
- const subtitle = completed
- ? '探索该阶段相关资料(已完成)'
- : `探索该阶段相关资料(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
@@ -225,6 +219,7 @@ const ChenyunPlace7 = () => {
src={getStaticImage('chenyun/place7/继续前进')}
button
onClick={() => {
+ setLastVisitedPlace('place8')
saveChenyunStore()
navigate(`/${moduleName}/${starName}/place8`)
}}
diff --git a/src/pages/chenyun/place/place8.tsx b/src/pages/chenyun/place/place8.tsx
index 44d6688..2ba76e3 100644
--- a/src/pages/chenyun/place/place8.tsx
+++ b/src/pages/chenyun/place/place8.tsx
@@ -40,10 +40,6 @@ const ChenyunPlace8 = () => {
const place = 'place8'
const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useChenyunStore()
- useEffect(() => {
- setLastVisitedPlace(place)
- }, [setLastVisitedPlace])
-
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
const visited = new Set(visitedExhibits)
@@ -92,9 +88,7 @@ const ChenyunPlace8 = () => {
const progressCount = visited.size
const total = 2
const completed = progressCount >= total
- const subtitle = completed
- ? '探索该阶段相关资料(已完成)'
- : `探索该阶段相关资料(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
@@ -147,6 +141,7 @@ const ChenyunPlace8 = () => {
src={getStaticImage('chenyun/place8/继续前进6')}
button
onClick={() => {
+ setLastVisitedPlace('place9')
saveChenyunStore()
navigate(`/${moduleName}/${starName}/place9`)
}}
diff --git a/src/pages/chenyun/place/place9.tsx b/src/pages/chenyun/place/place9.tsx
index ea796fe..7924b67 100644
--- a/src/pages/chenyun/place/place9.tsx
+++ b/src/pages/chenyun/place/place9.tsx
@@ -1,10 +1,11 @@
-import { useState, useRef, useEffect } from 'react'
+import { useState, useRef } 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'
import { useChenyunStore, saveChenyunStore } from '@/store/chenyunStore'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
+import { setCourseTimeProgress } from '@/lib/utils/api-common'
// starName 固定为 chenyun
const starName = 'chenyun'
@@ -22,11 +23,7 @@ const ChenyunPlace9 = () => {
// 使用持久化的访问状态
const place = 'place9'
- const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useChenyunStore()
-
- useEffect(() => {
- setLastVisitedPlace(place)
- }, [setLastVisitedPlace])
+ const { markExhibitVisited, getVisitedExhibits } = useChenyunStore()
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
@@ -61,9 +58,7 @@ const ChenyunPlace9 = () => {
const progressCount = visited.size
const total = 1
const completed = progressCount >= total
- const subtitle = completed
- ? '探索该阶段相关资料(已完成)'
- : `探索该阶段相关资料(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
@@ -108,6 +103,7 @@ const ChenyunPlace9 = () => {
src={getStaticImage('chenyun/place9/离开场景')}
button
onClick={() => {
+ setCourseTimeProgress(22)
saveChenyunStore()
navigate(`/${moduleName}/${starName}/home`)
}}
diff --git a/src/pages/chenyun/reflection/index.tsx b/src/pages/chenyun/reflection/index.tsx
index b4e5e9d..1e69c8f 100644
--- a/src/pages/chenyun/reflection/index.tsx
+++ b/src/pages/chenyun/reflection/index.tsx
@@ -35,8 +35,8 @@ const ChenyunReflection = () => {
// ========== 顶层状态管理 ==========
/** 当前展开的文件 */
- const [expandedFile, setExpandedFile] = useState(
- () => (useChenyunStore.getState().isYinyuanSolved ? 'mimian' : null)
+ const [expandedFile, setExpandedFile] = useState(() =>
+ useChenyunStore.getState().isYinyuanSolved ? 'mimian' : null
)
/** 当前播放的视频 */
diff --git a/src/pages/chenyun/reflection/mimian/useMimianState.ts b/src/pages/chenyun/reflection/mimian/useMimianState.ts
index 4fc54c8..9d33184 100644
--- a/src/pages/chenyun/reflection/mimian/useMimianState.ts
+++ b/src/pages/chenyun/reflection/mimian/useMimianState.ts
@@ -3,7 +3,8 @@
* 管理米棉之战相关的所有状态
*/
import { useState } from 'react'
-import { useChenyunStore } from '@/store/chenyunStore'
+import { useChenyunStore, saveChenyunStore } from '@/store/chenyunStore'
+import { setCourseTimeProgress } from '@/lib/utils/api-common'
/**
* 米棉之战状态管理 Hook
@@ -16,6 +17,8 @@ export const useMimianState = () => {
// 更新解决状态时,直接更新 store
const handleSetIsMimianSolved = (solved: boolean) => {
setMimianSolved(solved)
+ saveChenyunStore()
+ setCourseTimeProgress(23)
}
// 单选题1相关状态
diff --git a/src/pages/chenyun/reflection/yinyuan/useYinyuanState.ts b/src/pages/chenyun/reflection/yinyuan/useYinyuanState.ts
index fcb8fe2..9d89512 100644
--- a/src/pages/chenyun/reflection/yinyuan/useYinyuanState.ts
+++ b/src/pages/chenyun/reflection/yinyuan/useYinyuanState.ts
@@ -3,7 +3,7 @@
* 管理银元之战相关的所有状态
*/
import { useState } from 'react'
-import { useChenyunStore } from '@/store/chenyunStore'
+import { useChenyunStore, saveChenyunStore } from '@/store/chenyunStore'
/**
* 银元之战状态管理 Hook
@@ -16,6 +16,7 @@ export const useYinyuanState = () => {
// 更新解决状态时,直接更新 store
const handleSetIsYinyuanSolved = (solved: boolean) => {
setYinyuanSolved(solved)
+ saveChenyunStore()
}
// 多选题1相关状态
diff --git a/src/pages/huangyanpei/home.tsx b/src/pages/huangyanpei/home.tsx
index d19251b..872af75 100644
--- a/src/pages/huangyanpei/home.tsx
+++ b/src/pages/huangyanpei/home.tsx
@@ -1,6 +1,13 @@
+import { useEffect } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components'
+import { getUserDefine } from '@/lib/utils/api-common'
+import {
+ useHuangyanpeiStore,
+ HUANGYANPEI_LESSON_ID,
+ HUANGYANPEI_KEY,
+} from '@/store/huangyanpeiStore'
const Huangyanpei = () => {
const navigate = useNavigate()
@@ -11,6 +18,39 @@ const Huangyanpei = () => {
// starName 固定为 huangyanpei
const starName = 'huangyanpei'
+ const lastVisitedPlace = useHuangyanpeiStore((state) => state.lastVisitedPlace)
+ const reflectionRoute = useHuangyanpeiStore((state) => state.reflectionRoute)
+
+ useEffect(() => {
+ ;(async () => {
+ try {
+ const raw = await getUserDefine(HUANGYANPEI_LESSON_ID, HUANGYANPEI_KEY)
+ if (!raw) return
+
+ const data = JSON.parse(raw) as {
+ visitedExhibits?: Record
+ lastVisitedPlace?: string | null
+ reflectionRoute?: string
+ }
+
+ useHuangyanpeiStore.setState((prev) => ({
+ ...prev,
+ lastVisitedPlace:
+ data.lastVisitedPlace !== undefined ? data.lastVisitedPlace : prev.lastVisitedPlace,
+ reflectionRoute:
+ data.reflectionRoute &&
+ ['reflection', 'reflection2', 'reflection3', 'reflection4'].includes(
+ data.reflectionRoute
+ )
+ ? data.reflectionRoute
+ : (prev.reflectionRoute ?? 'reflection'),
+ }))
+ } catch (error) {
+ console.error('Failed to load Huangyanpei store', error)
+ }
+ })()
+ }, [])
+
if (!moduleName) {
return null
}
@@ -34,13 +74,13 @@ const Huangyanpei = () => {
navigate(`/${moduleName}/${starName}/place1`)}
+ onClick={() => navigate(`/${moduleName}/${starName}/${lastVisitedPlace || 'place1'}`)}
/>
{/* 体悟 */}
navigate(`/${moduleName}/${starName}/reflection`)}
+ onClick={() => navigate(`/${moduleName}/${starName}/${reflectionRoute || 'reflection'}`)}
/>
{/* 内化 */}
{
@@ -150,26 +188,13 @@ const HuangyanpeiInternalization = () => {
const [showVideo, setShowVideo] = useState(false)
const [postGuideVisible, setPostGuideVisible] = useState(false)
const [showLetter, setShowLetter] = useState(false)
- const [showLetterVideo, setShowLetterVideo] = useState(false)
const [showStudyGuide, setShowStudyGuide] = useState(false)
const videoRef = useRef(null)
- const letterVideoRef = useRef(null)
- const audioRef = useRef(null)
useEffect(() => {
setNarrationVisible(true)
}, [])
- // 组件卸载时清理背景音乐
- useEffect(() => {
- return () => {
- if (audioRef.current) {
- audioRef.current.pause()
- audioRef.current.currentTime = 0
- }
- }
- }, [])
-
const handleVideoCanPlay = () => {
if (showVideo && videoRef.current) {
videoRef.current.play().catch((error) => {
@@ -178,15 +203,6 @@ const HuangyanpeiInternalization = () => {
}
}
- // 信件视频加载完成后自动播放
- const handleLetterVideoCanPlay = () => {
- if (showLetterVideo && letterVideoRef.current) {
- letterVideoRef.current.play().catch((error) => {
- console.warn('信件视频播放失败:', error)
- })
- }
- }
-
const anchors: VideoAnchor[] = useMemo(
() =>
QUIZ_ANCHORS.map((item) => {
@@ -204,6 +220,26 @@ const HuangyanpeiInternalization = () => {
setDragQuizValues((prev) => ({ ...prev, [item.id]: val }))
setDragQuizErrors((prev) => ({ ...prev, [item.id]: '' }))
+ // 第 2 题:必须按顺序填入“有业”“乐业”
+ if (item.id === 'quiz-2b') {
+ const orderedAnswers = ['有业', '乐业']
+ const isCorrect =
+ val.length === orderedAnswers.length &&
+ orderedAnswers.every((answer, index) => val[index]?.optionValue === answer)
+
+ if (isCorrect) {
+ setActiveSuccess(item.id)
+ setSuccessContent('')
+ setResumeCallback(() => resume)
+ } else {
+ setDragQuizErrors((prev) => ({
+ ...prev,
+ [item.id]: item.explanation || '请你再想想吧!',
+ }))
+ }
+ return
+ }
+
// 特斯拉标签无法拖入答案框
const hasInvalidOption = val.some((v) => v.optionValue === '特斯拉')
if (hasInvalidOption) {
@@ -223,7 +259,7 @@ const HuangyanpeiInternalization = () => {
if (isCorrect) {
setActiveSuccess(item.id)
- setSuccessContent('回答正确,请继续探索。')
+ setSuccessContent('')
setResumeCallback(() => resume)
} else {
setDragQuizErrors((prev) => ({
@@ -238,14 +274,16 @@ const HuangyanpeiInternalization = () => {
({
value: opt.value,
label: opt.label,
}))}
blanks={item.blanks || []}
+ blankPrefixes={item.blankPrefixes}
value={dragQuizValues[item.id] || []}
error={dragQuizErrors[item.id]}
onChange={handleDragChange}
@@ -267,10 +305,12 @@ const HuangyanpeiInternalization = () => {
delete next[item.id]
return next
})
+ if (item.id === 'quiz-3') {
+ triggerPuzzleAnimation(16)
+ }
const cb = resumeCallback
setResumeCallback(null)
cb?.()
- triggerPuzzleAnimation(16)
}}
/>
>
@@ -279,6 +319,53 @@ const HuangyanpeiInternalization = () => {
}
}
+ // 单选题处理
+ if (item.type === 'single') {
+ return {
+ id: item.id,
+ time: item.time,
+ once: true,
+ afterResume: 'remove',
+ render: ({ resume }) => (
+ <>
+ ({
+ value: opt.value,
+ label: `${opt.value}. ${opt.label}`,
+ }))}
+ correctAnswer={correctAnswers[0] || ''}
+ onWrongSelect={(value) => {
+ const selected = item.options.find((opt) => opt.value === value)
+ return selected?.explanation || item.explanation || '请再想想吧!'
+ }}
+ onSubmit={(_value, isCorrect) => {
+ if (isCorrect) {
+ setActiveSuccess(item.id)
+ setSuccessContent('')
+ setResumeCallback(() => resume)
+ }
+ }}
+ />
+ {
+ setActiveSuccess(null)
+ setSuccessContent('')
+ const cb = resumeCallback
+ setResumeCallback(null)
+ cb?.()
+ }}
+ />
+ >
+ ),
+ }
+ }
+
// 多选题处理
if (item.type === 'multiple') {
return {
@@ -291,17 +378,17 @@ const HuangyanpeiInternalization = () => {
({
value: opt.value,
label: `${opt.value}. ${opt.label}`,
}))}
correctAnswer={correctAnswers}
errorMessage={item.explanation || '再思考一下,注意选择所有正确答案。'}
- onSubmit={(values: string[], isCorrect: boolean) => {
+ onSubmit={(_values: string[], isCorrect: boolean) => {
if (isCorrect) {
setActiveSuccess(item.id)
- setSuccessContent('回答正确,请继续探索。')
+ setSuccessContent('')
setResumeCallback(() => resume)
}
}}
@@ -369,9 +456,6 @@ const HuangyanpeiInternalization = () => {
animationSpeed={200}
/>
- {/* 内化结束后的背景音乐 */}
-
-
{showVideo && (
{
onEnded={() => {
setShowVideo(false)
setPostGuideVisible(true)
- if (audioRef.current) {
- audioRef.current.currentTime = 0
- audioRef.current.play().catch((error) => {
- console.warn('背景音乐播放失败:', error)
- })
- }
}}
/>
)}
@@ -446,38 +524,22 @@ const HuangyanpeiInternalization = () => {
{/* 信件组件 */}
{
- console.log('提交信件', values)
+ onClose={() => {
setShowLetter(false)
- setShowLetterVideo(true)
}}
+ onAnimationEnd={() => {
+ setShowStudyGuide(true)
+ }}
+ showVideo={true}
/>
- {/* 信件关闭后的视频 */}
- {showLetterVideo && (
- {
- setShowLetterVideo(false)
- setShowStudyGuide(true)
- }}
- />
- )}
-
{
- if (audioRef.current) {
- audioRef.current.pause()
- audioRef.current.currentTime = 0
- }
setShowStudyGuide(false)
+ setCourseTimeProgress(12)
navigate(`/${moduleName}/${starName}/home`)
}}
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_31.mp3')}
diff --git a/src/pages/huangyanpei/place/place1.tsx b/src/pages/huangyanpei/place/place1.tsx
index 2dda6e7..0859558 100644
--- a/src/pages/huangyanpei/place/place1.tsx
+++ b/src/pages/huangyanpei/place/place1.tsx
@@ -11,7 +11,7 @@ import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useState } from 'react'
import Cursor from '@/components/cursor'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
-import { useHuangyanpeiStore } from '@/store/huangyanpeiStore'
+import { useHuangyanpeiStore, saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
// starName 固定为 huangyanpei
const starName = 'huangyanpei'
@@ -41,7 +41,7 @@ const HuangyanpeiPlace1 = () => {
// 使用持久化的访问状态
const place = 'place1'
- const { markExhibitVisited, getVisitedExhibits } = useHuangyanpeiStore()
+ const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useHuangyanpeiStore()
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
@@ -54,9 +54,7 @@ const HuangyanpeiPlace1 = () => {
const progressCount = visited.size
const total = 1
const completed = progressCount >= total
- const subtitle = completed
- ? '探索黄炎培故居门前(已完成)'
- : `探索黄炎培故居门前(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
@@ -101,6 +99,8 @@ const HuangyanpeiPlace1 = () => {
src={getStaticImage('components/place/继续前进')}
button
onClick={() => {
+ setLastVisitedPlace('place2')
+ saveHuangyanpeiStore()
navigate(`/${moduleName}/${starName}/place2`)
}}
/>
@@ -130,10 +130,14 @@ const HuangyanpeiPlace1 = () => {
setShowGuideModal(false)
setVisible(true)
}}
- audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_01.mp3')}
+ audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_02.mp3')}
+ ailgn="left"
>
- 同学你好,欢迎来到“走进故居·对话”学习模块!{'\n'}
- 此刻你正站在黄炎培先生故居门前,接下来就跟着时光的脉络,拆解黄先生人生里五段滚烫的旅程:思想启蒙、教育实践、创立职教、延安对话、职教传薪。准备好一起走进这段炽热的岁月了吗?
+ 1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
+ {'\n'}
+ 2.在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点开查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习。
+ {'\n'}
+ 3.所有动态光点的展品全部学习后才能进入下一个场景学习。
{/* 引导 */}
@@ -169,14 +173,10 @@ const HuangyanpeiPlace1 = () => {
setShowAfterDialogGuide(false)}
- ailgn="left"
- audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_02.mp3')}
+ audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_01.mp3')}
>
- 1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
- {'\n'}
- 2.在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习。
- {'\n'}
- 3.所有动态光点的展品全部学习后才能进入下一个场景学习。
+ 同学你好,欢迎来到“走进故居·对话”学习模块!{'\n'}
+ 此刻你正站在黄炎培先生故居门前,接下来就跟着时光的脉络,拆解黄先生人生里五段滚烫的旅程:思想启蒙、教育实践、创立职教、延安对话、职教传薪。准备好一起走进这段炽热的岁月了吗?
)
diff --git a/src/pages/huangyanpei/place/place2.tsx b/src/pages/huangyanpei/place/place2.tsx
index 6b67dc0..25da973 100644
--- a/src/pages/huangyanpei/place/place2.tsx
+++ b/src/pages/huangyanpei/place/place2.tsx
@@ -4,7 +4,7 @@ import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import { useCursorController } from '@/hooks/useCursorController'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
-import { useHuangyanpeiStore } from '@/store/huangyanpeiStore'
+import { useHuangyanpeiStore, saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
const starName = 'huangyanpei'
@@ -25,7 +25,7 @@ const HuangyanpeiPlace2 = () => {
// 使用持久化的访问状态
const place = 'place2'
- const { markExhibitVisited, getVisitedExhibits } = useHuangyanpeiStore()
+ const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useHuangyanpeiStore()
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
@@ -62,9 +62,7 @@ const HuangyanpeiPlace2 = () => {
const progressCount = visited.size
const total = 2
const completed = progressCount >= total
- const subtitle = completed
- ? '探索黄炎培南洋公学(已完成)'
- : `探索黄炎培南洋公学(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
@@ -113,6 +111,8 @@ const HuangyanpeiPlace2 = () => {
src={getStaticImage('components/place/继续前进')}
button
onClick={() => {
+ setLastVisitedPlace('place3')
+ saveHuangyanpeiStore()
navigate(`/${moduleName}/${starName}/place3`)
}}
/>
diff --git a/src/pages/huangyanpei/place/place3.tsx b/src/pages/huangyanpei/place/place3.tsx
index 9c22c15..46b647f 100644
--- a/src/pages/huangyanpei/place/place3.tsx
+++ b/src/pages/huangyanpei/place/place3.tsx
@@ -14,7 +14,7 @@ import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import { useCursorController } from '@/hooks/useCursorController'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
-import { useHuangyanpeiStore } from '@/store/huangyanpeiStore'
+import { useHuangyanpeiStore, saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
const starName = 'huangyanpei'
const place = 'place3'
@@ -60,7 +60,7 @@ const HuangyanpeiPlace3 = () => {
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(null)
// 使用持久化的访问状态
- const { markExhibitVisited, getVisitedExhibits } = useHuangyanpeiStore()
+ const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useHuangyanpeiStore()
// 从 store 获取已访问的展品
const visitedExhibits = getVisitedExhibits(place)
@@ -239,9 +239,7 @@ const HuangyanpeiPlace3 = () => {
const progressCount = visited.size
const total = 3
const completed = progressCount >= total
- const subtitle = completed
- ? '探索黄炎培川沙小学堂(已完成)'
- : `探索黄炎培川沙小学堂(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
@@ -299,6 +297,8 @@ const HuangyanpeiPlace3 = () => {
src={getStaticImage('components/place/继续前进')}
button
onClick={() => {
+ setLastVisitedPlace('place4')
+ saveHuangyanpeiStore()
navigate(`/${moduleName}/${starName}/place4`)
}}
/>
diff --git a/src/pages/huangyanpei/place/place4.tsx b/src/pages/huangyanpei/place/place4.tsx
index 5f28193..d2c30b8 100644
--- a/src/pages/huangyanpei/place/place4.tsx
+++ b/src/pages/huangyanpei/place/place4.tsx
@@ -15,7 +15,7 @@ import { useNavigate, useParams } from 'react-router-dom'
import type { VideoRef } from '@/components/video'
import { useCursorController } from '@/hooks/useCursorController'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
-import { useHuangyanpeiStore } from '@/store/huangyanpeiStore'
+import { useHuangyanpeiStore, saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
const starName = 'huangyanpei'
@@ -38,7 +38,7 @@ const HuangyanpeiPlace4 = () => {
// 使用持久化的访问状态
const place = 'place4'
- const { markExhibitVisited, getVisitedExhibits } = useHuangyanpeiStore()
+ const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useHuangyanpeiStore()
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
@@ -106,9 +106,7 @@ const HuangyanpeiPlace4 = () => {
const progressCount = visited.size
const total = 4
const completed = progressCount >= total
- const subtitle = completed
- ? '探索黄炎培创立职教(已完成)'
- : `探索黄炎培创立职教(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
@@ -180,6 +178,8 @@ const HuangyanpeiPlace4 = () => {
src={getStaticImage('components/place/继续前进')}
button
onClick={() => {
+ setLastVisitedPlace('place5')
+ saveHuangyanpeiStore()
navigate(`/${moduleName}/${starName}/place5`)
}}
/>
@@ -313,7 +313,7 @@ const HuangyanpeiPlace4 = () => {
{
// 使用持久化的访问状态
const place = 'place5'
- const { markExhibitVisited, getVisitedExhibits } = useHuangyanpeiStore()
+ const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useHuangyanpeiStore()
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
@@ -81,9 +81,7 @@ const HuangyanpeiPlace5 = () => {
const progressCount = visited.size
const total = 2
const completed = progressCount >= total
- const subtitle = completed
- ? '探索黄炎培延安对话(已完成)'
- : `探索黄炎培延安对话(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
@@ -140,6 +138,8 @@ const HuangyanpeiPlace5 = () => {
src={getStaticImage('components/place/继续前进')}
button
onClick={() => {
+ setLastVisitedPlace('place6')
+ saveHuangyanpeiStore()
navigate(`/${moduleName}/${starName}/place6`)
}}
/>
@@ -147,7 +147,7 @@ const HuangyanpeiPlace5 = () => {
{/* 对话后显示展品2视频播放(全屏) */}
{videoVisible && (
-
+
{
const progressCount = visited.size
const total = 1
const completed = progressCount >= total
- const subtitle = completed
- ? '探索黄炎培职教传薪(已完成)'
- : `探索黄炎培职教传薪(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
@@ -162,6 +161,8 @@ const HuangyanpeiPlace6 = () => {
// 标记为已显示过,防止再次弹出
setHasShownEnding(true)
setLeave(true)
+ setCourseTimeProgress(10)
+ saveHuangyanpeiStore()
}}
animationSpeed={200}
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_22.mp3')}
diff --git a/src/pages/huangyanpei/reflection/scene1.tsx b/src/pages/huangyanpei/reflection/scene1.tsx
index 476bac0..b79f1c2 100644
--- a/src/pages/huangyanpei/reflection/scene1.tsx
+++ b/src/pages/huangyanpei/reflection/scene1.tsx
@@ -4,7 +4,7 @@ import { BambooPuzzleGame, QuestionModal } from './components'
import { SuccessAlert } from '@/components/layout'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
-import { useHuangyanpeiStore } from '@/store/huangyanpeiStore'
+import { useHuangyanpeiStore, saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
const starName = 'huangyanpei'
@@ -19,11 +19,16 @@ const HuangyanpeiReflectionScene1 = () => {
const [showGame, setShowGame] = useState(false)
const [showSuccessAlert, setShowSuccessAlert] = useState(false)
const [showNextGuide, setShowNextGuide] = useState(false)
- const [nextGuideShown, setNextGuideShown] = useState(false)
// 使用持久化的reflection cursor状态
const scene = 'scene1'
- const { markReflectionCursorVisited, getVisitedReflectionCursors } = useHuangyanpeiStore()
+ const {
+ markReflectionCursorVisited,
+ getVisitedReflectionCursors,
+ setReflectionRoute,
+ hasShownReflectionCompletionGuide,
+ markReflectionCompletionGuideShown,
+ } = useHuangyanpeiStore()
// 从 store 获取已完成的cursor,转换为 Set 用于兼容现有代码
const visitedCursors = getVisitedReflectionCursors(scene)
@@ -32,23 +37,22 @@ const HuangyanpeiReflectionScene1 = () => {
const progressCount = visited.size
const total = 2
const completed = progressCount >= total
- const subtitle = completed
- ? '探索川沙小学堂·启蒙之始(已完成)'
- : `探索川沙小学堂·启蒙之始(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
// 两个任务都完成后 500ms 弹出下一站提示(仅一次)
useEffect(() => {
- if (visited.has(1) && visited.has(2) && !nextGuideShown) {
+ if (visited.has(1) && visited.has(2) && !hasShownReflectionCompletionGuide(scene)) {
const timer = setTimeout(() => {
setShowNextGuide(true)
- setNextGuideShown(true)
+ markReflectionCompletionGuideShown(scene)
+ saveHuangyanpeiStore()
}, 500)
return () => clearTimeout(timer)
}
- }, [visited, nextGuideShown])
+ }, [visited, scene, hasShownReflectionCompletionGuide, markReflectionCompletionGuideShown])
return (
{
visible={showNextGuide}
onContinue={() => {
setShowNextGuide(false)
+ setReflectionRoute('reflection2')
+ saveHuangyanpeiStore()
navigate(`/${moduleName}/${starName}/reflection2`)
}}
verticalAlign="center"
diff --git a/src/pages/huangyanpei/reflection/scene2.tsx b/src/pages/huangyanpei/reflection/scene2.tsx
index 65d8ef2..559b689 100644
--- a/src/pages/huangyanpei/reflection/scene2.tsx
+++ b/src/pages/huangyanpei/reflection/scene2.tsx
@@ -1,11 +1,16 @@
import { useState, useEffect } from 'react'
-import { Title, Image, Cursor, StudyGuideModal, SingleChoiceQuizPanel } from '@/components'
+import { Title, Image, Cursor, StudyGuideModal } from '@/components'
import { SuccessAlert } from '@/components/layout'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import ChoiceDialog1 from './scene2/ChoiceDialog1'
import ChoiceDialog2 from './scene2/ChoiceDialog2'
-import { useHuangyanpeiStore } from '@/store/huangyanpeiStore'
+import QuizDialog from './scene2/QuizDialog'
+import {
+ useHuangyanpeiStore,
+ saveHuangyanpeiStore,
+ loadHuangyanpeiStore,
+} from '@/store/huangyanpeiStore'
const starName = 'huangyanpei'
@@ -20,7 +25,6 @@ const HuangyanpeiReflectionScene2 = () => {
const [showDialog2, setShowDialog2] = useState(false)
const [choice2, setChoice2] = useState<'铁工科' | '商科' | null>(null)
const [showCompleteGuide, setShowCompleteGuide] = useState(false)
- const [completeGuideShown, setCompleteGuideShown] = useState(false)
// 题目显示状态(ChoiceDialog1关闭后)
const [showQuiz, setShowQuiz] = useState(false)
// 题目成功提示状态
@@ -28,7 +32,15 @@ const HuangyanpeiReflectionScene2 = () => {
// 使用持久化的reflection cursor状态
const scene = 'scene2'
- const { markReflectionCursorVisited, getVisitedReflectionCursors } = useHuangyanpeiStore()
+ const {
+ markReflectionCursorVisited,
+ getVisitedReflectionCursors,
+ setReflectionRoute,
+ reflectionScene2Choice,
+ setReflectionScene2Choice,
+ hasShownReflectionCompletionGuide,
+ markReflectionCompletionGuideShown,
+ } = useHuangyanpeiStore()
// 从 store 获取已完成的cursor,转换为 Set 用于兼容现有代码
const visitedCursors = getVisitedReflectionCursors(scene)
@@ -37,31 +49,69 @@ const HuangyanpeiReflectionScene2 = () => {
const progressCount = visited.size
const total = 2
const completed = progressCount >= total
- const subtitle = completed
- ? '探索浦东中学·成长之阶(已完成)'
- : `探索浦东中学·成长之阶(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
+ // 进入页面时读取缓存中的 choice2
+ useEffect(() => {
+ let mounted = true
+
+ const syncChoice2FromCache = async () => {
+ await loadHuangyanpeiStore()
+ if (!mounted) {
+ return
+ }
+ const latestChoice2 = useHuangyanpeiStore.getState().reflectionScene2Choice
+ if (latestChoice2) {
+ setChoice2(latestChoice2)
+ }
+ }
+
+ void syncChoice2FromCache()
+
+ return () => {
+ mounted = false
+ }
+ }, [])
+
+ // 本地状态与 store 中的 choice2 保持同步
+ useEffect(() => {
+ if (reflectionScene2Choice && reflectionScene2Choice !== choice2) {
+ setChoice2(reflectionScene2Choice)
+ }
+ }, [reflectionScene2Choice, choice2])
+
// 两个任务都完成后,显示完成提示(仅一次)
useEffect(() => {
- if (completed && choice2 && !completeGuideShown) {
+ if (completed && choice2 && !hasShownReflectionCompletionGuide(scene)) {
const timer = setTimeout(() => {
setShowCompleteGuide(true)
- setCompleteGuideShown(true)
+ markReflectionCompletionGuideShown(scene)
+ saveHuangyanpeiStore()
}, 500)
return () => clearTimeout(timer)
}
- }, [completed, choice2, completeGuideShown])
+ }, [
+ completed,
+ choice2,
+ scene,
+ hasShownReflectionCompletionGuide,
+ markReflectionCompletionGuideShown,
+ ])
// 处理完成提示的继续按钮点击
const handleCompleteGuideContinue = () => {
setShowCompleteGuide(false)
// 根据选择决定导航目标
if (choice2 === '铁工科') {
+ setReflectionRoute('reflection3')
+ saveHuangyanpeiStore()
navigate(`/${moduleName}/${starName}/reflection3`)
} else if (choice2 === '商科') {
+ setReflectionRoute('reflection4')
+ saveHuangyanpeiStore()
navigate(`/${moduleName}/${starName}/reflection4`)
}
}
@@ -89,10 +139,19 @@ const HuangyanpeiReflectionScene2 = () => {
src={getStaticImage('components/离开')}
button
onClick={() => {
- navigate(`/${moduleName}/${starName}/reflection`)
+ navigate(`/${moduleName}/${starName}/home`)
}}
/>
+ {completed && (
+
handleCompleteGuideContinue()}
+ />
+ )}
+
setShowGuideModal(false)}
@@ -133,7 +192,6 @@ const HuangyanpeiReflectionScene2 = () => {
visible={showDialog1}
onClose={() => {
setShowDialog1(false)
- // 关闭后显示题目
setShowQuiz(true)
}}
onSelect={() => {
@@ -147,31 +205,19 @@ const HuangyanpeiReflectionScene2 = () => {
onClose={() => setShowDialog2(false)}
onSelect={(choice) => {
setChoice2(choice)
+ setReflectionScene2Choice(choice)
markReflectionCursorVisited(scene, 2)
+ saveHuangyanpeiStore()
}}
/>
- {/* 题目(ChoiceDialog1关闭后) */}
- {
- return '是这个数目吗?你要不要再算一下!'
+ onClose={() => setShowQuiz(false)}
+ onComplete={() => {
+ setShowQuiz(false)
+ setShowQuizSuccess(true)
}}
- onSubmit={(value, isCorrect) => {
- if (isCorrect) {
- setShowQuiz(false)
- setShowQuizSuccess(true)
- }
- }}
- zIndex={10001}
/>
{/* 题目成功提示 */}
diff --git a/src/pages/huangyanpei/reflection/scene2/QuizDialog.tsx b/src/pages/huangyanpei/reflection/scene2/QuizDialog.tsx
new file mode 100644
index 0000000..6b0b3c3
--- /dev/null
+++ b/src/pages/huangyanpei/reflection/scene2/QuizDialog.tsx
@@ -0,0 +1,165 @@
+import { useState, useEffect, useMemo, useCallback } from 'react'
+import { AnimatePresence, motion } from 'framer-motion'
+import { cn, getStaticImage } from '@/lib/utils'
+import { Image } from '@/components'
+import { SuccessAlert } from '@/components/layout'
+
+export interface Scene2QuizDialogProps {
+ visible?: boolean
+ onClose?: () => void
+ onComplete?: () => void
+}
+
+const Scene2QuizDialog = ({ visible = true, onClose, onComplete }: Scene2QuizDialogProps) => {
+ const maskSrc = getStaticImage('components/蒙版遮罩')
+ const backSrc = getStaticImage('components/关闭')
+
+ const [mounted, setMounted] = useState(false)
+ const [errorVisible, setErrorVisible] = useState(false)
+
+ const options = useMemo(
+ () => [
+ { value: 'A', label: 'A. 800 两白银' },
+ { value: 'B', label: 'B. 815 两白银' },
+ { value: 'C', label: 'C. 820 两白银' },
+ { value: 'D', label: 'D. 836 两白银' },
+ ],
+ []
+ )
+
+ const overlayStyle = useMemo(
+ () => ({
+ position: 'fixed',
+ inset: 0,
+ zIndex: 10001,
+ pointerEvents: 'none',
+ }),
+ []
+ )
+
+ useEffect(() => {
+ setMounted(true)
+ }, [])
+
+ useEffect(() => {
+ if (!visible) {
+ setErrorVisible(false)
+ }
+ }, [visible])
+
+ const handleClose = useCallback(
+ (e: React.MouseEvent) => {
+ e.stopPropagation()
+ setErrorVisible(false)
+ onClose?.()
+ },
+ [onClose]
+ )
+
+ const handleOptionClick = useCallback(
+ (value: string) => {
+ if (value === 'C') {
+ setErrorVisible(false)
+ onComplete?.()
+ return
+ }
+
+ setErrorVisible(true)
+ },
+ [onComplete]
+ )
+
+ const handleErrorContinue = useCallback(() => {
+ setErrorVisible(false)
+ }, [])
+
+ if (!mounted) return null
+
+ return (
+
+ {visible && (
+
+
+ {maskSrc && (
+
+ )}
+
+ {onClose && (
+
+ )}
+
+
+
+
+
+
【单选】
+
+ 杨斯盛先生近期监工浦东中学建设,有一份上月的工人工钱支出表需要核对,你来告诉我答案吧?
+
+
+
+
+
+
+
+
+
+
答题区
+
+
+ {options.map((option) => (
+
handleOptionClick(option.value)}
+ >
+ {option.label}
+
+ ))}
+
+
+
+
+
+
+
+
+ )}
+
+ )
+}
+
+export default Scene2QuizDialog
diff --git a/src/pages/huangyanpei/reflection/scene3.tsx b/src/pages/huangyanpei/reflection/scene3.tsx
index 14a706b..9d3fc24 100644
--- a/src/pages/huangyanpei/reflection/scene3.tsx
+++ b/src/pages/huangyanpei/reflection/scene3.tsx
@@ -8,6 +8,7 @@ import ViseAssemblyDialog from './scene3/ViseAssemblyDialog'
import type { VideoRef } from '@/components/video'
import { QuizDialog } from './components'
import { useHuangyanpeiStore } from '@/store/huangyanpeiStore'
+import { setCourseTimeProgress } from '@/lib/utils/api-common'
const starName = 'huangyanpei'
@@ -45,9 +46,7 @@ const HuangyanpeiReflectionScene3 = () => {
const progressCount = visited.size
const total = 3
const completed = progressCount >= total
- const subtitle = completed
- ? '探索中华职业学校·立业之本·铁工科(已完成)'
- : `探索中华职业学校·立业之本·铁工科(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
@@ -222,6 +221,7 @@ const HuangyanpeiReflectionScene3 = () => {
showSkipButton
onEnded={() => {
setShowVideo(false)
+ setCourseTimeProgress(11)
navigate(`/${moduleName}/${starName}/home`)
}}
/>
diff --git a/src/pages/huangyanpei/reflection/scene4.tsx b/src/pages/huangyanpei/reflection/scene4.tsx
index 16216d9..82b6c58 100644
--- a/src/pages/huangyanpei/reflection/scene4.tsx
+++ b/src/pages/huangyanpei/reflection/scene4.tsx
@@ -9,6 +9,7 @@ import type { VideoRef } from '@/components/video'
import { QuizDialog } from './components'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { useHuangyanpeiStore } from '@/store/huangyanpeiStore'
+import { setCourseTimeProgress } from '@/lib/utils/api-common'
const starName = 'huangyanpei'
@@ -60,9 +61,7 @@ const HuangyanpeiReflectionScene4 = () => {
const progressCount = visited.size
const total = 3
const completed = progressCount >= total
- const subtitle = completed
- ? '探索中华职业学院·立业之本·商科(已完成)'
- : `探索中华职业学院·立业之本·商科(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
@@ -211,6 +210,7 @@ const HuangyanpeiReflectionScene4 = () => {
playsInline
onEnded={() => {
setShowVideo(false)
+ setCourseTimeProgress(11)
navigate(`/${moduleName}/${starName}/home`)
}}
/>
diff --git a/src/pages/luxun/home.tsx b/src/pages/luxun/home.tsx
index 7b90c57..2add52f 100644
--- a/src/pages/luxun/home.tsx
+++ b/src/pages/luxun/home.tsx
@@ -1,6 +1,20 @@
+import { useEffect } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components'
+import { getUserDefine } from '@/lib/utils/api-common'
+import { useLuxunStore, LUXUN_LESSON_ID, LUXUN_KEY } from '@/store/luxunStore'
+
+const deserializeVisitedExhibits = (
+ data: Record | undefined
+): Record> => {
+ if (!data || typeof data !== 'object') return {}
+ const visitedExhibits: Record> = {}
+ for (const [place, exhibits] of Object.entries(data)) {
+ visitedExhibits[place] = new Set(Array.isArray(exhibits) ? exhibits : [])
+ }
+ return visitedExhibits
+}
const Luxun = () => {
const navigate = useNavigate()
@@ -11,6 +25,38 @@ const Luxun = () => {
// starName 固定为 luxun
const starName = 'luxun'
+ const lastVisitedPlace = useLuxunStore((state) => state.lastVisitedPlace)
+ const reflectionRoute = useLuxunStore((state) => state.reflectionRoute)
+
+ useEffect(() => {
+ ;(async () => {
+ try {
+ const raw = await getUserDefine(LUXUN_LESSON_ID, LUXUN_KEY)
+ if (!raw) return
+
+ const data = JSON.parse(raw) as {
+ visitedExhibits?: Record
+ lastVisitedPlace?: string | null
+ reflectionRoute?: string
+ }
+
+ useLuxunStore.setState((prev) => ({
+ ...prev,
+ visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
+ lastVisitedPlace:
+ data.lastVisitedPlace !== undefined ? data.lastVisitedPlace : prev.lastVisitedPlace,
+ reflectionRoute:
+ data.reflectionRoute &&
+ ['reflection', 'reflection2', 'reflection3'].includes(data.reflectionRoute)
+ ? data.reflectionRoute
+ : (prev.reflectionRoute ?? 'reflection'),
+ }))
+ } catch (error) {
+ console.error('Failed to load Luxun store', error)
+ }
+ })()
+ }, [])
+
if (!moduleName) {
return null
}
@@ -34,13 +80,13 @@ const Luxun = () => {
navigate(`/${moduleName}/${starName}/place1`)}
+ onClick={() => navigate(`/${moduleName}/${starName}/${lastVisitedPlace || 'place1'}`)}
/>
{/* 体悟 */}
navigate(`/${moduleName}/${starName}/reflection`)}
+ onClick={() => navigate(`/${moduleName}/${starName}/${reflectionRoute || 'reflection'}`)}
/>
{/* 内化 */}
optionValue
@@ -78,7 +81,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
id: 'quiz-2',
time: 60 * 5 + 2, // 5:02
title: '',
- prompt: '【多选】对于鼓励和培养青年一代,鲁迅先生的做法是( )。',
+ prompt: '【多选】对于鼓励和培养青年一代,鲁迅先生的做法是:',
type: 'multiple',
options: [
{ value: 'A', label: '组织文学社团', isCorrect: true },
@@ -97,7 +100,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
id: 'quiz-3',
time: 60 * 6 + 39, // 6:39
title: '',
- prompt: '【单选】以下那句诗出自鲁迅的作品( )。',
+ prompt: '【单选】以下那句诗出自鲁迅的作品:',
type: 'single',
options: [
{
@@ -123,6 +126,56 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
},
],
},
+ {
+ id: 'quiz-4',
+ time: 60 * 7 + 59, // 7:59
+ title: '',
+ prompt: '【拖拽】鲁迅先生一生为国为民,写下了大量抨击黑暗社会的檄文',
+ promptSuffix:
+ ',他以无所畏惧的姿态和极大的热情,参加反对封建专制、反对帝国主义侵略和国民党反动统治的实际斗争。',
+ type: 'drag',
+ blanks: [
+ { id: 'blank-1', label: '___' },
+ { id: 'blank-2', label: '___' },
+ ],
+ blankPrefixes: [',如', '如'],
+ blankSeparators: [''],
+ options: [
+ { value: '七首', label: '七首' },
+ { value: '投枪', label: '投枪' },
+ { value: '清泉', label: '清泉' },
+ ],
+ correctAnswersMap: {
+ 'blank-1': '七首',
+ 'blank-2': '投枪',
+ },
+ explanation: '“清泉”不是鲁迅文章的意象。请你再想想吧!',
+ },
+ {
+ id: 'quiz-5',
+ time: 60 * 9 + 53, // 9:53
+ title: '',
+ prompt: '【多选】我们能从鲁迅身上学习到:',
+ type: 'multiple',
+ options: [
+ {
+ value: 'A',
+ label: '至诚爱国,为民族独立与人民幸福奋斗不息的精神',
+ isCorrect: true,
+ },
+ {
+ value: 'B',
+ label: '追求光明、追求真理的人生追求',
+ isCorrect: true,
+ },
+ {
+ value: 'C',
+ label: '博大的文化胸襟与崇高的人格力量',
+ isCorrect: true,
+ },
+ ],
+ explanation: '请再仔细分析一下题目,做出更全面的选择吧!',
+ },
]
const LuxunInternalization = () => {
@@ -137,13 +190,9 @@ const LuxunInternalization = () => {
const [showNarration, setShowNarration] = useState(false) // 是否显示旁白
const [visible, setVisible] = useState(false)
const [showVideo, setShowVideo] = useState(false)
- const [postGuideVisible, setPostGuideVisible] = useState(false)
const [showLetter, setShowLetter] = useState(false)
- const [showLetterVideo, setShowLetterVideo] = useState(false)
const [showStudyGuide, setShowStudyGuide] = useState(false)
const videoRef = useRef(null)
- const letterVideoRef = useRef(null)
- const audioRef = useRef(null)
//碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
// 旁白关闭后显示对话引导
@@ -153,16 +202,6 @@ const LuxunInternalization = () => {
}
}, [showNarration])
- // 组件卸载时清理背景音乐
- useEffect(() => {
- return () => {
- if (audioRef.current) {
- audioRef.current.pause()
- audioRef.current.currentTime = 0
- }
- }
- }, [])
-
const handleVideoCanPlay = () => {
if (showVideo && videoRef.current) {
videoRef.current.play().catch((error) => {
@@ -171,15 +210,6 @@ const LuxunInternalization = () => {
}
}
- // 信件视频加载完成后自动播放
- const handleLetterVideoCanPlay = () => {
- if (showLetterVideo && letterVideoRef.current) {
- letterVideoRef.current.play().catch((error) => {
- console.warn('信件视频播放失败:', error)
- })
- }
- }
-
const anchors: VideoAnchor[] = useMemo(
() =>
QUIZ_ANCHORS.map((item) => {
@@ -252,6 +282,7 @@ const LuxunInternalization = () => {
label: opt.label,
}))}
blanks={item.blanks || []}
+ blankPrefixes={item.blankPrefixes}
blankSeparators={item.blankSeparators}
value={dragQuizValues[item.id] || []}
error={dragQuizErrors[item.id]}
@@ -312,15 +343,15 @@ const LuxunInternalization = () => {
}
return undefined
}}
- onSubmit={(value: string, isCorrect: boolean) => {
+ onSubmit={(_value: string, isCorrect: boolean) => {
if (isCorrect) {
setActiveSuccess(item.id)
// 为题目3提供正确答案的详细解释
if (item.id === 'quiz-3') {
const correctOption = item.options.find((opt) => opt.isCorrect)
- setSuccessContent(correctOption?.explanation || '回答正确,请继续探索。')
+ setSuccessContent(correctOption?.explanation || '')
} else {
- setSuccessContent('回答正确,请继续探索。')
+ setSuccessContent('')
}
setResumeCallback(() => resume)
}
@@ -362,10 +393,10 @@ const LuxunInternalization = () => {
}))}
correctAnswer={correctAnswers}
errorMessage={item.explanation || '再思考一下,注意选择所有正确答案。'}
- onSubmit={(values: string[], isCorrect: boolean) => {
+ onSubmit={(_values: string[], isCorrect: boolean) => {
if (isCorrect) {
setActiveSuccess(item.id)
- setSuccessContent('回答正确,请继续探索。')
+ setSuccessContent('')
setResumeCallback(() => resume)
}
}}
@@ -396,7 +427,14 @@ const LuxunInternalization = () => {
render: () => null,
}
}),
- [activeSuccess, resumeCallback, successContent, dragQuizValues, dragQuizErrors]
+ [
+ activeSuccess,
+ resumeCallback,
+ successContent,
+ dragQuizValues,
+ dragQuizErrors,
+ triggerPuzzleAnimation,
+ ]
)
if (!moduleName) return null
@@ -416,9 +454,6 @@ const LuxunInternalization = () => {
onClick={() => navigate(`/${moduleName}/${starName}/home`)}
/>
- {/* 内化结束后的背景音乐 */}
-
-
{showVideo && (
{
setShowVideo(false)
setShowLetter(true)
// setPostGuideVisible(true);
- if (audioRef.current) {
- audioRef.current.currentTime = 0
- audioRef.current.play().catch((error) => {
- console.warn('背景音乐播放失败:', error)
- })
- }
}}
/>
)}
@@ -503,38 +532,22 @@ const LuxunInternalization = () => {
{/* 信件组件 */}
{
- console.log('提交信件', values)
+ onClose={() => {
setShowLetter(false)
- setShowLetterVideo(true)
}}
+ onAnimationEnd={() => {
+ setShowStudyGuide(true)
+ }}
+ showVideo={true}
/>
- {/* 信件关闭后的视频 */}
- {showLetterVideo && (
- {
- setShowLetterVideo(false)
- setShowStudyGuide(true)
- }}
- />
- )}
-
{
- if (audioRef.current) {
- audioRef.current.pause()
- audioRef.current.currentTime = 0
- }
setShowStudyGuide(false)
+ setCourseTimeProgress(9)
navigate(`/${moduleName}/${starName}/home`)
}}
audioSrc={getPublicPath('audio/luxun/mp3_luxun_38.mp3')}
diff --git a/src/pages/luxun/place/place1.tsx b/src/pages/luxun/place/place1.tsx
index 515b111..f0680d1 100644
--- a/src/pages/luxun/place/place1.tsx
+++ b/src/pages/luxun/place/place1.tsx
@@ -14,7 +14,7 @@ import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useState } from 'react'
import { useCursorController } from '@/hooks/useCursorController'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
-import { useLuxunStore } from '@/store/luxunStore'
+import { useLuxunStore, saveLuxunStore } from '@/store/luxunStore'
// starName 固定为 luxun
const starName = 'luxun'
@@ -50,7 +50,7 @@ const LuxunPlace1 = () => {
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null)
// 使用持久化的访问状态
const place = 'place1'
- const { markExhibitVisited, getVisitedExhibits } = useLuxunStore()
+ const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useLuxunStore()
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
@@ -93,9 +93,7 @@ const LuxunPlace1 = () => {
// setAfterExhibitDialogShown(true);
// }
// }, [completed, afterExhibitDialogShown]);
- const subtitle = completed
- ? '探索鲁迅故居一楼客厅(已完成)'
- : `探索鲁迅故居一楼客厅(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
@@ -148,6 +146,8 @@ const LuxunPlace1 = () => {
src={getStaticImage('components/place/继续前进')}
button
onClick={() => {
+ setLastVisitedPlace('place2')
+ saveLuxunStore()
navigate(`/${moduleName}/${starName}/place2`)
}}
/>
@@ -194,10 +194,14 @@ const LuxunPlace1 = () => {
setShowAfterDialogGuide(true)
}
}}
- audioSrc={getPublicPath('audio/luxun/mp3_luxun_01.mp3')}
+ audioSrc={getPublicPath('audio/luxun/mp3_luxun_02.mp3')}
+ ailgn="left"
>
- {`同学你好,欢迎来到“走进故居·对话”学习模块!
-此刻你正立在鲁迅故居的一楼客厅里,接下来请随旧时光的纹路,一同翻开先生思想长卷里的5段深刻轨迹:风雨前夕的思想淬炼、杂文集里的匕首与投枪、历史新编中的现实呐喊、为亡友编纂的精神丰碑、6平方米的红色庇护所。准备好共赴这场笔尖与灵魂的滚烫相遇了吗?`}
+ 1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
+ {'\n'}
+ 2.在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点开查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习。
+ {'\n'}
+ 3.所有动态光点的展品全部学习后才能进入下一个场景学习。
{/* 引导 */}
@@ -229,14 +233,10 @@ const LuxunPlace1 = () => {
setShowAfterDialogGuide(false)}
- ailgn="left"
- audioSrc={getPublicPath('audio/luxun/mp3_luxun_02.mp3')}
+ audioSrc={getPublicPath('audio/luxun/mp3_luxun_01.mp3')}
>
- 1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
- {'\n'}
- 2.在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习。
- {'\n'}
- 3.所有动态光点的展品全部学习后才能进入下一个场景学习。
+ {`同学你好,欢迎来到“走进故居·对话”学习模块!
+此刻你正立在鲁迅故居的一楼客厅里,接下来请随旧时光的纹路,一同翻开先生思想长卷里的5段深刻轨迹:风雨前夕的思想淬炼、杂文集里的匕首与投枪、历史新编中的现实呐喊、为亡友编纂的精神丰碑、6平方米的红色庇护所。准备好共赴这场笔尖与灵魂的滚烫相遇了吗?`}
{/* 浏览完展品后的对话 */}
diff --git a/src/pages/luxun/place/place2.tsx b/src/pages/luxun/place/place2.tsx
index 9166cfd..0cb06ad 100644
--- a/src/pages/luxun/place/place2.tsx
+++ b/src/pages/luxun/place/place2.tsx
@@ -8,7 +8,7 @@ import { useState } from 'react'
import { useCursorController } from '@/hooks/useCursorController'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import type { DragFillResult } from '@/components'
-import { useLuxunStore } from '@/store/luxunStore'
+import { useLuxunStore, saveLuxunStore } from '@/store/luxunStore'
// starName 固定为 luxun
const starName = 'luxun'
@@ -33,7 +33,7 @@ const LuxunPlace2 = () => {
// 使用持久化的访问状态
const place = 'place2'
- const { markExhibitVisited, getVisitedExhibits } = useLuxunStore()
+ const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useLuxunStore()
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
@@ -128,9 +128,7 @@ const LuxunPlace2 = () => {
const progressCount = visited.size
const total = 3
const completed = progressCount >= total
- const subtitle = completed
- ? '探索卧室兼书房(已完成)'
- : `探索卧室兼书房(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
@@ -188,6 +186,8 @@ const LuxunPlace2 = () => {
src={getStaticImage('luxun/place2/前进')}
button
onClick={() => {
+ setLastVisitedPlace('place3')
+ saveLuxunStore()
navigate(`/${moduleName}/${starName}/place3`)
}}
/>
diff --git a/src/pages/luxun/place/place3.tsx b/src/pages/luxun/place/place3.tsx
index 4385480..fa4bcd2 100644
--- a/src/pages/luxun/place/place3.tsx
+++ b/src/pages/luxun/place/place3.tsx
@@ -13,7 +13,7 @@ import { useState } from 'react'
import SuccessAlert from '@/components/layout/alert/success-alert'
import { useCursorController } from '@/hooks/useCursorController'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
-import { useLuxunStore } from '@/store/luxunStore'
+import { useLuxunStore, saveLuxunStore } from '@/store/luxunStore'
// starName 固定为 luxun
const starName = 'luxun'
@@ -57,7 +57,7 @@ const LuxunPlace3 = () => {
// 使用持久化的访问状态
const place = 'place3'
- const { markExhibitVisited, getVisitedExhibits } = useLuxunStore()
+ const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useLuxunStore()
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
@@ -133,9 +133,7 @@ const LuxunPlace3 = () => {
const progressCount = visited.size
const total = 5
const completed = progressCount >= total
- const subtitle = completed
- ? '探索二楼卧室兼书房的书桌(已完成)'
- : `探索二楼卧室兼书房的书桌(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
@@ -212,6 +210,8 @@ const LuxunPlace3 = () => {
src={getStaticImage('luxun/place3/前进按钮')}
button
onClick={() => {
+ setLastVisitedPlace('place4')
+ saveLuxunStore()
navigate(`/${moduleName}/${starName}/place4`)
}}
/>
diff --git a/src/pages/luxun/place/place4.tsx b/src/pages/luxun/place/place4.tsx
index 13426b6..28e1e9d 100644
--- a/src/pages/luxun/place/place4.tsx
+++ b/src/pages/luxun/place/place4.tsx
@@ -16,7 +16,8 @@ import { useState, useRef, useMemo } from 'react'
import SuccessAlert from '@/components/layout/alert/success-alert'
import { useCursorController } from '@/hooks/useCursorController'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
-import { useLuxunStore } from '@/store/luxunStore'
+import { useLuxunStore, saveLuxunStore } from '@/store/luxunStore'
+import { setCourseTimeProgress } from '@/lib/utils/api-common'
// starName 固定为 luxun
const starName = 'luxun'
@@ -56,7 +57,7 @@ const LuxunPlace5 = () => {
// 使用持久化的访问状态
const place = 'place4'
- const { markExhibitVisited, getVisitedExhibits } = useLuxunStore()
+ const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useLuxunStore()
// 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码
const visitedExhibits = getVisitedExhibits(place)
@@ -162,9 +163,7 @@ const LuxunPlace5 = () => {
const progressCount = visited.size
const total = 3
const completed = progressCount >= total
- const subtitle = completed
- ? '探索鲁迅故居的客房(已完成)'
- : `探索鲁迅故居的客房(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
@@ -221,6 +220,7 @@ const LuxunPlace5 = () => {
button
onClick={() => {
// 完成后先播放视频,不立即跳转页面
+ saveLuxunStore()
setShowEndingVideo(true)
}}
/>
@@ -378,6 +378,7 @@ const LuxunPlace5 = () => {
setEndingGuideVisible(visible)
if (!visible && moduleName) {
// 对话结束后返回鲁迅首页
+ setCourseTimeProgress(7)
navigate(`/${moduleName}/${starName}/home`)
}
}}
diff --git a/src/pages/luxun/reflection/scene1.tsx b/src/pages/luxun/reflection/scene1.tsx
index 688c55f..2c4b8ab 100644
--- a/src/pages/luxun/reflection/scene1.tsx
+++ b/src/pages/luxun/reflection/scene1.tsx
@@ -4,6 +4,7 @@ import { Image, StudyGuideModal } from '@/components'
import SuccessAlert from '@/components/layout/alert/success-alert'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
+import { useLuxunStore, saveLuxunStore } from '@/store/luxunStore'
const starName = 'luxun'
@@ -71,6 +72,7 @@ const LuxunReflectionScene1 = () => {
//碎片动画触发方法
const { triggerPuzzleAnimation } = usePuzzleAnimation()
+ const { setReflectionRoute } = useLuxunStore()
// 已点击的城市ID集合
const [clickedCities, setClickedCities] = useState>(new Set())
@@ -222,6 +224,8 @@ const LuxunReflectionScene1 = () => {
className="text-4xl font-bold h-16 cursor-pointer relative"
onClick={() => {
if (moduleName && allCompleted) {
+ setReflectionRoute('reflection2')
+ saveLuxunStore()
navigate(`/${moduleName}/${starName}/reflection2`)
}
}}
diff --git a/src/pages/luxun/reflection/scene2.tsx b/src/pages/luxun/reflection/scene2.tsx
index 8f60780..31ec64a 100644
--- a/src/pages/luxun/reflection/scene2.tsx
+++ b/src/pages/luxun/reflection/scene2.tsx
@@ -5,6 +5,7 @@ import type { DragFillResult } from '@/components'
import ErrorAlert from '@/components/layout/alert/error-alert'
import SuccessAlert from '@/components/layout/alert/success-alert'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
+import { useLuxunStore, saveLuxunStore } from '@/store/luxunStore'
const starName = 'luxun'
@@ -147,6 +148,8 @@ const LuxunReflectionScene2 = () => {
moduleName: string
}>()
+ const { setReflectionRoute } = useLuxunStore()
+
// DragFill 的值
const [dragFillValue, setDragFillValue] = useState([])
@@ -388,6 +391,8 @@ const LuxunReflectionScene2 = () => {
className="text-4xl font-bold h-16 cursor-pointer relative"
onClick={() => {
if (moduleName && allCompleted) {
+ setReflectionRoute('reflection3')
+ saveLuxunStore()
navigate(`/${moduleName}/${starName}/reflection3`)
}
}}
diff --git a/src/pages/luxun/reflection/scene3.tsx b/src/pages/luxun/reflection/scene3.tsx
index ae6975d..4f6d8d1 100644
--- a/src/pages/luxun/reflection/scene3.tsx
+++ b/src/pages/luxun/reflection/scene3.tsx
@@ -5,6 +5,7 @@ import { Image, GuideSteps, GuideStepsItem, StudyGuideModal, EndingOverlay } fro
import { SingleChoiceQuizPanel } from '@/components/layout/quiz'
import SuccessAlert from '@/components/layout/alert/success-alert'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
+import { setCourseTimeProgress } from '@/lib/utils/api-common'
const starName = 'luxun'
@@ -339,6 +340,7 @@ const LuxunReflectionScene3 = () => {
src={getStaticImage('components/place/离开场景')}
button
onClick={() => {
+ setCourseTimeProgress(8)
navigate(`/${moduleName}/${starName}/home`)
}}
/>
diff --git a/src/pages/maozedong/demo.tsx b/src/pages/maozedong/demo.tsx
deleted file mode 100644
index d50cbc6..0000000
--- a/src/pages/maozedong/demo.tsx
+++ /dev/null
@@ -1,141 +0,0 @@
-import { Video, type VideoAnchor } from '@/components'
-import { cn } from '@/lib/utils'
-import { getPublicPath } from '@/lib/utils'
-import { useRef } from 'react'
-
-// 光标组件 - 带旋转动画
-// Safari 兼容:使用 onTouchEnd 和 -webkit-tap-highlight-color
-const Cursor = ({ className, onClick }: { className?: string; onClick: () => void }) => (
-
{
- e.preventDefault() // 防止 Safari 触发两次
- onClick()
- }}
- style={{
- WebkitTapHighlightColor: 'transparent',
- WebkitTouchCallout: 'none',
- WebkitUserSelect: 'none',
- touchAction: 'manipulation',
- }}
- src="http://101.132.152.157/hsyjgrl/images/gemingxianqu/mzd/congress/%E5%85%89%E6%A0%87.png"
- alt=""
- draggable={false}
- />
-)
-
-const Home = () => {
- const bgmRef = useRef(null)
-
- // 播放 BGM
- const playBGM = () => {
- bgmRef.current?.play().catch(() => {})
- }
-
- const anchors: VideoAnchor[] = [
- {
- id: 'anchor-1',
- time: 8,
- afterResume: 'hide', // 恢复后隐藏,seek 回来可重新触发
- onTrigger: () => console.log('埋点:锚点 1 触发'),
- render: ({ resume, unmute }) => (
- {
- unmute()
- playBGM() // 第一个键点开始播放 BGM
- resume()
- }}
- />
- ),
- },
- {
- id: 'anchor-2',
- time: 22,
- afterResume: 'hide',
- render: ({ resume, unmute }) => (
- {
- unmute()
- resume()
- }}
- />
- ),
- },
- {
- id: 'anchor-3',
- time: 32,
- afterResume: 'hide',
- render: ({ resume, unmute }) => (
- {
- unmute()
- resume()
- }}
- />
- ),
- },
- {
- id: 'anchor-4',
- time: 40,
- afterResume: 'hide',
- render: ({ resume, unmute }) => (
- {
- unmute()
- resume()
- }}
- />
- ),
- },
- {
- id: 'anchor-5',
- time: 56,
- afterResume: 'hide',
- render: ({ resume, unmute }) => (
- {
- unmute()
- resume()
- }}
- />
- ),
- },
- {
- id: 'anchor-6',
- time: 74,
- afterResume: 'hide',
- render: ({ resume, unmute }) => (
- {
- unmute()
- resume()
- }}
- />
- ),
- },
- ]
-
- return (
-
- )
-}
-
-export default Home
diff --git a/src/pages/maozedong/home.tsx b/src/pages/maozedong/home.tsx
index 957848f..c938426 100644
--- a/src/pages/maozedong/home.tsx
+++ b/src/pages/maozedong/home.tsx
@@ -1,6 +1,7 @@
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components'
+import { useMaozedongStore } from '@/store/maozedongStore'
const Maozedong = () => {
const navigate = useNavigate()
@@ -11,6 +12,7 @@ const Maozedong = () => {
// starName 固定为 maozedong
const starName = 'maozedong'
+ const lastVisitedPlace = useMaozedongStore((state) => state.lastVisitedPlace)
if (!moduleName) return null
return (
@@ -24,17 +26,25 @@ const Maozedong = () => {
className="absolute right-37 top-22"
src={getStaticImage('components/革命先驱故居-返回')}
button
- onClick={() => navigate(`/entrance/${moduleName}`)}
+ onClick={() => navigate(`/entrance/revolutionary`)}
/>
navigate(`/${moduleName}/${starName}/place1`)}
+ onClick={() => navigate(`/${moduleName}/${starName}/${lastVisitedPlace || 'place1'}`)}
+ />
+ navigate(`/${moduleName}/${starName}/reflection`)}
+ />
+ navigate(`/${moduleName}/${starName}/internalization`)}
/>
-
-
)
diff --git a/src/pages/maozedong/internalization.tsx b/src/pages/maozedong/internalization.tsx
new file mode 100644
index 0000000..08ea5e1
--- /dev/null
+++ b/src/pages/maozedong/internalization.tsx
@@ -0,0 +1,501 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import {
+ Image,
+ Video,
+ type VideoAnchor,
+ type VideoRef,
+ type DragFillResult,
+ SingleChoiceQuizPanel,
+ LetterToMaozedong,
+ EndingOverlay,
+} from '@/components'
+import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
+import { GuideSteps, GuideStepsItem } from '@/components'
+import { BasketQuizPanel, DragQuizPanel, SuccessAlert } from '@/components/layout'
+
+const starName = 'maozedong'
+
+const MaozedongInternalization = () => {
+ const navigate = useNavigate()
+ const { moduleName } = useParams<{ moduleName: string }>()
+
+ // 进页面:先播“引入视频” -> 引入对话(图1) -> 主视频(视频11)
+ const [showIntroVideo, setShowIntroVideo] = useState(true)
+ const [introQuizVisible, setIntroQuizVisible] = useState(false)
+ const [introQuizSuccessVisible, setIntroQuizSuccessVisible] = useState(false)
+ const [introDialogVisible, setIntroDialogVisible] = useState(false)
+ const [showMainVideo, setShowMainVideo] = useState(false)
+
+ // 题目弹层状态(视频锚点 render 中使用)
+ const [activeSuccess, setActiveSuccess] = useState(null)
+ const [resumeCallback, setResumeCallback] = useState<(() => void) | null>(null)
+ const [dragQuizValues, setDragQuizValues] = useState>({})
+ const [dragQuizErrors, setDragQuizErrors] = useState>({})
+ const [dragQuizFeedbackTypes, setDragQuizFeedbackTypes] = useState<
+ Record
+ >({})
+
+ // 写信、寄信动画与完成提示
+ const [showLetter, setShowLetter] = useState(false)
+ const [showCompleted, setShowCompleted] = useState(false)
+
+ const videoRef = useRef(null)
+
+ useEffect(() => {}, [])
+
+ const handleVideoCanPlay = () => {
+ if (showMainVideo && videoRef.current) {
+ videoRef.current.play().catch(() => {})
+ }
+ }
+
+ // —— 题目配置:题目2(1:59)、题目3(4:31)、题目4(6:58)、题目5(7:15)——
+ type DragQuizConfig = {
+ id: string
+ time: number
+ title: string
+ prompt: string
+ correct: string[]
+ wrongValue: string
+ wrongHint: string
+ optionHints: Partial>
+ options: { value: string; label: string }[]
+ blanks: { id: string; label: string }[]
+ separators?: string[]
+ }
+
+ const DRAG_QUIZZES: DragQuizConfig[] = useMemo(
+ () => [
+ {
+ id: 'quiz-2',
+ time: 60 * 1 + 59,
+ title: '',
+ prompt: '请将你觉得属于新时代坚持“实事求是”思想的做法拖入篮子吧!',
+ correct: ['A', 'B', 'C'],
+ wrongValue: 'D',
+ wrongHint:
+ '这种做法脱离实际,违背了“实事求是”的思想路线,容易导致发展走弯路。请你再想想吧!',
+ optionHints: {
+ A: '恭喜你,选择正确!这是党在精准把握发展阶段特征的基础上作出的科学判断,体现了“实事求是”的思想精髓。',
+ B: '恭喜你,选择正确!这是从上海实际出发,立足自身优势精准定位发展方向,是“实事求是”在区域发展中的生动体现。',
+ C: '恭喜你,选择正确!这是立足实际、聚焦问题、务求实效的实践路径,是“实事求是”在改革发展中的具体践行。',
+ D: '这种做法脱离实际,违背了“实事求是”的思想路线,容易导致发展走弯路。请你再想想吧!',
+ },
+ options: [
+ {
+ value: 'A',
+ label: '党的十八大以来,根据世情国情变化作出“社会主要矛盾转化”的新论断',
+ },
+ {
+ value: 'B',
+ label: '基于上海的区位优势和发展基础,党中央赋予上海建设“五个中心”的使命',
+ },
+ {
+ value: 'C',
+ label: '上海以问题为导向,推动“五个中心”联动发展,将改革开放“施工图”转化为“实景画”',
+ },
+ { value: 'D', label: '不顾本地资源禀赋,盲目照搬其他地区的产业发展模式' },
+ ],
+ blanks: [
+ { id: 'basket-1', label: '' },
+ { id: 'basket-2', label: '' },
+ { id: 'basket-3', label: '' },
+ ],
+ },
+ {
+ id: 'quiz-3',
+ time: 60 * 4 + 31,
+ title: '',
+ prompt: '请将你认为符合新时代践行“群众路线”思想的做法拖入篮子吧!',
+ correct: ['A', 'C', 'D'],
+ wrongValue: 'B',
+ wrongHint:
+ '这种做法违背了以人民为中心的发展思想,不符合新时代群众路线的要求。请你再想想吧!',
+ optionHints: {
+ A: '恭喜你,选择正确!这一理念是新时代群众路线在城市治理中的生动表达,体现了以人民为中心的发展思想。',
+ B: '这种做法违背了以人民为中心的发展思想,不符合新时代群众路线的要求。请你再想想吧!',
+ C: '恭喜你,选择正确!这是群众路线在民主政治领域的创新实践,保障了人民群众的参与权、表达权,让人民真正成为城市的主人。',
+ D: '恭喜你,选择正确!这是群众路线在新时代的深化与拓展,充分调动了人民群众的积极性、主动性、创造性。',
+ },
+ options: [
+ { value: 'A', label: '提出“人民城市人民建,人民城市为人民”的重要理念' },
+ { value: 'B', label: '城市在经济发展中只关注企业效益,忽视普通市民的民生需求' },
+ {
+ value: 'C',
+ label: '上海作为全过程人民民主首提地,搭建多元民主平台让群众声音直达决策层面',
+ },
+ {
+ value: 'D',
+ label: '上海通过推进“全过程人民民主”,汇聚各方智慧共同推进中国式现代化',
+ },
+ ],
+ blanks: [
+ { id: 'basket-1', label: '' },
+ { id: 'basket-2', label: '' },
+ { id: 'basket-3', label: '' },
+ ],
+ },
+ {
+ id: 'quiz-4',
+ time: 60 * 6 + 58,
+ title: '',
+ prompt: '请将你认为符合新时代践行“独立自主”思想的做法拖入篮子吧!',
+ correct: ['B', 'C', 'D'],
+ wrongValue: 'A',
+ wrongHint:
+ '这种做法违背了“独立自主”的原则,会导致发展受制于他人,不符合中国式现代化的本质要求。请你再想想吧!',
+ optionHints: {
+ A: '这种做法违背了“独立自主”的原则,会导致发展受制于他人,不符合中国式现代化的本质要求。请你再想想吧!',
+ B: '恭喜你,选择正确!这是我国践行独立自主思想的典范,彰显了我国在高端制造领域依靠自身力量突破核心技术的决心与能力。',
+ C: '恭喜你,选择正确!这是上海在改革开放中坚持独立自主、探索制度创新的生动实践,体现了在开放中掌握发展主动权的思路。',
+ D: '恭喜你,选择正确!这是上海坚持把发展放在自己力量基点上的具体体现,通过打造自主产业集群,在开放合作中牢牢掌握了发展主动权。',
+ },
+ options: [
+ { value: 'A', label: '部分地区在产业发展中完全依赖国外技术,放弃自主研发投入' },
+ {
+ value: 'B',
+ label: 'C919大飞机研制成功,实现百余项关键技术突破,60%的自主配套率',
+ },
+ {
+ value: 'C',
+ label: '上海自贸试验区率先试点“负面清单管理”,临港新片区打造全产业链集群',
+ },
+ {
+ value: 'D',
+ label: '临港新片区布局“东方芯港”“生命蓝湾”等特色产业园区,构建自主可控产业生态',
+ },
+ ],
+ blanks: [
+ { id: 'basket-1', label: '' },
+ { id: 'basket-2', label: '' },
+ { id: 'basket-3', label: '' },
+ ],
+ },
+ {
+ id: 'quiz-5',
+ time: 60 * 7 + 15,
+ title: '',
+ prompt: '毛泽东思想活的灵魂包括',
+ correct: ['实事求是', '群众路线', '独立自主'],
+ wrongValue: '党的建设',
+ wrongHint: '党的建设是新民主主义革命胜利的“三大法宝”之一。请你再想想吧!',
+ optionHints: {
+ 实事求是: '',
+ 群众路线: '',
+ 独立自主: '',
+ 党的建设: '党的建设是新民主主义革命胜利的“三大法宝”之一。请你再想想吧!',
+ },
+ options: [
+ { value: '实事求是', label: 'A.实事求是' },
+ { value: '群众路线', label: 'B.群众路线' },
+ { value: '独立自主', label: 'C.独立自主' },
+ { value: '党的建设', label: 'D.党的建设' },
+ ],
+ blanks: [
+ { id: 'blank-1', label: '' },
+ { id: 'blank-2', label: '' },
+ { id: 'blank-3', label: '' },
+ ],
+ separators: ['、', '、'],
+ },
+ ],
+ []
+ )
+
+ const anchors: VideoAnchor[] = useMemo(() => {
+ return DRAG_QUIZZES.map((q) => ({
+ id: q.id,
+ time: q.time,
+ once: true,
+ afterResume: 'remove',
+ render: ({ resume }) => {
+ const handleDragChange = (val: DragFillResult[]) => {
+ // quiz-5:参考黄炎培 DragQuizPanel 的用法,不做“每次拖拽都提示”,仅在填满后校验并提示
+ if (q.id === 'quiz-5') {
+ const valid = val.filter((v) => q.blanks.some((b) => b.id === v.blankId))
+
+ if (valid.some((v) => v.optionValue === q.wrongValue)) {
+ setDragQuizErrors((prevErr) => ({ ...prevErr, [q.id]: q.wrongHint }))
+ // 错误项拖拽后回到原来的选项框位(从结果中移除)
+ setDragQuizValues((prev) => ({
+ ...prev,
+ [q.id]: valid.filter((v) => v.optionValue !== q.wrongValue),
+ }))
+ return
+ }
+
+ setDragQuizErrors((prevErr) => ({ ...prevErr, [q.id]: '' }))
+ setDragQuizValues((prev) => ({ ...prev, [q.id]: valid }))
+
+ if (valid.length < q.blanks.length) return
+
+ const filledSet = new Set(valid.map((v) => v.optionValue))
+ const answerSet = new Set(q.correct)
+ const isCorrect =
+ filledSet.size >= answerSet.size && [...answerSet].every((a) => filledSet.has(a))
+
+ if (isCorrect) {
+ setActiveSuccess(q.id)
+ setResumeCallback(() => resume)
+ }
+ return
+ }
+
+ const prev = dragQuizValues[q.id] || []
+ const valid = val.filter((v) => q.blanks.some((b) => b.id === v.blankId))
+ const prevOptionSet = new Set(prev.map((v) => v.optionValue))
+ const newlyAdded = valid.find((v) => !prevOptionSet.has(v.optionValue))
+ const newlyAddedValue = newlyAdded?.optionValue
+ const isNewlyWrong = newlyAddedValue ? newlyAddedValue === q.wrongValue : false
+ const isNewlyCorrect = newlyAddedValue ? newlyAddedValue !== q.wrongValue : false
+
+ if (valid.some((v) => v.optionValue === q.wrongValue)) {
+ const wrongMsg = newlyAdded?.optionValue
+ ? q.optionHints[newlyAdded.optionValue] || q.wrongHint
+ : q.wrongHint
+ setDragQuizErrors((prevErr) => ({ ...prevErr, [q.id]: wrongMsg }))
+ setDragQuizFeedbackTypes((prevType) => ({ ...prevType, [q.id]: 'error' }))
+ setDragQuizValues((prev) => ({
+ ...prev,
+ [q.id]: valid.filter((v) => v.optionValue !== q.wrongValue),
+ }))
+ return
+ }
+
+ const defaultTip = isNewlyCorrect ? '恭喜你,选择正确!' : ''
+ const tip = newlyAddedValue ? q.optionHints[newlyAddedValue] || defaultTip : ''
+ setDragQuizErrors((prevErr) => ({ ...prevErr, [q.id]: tip }))
+ if (newlyAddedValue) {
+ setDragQuizFeedbackTypes((prevType) => ({
+ ...prevType,
+ [q.id]: isNewlyWrong ? 'error' : 'success',
+ }))
+ }
+ setDragQuizValues((prev) => ({ ...prev, [q.id]: valid }))
+
+ const filledSet = new Set(valid.map((v) => v.optionValue))
+ const answerSet = new Set(q.correct)
+ const isCorrect =
+ filledSet.size >= answerSet.size && [...answerSet].every((a) => filledSet.has(a))
+
+ if (isCorrect) {
+ setActiveSuccess(q.id)
+ setResumeCallback(() => resume)
+ }
+ }
+
+ return (
+ <>
+ {q.id === 'quiz-5' ? (
+
+ ) : (
+
+ )}
+
+ {
+ setActiveSuccess(null)
+ setDragQuizValues((prev) => {
+ const next = { ...prev }
+ delete next[q.id]
+ return next
+ })
+ setDragQuizErrors((prev) => {
+ const next = { ...prev }
+ delete next[q.id]
+ return next
+ })
+ setDragQuizFeedbackTypes((prev) => {
+ const next = { ...prev }
+ delete next[q.id]
+ return next
+ })
+ const cb = resumeCallback
+ setResumeCallback(null)
+ if (q.id === 'quiz-5') {
+ cb?.()
+ return
+ }
+ cb?.()
+ }}
+ />
+ >
+ )
+ },
+ }))
+ }, [
+ DRAG_QUIZZES,
+ activeSuccess,
+ dragQuizErrors,
+ dragQuizFeedbackTypes,
+ dragQuizValues,
+ resumeCallback,
+ ])
+
+ if (!moduleName) return null
+
+ return (
+
+ navigate(`/${moduleName}/${starName}/home`)}
+ />
+
+ {/* 引入视频:进入页面自动播放(暂用其他视频代替) */}
+ {showIntroVideo && (
+ {
+ setShowIntroVideo(false)
+ setIntroQuizVisible(true)
+ }}
+ />
+ )}
+
+ {
+ if (value === 'A') {
+ return '中共一大标志着党的成立。请你再想想吧!'
+ }
+ if (value === 'B') {
+ return '遵义会议确立了毛泽东在党内的领导地位。请你再想想吧!'
+ }
+ return '请结合党史时间线再想想吧!'
+ }}
+ onSubmit={(_value, isCorrect) => {
+ if (!isCorrect) return
+ setIntroQuizVisible(false)
+ setIntroQuizSuccessVisible(true)
+ }}
+ />
+
+ {
+ setIntroQuizSuccessVisible(false)
+ setIntroDialogVisible(true)
+ }}
+ />
+
+ {/* 页面进入后直接播放引入视频,不再展示额外引导 */}
+
+ {/* 引入视频结束后的对话(图1) */}
+ {
+ setIntroDialogVisible(v)
+ if (!v) {
+ setShowMainVideo(true)
+ handleVideoCanPlay()
+ }
+ }}
+ showMask={false}
+ zIndex={60}
+ >
+
+ 关于“实事求是、群众路线、独立自主”这十二个字我还是有些不理解,总感觉这十二个字离我们很遥远。
+
+
+ “实事求是、群众路线、独立自主”这十二个字,可以说是中国共产党人认识世界、改造世界的根本方法与价值遵循。今天我们有幸邀请到了华东师范大学马克思主义学院马立政副教授。关于你的问题,我想马教授会给你指点迷津!
+
+
+
+ {/* 主视频(视频11):对话结束自动播放,并插入题目锚点 */}
+ {showMainVideo && (
+ {
+ setShowMainVideo(false)
+ // 等视频组件卸载后再弹出信件
+ setTimeout(() => {
+ setShowLetter(true)
+ }, 300)
+ }}
+ />
+ )}
+
+ {/* 写给毛主席的信:视频播完后出现 */}
+ {
+ setShowLetter(false)
+ }}
+ onAnimationEnd={() => {
+ setShowCompleted(true)
+ }}
+ showVideo={true}
+ />
+
+ {
+ setShowCompleted(false)
+ navigate(`/${moduleName}/${starName}/home`)
+ }}
+ />
+
+ )
+}
+
+export default MaozedongInternalization
diff --git a/src/pages/maozedong/place/place1.tsx b/src/pages/maozedong/place/place1.tsx
index 9eb434e..d11845a 100644
--- a/src/pages/maozedong/place/place1.tsx
+++ b/src/pages/maozedong/place/place1.tsx
@@ -6,15 +6,19 @@ import {
StudyGuideModal,
Title,
SelectFillQuizPanel,
+ Video,
+ type VideoRef,
} from '@/components'
import { useNavigate, useParams } from 'react-router-dom'
-import { getBackgroundImage, getStaticImage } from '@/lib/utils'
-import { useState } from 'react'
+import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
+import { useMemo, useRef, useState } from 'react'
import Cursor from '@/components/cursor'
import { useCursorController } from '@/hooks/useCursorController'
import SuccessAlert from '@/components/layout/alert/success-alert'
import ScaledPortal from '@/components/layout/ScaledPortal'
import type { SelectFillResult } from '@/components/layout/quiz'
+import { saveMaozedongStore, useMaozedongStore } from '@/store/maozedongStore'
+import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
// starName 固定为 maozedong
const starName = 'maozedong'
@@ -31,28 +35,34 @@ const MaozedongPlace1 = () => {
moduleName: string
}>()
+ // 获取碎片动画触发方法
+ const { triggerPuzzleAnimation } = usePuzzleAnimation()
+
+ // 接入持久化:记录本场景 place1 的已访问展品与断点
+ const markVisited = useMaozedongStore((s) => s.markExhibitVisited)
+ const getVisited = useMaozedongStore((s) => s.getVisitedExhibits)
+ const setLastVisitedPlace = useMaozedongStore((s) => s.setLastVisitedPlace)
+ const persistedVisited = useMemo(() => new Set(getVisited('place1') as (1 | 2)[]), [getVisited])
+
// 学习引导弹窗
const [showGuideModal, setShowGuideModal] = useState(true)
// 对话结束后的学习引导弹窗
const [showAfterDialogGuide, setShowAfterDialogGuide] = useState(false)
- // 展品1的对话引导状态
- const [guideVisible1, setGuideVisible1] = useState(false)
- const [guideStep1, setGuideStep1] = useState(0)
-
// 展品2的对话引导状态
const [guideVisible2, setGuideVisible2] = useState(false)
const [guideStep2, setGuideStep2] = useState(0)
// 当前激活的展品 ID
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null)
- // 已访问的展品(本页内状态;如需持久化可接入 store)
- const [visited, setVisited] = useState>(() => new Set())
+ // 已访问的展品(初始化自 store;随后每次关闭展品时写回)
+ const [visited, setVisited] = useState>(() => new Set(persistedVisited))
- // 展品2对话结束后的选择填空题
+ // 展品2关闭后播放的视频 + 选择填空题
+ const [showVideo2, setShowVideo2] = useState(false)
+ const video2Ref = useRef(null)
const [quizVisible, setQuizVisible] = useState(false)
- const [quizPassed, setQuizPassed] = useState(false)
const [successAlertVisible, setSuccessAlertVisible] = useState(false)
// Cursor 顺序控制:1 -> 2
@@ -70,23 +80,6 @@ const MaozedongPlace1 = () => {
if (id === 1) {
setGuideVisible2(true)
setGuideStep2(0)
- } else if (id === 2) {
- setGuideVisible1(true)
- setGuideStep1(0)
- }
- }
-
- // 监听展品1对话引导步骤变化
- const handleGuideStepChange1 = (step: number) => {
- setGuideStep1(step)
- }
-
- // 监听展品1对话引导完成
- const handleGuideVisibleChange1 = (visible: boolean) => {
- setGuideVisible1(visible)
- // 对话结束后,如果还未完成该题,则弹出选择填空题
- if (!visible) {
- setQuizVisible(true)
}
}
@@ -103,7 +96,7 @@ const MaozedongPlace1 = () => {
const progressCount = visited.size
const total = 2
const completed = progressCount >= total
- const subtitle = completed ? '关于中共一大(已完成)' : `关于中共一大(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
if (!moduleName) return null
@@ -154,45 +147,14 @@ const MaozedongPlace1 = () => {
src={getStaticImage(`${starName}/place1/场景1继续前进`)}
button
onClick={() => {
+ setLastVisitedPlace('place2')
+ saveMaozedongStore()
navigate(`/${moduleName}/${starName}/place2`)
}}
/>
)}
{/* 展品2的对话引导 */}
-
-
- 小沪!我了解过,毛主席是以湖南代表身份参加中共一大的。但他之前不是在湖南一师读书,后来组织新民学会吗?怎么想到要建全国性的共产党呀?
-
-
- 同学问得好!毛主席在一师读书时,受杨昌济先生影响,读了很多进步书刊。后来和蔡和森、萧子升成立新民学会,最初只是想"改造中国与世界"——但到底怎么改?他们争论过很多次。
-
-
- 后来在陈独秀先生的影响下,让他坚定地认为如果要进行改造,就必须来场革命!如果革命成功,上策便是学习苏联,而马克思主义成为他后续抉择的指引。
-
-
- “我第二次到北京期间,读了很多关于俄国情况的书。我热心地搜寻那时候能找到的为数不多的用中文写的共产主义书籍,有三本书特别深地铭刻在我的心中,建立起我对马克思主义的信仰。
-
-
- 我一旦接受了马克思主义是对历史的正确解释以后,我对马克思主义的信仰就没有动摇过。这三本书是《共产党宣言》,陈望道译,这是用中文出版的第一本马克思主义的书;《阶级斗争》,考茨基著;《社会主义史》,柯卡普著。”
-
-
- 1920年,毛主席从北京辗转抵达上海,1920年5月5日到7月初,在上海居住的2个月期间,毛主席的思想完成了向马克思主义者的转变。
-
-
- “到了一九二〇年夏天,在理论上,而且在某种程度的行动上,我已成为一个马克思主义者了。”毛泽东的这番表白,见载于埃德加·斯诺的《西行漫记》。
-
-
-
- {/* 展品1的对话引导 */}
{
highlightSelectors={[]}
zIndex={10001}
>
-
+
那参加中共一大时,毛主席对未来有什么想法吗?
-
+
当时全国只有50多个党员,毛主席所在的湖南也才几个。但他觉得,重要的是把马克思主义和中国的实际情况结合起来。就像种田,种子再好,也得看土壤——中国的"土壤"就是农民占大多数,这是和苏联不一样的地方。
@@ -214,11 +179,27 @@ const MaozedongPlace1 = () => {
{
+ if (activeExhibitId === 2) {
+ triggerPuzzleAnimation(1)
+ }
+ if (activeExhibitId === 1) {
+ triggerPuzzleAnimation(2)
+ }
+ }}
onClose={() => {
+ if (activeExhibitId === 2) {
+ setActiveExhibitId(null)
+ setShowVideo2(true)
+ return
+ }
+
if (activeExhibitId !== null) {
setVisited((prev) => {
const next = new Set(prev)
next.add(activeExhibitId)
+ // 写回持久化
+ markVisited('place1', activeExhibitId)
return next
})
}
@@ -226,6 +207,26 @@ const MaozedongPlace1 = () => {
}}
/>
+ {/* 展品2关闭后播放的视频 */}
+ {showVideo2 && (
+ {
+ setShowVideo2(false)
+ setQuizVisible(true)
+ }}
+ onSkip={() => {
+ setShowVideo2(false)
+ setQuizVisible(true)
+ }}
+ />
+ )}
+
{/* 展品2对话结束后的选择填空题 + 成功提示(使用 ScaledPortal) */}
<>
@@ -256,7 +257,6 @@ const MaozedongPlace1 = () => {
onSubmit={(_results: SelectFillResult[], isCorrect: boolean) => {
if (isCorrect) {
setQuizVisible(false)
- setQuizPassed(true)
setSuccessAlertVisible(true)
}
}}
@@ -271,6 +271,12 @@ const MaozedongPlace1 = () => {
visible={successAlertVisible}
onContinue={() => {
setSuccessAlertVisible(false)
+ setVisited((prev) => {
+ const next = new Set(prev)
+ next.add(2)
+ markVisited('place1', 2)
+ return next
+ })
}}
/>
>
@@ -279,26 +285,28 @@ const MaozedongPlace1 = () => {
{/* 学习引导词 */}
{
setShowGuideModal(false)
setShowAfterDialogGuide(true)
}}
+ ailgn="left"
>
- {`同学你好,欢迎来到“走进故居·对话”学习模块!
- 此刻你正处在毛泽东旧居陈列馆里,接下来请跟着时光的脚步,一同回望毛泽东主席人生中4段风云激荡的岁月:摸索方向 确立信仰、播撒红色火种、吹响合作号角、唤起工农群众。准备好推开这扇门,走进那段滚烫的峥嵘岁月了吗?`}
+ 1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
+ {'\n'}
+ 2.在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点开查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习。
+ {'\n'}
+ 3.所有动态光点的展品全部学习后才能进入下一个场景学习。
{/* 对话结束后的学习引导 */}
setShowAfterDialogGuide(false)}
- ailgn="left"
>
- 1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
- {'\n'}
- 2.在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习。
- {'\n'}
- 3.所有动态光点的展品全部学习后才能进入下一个场景学习。
+ {`同学你好,欢迎来到“走进故居·对话”学习模块!
+ 此刻你正处在毛泽东旧居陈列馆里,接下来请跟着时光的脚步,一同回望毛泽东主席人生中4段风云激荡的岁月:摸索方向 确立信仰、播撒红色火种、吹响合作号角、唤起工农群众。准备好推开这扇门,走进那段滚烫的峥嵘岁月了吗?`}
)
diff --git a/src/pages/maozedong/place/place2.tsx b/src/pages/maozedong/place/place2.tsx
index ab0527f..88fb779 100644
--- a/src/pages/maozedong/place/place2.tsx
+++ b/src/pages/maozedong/place/place2.tsx
@@ -5,14 +5,18 @@ import {
Image,
Title,
MultipleChoiceQuizPanel,
+ Video,
+ type VideoRef,
} from '@/components'
import { useNavigate, useParams } from 'react-router-dom'
-import { getBackgroundImage, getStaticImage } from '@/lib/utils'
-import { useState } from 'react'
+import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
+import { useMemo, useRef, useState } from 'react'
import Cursor from '@/components/cursor'
import { useCursorController } from '@/hooks/useCursorController'
import SuccessAlert from '@/components/layout/alert/success-alert'
import ScaledPortal from '@/components/layout/ScaledPortal'
+import { useMaozedongStore, saveMaozedongStore } from '@/store/maozedongStore'
+import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
// starName 固定为 maozedong
const starName = 'maozedong'
@@ -29,10 +33,17 @@ const MaozedongPlace2 = () => {
moduleName: string
}>()
+ // 获取碎片动画触发方法
+ const { triggerPuzzleAnimation } = usePuzzleAnimation()
+
// 当前激活的展品 ID
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null)
- // 已访问的展品(本页内状态;如需持久化可接入 store)
- const [visited, setVisited] = useState
>(() => new Set())
+ // 持久化接入
+ const markVisited = useMaozedongStore((s) => s.markExhibitVisited)
+ const getVisited = useMaozedongStore((s) => s.getVisitedExhibits)
+ const setLastVisitedPlace = useMaozedongStore((s) => s.setLastVisitedPlace)
+ const persistedVisited = useMemo(() => new Set(getVisited('place2') as (1 | 2)[]), [getVisited])
+ const [visited, setVisited] = useState>(() => new Set(persistedVisited))
// 展品1的对话引导状态
const [guideVisible1, setGuideVisible1] = useState(false)
@@ -42,6 +53,10 @@ const MaozedongPlace2 = () => {
const [guideVisible2, setGuideVisible2] = useState(false)
const [guideStep2, setGuideStep2] = useState(0)
+ // 展品1对话结束后播放的视频状态(播完后再出题目)
+ const [showVideo, setShowVideo] = useState(false)
+ const videoRef = useRef(null)
+
// 展品1对话结束后的多选题
const [quizVisible, setQuizVisible] = useState(false)
const [quizPassed, setQuizPassed] = useState(false)
@@ -64,7 +79,8 @@ const MaozedongPlace2 = () => {
// 重新进入时重置题目完成状态
setQuizPassed(false)
} else if (id === 2) {
- setGuideVisible2(true)
+ // 展品2:先看展品,关闭展品后再显示对话
+ setGuideVisible2(false)
setGuideStep2(0)
}
}
@@ -78,7 +94,7 @@ const MaozedongPlace2 = () => {
const handleGuideVisibleChange1 = (visible: boolean) => {
setGuideVisible1(visible)
if (!visible && !quizPassed) {
- setQuizVisible(true)
+ setShowVideo(true)
}
}
@@ -95,7 +111,7 @@ const MaozedongPlace2 = () => {
const progressCount = visited.size
const total = 2
const completed = progressCount >= total
- const subtitle = completed ? '关于中共二大(已完成)' : `关于中共二大(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
if (!moduleName) return null
@@ -119,7 +135,7 @@ const MaozedongPlace2 = () => {
button
onClick={() => {
if (moduleName && starName) {
- navigate(`/${moduleName}/${starName}/home`)
+ navigate(`/${moduleName}/${starName}/place1`)
}
}}
/>
@@ -146,6 +162,8 @@ const MaozedongPlace2 = () => {
src={getStaticImage('components/place/继续前进')}
button
onClick={() => {
+ setLastVisitedPlace('place3')
+ saveMaozedongStore()
navigate(`/${moduleName}/${starName}/place3`)
}}
/>
@@ -155,18 +173,54 @@ const MaozedongPlace2 = () => {
{
+ if (activeExhibitId === 1) {
+ triggerPuzzleAnimation(3)
+ }
+ if (activeExhibitId === 2) {
+ triggerPuzzleAnimation(4)
+ }
+ }}
onClose={() => {
- if (activeExhibitId !== null) {
+ const currentId = activeExhibitId
+
+ if (currentId !== null) {
setVisited((prev) => {
const next = new Set(prev)
- next.add(activeExhibitId)
+ next.add(currentId)
+ markVisited('place2', currentId)
+ saveMaozedongStore()
return next
})
}
setActiveExhibitId(null)
+
+ // 展品2:关闭后显示对话
+ if (currentId === 2) {
+ setGuideStep2(0)
+ setGuideVisible2(true)
+ }
}}
/>
+ {/* 展品1对话结束后播放的视频:播放完毕后出现题目 */}
+ {showVideo && (
+
+ {
+ setShowVideo(false)
+ setQuizVisible(true)
+ }}
+ />
+
+ )}
+
{/* 展品1的对话引导(文字+语音播报占位) */}
{
highlightSelectors={[]}
zIndex={10001}
>
-
+
中共一大定了“推翻资产阶级政权”的目标,但中共二大提
出了“反帝反封建的民主革命纲领”,为什么会有这样的转变呢?
-
+
中共一大时我们中国共产党刚成立,像刚学走路的孩子,
目标很宏大——“建立无产阶级专政”“消灭阶级”。但毛主席回到湖南后,
做了实际调查发现必须先解决“眼前的敌人”。
- “眼前的敌人”?
-
- 对!想想看1922年长沙的码头工人罢工,安源的路矿工人罢工,
- 他们的敌人是谁?是外国资本家,是军阀。中共二大时毛主席虽没有出席,
- 但完全赞同大会制定的“最低纲领”——打倒军阀,建立国内和平;
+
+ “眼前的敌人”?
+
+
+ 对!想想看1922年长沙的码头工人罢工,安源的路矿工人罢工,他们的敌人是谁?是外国资本家,是军阀。中共二大时毛主席虽然没有出席,但完全赞同“最低纲领”——打倒军阀、推翻帝国主义压迫,实现民族独立和人民民主。
- 推翻国际帝国主义的压迫,达到中华民族的完全独立;
- 统一中国为真正的民主共和国。这就像盖房子,得先把地基打好;
- 等打倒了军阀和帝国主义,再谈社会主义也不迟。
+ 这就像盖房子,得先把地基打好;等打倒了军阀和帝国主义,再谈社会主义也不迟。
@@ -209,7 +267,7 @@ const MaozedongPlace2 = () => {
highlightSelectors={[]}
zIndex={10001}
>
-
+
后来喊出的“打倒列强,除军阀”的口号,就是从这儿来的。
@@ -219,7 +277,7 @@ const MaozedongPlace2 = () => {
<>
{
moduleName: string
}>()
+ // 获取碎片动画触发方法
+ const { triggerPuzzleAnimation } = usePuzzleAnimation()
+
// 当前激活的展品 ID
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(null)
- // 已访问的展品(本页内状态;如需持久化可接入 store)
- const [visited, setVisited] = useState>(() => new Set())
+ // 持久化接入
+ const markVisited = useMaozedongStore((s) => s.markExhibitVisited)
+ const getVisited = useMaozedongStore((s) => s.getVisitedExhibits)
+ const setLastVisitedPlace = useMaozedongStore((s) => s.setLastVisitedPlace)
+ const persistedVisited = useMemo(
+ () => new Set(getVisited('place3') as (1 | 2 | 3)[]),
+ [getVisited]
+ )
+ const [visited, setVisited] = useState>(() => new Set(persistedVisited))
+ // 入场旁白
+ const [narrationVisible, setNarrationVisible] = useState(false)
// 光标3对应的对话状态
const [guide3Visible, setGuide3Visible] = useState(false)
@@ -59,6 +74,8 @@ const MaozedongPlace3 = () => {
const handleOpenExhibit = (id: 1 | 2 | 3) => {
setActiveExhibitId(id)
+ setLastVisitedPlace('place3')
+ saveMaozedongStore()
if (id === 3) {
setGuide3Step(0)
setGuide3Visible(true)
@@ -68,7 +85,13 @@ const MaozedongPlace3 = () => {
const progressCount = visited.size
const total = 3
const completed = progressCount >= total
- const subtitle = completed ? '关于中共三大(已完成)' : `关于中共三大(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
+
+ useEffect(() => {
+ if (visited.has(1) && visited.has(2)) {
+ setNarrationVisible(true)
+ }
+ }, [visited])
if (!moduleName) return null
@@ -79,6 +102,13 @@ const MaozedongPlace3 = () => {
backgroundImage: getBackgroundImage(`${starName}/place3/场景3`),
}}
>
+ setNarrationVisible(false)}
+ audioSrc={getPublicPath('audio/maozedong/mp3_mzd_12.mp3')}
+ text={`本党(国民党)为革命党,凡有利于革命的可采用,有害于革命的即应摒弃。——(毛泽东在国民党“一大”上的发言)`}
+ />
+
{/* 标题 */}
{
button
onClick={() => {
if (moduleName && starName) {
- navigate(`/${moduleName}/${starName}/home`)
+ navigate(`/${moduleName}/${starName}/place2`)
}
}}
/>
@@ -126,6 +156,8 @@ const MaozedongPlace3 = () => {
src={getStaticImage('components/place/继续前进')}
button
onClick={() => {
+ setLastVisitedPlace('place4')
+ saveMaozedongStore()
navigate(`/${moduleName}/${starName}/place4`)
}}
/>
@@ -143,14 +175,14 @@ const MaozedongPlace3 = () => {
highlightSelectors={[]}
zIndex={10001}
>
-
- 中共三大决定和国民党合作,但党内有争论。毛主席当时是什 么态度呢?
+
+ 中共三大决定和国民党合作,但党内有争论。毛主席当时是什么态度呢?
-
- 争论确实激烈!有人觉得和国民党合作是“降低身份”,但毛
- 主席认为这是必要的。当时孙中山先生领导的国民党有革命传
- 统,但缺真正的革命力量;我们有马克思主义,但缺群众基础。
- 国共合作就好比“星火借烈焰,共燃燎原势”。
+
+ 争论确实激烈!有人觉得和国民党合作是“降低身份”,但毛主席认为这是必要的。当时孙中山先生领导的国民党有革命传统,但缺真正的革命力量;我们有马克思主义,但缺群众基础。国共合作就好比“星火借烈焰,共燃燎原势”。
@@ -158,6 +190,17 @@ const MaozedongPlace3 = () => {
{
+ if (activeExhibitId === 3) {
+ triggerPuzzleAnimation(5)
+ }
+ if (activeExhibitId === 1) {
+ triggerPuzzleAnimation(6)
+ }
+ if (activeExhibitId === 2) {
+ triggerPuzzleAnimation(7)
+ }
+ }}
onClose={() => {
const currentId = activeExhibitId
@@ -174,6 +217,8 @@ const MaozedongPlace3 = () => {
setVisited((prev) => {
const next = new Set(prev)
next.add(currentId)
+ markVisited('place3', currentId)
+ saveMaozedongStore()
return next
})
}
@@ -185,7 +230,7 @@ const MaozedongPlace3 = () => {
{showVideo3 && (
{
next.add(3)
return next
})
+ markVisited('place3', 3)
}
}}
/>
diff --git a/src/pages/maozedong/place/place4.tsx b/src/pages/maozedong/place/place4.tsx
index 3981af6..73da274 100644
--- a/src/pages/maozedong/place/place4.tsx
+++ b/src/pages/maozedong/place/place4.tsx
@@ -11,11 +11,13 @@ import {
} from '@/components'
import { useNavigate, useParams } from 'react-router-dom'
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
-import { useEffect, useRef, useState } from 'react'
+import { useMemo, useRef, useState } from 'react'
import Cursor from '@/components/cursor'
import { useCursorController } from '@/hooks/useCursorController'
import SuccessAlert from '@/components/layout/alert/success-alert'
import ScaledPortal from '@/components/layout/ScaledPortal'
+import { useMaozedongStore, saveMaozedongStore } from '@/store/maozedongStore'
+import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
// starName 固定为 maozedong
const starName = 'maozedong'
@@ -35,28 +37,36 @@ const MaozedongPlace4 = () => {
moduleName: string
}>()
+ // 获取碎片动画触发方法
+ const { triggerPuzzleAnimation } = usePuzzleAnimation()
+
// 场景级引导对话显示状态(暂未使用,可根据需要开启)
const [visible, setVisible] = useState(false)
// 当前激活的展品 ID
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | 4 | 5 | null>(null)
- // 已访问的展品(本页内状态;如需持久化可接入 store)
- const [visited, setVisited] = useState>(() => new Set())
+ // 持久化接入
+ const markVisited = useMaozedongStore((s) => s.markExhibitVisited)
+ const getVisited = useMaozedongStore((s) => s.getVisitedExhibits)
+ const persistedVisited = useMemo(
+ () => new Set(getVisited('place4') as (1 | 2 | 3 | 4 | 5)[]),
+ [getVisited]
+ )
+ const [visited, setVisited] = useState>(() => new Set(persistedVisited))
// 光标1的对话状态
const [guide1Visible, setGuide1Visible] = useState(false)
const [guide1Step, setGuide1Step] = useState(0)
- // 光标3+光标5均查看后的总结对话
- const [guide35Visible, setGuide35Visible] = useState(false)
- const [guide35Step, setGuide35Step] = useState(0)
- const [guide35Shown, setGuide35Shown] = useState(false)
+ // 光标3的对话状态
+ const [guide3Visible, setGuide3Visible] = useState(false)
+ const [guide3Step, setGuide3Step] = useState(0)
// 光标4(展品4)对话
- const [guide4Visible, setGuide4Visible] = useState(false)
- const [guide4Step, setGuide4Step] = useState(0)
-
- // 关闭展品4后的结束语
+ // 光标4关闭后的过场视频与总结对话
+ const [showVideo4, setShowVideo4] = useState(false)
+ const [guide4SummaryVisible, setGuide4SummaryVisible] = useState(false)
+ const [guide4SummaryStep, setGuide4SummaryStep] = useState(0)
const [endingVisible, setEndingVisible] = useState(false)
// 光标2的对话状态
@@ -94,27 +104,18 @@ const MaozedongPlace4 = () => {
setGuide2Visible(true)
setQuizStep(0)
}
- if (id === 4) {
- setGuide4Step(0)
- setGuide4Visible(true)
+ if (id === 3) {
+ setGuide3Step(0)
+ setGuide3Visible(true)
}
}
- // 光标3和光标5都已查看后,弹出一次总结对话(图1)
- useEffect(() => {
- if (guide35Shown) return
- if (!visited.has(3) || !visited.has(5)) return
- setGuide35Step(0)
- setGuide35Visible(true)
- setGuide35Shown(true)
- }, [visited, guide35Shown])
-
if (!moduleName) return null
const progressCount = visited.size
const total = 5
const completed = progressCount >= total
- const subtitle = completed ? '关于中共四大(已完成)' : `关于中共四大(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
return (
{
className="absolute top-10 left-1/2 -translate-x-1/2"
/>
+
{
+ if (moduleName && starName) {
+ navigate(`/${moduleName}/${starName}/place3`)
+ }
+ }}
+ />
+
{completed && (
{
highlightSelectors={[]}
zIndex={10001}
>
-
+
看来中共四大明确提出了“无产阶级领导权”和“工农联盟”,这和毛主席的实践有关吗?
-
+
太有关系了!中共三大后毛主席到上海工作了一段时间,看到工人运动轰轰烈烈,
但农民还是“沉默的大多数”。
@@ -249,29 +264,37 @@ const MaozedongPlace4 = () => {
highlightSelectors={[]}
zIndex={10001}
>
- 那中共四大为什么召开呢?
-
+
+ 那中共四大为什么召开呢?
+
+
因为此前中共三大虽然确定了国共合作的方针,但未明确解决
“谁领导革命”的问题。我们必须要明确“谁领导革命”“依靠谁革命” 这两个基本问题。
- {/* 光标3 + 光标5 都已查看后的对话(图1) */}
+ {/* 光标3的对话:点击展品3时出现(图1) */}
{
- setGuide35Visible(nextVisible)
+ setGuide3Visible(nextVisible)
}}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
-
+
所以毛主席觉得,光靠工人不够,得把农民拉进革命队伍?
-
+
对!无产阶级要领导革命,必须联合农民——他们是工人阶级的“天然盟友”。
四大虽然还没有明确提出“农村包围城市”,但已经意识到农民问题的重要性。
@@ -281,36 +304,30 @@ const MaozedongPlace4 = () => {
- {/* 光标4打开展品4时出现的对话(图2、图3) */}
+ {/* 光标4视频结束后出现的总结对话(图1) */}
{
- setGuide4Visible(nextVisible)
+ setGuide4SummaryVisible(nextVisible)
+ if (!nextVisible) {
+ setEndingVisible(true)
+ }
}}
showMask={false}
highlightSelectors={[]}
zIndex={10001}
>
-
- 这张照片看起来就是在四大前后拍摄的,那为什么全家福中缺少了男主人呢?
-
-
- 这是1924年杨开慧与毛岸英、毛岸青的合影,是这个家庭留下的唯一一张“全家福”,
- 被誉为“不完美的完美全家福”。说它“不完美”,是因为当时党的纪律要求重要人物不得拍照,“全家福”里缺少了男主人。
-
-
- 为何又要说它“完美”呢?是因为它完美地诠释了共产党人严格遵守党的规章制度,
- 舍小家为大家的家国情怀。六年后,照片中的杨开慧在湖南牺牲。二十六年后,毛岸英在朝鲜战场牺牲。
- 为了中国的革命事业,毛主席一家先后牺牲了六位亲人……
-
-
+
为有牺牲多壮志,敢教日月换新天。小沪,通过您的讲解,
我明白了中共一大到四大是在艰难探索中不断前进的——从建党的理想,
到制定民主革命纲领;从国共合作,到发现农民的力量。这些探索对我们今天有什么启示呢?
-
+
同学问到了根子上!革命就像爬山,一开始可能看不清山顶的样子,
但只要沿着正确的方向,一步一步扎扎实实地走,总会离目标越来越近。
@@ -320,7 +337,7 @@ const MaozedongPlace4 = () => {
把这些道理想通了,将来不管做什么事,都能脚踏实地。
- “实事求是”这四个字, 是我们党一路走来的法宝,希望你也能记住。
+ “实事求是”这四个字,是我们党一路走来的法宝,希望你也能记住。
@@ -328,6 +345,23 @@ const MaozedongPlace4 = () => {
{
+ if (activeExhibitId === 2) {
+ triggerPuzzleAnimation(8)
+ }
+ if (activeExhibitId === 1) {
+ triggerPuzzleAnimation(9)
+ }
+ if (activeExhibitId === 3) {
+ triggerPuzzleAnimation(10)
+ }
+ if (activeExhibitId === 4) {
+ triggerPuzzleAnimation(12)
+ }
+ if (activeExhibitId === 5) {
+ triggerPuzzleAnimation(11)
+ }
+ }}
onClose={() => {
const currentId = activeExhibitId
@@ -340,12 +374,17 @@ const MaozedongPlace4 = () => {
return
}
- // 展品4:关闭后出现结束语(EndingOverlay)
+ // 展品4:关闭后播放视频,视频结束后显示总结对话,并立即标记已访问
if (currentId === 4) {
- setGuide4Step(0)
- setGuide4Visible(false)
setActiveExhibitId(null)
- setEndingVisible(true)
+ setVisited((prev) => {
+ const next = new Set(prev)
+ next.add(4)
+ markVisited('place4', 4)
+ saveMaozedongStore()
+ return next
+ })
+ setShowVideo4(true)
return
}
@@ -353,6 +392,8 @@ const MaozedongPlace4 = () => {
setVisited((prev) => {
const next = new Set(prev)
next.add(currentId)
+ markVisited('place4', currentId)
+ saveMaozedongStore()
return next
})
}
@@ -360,27 +401,42 @@ const MaozedongPlace4 = () => {
}}
/>
- {/* 结束语(图4):关闭展品4后出现 */}
{
setEndingVisible(false)
- // 结束语关闭后,标记展品4为已查看
- setVisited((prev) => {
- const next = new Set(prev)
- next.add(4)
- return next
- })
}}
animationSpeed={200}
/>
+ {/* 光标4:展品关闭后播放的视频 */}
+ {showVideo4 && (
+ {
+ setShowVideo4(false)
+ setGuide4SummaryStep(0)
+ setGuide4SummaryVisible(true)
+ }}
+ onEnded={() => {
+ setShowVideo4(false)
+ setGuide4SummaryStep(0)
+ setGuide4SummaryVisible(true)
+ }}
+ />
+ )}
+
{/* 光标2:展品关闭后播放的视频 */}
{showVideo2 && (
{
content={successAlertContent}
onContinue={() => {
setSuccessAlertVisible(false)
+ //答题结束,标记展品4为已查看
+ markVisited('place4', 2)
+
// 进入下一题
setQuizStep((prev) => {
const next = prev + 1
diff --git a/src/pages/maozedong/reflection.tsx b/src/pages/maozedong/reflection.tsx
new file mode 100644
index 0000000..c6e15d3
--- /dev/null
+++ b/src/pages/maozedong/reflection.tsx
@@ -0,0 +1,482 @@
+import {
+ SingleChoiceQuizPanel,
+ Video,
+ type VideoAnchor,
+ type VideoRef,
+ EndingOverlay,
+ StudyGuideModal,
+} from '@/components'
+import { SuccessAlert } from '@/components/layout'
+import { cn, getStaticImage } from '@/lib/utils'
+import { getPublicPath } from '@/lib/utils'
+import { useRef, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+
+// 光标组件 - 带旋转动画
+// Safari 兼容:使用 onTouchEnd 和 -webkit-tap-highlight-color
+const Cursor = ({ className, onClick }: { className?: string; onClick: () => void }) => (
+
{
+ e.preventDefault() // 防止 Safari 触发两次
+ onClick()
+ }}
+ style={{
+ WebkitTapHighlightColor: 'transparent',
+ WebkitTouchCallout: 'none',
+ WebkitUserSelect: 'none',
+ touchAction: 'manipulation',
+ }}
+ src={getStaticImage('components/光标')}
+ alt=""
+ draggable={false}
+ />
+)
+
+const Home = () => {
+ const bgmRef = useRef(null)
+ const videoRef = useRef(null)
+ const navigate = useNavigate()
+ const [showGuideModal, setShowGuideModal] = useState(true)
+ const [endingVisible, setEndingVisible] = useState(false)
+
+ const moduleName = 'maozedong'
+
+ // 播放 BGM
+ const playBGM = () => {
+ bgmRef.current?.play().catch(() => {})
+ }
+
+ const QuizCursor = ({ resume, unmute }: { resume: () => void; unmute: () => void }) => {
+ const [visible, setVisible] = useState(false)
+ const [showSuccessAlert, setShowSuccessAlert] = useState(false)
+ const hasResumedRef = useRef(false)
+
+ const options = [
+ {
+ value: 'A',
+ label: 'A. 基层情况对决策至关重要,关乎无产阶级领导权、工农联盟议题能否贴合实际',
+ },
+ {
+ value: 'B',
+ label: 'B. 深入基层,收集各地党组织、工农运动实情,再推进筹备等备',
+ },
+ ]
+
+ return (
+ <>
+ {
+ unmute()
+ setVisible(true)
+ }}
+ />
+ {
+ if (!isCorrect) return
+ setVisible(false)
+ setShowSuccessAlert(true)
+ }}
+ />
+ {
+ if (hasResumedRef.current) return
+ hasResumedRef.current = true
+ setShowSuccessAlert(false)
+ resume()
+ }}
+ onClose={() => {
+ if (hasResumedRef.current) return
+ hasResumedRef.current = true
+ setShowSuccessAlert(false)
+ resume()
+ }}
+ />
+ >
+ )
+ }
+
+ const QuizCursor2 = ({ resume, unmute }: { resume: () => void; unmute: () => void }) => {
+ const [visible, setVisible] = useState(false)
+ const [showSuccessAlert, setShowSuccessAlert] = useState(false)
+ const hasResumedRef = useRef(false)
+
+ const options = [
+ {
+ value: 'A',
+ label: 'A. 以强硬指令,要求各地限时上报意见',
+ },
+ {
+ value: 'B',
+ label: 'B. 讲清意见对四大讨论无产阶级领导权、工农联盟的重要性,以革命大义动员党员',
+ },
+ ]
+
+ return (
+ <>
+ {
+ unmute()
+ setVisible(true)
+ }}
+ />
+ {
+ if (!isCorrect) return
+ setVisible(false)
+ setShowSuccessAlert(true)
+ }}
+ />
+ {
+ if (hasResumedRef.current) return
+ hasResumedRef.current = true
+ setShowSuccessAlert(false)
+ resume()
+ }}
+ onClose={() => {
+ if (hasResumedRef.current) return
+ hasResumedRef.current = true
+ setShowSuccessAlert(false)
+ resume()
+ }}
+ />
+ >
+ )
+ }
+
+ const EndingCursor = ({ resume, unmute }: { resume: () => void; unmute: () => void }) => {
+ const [questionVisible, setQuestionVisible] = useState(false)
+ // const [endingVisible, setEndingVisible] = useState(false)
+ // const hasResumedRef = useRef(false)
+
+ const options = [
+ {
+ value: 'A',
+ label: 'A. 仅安心调养',
+ },
+ {
+ value: 'B',
+ label: 'B. 在调养修整后调研农民运动',
+ },
+ ]
+
+ return (
+ <>
+ {
+ unmute()
+ setQuestionVisible(true)
+ }}
+ />
+ {
+ if (!isCorrect) return
+ setQuestionVisible(false)
+ setEndingVisible(true)
+ }}
+ />
+ {/* {
+ if (hasResumedRef.current) return
+ hasResumedRef.current = true
+ setEndingVisible(false)
+ resume()
+ navigate(`/${moduleName}/maozedong/home`)
+ }}
+ /> */}
+ >
+ )
+ }
+
+ const anchors: VideoAnchor[] = [
+ {
+ id: 'anchor-0',
+ time: 3,
+ afterResume: 'hide', // 恢复后隐藏,seek 回来可重新触发
+ render: ({ resume, unmute }) => (
+ {
+ unmute()
+ resume()
+ }}
+ />
+ ),
+ },
+ {
+ id: 'anchor-1',
+ time: 8,
+ afterResume: 'hide', // 恢复后隐藏,seek 回来可重新触发
+ onTrigger: () => console.log('埋点:锚点 1 触发'),
+ render: ({ resume, unmute }) => (
+ {
+ unmute()
+ resume()
+ }}
+ />
+ ),
+ },
+ {
+ id: 'anchor-2',
+ time: 22,
+ afterResume: 'hide',
+ render: ({ resume, unmute }) => (
+ {
+ unmute()
+ resume()
+ }}
+ />
+ ),
+ },
+ {
+ id: 'anchor-3',
+ time: 32,
+ afterResume: 'hide',
+ render: ({ resume, unmute }) => (
+ {
+ unmute()
+ resume()
+ }}
+ />
+ ),
+ },
+ {
+ id: 'anchor-4',
+ time: 40,
+ afterResume: 'hide',
+ render: ({ resume, unmute }) => (
+ {
+ unmute()
+ resume()
+ }}
+ />
+ ),
+ },
+ {
+ id: 'anchor-4-1',
+ time: 52,
+ afterResume: 'hide',
+ render: ({ resume, unmute }) => (
+ {
+ unmute()
+ resume()
+ }}
+ />
+ ),
+ },
+ {
+ id: 'anchor-5',
+ time: 56,
+ afterResume: 'hide',
+ render: ({ resume, unmute }) => (
+ {
+ unmute()
+ resume()
+ }}
+ />
+ ),
+ },
+ {
+ id: 'anchor-6',
+ time: 74,
+ afterResume: 'hide',
+ render: ({ resume, unmute }) => (
+ {
+ unmute()
+ resume()
+ }}
+ />
+ ),
+ },
+ {
+ id: 'anchor-6-1',
+ time: 83,
+ afterResume: 'hide',
+ render: ({ resume, unmute }) => ,
+ },
+ {
+ id: 'anchor-7',
+ time: 88,
+ afterResume: 'hide',
+ render: ({ resume, unmute }) => (
+ {
+ unmute()
+ resume()
+ }}
+ />
+ ),
+ },
+ {
+ id: 'anchor-8',
+ time: 95,
+ afterResume: 'hide',
+ render: ({ resume, unmute }) => (
+ {
+ unmute()
+ resume()
+ }}
+ />
+ ),
+ },
+ {
+ id: 'anchor-9',
+ time: 107,
+ afterResume: 'hide',
+ render: ({ resume, unmute }) => (
+ {
+ unmute()
+ resume()
+ }}
+ />
+ ),
+ },
+ {
+ id: 'anchor-10',
+ time: 116,
+ afterResume: 'hide',
+ render: ({ resume, unmute }) => ,
+ },
+ {
+ id: 'anchor-11',
+ time: 120,
+ afterResume: 'hide',
+ render: ({ resume, unmute }) => (
+ {
+ unmute()
+ resume()
+ }}
+ />
+ ),
+ },
+ {
+ id: 'anchor-12',
+ time: 127,
+ afterResume: 'hide',
+ render: ({ resume, unmute }) => (
+ {
+ unmute()
+ resume()
+ }}
+ />
+ ),
+ },
+ {
+ id: 'anchor-13',
+ time: 141,
+ afterResume: 'hide',
+ render: ({ resume, unmute }) => (
+ {
+ unmute()
+ resume()
+ }}
+ />
+ ),
+ },
+ {
+ id: 'anchor-14',
+ time: 153,
+ afterResume: 'hide',
+ render: ({ resume, unmute }) => ,
+ },
+ ]
+
+ return (
+
+ {/* BGM 循环播放 */}
+
+
{
+ setEndingVisible(true)
+ }}
+ />
+
+ {
+ setShowGuideModal(false)
+ playBGM()
+ videoRef.current?.play()
+ }}
+ >
+ {`同学你好,欢迎来到“跨越时空·体悟”学习模块!
+请登陆“时空回溯・亲历时代”体验仓,下一站:1924年,上海。
+1924年的上海,革命浪潮暗涌,中共四大筹备工作关乎革命前途。无产阶级领导权、工农联盟等核心议题亟待明确,毛泽东同志在筹备关键节点面临诸多重要抉择。请你化身历史见证者,站在毛主席的视角做出选择,解锁四大筹备的关键密码,感受革命先辈的坚定与智慧!完成三次选择后,可进入下一阶段的学习。时空门已开,请做好准备迎接考验吧!`}
+
+
+ {
+ setEndingVisible(false)
+ navigate(`/${moduleName}/maozedong/home`)
+ }}
+ />
+
+ )
+}
+
+export default Home
diff --git a/src/pages/nieer/components/BeijingUnlockInteraction.tsx b/src/pages/nieer/components/BeijingUnlockInteraction.tsx
new file mode 100644
index 0000000..5ba4058
--- /dev/null
+++ b/src/pages/nieer/components/BeijingUnlockInteraction.tsx
@@ -0,0 +1,335 @@
+import { DragFill, Image, type DragFillOption, type DragFillResult } from '@/components'
+import { cn, getStaticImage } from '@/lib/utils'
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { toJpeg } from 'html-to-image'
+
+type ParadeOption = {
+ id: string
+ name: string
+ src: string
+}
+
+type SloganOption = {
+ id: string
+ src: string
+}
+
+const PARADE_OPTIONS: ParadeOption[] = [
+ {
+ id: 'amphibious',
+ name: '两栖突击方队',
+ src: 'nieer/reflection/lobby/content/场景6两栖突击方队',
+ },
+ {
+ id: 'air-escort',
+ name: '空中护旗梯队',
+ src: 'nieer/reflection/lobby/content/场景6空中护旗梯队',
+ },
+ {
+ id: 'df5c',
+ name: '东风-5C液体洲际战略核导弹',
+ src: 'nieer/reflection/lobby/content/场景6“东风-5C”液体洲际战略核导弹',
+ },
+ {
+ id: 'sea-unmanned',
+ name: '海上无人作战方队',
+ src: 'nieer/reflection/lobby/content/场景6海上无人作战方队',
+ },
+ {
+ id: 'honor-guard',
+ name: '中国人民解放军仪仗方队',
+ src: 'nieer/reflection/lobby/content/场景6中国人民解放军仪仗方队',
+ },
+ { id: 'rocket-force', name: '火箭军方队', src: 'nieer/reflection/lobby/content/场景6火箭军方队' },
+]
+
+const SLOGAN_OPTIONS: SloganOption[] = [
+ { id: 's1', src: 'nieer/reflection/lobby/content/场景6文字1' },
+ { id: 's2', src: 'nieer/reflection/lobby/content/场景6文字2' },
+ { id: 's3', src: 'nieer/reflection/lobby/content/场景6文字3' },
+ { id: 's4', src: 'nieer/reflection/lobby/content/场景6文字4' },
+ { id: 's5', src: 'nieer/reflection/lobby/content/场景6文字5' },
+]
+
+type BeijingUnlockInteractionProps = {
+ onFinished: () => void
+ onSelectionChange?: (selection: {
+ paradeId?: string
+ paradeName?: string
+ paradeImagePath?: string
+ sloganId?: string
+ sloganImagePath?: string
+ previewImageDataUrl?: string
+ }) => void
+}
+
+export default function BeijingUnlockInteraction({
+ onFinished,
+ onSelectionChange,
+}: BeijingUnlockInteractionProps) {
+ const [paradeFill, setParadeFill] = useState([])
+ const [sloganFill, setSloganFill] = useState([])
+ const finishedRef = useRef(false)
+ const previewRef = useRef(null)
+
+ const paradeValue = paradeFill.find((v) => v.blankId === 'parade')?.optionValue ?? null
+ const sloganValue = sloganFill.find((v) => v.blankId === 'slogan')?.optionValue ?? null
+
+ const selectedParade = useMemo(
+ () =>
+ paradeValue
+ ? (PARADE_OPTIONS.find((opt) => opt.id === paradeValue.replace('parade:', '')) ?? null)
+ : null,
+ [paradeValue]
+ )
+ const selectedSlogan = useMemo(
+ () =>
+ sloganValue
+ ? (SLOGAN_OPTIONS.find((opt) => opt.id === sloganValue.replace('slogan:', '')) ?? null)
+ : null,
+ [sloganValue]
+ )
+
+ useEffect(() => {
+ onSelectionChange?.({
+ paradeId: selectedParade?.id,
+ paradeName: selectedParade?.name,
+ paradeImagePath: selectedParade?.src,
+ sloganId: selectedSlogan?.id,
+ sloganImagePath: selectedSlogan?.src,
+ })
+ }, [onSelectionChange, selectedParade, selectedSlogan])
+
+ useEffect(() => {
+ let cancelled = false
+ const waitImagesReady = async (root: HTMLElement): Promise => {
+ const images = Array.from(root.querySelectorAll('img'))
+ await Promise.all(
+ images.map(
+ (img) =>
+ new Promise((resolve) => {
+ if (img.complete && img.naturalWidth > 0) {
+ resolve()
+ return
+ }
+ const done = () => resolve()
+ img.addEventListener('load', done, { once: true })
+ img.addEventListener('error', done, { once: true })
+ })
+ )
+ )
+ }
+ const capturePreview = async () => {
+ if (!selectedParade || !selectedSlogan) return
+ const previewEl = previewRef.current
+ if (!previewEl) return
+ try {
+ await waitImagesReady(previewEl)
+ await new Promise((resolve) => requestAnimationFrame(() => resolve(null)))
+ const dataUrl = await toJpeg(previewEl, {
+ cacheBust: false,
+ skipFonts: true,
+ quality: 0.72,
+ pixelRatio: 1,
+ canvasWidth: Math.floor(previewEl.offsetWidth * 0.66),
+ canvasHeight: Math.floor(previewEl.offsetHeight * 0.66),
+ })
+ if (cancelled) return
+ onSelectionChange?.({
+ paradeId: selectedParade.id,
+ paradeName: selectedParade.name,
+ paradeImagePath: selectedParade.src,
+ sloganId: selectedSlogan.id,
+ sloganImagePath: selectedSlogan.src,
+ previewImageDataUrl: dataUrl,
+ })
+ if (!finishedRef.current) {
+ finishedRef.current = true
+ onFinished()
+ }
+ } catch {
+ // ignore screenshot failure, fallback to image-path based display
+ }
+ }
+ capturePreview()
+ return () => {
+ cancelled = true
+ }
+ }, [onFinished, onSelectionChange, selectedParade, selectedSlogan])
+
+ const paradeDragOptions: DragFillOption[] = PARADE_OPTIONS.map((opt) => ({
+ value: `parade:${opt.id}`,
+ label: opt.name,
+ }))
+ const sloganDragOptions: DragFillOption[] = SLOGAN_OPTIONS.map((opt) => ({
+ value: `slogan:${opt.id}`,
+ label: opt.id,
+ }))
+
+ return (
+
+
+
+
+
+ 阅兵方阵
+
+
+
+
{
+ // 只保留 parade 类型填充值,防止误投
+ const valid = next.filter(
+ (v) => v.blankId === 'parade' && v.optionValue.startsWith('parade:')
+ )
+ setParadeFill(valid.slice(-1))
+ }}
+ showSubmitButton={false}
+ hideLabels
+ layout="horizontal-options"
+ optionClassName="gap-3 grid grid-cols-3 py-4"
+ blankClassName="hidden"
+ // 这里不要渲染同名 blank 节点,避免覆盖右侧真实释放区命中
+ renderBlank={() => null}
+ renderOption={(option, isUsed, isDragging) => {
+ const item = PARADE_OPTIONS.find((opt) => `parade:${opt.id}` === option.value)
+ if (!item) return null
+ return (
+ {
+ if (isUsed) return
+ setParadeFill([{ blankId: 'parade', optionValue: option.value }])
+ }}
+ >
+
+
+ {item.name}
+
+
+ )
+ }}
+ renderDragging={(option) => {
+ const item = PARADE_OPTIONS.find((opt) => `parade:${opt.id}` === option.value)
+ if (!item) return null
+ return (
+
+ )
+ }}
+ />
+
+
+ {/* 方阵展示区 */}
+
+ {/* 释放区域 */}
+
+
+
+
+ {selectedParade && (
+ <>
+
+ {selectedParade.name}
+
+
+
+
+ >
+ )}
+ {selectedSlogan && (
+
+ )}
+
+
+
+
+
+
词条:
+
+
{
+ const valid = next.filter(
+ (v) => v.blankId === 'slogan' && v.optionValue.startsWith('slogan:')
+ )
+ setSloganFill(valid.slice(-1))
+ }}
+ showSubmitButton={false}
+ hideLabels
+ layout="horizontal-options"
+ optionClassName="flex items-center justify-center gap-0"
+ blankClassName="hidden"
+ // 这里不要渲染同名 blank 节点,避免覆盖右侧真实释放区命中
+ renderBlank={() => null}
+ renderOption={(option, isUsed, isDragging) => {
+ const item = SLOGAN_OPTIONS.find((opt) => `slogan:${opt.id}` === option.value)
+ if (!item) return null
+ return (
+ {
+ if (isUsed) return
+ setSloganFill([{ blankId: 'slogan', optionValue: option.value }])
+ }}
+ />
+ )
+ }}
+ renderDragging={(option) => {
+ const item = SLOGAN_OPTIONS.find((opt) => `slogan:${opt.id}` === option.value)
+ if (!item) return null
+ return (
+
+ )
+ }}
+ />
+
+
+
+ )
+}
diff --git a/src/pages/nieer/components/InputBg.tsx b/src/pages/nieer/components/InputBg.tsx
new file mode 100644
index 0000000..2f4d621
--- /dev/null
+++ b/src/pages/nieer/components/InputBg.tsx
@@ -0,0 +1,74 @@
+import { cn, getBackgroundImage, getStaticImage } from '@/lib/utils'
+import { type ChangeEvent, type InputHTMLAttributes } from 'react'
+
+export interface InputBgProps extends Omit<
+ InputHTMLAttributes,
+ 'value' | 'onChange' | 'className'
+> {
+ value: string
+ onChange: (value: string) => void
+ className?: string
+ inputClassName?: string
+ /**
+ * 背景图路径(不需要写后缀),例如:`nieer/reflection/lobby/输入`
+ */
+ bgImagePath?: string
+}
+
+/**
+ * 背景图输入框(无边框/无默认样式)。
+ * 默认背景:`src/assets/images/nieer/reflection/lobby/输入.png`
+ */
+export function InputBg({
+ value,
+ onChange,
+ className,
+ inputClassName,
+ bgImagePath = 'nieer/reflection/lobby/输入',
+ placeholder,
+ disabled,
+ ...rest
+}: InputBgProps) {
+ const handleChange = (e: ChangeEvent) => {
+ onChange(e.target.value)
+ }
+
+ return (
+
+
})
+
+
+
+ )
+}
+
+export default InputBg
diff --git a/src/pages/nieer/components/PianoUnlockInteraction.tsx b/src/pages/nieer/components/PianoUnlockInteraction.tsx
new file mode 100644
index 0000000..92f4782
--- /dev/null
+++ b/src/pages/nieer/components/PianoUnlockInteraction.tsx
@@ -0,0 +1,160 @@
+import { Image } from '@/components'
+import { getPublicPath, getStaticImage } from '@/lib/utils'
+import { useAudioManager } from '@/hooks/useAudioManager'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+
+type PianoUnlockInteractionProps = {
+ /**
+ * 音频全部播完(显示交互 9 后播放 `all.wav` 并结束)时回调
+ * 用于通知外层显示“分享”按钮。
+ */
+ onFinished: () => void
+}
+
+/**
+ * 欧洲·亚洲(sceneId=3)UnlockHintDialog:钢琴交互
+ * - 默认显示 交互 1
+ * - 点击依次播放 1~7.wav(每段结束后切到下一张交互图)
+ * - 当显示交互 8 时,点击播放 all.wav,并切到交互 9;播放结束后 onFinished
+ */
+export default function PianoUnlockInteraction({ onFinished }: PianoUnlockInteractionProps) {
+ const [step, setStep] = useState(1) // 1..9
+
+ // 选择一个独立 channel,避免与其它对话/旁白音频互相抢占
+ const {
+ play,
+ stop,
+ isPlaying: audioIsPlaying,
+ currentSrc: audioCurrentSrc,
+ } = useAudioManager({
+ mode: 'channel',
+ channelId: 'nieer-unlock-piano',
+ })
+
+ const expectedAudioSrcRef = useRef(null)
+ const nextStepRef = useRef(null)
+ const startedRef = useRef(false)
+ const allFinishedRef = useRef(false)
+
+ const imageSrc = useMemo(() => {
+ return getStaticImage(`nieer/reflection/lobby/piano/弹琴交互${step}`)
+ }, [step])
+
+ // 卸载时清理音频
+ useEffect(() => {
+ return () => {
+ stop()
+ }
+ }, [stop])
+
+ // 点击映射:
+ // - step=1 播放 1 -> 2
+ // - step=2 播放 2 -> 3
+ // - step=3 点击后只切到 4(不播放),以满足“点击过3和4才播放3音频”
+ // - step=4 播放 3 -> 5
+ // - step=5 播放 4 -> 6
+ // - step=6 播放 5 -> 7
+ // - step=7 播放 6 -> 8
+ // - step=8 播放 7 -> 9(到 9 后自动播放 all.wav 并结束)
+ const trigger = useCallback(() => {
+ if (audioIsPlaying) return
+ if (step >= 9) return
+
+ const playSeg = (segIndex: number, after: number) => {
+ const segSrc = getPublicPath(`audio/nieer/reflection/${segIndex}.wav`)
+ expectedAudioSrcRef.current = segSrc
+ nextStepRef.current = after
+ startedRef.current = false
+ play(segSrc)
+ }
+
+ if (step === 1) {
+ playSeg(1, 2)
+ return
+ }
+ if (step === 2) {
+ playSeg(2, 3)
+ return
+ }
+ if (step === 3) {
+ // 关键:step3 先不播放,直接推进到 step4
+ expectedAudioSrcRef.current = null
+ nextStepRef.current = null
+ startedRef.current = false
+ setStep(4)
+ return
+ }
+ if (step === 4) {
+ playSeg(3, 5)
+ return
+ }
+ if (step === 5) {
+ playSeg(4, 6)
+ return
+ }
+ if (step === 6) {
+ playSeg(5, 7)
+ return
+ }
+ if (step === 7) {
+ playSeg(6, 8)
+ return
+ }
+ if (step === 8) {
+ playSeg(7, 9)
+ }
+ }, [audioIsPlaying, play, setStep, step])
+
+ // 播放开始/结束判定(用于“1~7段音频”)
+ useEffect(() => {
+ const expected = expectedAudioSrcRef.current
+ if (!expected) return
+
+ // 只有当我们已经“确实开始播放”(isPlaying==true)后,才认为已结束。
+ if (audioIsPlaying && audioCurrentSrc === expected) {
+ startedRef.current = true
+ return
+ }
+
+ if (!audioIsPlaying && startedRef.current && audioCurrentSrc === expected) {
+ startedRef.current = false
+ expectedAudioSrcRef.current = null
+
+ const nextStep = nextStepRef.current
+ nextStepRef.current = null
+
+ if (nextStep) {
+ setStep(nextStep)
+ } else {
+ // all.wav 播完后标记结束,避免 step=9 effect 重复触发
+ allFinishedRef.current = true
+ onFinished()
+ }
+ }
+ }, [audioCurrentSrc, audioIsPlaying, onFinished])
+
+ // step=9:自动播放 all.wav 并结束
+ useEffect(() => {
+ if (step !== 9) return
+ if (allFinishedRef.current) return
+
+ const allSrc = getPublicPath('audio/nieer/reflection/all.wav')
+ // 如果已经在播放/已播放到 all,就不重复触发
+ if (expectedAudioSrcRef.current === allSrc && audioIsPlaying) return
+
+ expectedAudioSrcRef.current = allSrc
+ nextStepRef.current = null
+ startedRef.current = false
+ play(allSrc)
+ }, [audioIsPlaying, play, step])
+
+ return (
+ trigger()}
+ />
+ )
+}
diff --git a/src/pages/nieer/components/PostcardUnlockInteraction.tsx b/src/pages/nieer/components/PostcardUnlockInteraction.tsx
new file mode 100644
index 0000000..379f7f0
--- /dev/null
+++ b/src/pages/nieer/components/PostcardUnlockInteraction.tsx
@@ -0,0 +1,131 @@
+import { Image } from '@/components'
+import { getStaticImage } from '@/lib/utils'
+import { useEffect, useMemo, useRef, useState } from 'react'
+
+type DragItemId = 'item1' | 'item2'
+
+type PostcardUnlockInteractionProps = {
+ /** 两个素材都完成放置时回调(用于外层开启分享按钮) */
+ onFinished: () => void
+ /** 拖拽状态变化回调(用于旅行箱保存成果) */
+ onStateChange?: (state: {
+ hasPlane: boolean
+ hasText: boolean
+ postcardImagePath: string
+ }) => void
+}
+
+export default function PostcardUnlockInteraction({
+ onFinished,
+ onStateChange,
+}: PostcardUnlockInteractionProps) {
+ const [dropped, setDropped] = useState>({
+ item1: false,
+ item2: false,
+ })
+ const finishedRef = useRef(false)
+
+ const postcardSrc = useMemo(() => {
+ const has1 = dropped.item1
+ const has2 = dropped.item2
+ if (has1 && has2) return getStaticImage('nieer/reflection/lobby/content/场景5全部反馈')
+ if (has1) return getStaticImage('nieer/reflection/lobby/content/场景5有飞机反馈')
+ if (has2) return getStaticImage('nieer/reflection/lobby/content/场景5有文字反馈')
+ return getStaticImage('nieer/reflection/lobby/content/场景5待交互')
+ }, [dropped.item1, dropped.item2])
+
+ useEffect(() => {
+ const has1 = dropped.item1
+ const has2 = dropped.item2
+ const postcardImagePath =
+ has1 && has2
+ ? 'nieer/reflection/lobby/content/场景5全部反馈'
+ : has1
+ ? 'nieer/reflection/lobby/content/场景5有飞机反馈'
+ : has2
+ ? 'nieer/reflection/lobby/content/场景5有文字反馈'
+ : 'nieer/reflection/lobby/content/场景5待交互'
+ onStateChange?.({
+ hasPlane: has1,
+ hasText: has2,
+ postcardImagePath,
+ })
+ }, [dropped.item1, dropped.item2, onStateChange])
+
+ const placeItem = (id: DragItemId) => {
+ setDropped((prev) => {
+ if (prev[id]) return prev
+ const next = { ...prev, [id]: true }
+ if (next.item1 && next.item2 && !finishedRef.current) {
+ finishedRef.current = true
+ onFinished()
+ }
+ return next
+ })
+ }
+
+ const handleDragStart = (id: DragItemId) => (event: React.DragEvent) => {
+ event.dataTransfer.setData('text/plain', id)
+ event.dataTransfer.effectAllowed = 'move'
+ }
+
+ const handleDrop = (event: React.DragEvent) => {
+ event.preventDefault()
+ const id = event.dataTransfer.getData('text/plain') as DragItemId
+ if (id === 'item1' || id === 'item2') {
+ placeItem(id)
+ }
+ }
+
+ return (
+
+
+
+ {/* 明信片投放区域:拖入任一素材即更新对应反馈图 */}
+
e.preventDefault()}
+ onDrop={handleDrop}
+ />
+
+ {!dropped.item1 && (
+ placeItem('item1')}
+ />
+ )}
+
+ {!dropped.item2 && (
+ placeItem('item2')}
+ />
+ )}
+
+ {/* 完成:两个素材都拖拽完成后显示 */}
+ {dropped.item1 && dropped.item2 && (
+
+ )}
+
+ )
+}
diff --git a/src/pages/nieer/components/SelectBg.tsx b/src/pages/nieer/components/SelectBg.tsx
new file mode 100644
index 0000000..b6f7430
--- /dev/null
+++ b/src/pages/nieer/components/SelectBg.tsx
@@ -0,0 +1,214 @@
+import { cn, getBackgroundImage, getStaticImage } from '@/lib/utils'
+import { createPortal } from 'react-dom'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+
+export type SelectBgOption = {
+ value: string
+ label: string
+ disabled?: boolean
+}
+
+export interface SelectBgProps {
+ options: SelectBgOption[]
+ value: string
+ onChange: (value: string) => void
+ placeholder?: string
+ disabled?: boolean
+ className?: string
+ triggerClassName?: string
+ dropdownClassName?: string
+ optionClassName?: string
+ zIndex?: number
+ /**
+ * 未选中背景(默认从 `assets/images/nieer/reflection/lobby/select/未选中.png` 读取)
+ * 传入时不需要写后缀名,比如:`nieer/reflection/lobby/select/未选中`
+ */
+ unselectedBgPath?: string
+ /**
+ * 选中背景(默认从 `assets/images/nieer/reflection/lobby/select/选中.png` 读取)
+ * 传入时不需要写后缀名,比如:`nieer/reflection/lobby/select/选中`
+ */
+ selectedBgPath?: string
+}
+
+const DEFAULT_TRIGGER_BG_PATH = 'nieer/reflection/lobby/输入'
+const DEFAULT_UNSELECTED_BG_PATH = 'nieer/reflection/lobby/select/未选中'
+const DEFAULT_SELECTED_BG_PATH = 'nieer/reflection/lobby/select/选中'
+
+/**
+ * Select - 背景图状态的下拉选择(无默认边框/外观样式)
+ */
+export function SelectBg({
+ options,
+ value,
+ onChange,
+ placeholder = '请选择',
+ disabled = false,
+ className,
+ triggerClassName,
+ dropdownClassName,
+ optionClassName,
+ zIndex = 1000,
+ unselectedBgPath = DEFAULT_UNSELECTED_BG_PATH,
+ selectedBgPath = DEFAULT_SELECTED_BG_PATH,
+}: SelectBgProps) {
+ const [isOpen, setIsOpen] = useState(false)
+ const [hoveredValue, setHoveredValue] = useState
(null)
+ const [dropdownPosition, setDropdownPosition] = useState<{
+ top: number
+ left: number
+ width: number
+ } | null>(null)
+
+ const triggerRef = useRef(null)
+ const dropdownRef = useRef(null)
+
+ const selectedOption = useMemo(() => options.find((opt) => opt.value === value), [options, value])
+
+ const updateDropdownPosition = useCallback(() => {
+ const el = triggerRef.current
+ if (!el) return
+ const rect = el.getBoundingClientRect()
+ setDropdownPosition({
+ top: rect.top + window.scrollY,
+ left: rect.left + window.scrollX,
+ width: rect.width,
+ })
+ }, [])
+
+ const close = useCallback(() => setIsOpen(false), [])
+
+ const open = useCallback(() => {
+ if (disabled) return
+ setIsOpen(true)
+ // 等到下一帧确保布局稳定再计算位置
+ setTimeout(() => updateDropdownPosition(), 0)
+ }, [disabled, updateDropdownPosition])
+
+ const handleSelect = useCallback(
+ (nextValue: string) => {
+ const opt = options.find((o) => o.value === nextValue)
+ if (opt?.disabled) return
+ onChange(nextValue)
+ close()
+ },
+ [close, onChange, options]
+ )
+
+ useEffect(() => {
+ if (!isOpen) return
+
+ const onMouseDown = (event: MouseEvent) => {
+ const target = event.target as Node
+ if (
+ triggerRef.current &&
+ !triggerRef.current.contains(target) &&
+ dropdownRef.current &&
+ !dropdownRef.current.contains(target)
+ ) {
+ close()
+ }
+ }
+
+ const onScrollOrResize = () => updateDropdownPosition()
+ document.addEventListener('mousedown', onMouseDown)
+ window.addEventListener('scroll', onScrollOrResize, true)
+ window.addEventListener('resize', onScrollOrResize)
+
+ return () => {
+ document.removeEventListener('mousedown', onMouseDown)
+ window.removeEventListener('scroll', onScrollOrResize, true)
+ window.removeEventListener('resize', onScrollOrResize)
+ }
+ }, [close, isOpen, updateDropdownPosition])
+
+ return (
+
+
{
+ if (disabled) return
+ if (isOpen) close()
+ else open()
+ }}
+ className={cn(
+ 'relative w-full h-[56px] bg-cover bg-center bg-no-repeat flex items-center justify-center select-none',
+ disabled && 'cursor-not-allowed opacity-60',
+ triggerClassName
+ )}
+ style={{
+ // 避免背景层抢点击
+ pointerEvents: 'auto',
+ outline: 'none',
+ }}
+ >
+
})
+
+
+ {selectedOption ? selectedOption.label : placeholder}
+
+
+
+ {isOpen &&
+ dropdownPosition &&
+ createPortal(
+
+
+ {options.map((opt) => {
+ const isHovered = opt.value === hoveredValue
+ return (
+
+ )
+ })}
+
+
,
+ document.body
+ )}
+
+ )
+}
+
+export default SelectBg
diff --git a/src/pages/nieer/components/index.ts b/src/pages/nieer/components/index.ts
new file mode 100644
index 0000000..4bf8fef
--- /dev/null
+++ b/src/pages/nieer/components/index.ts
@@ -0,0 +1,5 @@
+export { InputBg } from './InputBg'
+export { SelectBg } from './SelectBg'
+export { default as PianoUnlockInteraction } from './PianoUnlockInteraction'
+export { default as PostcardUnlockInteraction } from './PostcardUnlockInteraction'
+export { default as BeijingUnlockInteraction } from './BeijingUnlockInteraction'
diff --git a/src/pages/nieer/home.tsx b/src/pages/nieer/home.tsx
new file mode 100644
index 0000000..50fa122
--- /dev/null
+++ b/src/pages/nieer/home.tsx
@@ -0,0 +1,53 @@
+import { getBackgroundImage, getStaticImage } from '@/lib/utils'
+import { useNavigate, useParams } from 'react-router-dom'
+import { Image } from '@/components'
+import { useNieerStore } from '@/store/nieerStore'
+
+const NieerHome = () => {
+ const navigate = useNavigate()
+ const { moduleName } = useParams<{ moduleName: string }>()
+
+ // starName 固定为 nieer
+ const starName = 'nieer'
+ const lastVisitedPlace = useNieerStore((s) => s.lastVisitedPlace)
+
+ if (!moduleName) return null
+
+ return (
+
+
navigate(`/entrance/${moduleName}`)}
+ />
+
+
+ {/* 对话 */}
+ navigate(`/${moduleName}/${starName}/${lastVisitedPlace || 'place1'}`)}
+ />
+ {/* 体悟 / 内化 预留 */}
+ navigate(`/${moduleName}/${starName}/reflection`)}
+ />
+ navigate(`/${moduleName}/${starName}/internalization`)}
+ />
+
+
+ )
+}
+
+export default NieerHome
diff --git a/src/pages/nieer/internalization.tsx b/src/pages/nieer/internalization.tsx
new file mode 100644
index 0000000..f80f00c
--- /dev/null
+++ b/src/pages/nieer/internalization.tsx
@@ -0,0 +1,348 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import {
+ Image,
+ Video,
+ type VideoAnchor,
+ type VideoRef,
+ LetterToNieer,
+ Narration,
+ EndingOverlay,
+} from '@/components'
+import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
+import { GuideSteps, GuideStepsItem } from '@/components'
+import { SuccessAlert } from '@/components/layout'
+import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/layout/quiz'
+
+const starName = 'nieer'
+
+type QuizOption = {
+ value: string
+ label: string
+ isCorrect?: boolean
+ explanation?: string
+}
+
+type QuizAnchor = {
+ id: string
+ time: number
+ type: 'single' | 'multiple'
+ title: string
+ options: QuizOption[]
+ correctAnswer: string | string[]
+ successTitle?: string
+}
+
+const QUIZ_ANCHORS: QuizAnchor[] = [
+ {
+ id: 'quiz-1',
+ time: 60 * 2, // 2:00
+ type: 'single',
+ title: '【单选】《义勇军进行曲》法定管弦乐配器版的创作者是:',
+ options: [
+ {
+ value: 'A',
+ label: 'A. 贺绿汀邀请的阿隆·阿甫夏洛穆夫',
+ explanation: '这是《义勇军进行曲》的第一个管弦乐配器版,并非法定版哦!请你再想想吧!',
+ },
+ { value: 'B', label: 'B. 李焕之', isCorrect: true, explanation: '' },
+ {
+ value: 'C',
+ label: 'C. 聂耳',
+ explanation: '聂耳是《义勇军进行曲》的作曲者,并非管弦乐配器版的创作者。请你再想想吧!',
+ },
+ {
+ value: 'D',
+ label: 'D. 林超夏',
+ explanation: '林超夏完成的管弦乐配器版,并非广为熟知的法定版。请你再想想吧!',
+ },
+ ],
+ correctAnswer: 'B',
+ },
+ {
+ id: 'quiz-2',
+ time: 60 * 4 + 39, // 4:39
+ type: 'multiple',
+ title: '【多选】音乐作品要传递时代力量与精神,需具备哪些特质?',
+ options: [
+ { value: 'A', label: 'A. 拥有积极向上的音型与坚定有力的节奏,易于传唱与共鸣' },
+ { value: 'B', label: 'B. 贴近民众心声,契合人类共通的真挚情感(如家国情怀、正义坚守)' },
+ { value: 'C', label: 'C. 聚焦时代主题,呼应国家与民族的发展需求' },
+ {
+ value: 'D',
+ label: 'D. 追求复杂华丽的编曲技巧,彰显专业门槛',
+ explanation:
+ '如《义勇军进行曲》这样的作品,节奏并不复杂,核心在于情感与主题的传递,而非技巧的堆砌。请你再想想吧!',
+ },
+ ],
+ correctAnswer: ['A', 'B', 'C'],
+ },
+ {
+ id: 'quiz-3',
+ time: 60 * 6 + 31, // 6:31
+ type: 'multiple',
+ title: '【多选】聂耳的音乐作品具有鲜明的“人民性”,主要体现在:',
+ options: [
+ { value: 'A', label: 'A. 关注劳苦大众的生活与困境,如《开矿歌》《大路歌》' },
+ { value: 'B', label: 'B. 为少年儿童创作贴合其生活的作品,如《卖报歌》' },
+ { value: 'C', label: 'C. 关注女性群体的境遇,如《梅娘曲》《新女性》《铁蹄下的歌女》' },
+ {
+ value: 'D',
+ label: 'D. 仅围绕抗日救亡主题,不涉及其他社会群体',
+ explanation:
+ '聂耳的创作既聚焦抗日救亡,也覆盖多个社会群体,体现了广泛的人民性。请你再想想吧!',
+ },
+ ],
+ correctAnswer: ['A', 'B', 'C'],
+ },
+ {
+ id: 'quiz-4',
+ time: 60 * 7 + 11, // 7:11
+ type: 'single',
+ title:
+ '【判断】聂耳在创作中注重挖掘中华优秀传统音乐文化元素,《翠湖春晓》《金蛇狂舞》就是他改编传统音乐的经典作品。',
+ options: [
+ {
+ value: 'A',
+ label: 'A. 正确',
+ isCorrect: true,
+ explanation:
+ '这两部作品是聂耳对传统音乐元素的整理、挖掘与再传播,彰显了鲜明的文化传承意识。',
+ },
+ {
+ value: 'B',
+ label: 'B. 错误',
+ explanation: '聂耳的创作中融入了传统音乐元素,这两部作品正是典型代表哦!请你再想想吧!',
+ },
+ ],
+ correctAnswer: 'A',
+ },
+ {
+ id: 'quiz-5',
+ time: 60 * 9 + 14, // 9:14
+ type: 'multiple',
+ title: '【多选】聂耳能创作出众多兼具传唱力与思想性的经典作品,源于他身上具有以下宝贵闪光点:',
+ options: [
+ { value: 'A', label: 'A. 对新鲜事物的探索精神' },
+ { value: 'B', label: 'B. 关注社会时事的批判思维' },
+ {
+ value: 'C',
+ label: 'C. 只为个人名利创作的追求',
+ explanation: '“只为个人名利创作的追求”与聂耳“音乐救国”的初心相悖哦!请你再想想吧!',
+ },
+ { value: 'D', label: 'D. 对国家民族未来的深刻思考' },
+ ],
+ correctAnswer: ['A', 'B', 'D'],
+ },
+]
+
+const NieerInternalization = () => {
+ const navigate = useNavigate()
+ const { moduleName } = useParams<{ moduleName: string }>()
+
+ const [narrationVisible, setNarrationVisible] = useState(true)
+ const [visible, setVisible] = useState(false)
+ const [showVideo, setShowVideo] = useState(false)
+ const [showLetter, setShowLetter] = useState(false)
+ const [showStudyGuide, setShowStudyGuide] = useState(false)
+
+ const videoRef = useRef(null)
+
+ const [activeSuccess, setActiveSuccess] = useState(null)
+ const [successContent, setSuccessContent] = useState('')
+ const [resumeCallback, setResumeCallback] = useState<(() => void) | null>(null)
+
+ useEffect(() => {
+ if (!narrationVisible) {
+ setVisible(true)
+ }
+ }, [narrationVisible])
+
+ const handleVideoCanPlay = () => {
+ if (showVideo && videoRef.current) {
+ videoRef.current.play().catch(() => {})
+ }
+ }
+
+ const anchors: VideoAnchor[] = useMemo(() => {
+ return QUIZ_ANCHORS.map((item) => {
+ if (item.type === 'single') {
+ const correct = item.correctAnswer as string
+ return {
+ id: item.id,
+ time: item.time,
+ once: true,
+ afterResume: 'remove',
+ render: ({ resume }) => (
+ <>
+ ({ value: opt.value, label: opt.label }))}
+ correctAnswer={correct}
+ onWrongSelect={(value: string) => {
+ const opt = item.options.find((o) => o.value === value)
+ return opt?.explanation || '再思考一下,选择正确答案。'
+ }}
+ onSubmit={(value: string, isCorrect: boolean) => {
+ if (isCorrect) {
+ const opt = item.options.find((o) => o.value === value)
+ setActiveSuccess(item.id)
+ setSuccessContent(opt?.explanation || '')
+ setResumeCallback(() => resume)
+ }
+ }}
+ />
+ {
+ setActiveSuccess(null)
+ setSuccessContent('')
+ const cb = resumeCallback
+ setResumeCallback(null)
+ cb?.()
+ }}
+ />
+ >
+ ),
+ }
+ }
+
+ const correctAnswers = item.correctAnswer as string[]
+ return {
+ id: item.id,
+ time: item.time,
+ once: true,
+ afterResume: 'remove',
+ render: ({ resume }) => (
+ <>
+ ({ value: opt.value, label: opt.label }))}
+ correctAnswer={correctAnswers}
+ errorMessage={(selectedValues: string[]) => {
+ const picked = new Set(selectedValues)
+ const wrongOpt = item.options.find((o) => picked.has(o.value) && o.explanation)
+ if (wrongOpt?.explanation) return wrongOpt.explanation
+ return '你的选择还不够全面哦~请你再想想吧!'
+ }}
+ onSubmit={(values: string[], isCorrect: boolean) => {
+ if (isCorrect) {
+ setActiveSuccess(item.id)
+ setSuccessContent('')
+ setResumeCallback(() => resume)
+ }
+ }}
+ />
+ {
+ setActiveSuccess(null)
+ setSuccessContent('')
+ const cb = resumeCallback
+ setResumeCallback(null)
+ cb?.()
+ }}
+ />
+ >
+ ),
+ }
+ })
+ }, [activeSuccess, resumeCallback, successContent])
+
+ if (!moduleName) return null
+
+ return (
+
+ {/* 离开按钮 */}
+ navigate(`/${moduleName}/${starName}/home`)}
+ />
+
+ {/* 旁白 */}
+ {
+ setNarrationVisible(false)
+ }}
+ text="从旧居亭子间的不眠之夜到国歌展示馆的激昂旋律,聂耳以青春为火,以音符为刃,在民族危亡的黑暗中奏响了救亡图存的强音。《义勇军进行曲》从电影主题曲到中华人民共和国国歌,从抗战年代的冲锋号到新时代的复兴曲,跨越近百年时光,依然是凝聚民族力量的精神纽带。今天,站在新时代的舞台上,我们更要读懂聂耳的作品与精神内核,让这份旋律中的力量内化于心、外化于行。"
+ />
+
+ {/* 视频前引导 */}
+ {
+ setVisible(v)
+ if (!v) {
+ setShowVideo(true)
+ handleVideoCanPlay()
+ }
+ }}
+ showMask={false}
+ >
+
+ 小沪,前两个模块中,我循着聂耳的足迹读懂了旋律背后的家国情怀,亲历了国歌跨越时空的动人故事。我还想从音乐这一艺术创作角度出发更多了解聂耳,你能给我讲讲吗?
+
+
+ 讲到音乐,可是很专业的领域了!今天我们有幸邀请到了上海音乐学院党委宣传部副部长,东方乐器博物馆馆长,研究员,肖阳。肖老师会从上海音乐学院对聂耳的研究、与《义勇军进行曲》的渊源的角度告诉你聂耳的音乐作品与时代价值。
+
+
+ 接下来,就让我们跟随肖老师一起,探寻聂耳音乐作品和艺术精神的当代意义吧!
+
+
+
+ {/* 内化主视频 */}
+ {showVideo && (
+ {
+ setShowVideo(false)
+ setTimeout(() => setShowLetter(true), 300)
+ }}
+ />
+ )}
+
+ {/* 写信组件 */}
+ {
+ setShowLetter(false)
+ }}
+ onAnimationEnd={() => {
+ setShowStudyGuide(true)
+ }}
+ showVideo={true}
+ />
+
+ {/* 内化结束 */}
+ {
+ setShowStudyGuide(false)
+ navigate(`/${moduleName}/${starName}/home`)
+ }}
+ />
+
+ )
+}
+
+export default NieerInternalization
diff --git a/src/pages/nieer/place/place1.tsx b/src/pages/nieer/place/place1.tsx
new file mode 100644
index 0000000..79c1a8b
--- /dev/null
+++ b/src/pages/nieer/place/place1.tsx
@@ -0,0 +1,450 @@
+import {
+ ExhibitOverlay,
+ GuideSteps,
+ GuideStepsItem,
+ Image,
+ SelectFillQuizPanel,
+ SingleChoiceQuizPanel,
+ Title,
+ StudyGuideModal,
+ Video,
+ type VideoRef,
+} from '@/components'
+import { SuccessAlert } from '@/components/layout'
+import { useNavigate, useParams } from 'react-router-dom'
+import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
+import { useRef, useState } from 'react'
+import Cursor from '@/components/cursor'
+import { useNieerStore, saveNieerStore } from '@/store/nieerStore'
+import { useCursorController } from '@/hooks/useCursorController'
+
+// starName 固定为 nieer
+const starName = 'nieer'
+
+// 展品 ID 与资源路径映射,ID 用来做唯一标识
+const exhibitSrcMap: Record<1 | 2 | 3, string> = {
+ 1: 'nieer/place1/场景1展品2',
+ 2: 'nieer/place1/场景1展品3',
+ 3: 'nieer/place1/场景1展品1',
+}
+
+const NieerPlace1 = () => {
+ const navigate = useNavigate()
+ const { moduleName } = useParams<{ moduleName: string }>()
+
+ const { markExhibitVisited, getVisitedExhibits } = useNieerStore()
+ const setLastVisitedPlace = useNieerStore((s) => s.setLastVisitedPlace)
+
+ // 当前激活的展品 ID
+ const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(null)
+
+ // 光标1的对话状态
+ const [guide1Visible, setGuide1Visible] = useState(false)
+ const [guide1Step, setGuide1Step] = useState(0)
+ // 光标1关闭展品后的题目
+ const [quiz1Visible, setQuiz1Visible] = useState(false)
+ const [successAlert1Visible, setSuccessAlert1Visible] = useState(false)
+
+ // 光标2:对话 -> 题目 -> 对话 -> 视频
+ const [guide2Visible, setGuide2Visible] = useState(false)
+ const [guide2Step, setGuide2Step] = useState(0)
+ const [quiz2Visible, setQuiz2Visible] = useState(false)
+ const [successAlert2Visible, setSuccessAlert2Visible] = useState(false)
+ const [afterQuizGuide2Visible, setAfterQuizGuide2Visible] = useState(false)
+ const [afterQuizGuide2Step, setAfterQuizGuide2Step] = useState(0)
+ const [showVideo2, setShowVideo2] = useState(false)
+ const video2Ref = useRef(null)
+
+ // 光标3的对话状态
+ const [guide3Visible, setGuide3Visible] = useState(false)
+ const [guide3Step, setGuide3Step] = useState(0)
+
+ // 学习引导弹窗(开场)
+ const [showGuideModal, setShowGuideModal] = useState(true)
+ // 说明框(沿用原有说明)
+ const [showAfterDialogGuide, setShowAfterDialogGuide] = useState(false)
+
+ const place = 'place1'
+ const visitedExhibits = getVisitedExhibits(place)
+ const visited = new Set(visitedExhibits) as Set<1 | 2 | 3>
+
+ // Cursor 控制器:使用依赖映射实现自动解锁
+ // cursor 1 初始启用,cursor 2 依赖展品1,cursor 3 依赖展品2
+ const { isEnabled } = useCursorController({
+ initialEnabled: [3],
+ dependencies: {
+ 2: [1], // cursor 2 依赖展品 1,当展品1被访问后自动解锁
+ 1: [3], // cursor 3 依赖展品 2,当展品2被访问后自动解锁
+ },
+ visited: visitedExhibits, // 传入数组,而不是 Set
+ })
+
+ if (!moduleName) return null
+
+ const total = 3
+ const progressCount = visited.size
+ const completed = progressCount >= total
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
+
+ const handleOpenExhibit = (id: 1 | 2 | 3) => {
+ setActiveExhibitId(id)
+ if (id === 1) {
+ setGuide1Step(0)
+ setGuide1Visible(true)
+ }
+ if (id === 2) {
+ setGuide2Step(0)
+ setGuide2Visible(true)
+ }
+ if (id === 3) {
+ setGuide3Step(0)
+ setGuide3Visible(true)
+ }
+ }
+
+ return (
+
+ {/* 标题 */}
+
+
+ navigate(`/${moduleName}/${starName}/home`)}
+ />
+
+ {/* 光标1 */}
+ handleOpenExhibit(1)}
+ read={visited.has(1)}
+ disabled={!isEnabled(1)}
+ />
+ {/* 光标2 */}
+ handleOpenExhibit(2)}
+ read={visited.has(2)}
+ disabled={!isEnabled(2)}
+ />
+ {/* 光标3 */}
+ handleOpenExhibit(3)}
+ read={visited.has(3)}
+ disabled={!isEnabled(3)}
+ />
+
+ {/* 继续前进 - 仅在完成所有展品后显示 */}
+ {completed && (
+ {
+ setLastVisitedPlace('place2')
+ saveNieerStore()
+ navigate(`/${moduleName}/${starName}/place2`)
+ }}
+ />
+ )}
+
+ {/* 展品遮罩层 */}
+ {
+ const closingId = activeExhibitId
+ setActiveExhibitId(null)
+ // 关闭展品时一并关闭对话(避免残留在下层)
+ setGuide1Visible(false)
+ setGuide2Visible(false)
+ setGuide3Visible(false)
+
+ if (closingId === 1) {
+ // 光标1:关闭展品后出题,答对才算完成并点亮 cursor
+ setQuiz1Visible(true)
+ return
+ }
+ if (closingId === 2) {
+ // 光标2:关闭展品后出题,答对后进入后置对话与视频
+ setQuiz2Visible(true)
+ return
+ }
+
+ // 其它展品:关闭即视为完成
+ if (closingId !== null) {
+ markExhibitVisited(place, closingId)
+ }
+ }}
+ />
+
+ {/* 光标1的对话(图1文案) */}
+
+
+ 这里提到聂耳发表了《中国歌舞短论》,批评脱离民众的音乐,这是为什么呢?
+
+
+ 你观察得很细致!这时的聂耳在明月歌舞剧社做练习生,社长李锦晖看中了他的潜力,虽然他当时小提琴水平不算专业,还是录取了他,还安排首席小提琴手专门辅导他。聂耳特别刻苦,每天至少练7个小时小提琴,很快就成长为首席小提琴手。
+
+
+ 但好景不长,1931年九一八事变后,全国抗日救亡浪潮高涨,可还存在很多脱离现实的靡靡之音,这让满怀爱国热忱的聂耳特别愤懑。
+
+
+ 1932年,他以“黑天使”为笔名在《电影艺术》杂志刊登《中国歌舞短论》,猛烈评击“软豆腐”式的音乐,提出我们需要“真刀真枪的硬功夫”,主张音乐要贴近民众、为抗日救亡服务。后来他离开了歌舞剧社。
+
+
+
+ {/* 光标2的对话(图1文案) */}
+
+
+ 离开歌舞剧社后,聂耳就加入左翼文艺团体了吗?
+
+
+ 没错!离开歌舞剧社后,聂耳结识了进步文艺家田汉,两人成为忘年交。1933年,在田汉的介绍下,聂耳秘密加入了中国共产党,之后就成为左翼剧联“音乐小组”的核心成员,还发起组织了“中国新兴音乐研究会”。
+
+
+
+ {/* 光标3的对话(参考陈云对话模块) */}
+
+
+ 小沪,聂耳是在九一八事变爆发后来到的上海吗?他为什么来上海呢?
+
+
+ 九一八事变爆发前,聂耳就已经来到上海了,核心原因是躲避迫害。聂耳中学就读的云南省立第一师范学校是昆明学生运动的中心,在那里他接触到了马克思主义进步思想,还秘密加入了中国共产主义青年团。
+
+
+ 也因为他积极参加学生运动,1930年被国民党当局列入了抓捕黑名单,在家人帮助下,他顶替哥哥的身份逃离昆明来到上海。
+
+
+ 来到上海后,1931年九一八事变爆发,继而1932年一·二八事变爆发,聂耳亲眼看见了日军进犯上海的暴行,也目睹了上海民众踊跃投身抗日的热烈场景。
+
+
+ 这些亲身经历让他深刻意识到,音乐不能只是娱乐,更能成为唤醒民众、凝聚抗战士气的“武器”。正是这份在上海的所见所感,让他坚定了“音乐救国”的方向,也为《义勇军进行曲》注入了源于现实的痛感与抗争决心。
+
+
+
+ {/* 光标2关闭展品后的填空题(图2) */}
+ ({ value: String(i), label: String(i) }))}
+ correctAnswers={{
+ b1: '1',
+ b2: '9',
+ b3: '3',
+ b4: '3',
+ }}
+ errorMessage="1933年,在田汉的介绍下,聂耳秘密加入了中国共产党。"
+ submitText="提交"
+ onSubmit={(_results, isCorrect) => {
+ if (isCorrect) {
+ setQuiz2Visible(false)
+ setSuccessAlert2Visible(true)
+ }
+ }}
+ />
+
+ {/* 光标2题目成功提示:成功后点亮 cursor2,并进入后置对话 */}
+ {
+ setSuccessAlert2Visible(false)
+ markExhibitVisited(place, 2)
+ saveNieerStore()
+ setAfterQuizGuide2Step(0)
+ setAfterQuizGuide2Visible(true)
+ }}
+ onClose={() => {
+ setSuccessAlert2Visible(false)
+ markExhibitVisited(place, 2)
+ saveNieerStore()
+ setAfterQuizGuide2Step(0)
+ setAfterQuizGuide2Visible(true)
+ }}
+ />
+
+ {/* 光标2答题结束后的对话(图3) */}
+ {
+ setAfterQuizGuide2Visible(nextVisible)
+ if (!nextVisible) {
+ setShowVideo2(true)
+ }
+ }}
+ showMask={false}
+ highlightSelectors={[]}
+ zIndex={10001}
+ >
+
+ 这一时期他深入工厂、码头,了解底层民众的疾苦,创作了一大批进步歌曲。比如《开矿歌》,他专门去矿山采风,把工人的劳动场景融入旋律,还唱给工人听,直到他们满意为止;
+
+
+ 《卖报歌》则是他看到卖报小女孩“小毛头”的艰辛生活后创作的,还根据小女孩的建议修改了歌词,特别贴近生活。这些创作都为他后来写《义勇军进行曲》积累了深厚的功底。
+
+
+
+ {/* 光标2对话结束后的视频(占位视频地址) */}
+ {showVideo2 && (
+ setShowVideo2(false)}
+ onSkip={() => setShowVideo2(false)}
+ />
+ )}
+
+ {/* 光标1关闭展品后的题目(图2) */}
+ {
+ if (isCorrect) {
+ setQuiz1Visible(false)
+ setSuccessAlert1Visible(true)
+ }
+ }}
+ />
+
+ {/* 光标1题目成功提示:成功后点亮 cursor1 */}
+ {
+ setSuccessAlert1Visible(false)
+ markExhibitVisited(place, 1)
+ saveNieerStore()
+ }}
+ onClose={() => {
+ setSuccessAlert1Visible(false)
+ markExhibitVisited(place, 1)
+ saveNieerStore()
+ }}
+ />
+
+ {/* 场景1学习引导(开场) */}
+ {
+ setShowGuideModal(false)
+ setShowAfterDialogGuide(true)
+ }}
+ ailgn="left"
+ >
+ 1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
+ {'\n'}
+ 2.在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点开查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习。
+ {'\n'}
+ 3.所有动态光点的展品全部学习后才能进入下一个场景学习。
+
+
+ {/* 说明框(内容沿用原有说明) */}
+ setShowAfterDialogGuide(false)}
+ >
+ 同学你好,欢迎来到“走进故居·对话”学习模块!{'\n'}
+ 此刻你正位立于国歌展示馆的入口,请随时光的脚步,在聂耳作品的“时光声浪”中聆听五段炽热的时代回响:民族危亡的怒吼声、时代呼唤的最强音、民族解放的冲锋号、强我中华的进行曲、伟大复兴的主旋律。你准备好跟随聂耳的音符,踏上这段炽热征程了吗?
+
+
+ )
+}
+
+export default NieerPlace1
diff --git a/src/pages/nieer/place/place2.tsx b/src/pages/nieer/place/place2.tsx
new file mode 100644
index 0000000..42eba70
--- /dev/null
+++ b/src/pages/nieer/place/place2.tsx
@@ -0,0 +1,204 @@
+import { ExhibitOverlay, GuideSteps, GuideStepsItem, Image, Title, Video } from '@/components'
+import Cursor from '@/components/cursor'
+import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
+import { useMemo, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { useNieerStore, saveNieerStore } from '@/store/nieerStore'
+import { useCursorController } from '@/hooks/useCursorController'
+
+const NieerPlace2 = () => {
+ const navigate = useNavigate()
+ const { moduleName } = useParams<{ moduleName: string }>()
+
+ const { markExhibitVisited, getVisitedExhibits } = useNieerStore()
+ const setLastVisitedPlace = useNieerStore((s) => s.setLastVisitedPlace)
+
+ const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null)
+
+ // 光标1的对话状态
+ const [guide1Visible, setGuide1Visible] = useState(false)
+ const [guide1Step, setGuide1Step] = useState(0)
+ // 光标2的对话状态
+ const [guide2Visible, setGuide2Visible] = useState(false)
+ const [guide2Step, setGuide2Step] = useState(0)
+
+ // 完成所有展品与对话后自动播放视频,视频结束后再显示“继续前进”
+ const [showCompleteVideo, setShowCompleteVideo] = useState(false)
+ const [completeVideoPlayed, setCompleteVideoPlayed] = useState(false)
+
+ const starName = 'nieer'
+ const place = 'place2'
+
+ const visitedExhibits = getVisitedExhibits(place)
+ const visited = useMemo(() => new Set(visitedExhibits), [visitedExhibits])
+
+ const groupTotal = 5
+ const groupProgress = getVisitedExhibits('place2').length + getVisitedExhibits('place3').length
+ const groupCompleted = groupProgress >= groupTotal
+ const subtitle = groupCompleted ? '已完成' : `${groupProgress}/${groupTotal}`
+
+ const pageProgress = visitedExhibits.length
+ const pageCompleted = pageProgress >= 2
+
+ const { isEnabled } = useCursorController({
+ initialEnabled: [1],
+ dependencies: {
+ 2: [1],
+ },
+ visited: visitedExhibits,
+ })
+
+ if (!moduleName) return null
+
+ const exhibitSrcMap: Record<1 | 2, string> = {
+ 1: 'nieer/place2/场景2展品1',
+ 2: 'nieer/place2/场景2展品2',
+ }
+
+ const handleOpenExhibit = (id: 1 | 2) => {
+ setActiveExhibitId(id)
+ if (id === 1) {
+ setGuide1Step(0)
+ setGuide1Visible(true)
+ }
+ if (id === 2) {
+ setGuide2Step(0)
+ setGuide2Visible(true)
+ }
+ }
+
+ return (
+
+
+
+ navigate(`/${moduleName}/${starName}/place1`)}
+ />
+
+ handleOpenExhibit(1)}
+ read={visited.has(1)}
+ disabled={!isEnabled(1)}
+ />
+ handleOpenExhibit(2)}
+ read={visited.has(2)}
+ disabled={!isEnabled(2)}
+ />
+
+ {pageCompleted && (
+ {
+ setLastVisitedPlace('place3')
+ saveNieerStore()
+ navigate(`/${moduleName}/${starName}/place3`)
+ }}
+ />
+ )}
+
+ {
+ const closingId = activeExhibitId
+ setActiveExhibitId(null)
+ setGuide1Visible(false)
+ setGuide2Visible(false)
+ if (closingId !== null) {
+ markExhibitVisited(place, closingId)
+ saveNieerStore()
+ }
+ // 直接在展品2窗口关闭后播放视频(受 useCursorController 控制顺序保障)
+ if (closingId === 2 && !completeVideoPlayed) {
+ setShowCompleteVideo(true)
+ }
+ }}
+ />
+
+ {/* 光标1对话(图1) */}
+
+
+ 这盒《风云儿女》的胶片太有年代感了!聂耳是怎么和《风云儿女》这部电影结缘,并给这部电影谱主题歌《义勇军进行曲》的啊?
+
+
+ 1935年的时候,日本帝国主义已经把侵略的魔爪伸向华北,中华民族到了生死存亡的关键时刻,全国人民都渴望有能凝聚斗志的精神力量。
+
+
+ 左翼文艺界想通过拍摄《风云儿女》这部抗日电影,让更多人看清侵略者的野心,唤醒大家的抗争意识,而电影的主题歌必须得有“振聋发聩”的力量。
+
+
+
+ {/* 光标2对话(图2) */}
+
+
+ 1935年2月,田汉创作了《风云儿女》剧本梗概,写下《义勇军进行曲》歌词后不久被捕,田汉的战友辗转把歌词转交给左翼剧联,聂耳得知后第一时间就主动申请完成曲创作。
+
+
+ 他之所以这么积极,一方面是和田汉的革命情谊——两人都是左翼文艺运动的核心力量,早就约定要一起用文艺为抗日救亡服务;
+
+
+ 更重要的是他对时代的感知,之前创作《开矿歌》《卖报歌》时,他就深入底层了解民众的苦难,清楚地知道大家需要什么样的声音。
+
+
+ 他当时说“这歌词太有力量了,我一定要把它谱成能让全中国人民都唱起来的歌”,这份信心来自他对民众心声的把握,也来自对救国使命的坚守。
+
+
+
+ )
+}
+
+export default NieerPlace2
diff --git a/src/pages/nieer/place/place3.tsx b/src/pages/nieer/place/place3.tsx
new file mode 100644
index 0000000..4138be3
--- /dev/null
+++ b/src/pages/nieer/place/place3.tsx
@@ -0,0 +1,352 @@
+import {
+ ExhibitOverlay,
+ GuideSteps,
+ GuideStepsItem,
+ Image,
+ SingleChoiceQuizPanel,
+ Title,
+ Video,
+ type VideoRef,
+} from '@/components'
+import { SuccessAlert } from '@/components/layout'
+import Cursor from '@/components/cursor'
+import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
+import { useRef, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { useNieerStore, saveNieerStore } from '@/store/nieerStore'
+import { useCursorController } from '@/hooks/useCursorController'
+
+const NieerPlace3 = () => {
+ const navigate = useNavigate()
+ const { moduleName } = useParams<{ moduleName: string }>()
+
+ const { markExhibitVisited, getVisitedExhibits } = useNieerStore()
+ const setLastVisitedPlace = useNieerStore((s) => s.setLastVisitedPlace)
+
+ const starName = 'nieer'
+ const place = 'place3'
+
+ // 展品弹窗
+ const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(null)
+
+ // 光标1对话
+ const [guide1Visible, setGuide1Visible] = useState(false)
+ const [guide1Step, setGuide1Step] = useState(0)
+
+ // 光标3对话
+ const [guide3Visible, setGuide3Visible] = useState(false)
+ const [guide3Step, setGuide3Step] = useState(0)
+
+ // 关窗后视频
+ const [showVideo, setShowVideo] = useState(false)
+ const videoRef = useRef(null)
+ const [videoForExhibitId, setVideoForExhibitId] = useState<1 | 3 | null>(null)
+
+ // 光标3视频结束后的题目
+ const [quiz3Visible, setQuiz3Visible] = useState(false)
+ const [successAlert3Visible, setSuccessAlert3Visible] = useState(false)
+
+ // place2 + place3 共 5 个展品进度
+ const groupTotal = 5
+ const groupProgress = getVisitedExhibits('place2').length + getVisitedExhibits('place3').length
+ const groupCompleted = groupProgress >= groupTotal
+ const subtitle = groupCompleted ? '已完成' : `${groupProgress}/${groupTotal}`
+
+ const visitedExhibits = getVisitedExhibits(place)
+ const visited = new Set(visitedExhibits)
+ const pageCompleted = visited.size >= 3
+
+ const { isEnabled } = useCursorController({
+ initialEnabled: [1],
+ dependencies: {
+ 2: [1],
+ 3: [2],
+ },
+ visited: visitedExhibits,
+ })
+
+ if (!moduleName) return null
+
+ const exhibitSrcMap: Record<1 | 2 | 3, string> = {
+ 1: 'nieer/place3/场景3展品1',
+ 2: 'nieer/place3/场景3展品2',
+ 3: 'nieer/place3/场景3展品3',
+ }
+
+ const handleOpenExhibit = (id: 1 | 2 | 3) => {
+ setActiveExhibitId(id)
+ if (id === 1) {
+ setGuide1Step(0)
+ setGuide1Visible(true)
+ }
+ if (id === 3) {
+ setGuide3Step(0)
+ setGuide3Visible(true)
+ }
+ }
+
+ const playPlaceholderVideoFor = (id: 1 | 3) => {
+ setVideoForExhibitId(id)
+ setShowVideo(true)
+ }
+
+ return (
+
+
+
+ navigate(`/${moduleName}/${starName}/place2`)}
+ />
+
+ handleOpenExhibit(1)}
+ read={visited.has(1)}
+ disabled={!isEnabled(1)}
+ />
+ handleOpenExhibit(2)}
+ read={visited.has(2)}
+ disabled={!isEnabled(2)}
+ />
+ handleOpenExhibit(3)}
+ read={visited.has(3)}
+ disabled={!isEnabled(3)}
+ />
+
+ {pageCompleted && (
+ {
+ setLastVisitedPlace('place4')
+ saveNieerStore()
+ navigate(`/${moduleName}/${starName}/place4`)
+ }}
+ />
+ )}
+
+ {
+ const closingId = activeExhibitId
+ setActiveExhibitId(null)
+ setGuide1Visible(false)
+ setGuide3Visible(false)
+
+ if (closingId === 1) {
+ // 光标1:关窗后播放视频,视频结束才算完成
+ playPlaceholderVideoFor(1)
+ return
+ }
+
+ if (closingId === 3) {
+ // 光标3:关窗后播放视频,视频结束再出题
+ playPlaceholderVideoFor(3)
+ return
+ }
+
+ if (closingId !== null) {
+ markExhibitVisited(place, closingId)
+ }
+ }}
+ />
+
+ {/* 光标1对话(图1) */}
+
+
+ 这份聂耳手写的《义勇军进行曲》的曲谱太珍贵了,在当时那么动乱的年代,他是怎么创作出这么激昂的旋律的?
+
+
+ 1935年3月,聂耳在上海霞飞路(现淮海中路1258号聂耳旧居)完成了《义勇军进行曲》的曲谱初稿的创作。
+
+
+
+ 当时他住的是一个亭子间,那里又小又暗,连一张像样的桌子都没有,为了找到最贴合歌词的旋律,他每天天不亮就起来哼唱,用筷子敲着桌子打节拍,有时候灵感来了,半夜还会起来修改,邻居都能听到他的歌声。
+
+
+
+ 草稿上的修改痕迹,藏着他的用心——比如他反复调整音符的节奏,把原本偏舒缓的旋律改得更急促、更有冲击力,就是想让旋律能跟上战士冲锋的步伐;还优化了歌词和旋律的搭配,让每一句都能唱得铿锵有力。
+
+
+ 他还专门去电通公司唱给同事听,把工人、学生、战士的反馈都记下来,一点点打磨。他还专门去电通公司唱给同事听,把工人、学生、战士的反馈都记下来,一点点打磨。
+
+
+
+ {/* 光标3对话(图2) */}
+
+
+ 原来初稿就倾注了这么多心血!他当时就是用这把小提琴创作的吗?
+
+
+ 这把小提琴其实是演员赵丹在电影《聂耳》中饰演聂耳时用的道具,不是聂耳本人的。你看到的抱着小提琴的聂耳的照片的拍摄地点已经是在海外了。
+
+
+ 当时他因为创作了太多进步歌曲,被国民党当局列入了黑名单,继续留在上海会有生命危险,因此他受党的指示前往海外暂避。但他心里一直惦记着谱曲任务,到海外后没敢耽误,立刻投入修改。
+
+
+ 虽然身在海外,但他通过海外华人社团、抗日刊物,一直关注着国内的抗日浪潮,还收集了海外华人传唱抗日歌曲的反馈,这些都成了他修改的重要参考。
+
+
+
+ {/* 关窗后的视频(占位视频地址) */}
+ {showVideo && videoForExhibitId !== null && (
+ {
+ const id = videoForExhibitId
+ setShowVideo(false)
+ setVideoForExhibitId(null)
+
+ if (id === 1) {
+ markExhibitVisited(place, 1)
+ saveNieerStore()
+ return
+ }
+
+ if (id === 3) {
+ setQuiz3Visible(true)
+ }
+ }}
+ onSkip={() => {
+ const id = videoForExhibitId
+ setShowVideo(false)
+ setVideoForExhibitId(null)
+
+ if (id === 1) {
+ markExhibitVisited(place, 1)
+ saveNieerStore()
+ return
+ }
+
+ if (id === 3) {
+ setQuiz3Visible(true)
+ }
+ }}
+ />
+ )}
+
+ {/* 光标3视频结束后的题目(图3) */}
+ {
+ if (value === 'B') {
+ return '卖报歌是聂耳创作的儿童歌曲,灵感来自街头卖报的小女孩“小毛头”,并非《风云儿女》的主题曲。请你再想想吧!'
+ }
+ if (value === 'C') {
+ return '开矿歌是聂耳为进步影片《母性之光》创作的插曲,展现工人劳动场景,和《风云儿女》无关。请你再想想吧!'
+ }
+ return undefined
+ }}
+ onSubmit={(_value, isCorrect) => {
+ if (isCorrect) {
+ setQuiz3Visible(false)
+ setSuccessAlert3Visible(true)
+ }
+ }}
+ />
+
+ {/* 光标3题目成功提示:成功后点亮 cursor3 */}
+ {
+ setSuccessAlert3Visible(false)
+ markExhibitVisited(place, 3)
+ saveNieerStore()
+ }}
+ onClose={() => {
+ setSuccessAlert3Visible(false)
+ markExhibitVisited(place, 3)
+ saveNieerStore()
+ }}
+ />
+
+ )
+}
+
+export default NieerPlace3
diff --git a/src/pages/nieer/place/place4.tsx b/src/pages/nieer/place/place4.tsx
new file mode 100644
index 0000000..e881302
--- /dev/null
+++ b/src/pages/nieer/place/place4.tsx
@@ -0,0 +1,187 @@
+import { ExhibitOverlay, GuideSteps, GuideStepsItem, Image, Title } from '@/components'
+import Cursor from '@/components/cursor'
+import { useCursorController } from '@/hooks/useCursorController'
+import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
+import { useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { useNieerStore, saveNieerStore } from '@/store/nieerStore'
+
+const NieerPlace4 = () => {
+ const navigate = useNavigate()
+ const { moduleName } = useParams<{ moduleName: string }>()
+
+ const starName = 'nieer'
+ const place = 'place4'
+ const { markExhibitVisited, getVisitedExhibits } = useNieerStore()
+ const setLastVisitedPlace = useNieerStore((s) => s.setLastVisitedPlace)
+
+ const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null)
+
+ // 光标2对话(图1)
+ const [guide2Visible, setGuide2Visible] = useState(false)
+ const [guide2Step, setGuide2Step] = useState(0)
+
+ // 光标1对话(图2)
+ const [guide1Visible, setGuide1Visible] = useState(false)
+ const [guide1Step, setGuide1Step] = useState(0)
+
+ const visitedExhibits = getVisitedExhibits(place)
+ const visited = new Set(visitedExhibits)
+
+ const total = 2
+ const progressCount = visited.size
+ const completed = progressCount >= total
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
+
+ const { isEnabled } = useCursorController({
+ initialEnabled: [2],
+ dependencies: {
+ 1: [2],
+ },
+ visited: visitedExhibits,
+ })
+
+ if (!moduleName) return null
+
+ const exhibitSrcMap: Record<1 | 2, string> = {
+ 1: 'nieer/place4/场景4展品1',
+ 2: 'nieer/place4/场景4展品2',
+ }
+
+ const handleOpenExhibit = (id: 1 | 2) => {
+ setActiveExhibitId(id)
+ if (id === 2) {
+ setGuide2Step(0)
+ setGuide2Visible(true)
+ }
+ if (id === 1) {
+ setGuide1Step(0)
+ setGuide1Visible(true)
+ }
+ }
+
+ return (
+
+
+
+ navigate(`/${moduleName}/${starName}/place3`)}
+ />
+
+ handleOpenExhibit(1)}
+ read={visited.has(1)}
+ disabled={!isEnabled(1)}
+ />
+ handleOpenExhibit(2)}
+ read={visited.has(2)}
+ disabled={!isEnabled(2)}
+ />
+
+ {completed && (
+ {
+ setLastVisitedPlace('place5')
+ saveNieerStore()
+ navigate(`/${moduleName}/${starName}/place5`)
+ }}
+ />
+ )}
+
+ {
+ const closingId = activeExhibitId
+ setActiveExhibitId(null)
+ setGuide2Visible(false)
+ setGuide1Visible(false)
+ if (closingId !== null) {
+ markExhibitVisited(place, closingId)
+ saveNieerStore()
+ }
+ }}
+ />
+
+ {/* 光标2对话(图1) */}
+
+
+ 随着《风云儿女》的上映,田汉作词、聂耳谱曲的《义勇军进行曲》真如聂耳所期待的一样,传遍了全国吧!
+
+
+ 1935年《风云儿女》公映后,这首歌就跟着影片走进了全国各地的影院,看完电影的观众都忍不住跟着哼唱。当时的百代唱片公司还专门录制了这首歌的唱片,在街头巷尾的唱片店、收音机里循环播放。
+
+
+ 因为它朗朗上口,哪怕是不识字的老百姓,教几遍就能学会,很快就成了街头巷尾、田间地头都能听到的旋律,真正做到了家喻户晓。
+
+
+
+ {/* 光标1对话(图2) */}
+
+
+ 这张指挥千人大合唱的照片好震撼!除了日常传唱,这首歌在抗战时期还发挥了哪些特别的作用呢?
+
+
+ 它的作用可太大了!你看这张1936年的照片,刘良模先生在上海南市公共体育场指挥数千人齐唱,就连前来镇压的警察都被感染,跟着一起唱。抗战期间,抗日军队的行军路上、根据地的文艺宣传队里、城市的救亡集会中,都能听到这首歌的声音。
+
+
+ 它就像冲锋号一样,每次响起都能凝聚起大家的斗志,让战士们更勇敢地奔赴战场,让民众们更坚定地支持抗战,成为整个民族团结抗敌的精神纽带,而它也不再只是文艺作品,更是凝聚了中国人民的抗战士气,成了抗日救亡运动的精神旗帜。
+
+
+
+ )
+}
+
+export default NieerPlace4
diff --git a/src/pages/nieer/place/place5.tsx b/src/pages/nieer/place/place5.tsx
new file mode 100644
index 0000000..68254f7
--- /dev/null
+++ b/src/pages/nieer/place/place5.tsx
@@ -0,0 +1,473 @@
+import {
+ ExhibitOverlay,
+ GuideSteps,
+ GuideStepsItem,
+ Image,
+ MatchQuizPanel,
+ MultipleChoiceQuizPanel,
+ SingleChoiceQuizPanel,
+ Title,
+} from '@/components'
+import Cursor from '@/components/cursor'
+import { ErrorAlert, SuccessAlert } from '@/components/layout'
+import { useCursorController } from '@/hooks/useCursorController'
+import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { useNieerStore, saveNieerStore } from '@/store/nieerStore'
+
+const NieerPlace5 = () => {
+ const navigate = useNavigate()
+ const { moduleName } = useParams<{ moduleName: string }>()
+
+ const starName = 'nieer'
+ const place = 'place5'
+ const { markExhibitVisited, getVisitedExhibits } = useNieerStore()
+ const setLastVisitedPlace = useNieerStore((s) => s.setLastVisitedPlace)
+
+ const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(null)
+
+ // 光标1对话(图1)
+ const [guide1Visible, setGuide1Visible] = useState(false)
+ const [guide1Step, setGuide1Step] = useState(0)
+
+ // 光标2对话(图2)
+ const [guide2Visible, setGuide2Visible] = useState(false)
+ const [guide2Step, setGuide2Step] = useState(0)
+
+ // 光标3对话(图5)
+ const [guide3Visible, setGuide3Visible] = useState(false)
+ const [guide3Step, setGuide3Step] = useState(0)
+
+ // 光标2题目1(图3:填空)
+ const [quiz2_1Visible, setQuiz2_1Visible] = useState(false)
+ const quiz2_1WrongCountRef = useRef(0)
+ const [quiz2_1HintVisible, setQuiz2_1HintVisible] = useState(false)
+ const [success2_1Visible, setSuccess2_1Visible] = useState(false)
+
+ // 光标2题目2(图4:多选)
+ const [quiz2_2Visible, setQuiz2_2Visible] = useState(false)
+ const [success2_2Visible, setSuccess2_2Visible] = useState(false)
+
+ // 光标3题目1(图6:配对)
+ const [quiz3_1Visible, setQuiz3_1Visible] = useState(false)
+ const [success3_1Visible, setSuccess3_1Visible] = useState(false)
+
+ // 光标3题目2(图7:判断)
+ const [quiz3_2Visible, setQuiz3_2Visible] = useState(false)
+ const [success3_2Visible, setSuccess3_2Visible] = useState(false)
+
+ const visitedExhibits = getVisitedExhibits(place)
+ const visited = useMemo(() => new Set(visitedExhibits), [visitedExhibits])
+
+ const total = 3
+ const progressCount = visited.size
+ const completed = progressCount >= total
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
+
+ const { isEnabled } = useCursorController({
+ initialEnabled: [1],
+ dependencies: {
+ 2: [1],
+ 3: [2],
+ },
+ visited: visitedExhibits,
+ })
+
+ useEffect(() => {
+ if (!quiz2_1Visible) {
+ quiz2_1WrongCountRef.current = 0
+ setQuiz2_1HintVisible(false)
+ }
+ }, [quiz2_1Visible])
+
+ if (!moduleName) return null
+
+ const exhibitSrcMap: Record<1 | 2 | 3, string> = {
+ 1: 'nieer/place5/场景5展品1',
+ 2: 'nieer/place5/场景5展品2',
+ 3: 'nieer/place5/场景5展品3',
+ }
+
+ const handleOpenExhibit = (id: 1 | 2 | 3) => {
+ setActiveExhibitId(id)
+
+ if (id === 1) {
+ setGuide1Step(0)
+ setGuide1Visible(true)
+ }
+
+ if (id === 2) {
+ setGuide2Step(0)
+ setGuide2Visible(true)
+ }
+
+ if (id === 3) {
+ setGuide3Step(0)
+ setGuide3Visible(true)
+ }
+ }
+
+ return (
+
+
+
+ navigate(`/${moduleName}/${starName}/place4`)}
+ />
+
+ handleOpenExhibit(1)}
+ read={visited.has(1)}
+ disabled={!isEnabled(1)}
+ />
+ handleOpenExhibit(2)}
+ read={visited.has(2)}
+ disabled={!isEnabled(2)}
+ />
+ handleOpenExhibit(3)}
+ read={visited.has(3)}
+ disabled={!isEnabled(3)}
+ />
+
+ {completed && (
+ {
+ setLastVisitedPlace('place6')
+ saveNieerStore()
+ navigate(`/${moduleName}/${starName}/place6`)
+ }}
+ />
+ )}
+
+ {
+ const closingId = activeExhibitId
+ setActiveExhibitId(null)
+ setGuide1Visible(false)
+ setGuide2Visible(false)
+ setGuide3Visible(false)
+
+ // 光标1:只需要对话,关窗即完成
+ if (closingId === 1) {
+ markExhibitVisited(place, 1)
+ saveNieerStore()
+ }
+ }}
+ />
+
+ {/* 光标1对话(图1) */}
+
+
+ 那2004年的宪法修正案又对国歌做了什么重要规定呢?为什么要把它写入宪法呀?
+
+
+ 2004年3月14日,第十届全国人民代表大会二次会议通过宪法修正案,明确规定“中华人民共和国国歌是《义勇军进行曲》”,这让国歌的地位有了根本大法的保障。
+
+
+ 把它写入宪法,是因为这首歌不仅记录了民族救亡的历史,更凝聚了中华民族的精神气节。从抗战时期的冲锋号到新时代的强国音,它早已成为国家尊严的象征,写入宪法能更好地培育全民的爱国意识和国家观念。
+
+
+
+ {/* 光标2对话(图2) - 对话结束后依次出现两道题 */}
+ {
+ setGuide2Visible(nextVisible)
+ if (!nextVisible) {
+ setQuiz2_1Visible(true)
+ }
+ }}
+ showMask={false}
+ highlightSelectors={[]}
+ zIndex={10001}
+ >
+
+ 小沪,从抗日战歌到正式被确立为国歌,中间经历了多久呀?《义勇军进行曲》第一次作为国歌奏响是在什么时候呢?
+
+
+ 时间跨度还真不短呢!1949年9月27日,中国人民政治协商会议第一届全体会议决定,在国歌未正式制定前,以《义勇军进行曲》作为代国歌。
+
+
+ 同年10月1日开国大典上,它第一次在天安门广场作为国歌庄严奏响,见证了新中国的诞生。直到1982年12月4日,第五届全国人民代表大会第五次会议才正式将它定为中华人民共和国国歌,完成了从代国歌到正式国歌的跨越。
+
+
+
+ {/* 光标2题目1(图3:选择题) - 错两次弹窗提示 */}
+ {
+ if (isCorrect) {
+ setQuiz2_1Visible(false)
+ setSuccess2_1Visible(true)
+ }
+ }}
+ onError={() => {
+ quiz2_1WrongCountRef.current += 1
+ if (quiz2_1WrongCountRef.current >= 2) {
+ setQuiz2_1HintVisible(true)
+ }
+ }}
+ />
+
+ setQuiz2_1HintVisible(false)}
+ />
+
+ {
+ setSuccess2_1Visible(false)
+ setQuiz2_2Visible(true)
+ }}
+ onClose={() => {
+ setSuccess2_1Visible(false)
+ setQuiz2_2Visible(true)
+ }}
+ />
+
+ {/* 光标2题目2(图4:多选) */}
+ {
+ if (isCorrect) {
+ setQuiz2_2Visible(false)
+ setSuccess2_2Visible(true)
+ }
+ }}
+ />
+
+ {
+ setSuccess2_2Visible(false)
+ markExhibitVisited(place, 2)
+ saveNieerStore()
+ }}
+ onClose={() => {
+ setSuccess2_2Visible(false)
+ markExhibitVisited(place, 2)
+ saveNieerStore()
+ }}
+ />
+
+ {/* 光标3对话(图5) - 对话结束后依次出现两道题 */}
+ {
+ setGuide3Visible(nextVisible)
+ if (!nextVisible) {
+ setQuiz3_1Visible(true)
+ }
+ }}
+ showMask={false}
+ highlightSelectors={[]}
+ zIndex={10001}
+ >
+
+ 根据宪法,2017年9月1日第十二届全国人民代表大会常务委员会第二十九次会议通过《中华人民共和国国歌法》,通过国家立法对国歌的奏唱场合、奏唱礼仪和宣传教育进行规范。
+
+
+
+ {/* 光标3题目1(图6:配对) */}
+ {
+ const expectedMap = new Map([
+ ['e1949', 't1949'],
+ ['e1982', 't1982'],
+ ['e2004', 't2004'],
+ ['e2017', 't2017'],
+ ])
+
+ const firstWrongLeft = matches.find(
+ (m) => expectedMap.get(m.leftId) !== m.rightId
+ )?.leftId
+ if (!firstWrongLeft) return '请你再想想吧!'
+
+ if (firstWrongLeft === 'e1949') {
+ return '1949年9月27日,中国人民政治协商会议第一届全体会议决定,在国歌未正式制定前,以《义勇军进行曲》作为代国歌。请你再想想吧!'
+ }
+ if (firstWrongLeft === 'e1982') {
+ return '1982年12月4日,第五届全国人民代表大会第五次会议正式将《义勇军进行曲》定为中华人民共和国国歌。请你再想想吧!'
+ }
+ if (firstWrongLeft === 'e2004') {
+ return '2004年3月14日,第十届全国人民代表大会二次会议通过宪法修正案,明确规定“中华人民共和国国歌是《义勇军进行曲》”。请你再想想吧!'
+ }
+ return '2017年9月1日第十二届全国人民代表大会常务委员会第二十九次会议通过《中华人民共和国国歌法》。请你再想想吧!'
+ }}
+ onSubmit={(_matches, isCorrect) => {
+ if (isCorrect) {
+ setQuiz3_1Visible(false)
+ setSuccess3_1Visible(true)
+ }
+ }}
+ />
+
+ {
+ setSuccess3_1Visible(false)
+ setQuiz3_2Visible(true)
+ }}
+ onClose={() => {
+ setSuccess3_1Visible(false)
+ setQuiz3_2Visible(true)
+ }}
+ />
+
+ {/* 光标3题目2(图7:判断) */}
+ {
+ if (isCorrect) {
+ setQuiz3_2Visible(false)
+ setSuccess3_2Visible(true)
+ }
+ }}
+ />
+
+ {
+ setSuccess3_2Visible(false)
+ markExhibitVisited(place, 3)
+ saveNieerStore()
+ }}
+ onClose={() => {
+ setSuccess3_2Visible(false)
+ markExhibitVisited(place, 3)
+ saveNieerStore()
+ }}
+ />
+
+ )
+}
+
+export default NieerPlace5
diff --git a/src/pages/nieer/place/place6.tsx b/src/pages/nieer/place/place6.tsx
new file mode 100644
index 0000000..43a0a91
--- /dev/null
+++ b/src/pages/nieer/place/place6.tsx
@@ -0,0 +1,176 @@
+import {
+ EndingOverlay,
+ ExhibitOverlay,
+ GuideSteps,
+ GuideStepsItem,
+ Image,
+ Title,
+} from '@/components'
+import Cursor from '@/components/cursor'
+import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
+import { useRef, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { useNieerStore, saveNieerStore } from '@/store/nieerStore'
+import { useCursorController } from '@/hooks/useCursorController'
+
+const NieerPlace6 = () => {
+ const navigate = useNavigate()
+ const { moduleName } = useParams<{ moduleName: string }>()
+
+ const starName = 'nieer'
+ const place = 'place6'
+ const { markExhibitVisited, getVisitedExhibits } = useNieerStore()
+
+ const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null)
+ const [guide1Visible, setGuide1Visible] = useState(false)
+ const [guide1Step, setGuide1Step] = useState(0)
+
+ const total = 2
+ const visitedExhibits = getVisitedExhibits(place)
+ const visited = new Set(visitedExhibits)
+ const progressCount = visited.size
+ const completed = progressCount >= total
+
+ const [endingVisible, setEndingVisible] = useState(false)
+ // 是否已展示过结束语(用于控制“离开场景”按钮出现时机)
+ // 若用户刷新页面且已完成,则默认视为结束语已展示,避免卡住无法离开
+ const [endingShown, setEndingShown] = useState(() => completed)
+ const endingShownRef = useRef(false)
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
+
+ const { isEnabled } = useCursorController({
+ initialEnabled: [1],
+ dependencies: {
+ 2: [1],
+ },
+ visited: visitedExhibits,
+ })
+
+ if (!moduleName) return null
+
+ const exhibitSrcMap: Record<1 | 2, string> = {
+ 1: 'nieer/place6/场景6展品1',
+ 2: 'nieer/place6/场景6展品2',
+ }
+
+ const handleOpenExhibit = (id: 1 | 2) => {
+ setActiveExhibitId(id)
+ if (id === 1) {
+ setGuide1Step(0)
+ setGuide1Visible(true)
+ }
+ }
+
+ return (
+
+
+
+ navigate(`/${moduleName}/${starName}/place5`)}
+ />
+
+ handleOpenExhibit(1)}
+ read={visited.has(1)}
+ disabled={!isEnabled(1)}
+ />
+ handleOpenExhibit(2)}
+ read={visited.has(2)}
+ disabled={!isEnabled(2)}
+ />
+
+ {/* 最后一个场景:完成后不显示“前进”按钮 */}
+ {completed && endingShown && !endingVisible && (
+ navigate(`/${moduleName}/${starName}/home`)}
+ />
+ )}
+
+ {
+ const closingId = activeExhibitId
+ setActiveExhibitId(null)
+ setGuide1Visible(false)
+
+ if (closingId !== null) {
+ markExhibitVisited(place, closingId)
+ saveNieerStore()
+
+ // 仅在“关闭展品”这一刻触发结束语:当本次关闭导致完成时,弹出 EndingOverlay
+ const nextVisitedSize = visited.has(closingId) ? visited.size : visited.size + 1
+ const willComplete = nextVisitedSize >= total
+ if (willComplete && !endingShownRef.current) {
+ endingShownRef.current = true
+ setEndingShown(true)
+ setEndingVisible(true)
+ }
+ }
+ }}
+ />
+
+ {/* 光标1对话(图1) */}
+
+
+ 小沪,这些不同场景下奏响国歌的画面太让人感动了!在和平年代,这首歌还有什么特别的意义呢?
+
+
+ 和平年代里,它的精神力量依然不减。你看这张许海峰奥运夺金的照片,国歌奏响时,是民族自豪的宣告;神舟飞船升空时的国歌,是航天人攻坚克难的礼赞;
+
+
+ 抗震救灾现场的国歌,是全国人民众志成城的誓言。它就像一根精神纽带,无论在高光时刻还是艰难关头,都能凝聚起全国人民的力量,提醒我们不忘历史、砥砺前行。
+
+
+
+ {/* completed 后的结束语:统一使用 EndingOverlay */}
+ setEndingVisible(false)}
+ />
+
+ )
+}
+
+export default NieerPlace6
diff --git a/src/pages/nieer/reflection/ContentDialog.tsx b/src/pages/nieer/reflection/ContentDialog.tsx
new file mode 100644
index 0000000..ff766c4
--- /dev/null
+++ b/src/pages/nieer/reflection/ContentDialog.tsx
@@ -0,0 +1,274 @@
+import { Image, Video } from '@/components'
+import { cn, getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
+import { useAudioManager } from '@/hooks/useAudioManager'
+import { motion } from 'framer-motion'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+
+type ContentPage =
+ | { type: 'image'; src: string; audioSrc?: string }
+ | { type: 'video'; src: string }
+
+// 每个场景的多页内容配置(按 scene.id 映射)
+const SCENE_PAGES: Record = {
+ 1: [
+ { type: 'image', src: '场景1内容', audioSrc: 'audio/test.mp3' },
+ { type: 'video', src: 'video/nieer/nieer_2_1.mp4' },
+ ],
+ 2: [{ type: 'image', src: '场景2内容', audioSrc: 'audio/test.mp3' }],
+ 3: [{ type: 'image', src: '场景3内容', audioSrc: 'audio/test.mp3' }],
+ 4: [{ type: 'image', src: '场景4内容', audioSrc: 'audio/test.mp3' }],
+ 5: [
+ { type: 'image', src: '场景5内容', audioSrc: 'audio/test.mp3' },
+ { type: 'video', src: 'video/nieer/nieer_2_4.mp4' },
+ ],
+ 6: [
+ { type: 'image', src: '场景6内容', audioSrc: 'audio/test.mp3' },
+ { type: 'video', src: 'video/nieer/nieer_2_4.mp4' },
+ ],
+}
+
+type LobbyDialogScene = {
+ id: number
+ city: string
+}
+
+interface ContentDialogProps {
+ starName: string
+ title?: string
+ scene: LobbyDialogScene
+ onClose: () => void
+ onEnterScene: (sceneId: number) => void
+}
+
+const ContentDialog = ({ starName, title, scene, onClose, onEnterScene }: ContentDialogProps) => {
+ const [, setContentLoaded] = useState(false)
+
+ const pages = useMemo(() => SCENE_PAGES[scene.id] ?? [], [scene.id])
+ const [pageIndex, setPageIndex] = useState(0)
+ const [showCaption, setShowCaption] = useState(false)
+
+ const currentPage = pages[pageIndex] as ContentPage | undefined
+ const isLastPage = pageIndex >= pages.length - 1
+
+ const {
+ play: audioPlay,
+ stop: audioStop,
+ isPlaying: audioIsPlaying,
+ currentSrc: audioCurrentSrc,
+ } = useAudioManager({
+ mode: 'channel',
+ channelId: 'nieer-reflection',
+ })
+ const audioPlayingRef = useRef(false)
+ // 预期要播放的音频(仅用于判断“是否真的成功开始播放”)
+ const expectedAudioSrcRef = useRef(null)
+ // 避免同一页图片重复触发 play(),导致音频被反复重置
+ const lastPlayedKeyRef = useRef(null)
+
+ const goNext = useCallback(() => {
+ audioPlayingRef.current = false
+ if (isLastPage) {
+ // 如果最后一页是视频,由 onEnded 触发寄语;否则直接显示
+ const lastPage = pages[pageIndex]
+ if (lastPage?.type !== 'video') {
+ setShowCaption(true)
+ }
+ } else {
+ setPageIndex((i) => i + 1)
+ setContentLoaded(false)
+ }
+ }, [isLastPage, pages, pageIndex])
+
+ const handleVideoEnded = useCallback(() => {
+ setShowCaption(true)
+ }, [])
+
+ // 重置状态
+ useEffect(() => {
+ setContentLoaded(false)
+ setPageIndex(0)
+ setShowCaption(false)
+ audioPlayingRef.current = false
+ expectedAudioSrcRef.current = null
+ lastPlayedKeyRef.current = null
+ audioStop()
+ }, [scene.id, audioStop])
+
+ // 图片页自动播放音频
+ useEffect(() => {
+ if (!currentPage || currentPage.type !== 'image') return
+
+ const key = `${scene.id}-${pageIndex}`
+ if (lastPlayedKeyRef.current === key) return
+
+ if (currentPage.audioSrc) {
+ const src = getPublicPath(currentPage.audioSrc)
+ expectedAudioSrcRef.current = src
+ // 不要在 play() 调用后立刻置为 true;浏览器策略可能拦截,导致 isPlaying=false,从而不该自动翻页。
+ audioPlayingRef.current = false
+ audioPlay(src)
+ lastPlayedKeyRef.current = key
+ } else {
+ audioPlayingRef.current = false
+ expectedAudioSrcRef.current = null
+ lastPlayedKeyRef.current = key
+ }
+ }, [pageIndex, scene.id, currentPage, audioPlay])
+
+ // 只有当 audio 实际进入播放状态时才认为“正在播放图片页音频”
+ useEffect(() => {
+ if (!expectedAudioSrcRef.current) return
+ if (audioIsPlaying && audioCurrentSrc === expectedAudioSrcRef.current) {
+ audioPlayingRef.current = true
+ }
+ }, [audioIsPlaying, audioCurrentSrc])
+
+ // 音频结束 → 自动翻页
+ useEffect(() => {
+ if (!audioPlayingRef.current) return
+ if (!audioIsPlaying) {
+ audioPlayingRef.current = false
+ goNext()
+ }
+ }, [audioIsPlaying, goNext])
+
+ const boardBg = useMemo(
+ () => getBackgroundImage(`${starName}/reflection/lobby/dialog/竖底板`),
+ [starName]
+ )
+
+ // 跳过按钮只用于“查看图片/播放图片对应音频”阶段;
+ // 播放视频时不显示跳过按钮(由视频播放结束后触发寄语/进入下一步)。
+ const shouldShowSkip = !showCaption && currentPage?.type === 'image'
+
+ return (
+
+ {/* 左侧竖标题 */}
+
+ {title && (
+
+ {title}
+
+ )}
+
+
+ {/* 内容框 */}
+
+ {currentPage?.type === 'image' && (
+
setContentLoaded(true)}
+ />
+ )}
+
+ {currentPage?.type === 'video' && (
+
+ setContentLoaded(true)}
+ onEnded={handleVideoEnded}
+ />
+
+ )}
+
+ {/* 关闭按钮 */}
+ {
+ audioStop()
+ onClose()
+ }}
+ />
+
+ {/* 跳过按钮 */}
+ {shouldShowSkip && (
+
+ )}
+
+ {/* 底部页码指示器 */}
+ {pages.length > 1 && (
+
+ {pages.map((_, i) => (
+
+ ))}
+
+ )}
+
+
+ {/* 寄语按钮 */}
+ {showCaption && (
+
+
onEnterScene(scene.id)}
+ />
+
+
+ )}
+
+ )
+}
+
+export default ContentDialog
diff --git a/src/pages/nieer/reflection/DialogLayer.tsx b/src/pages/nieer/reflection/DialogLayer.tsx
new file mode 100644
index 0000000..c7c334b
--- /dev/null
+++ b/src/pages/nieer/reflection/DialogLayer.tsx
@@ -0,0 +1,300 @@
+import { AnimatePresence, motion } from 'framer-motion'
+import { useEffect, useState } from 'react'
+import { Image } from '@/components'
+import {
+ BeijingUnlockInteraction,
+ InputBg,
+ PianoUnlockInteraction,
+ PostcardUnlockInteraction,
+ SelectBg,
+} from '../components'
+import { getStaticImage } from '@/lib/utils'
+import { LOBBY_COLUMNS, SCENE_TITLES, UNLOCK_TITLES } from './lobby.config'
+import ContentDialog from './ContentDialog'
+import UnlockHintDialog from './UnlockHintDialog'
+import type { DialogState } from './useDialogReducer'
+
+const starName = 'nieer'
+
+const ANTHEM_LYRICS = [
+ { value: '1', label: '起来!不愿做奴隶的人们!' },
+ { value: '2', label: '把我们的血肉,筑成我们新的长城!' },
+ { value: '3', label: '中华民族到了最危险的时候' },
+ { value: '4', label: '每个人被迫着发出最后的吼声' },
+ { value: '5', label: '起来!起来!起来!' },
+ { value: '6', label: '我们万众一心' },
+ { value: '7', label: '冒着敌人的炮火,前进!' },
+ { value: '8', label: '前进!前进!进!' },
+]
+
+const SMEDLEY_QUOTES = [
+ { value: '1', label: '建设一个强大的和自由的中国' },
+ { value: '2', label: '为我的遗体唱一首歌,中国的国歌……"起来"' },
+ { value: '3', label: '我的心灵在这个世界上除了中国的任何地方都未能找到安宁' },
+]
+
+interface DialogLayerProps {
+ state: DialogState
+ onEnterUnlock: () => void
+ onShare: (payload?: Record) => void
+ onClose: () => void
+}
+
+const DialogLayer = ({ state, onEnterUnlock, onShare, onClose }: DialogLayerProps) => {
+ const [selectedLyric, setSelectedLyric] = useState()
+ const [guizhouInput, setGuizhouInput] = useState('')
+ const [pianoFinished, setPianoFinished] = useState(false)
+ const [selectedQuote, setSelectedQuote] = useState()
+ const [postcardFinished, setPostcardFinished] = useState(false)
+ const [beijingFinished, setBeijingFinished] = useState(false)
+ const [postcardState, setPostcardState] = useState<{
+ hasPlane: boolean
+ hasText: boolean
+ postcardImagePath: string
+ }>({
+ hasPlane: false,
+ hasText: false,
+ postcardImagePath: 'nieer/reflection/lobby/content/场景5待交互',
+ })
+ const [beijingSelection, setBeijingSelection] = useState<{
+ paradeId?: string
+ paradeName?: string
+ paradeImagePath?: string
+ sloganId?: string
+ sloganImagePath?: string
+ previewImageDataUrl?: string
+ }>({})
+
+ const dialogNonce = state.mode === 'idle' ? 0 : state.nonce
+
+ // 每次点击卡片(nonce 变化)时,重置 unlock 交互状态
+ useEffect(() => {
+ setSelectedLyric(undefined)
+ setGuizhouInput('')
+ setPianoFinished(false)
+ setSelectedQuote(undefined)
+ setPostcardFinished(false)
+ setBeijingFinished(false)
+ setPostcardState({
+ hasPlane: false,
+ hasText: false,
+ postcardImagePath: 'nieer/reflection/lobby/content/场景5待交互',
+ })
+ setBeijingSelection({})
+ }, [dialogNonce])
+
+ if (state.mode === 'idle') return
+
+ const scene = LOBBY_COLUMNS.find((s) => s.id === state.sceneId)
+ if (!scene) return null
+
+ let canShare = true
+ if (state.sceneId === 1) {
+ canShare = !!selectedLyric
+ } else if (state.sceneId === 2) {
+ canShare = guizhouInput.trim().length > 0
+ } else if (state.sceneId === 3) {
+ // 欧洲·亚洲:钢琴交互播放完成后才允许分享
+ canShare = pianoFinished
+ } else if (state.sceneId === 4) {
+ canShare = !!selectedQuote
+ } else if (state.sceneId === 5) {
+ canShare = postcardFinished
+ } else if (state.sceneId === 6) {
+ canShare = beijingFinished && typeof beijingSelection.previewImageDataUrl === 'string'
+ }
+
+ const handleShareClick = () => {
+ if (state.mode !== 'unlock') return
+
+ if (state.sceneId === 1) {
+ const selected = ANTHEM_LYRICS.find((item) => item.value === selectedLyric)
+ onShare({
+ sceneId: 1,
+ lyricValue: selectedLyric ?? '',
+ lyricLabel: selected?.label ?? '',
+ })
+ return
+ }
+ if (state.sceneId === 2) {
+ onShare({
+ sceneId: 2,
+ reflectionText: guizhouInput.trim(),
+ })
+ return
+ }
+ if (state.sceneId === 3) {
+ onShare({
+ sceneId: 3,
+ pianoCompleted: pianoFinished,
+ pianoImagePath: 'nieer/reflection/lobby/box/音符',
+ })
+ return
+ }
+ if (state.sceneId === 4) {
+ const selected = SMEDLEY_QUOTES.find((item) => item.value === selectedQuote)
+ onShare({
+ sceneId: 4,
+ quoteValue: selectedQuote ?? '',
+ quoteLabel: selected?.label ?? '',
+ })
+ return
+ }
+ if (state.sceneId === 5) {
+ onShare({
+ sceneId: 5,
+ ...postcardState,
+ })
+ return
+ }
+ if (state.sceneId === 6) {
+ onShare({
+ sceneId: 6,
+ ...beijingSelection,
+ })
+ return
+ }
+
+ onShare()
+ }
+
+ return (
+
+ {state.mode === 'result' && (
+
+
+
+
+
+
+ )}
+
+ {state.mode === 'unlock' && (
+
+
+ {state.sceneId === 1 && (
+
+
+
+
+ )}
+
+ {state.sceneId === 2 && (
+
+
+ setGuizhouInput(value.slice(0, 10))}
+ placeholder="请输入你的爱国感悟(10字以内)"
+ maxLength={10}
+ />
+
+ )}
+
+ {state.sceneId === 3 && (
+
+
{
+ setPianoFinished(true)
+ }}
+ />
+
+ )}
+
+ {state.sceneId === 4 && (
+
+
+
+
+ )}
+
+ {state.sceneId === 5 && (
+ {
+ setPostcardFinished(true)
+ }}
+ onStateChange={setPostcardState}
+ />
+ )}
+
+ {state.sceneId === 6 && (
+ {
+ setBeijingFinished(true)
+ }}
+ onSelectionChange={setBeijingSelection}
+ />
+ )}
+
+
+ )}
+
+ {state.mode === 'content' && (
+
+ )}
+
+ )
+}
+
+export default DialogLayer
diff --git a/src/pages/nieer/reflection/UnlockHintDialog.tsx b/src/pages/nieer/reflection/UnlockHintDialog.tsx
new file mode 100644
index 0000000..ec34b71
--- /dev/null
+++ b/src/pages/nieer/reflection/UnlockHintDialog.tsx
@@ -0,0 +1,78 @@
+import { Image } from '@/components'
+import { getBackgroundImage, getStaticImage } from '@/lib/utils'
+import { motion } from 'framer-motion'
+import type { ReactNode } from 'react'
+
+type UnlockHintDialogProps = {
+ title?: string
+ starName: string
+ visible: boolean
+ onClose: () => void
+ onShare?: () => void
+ children?: ReactNode
+}
+
+const UnlockHintDialog = ({
+ title,
+ starName,
+ visible,
+ onClose,
+ onShare,
+ children,
+}: UnlockHintDialogProps) => {
+ return (
+
+ {visible && (
+
+
+
+ {title && (
+
+ )}
+
+
{children}
+
+ {onShare && (
+
+
+
+ )}
+
+ )}
+
+ )
+}
+
+export default UnlockHintDialog
diff --git a/src/pages/nieer/reflection/box.tsx b/src/pages/nieer/reflection/box.tsx
new file mode 100644
index 0000000..8d870bb
--- /dev/null
+++ b/src/pages/nieer/reflection/box.tsx
@@ -0,0 +1,132 @@
+import { Image } from '@/components'
+import { cn, getBackgroundImage, getStaticImage } from '@/lib/utils'
+import { useMemo } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { PLACE_REQUIRED_COUNTS } from './lobby.config'
+import { useNieerStore } from '@/store/nieerStore'
+
+const starName = 'nieer'
+
+const MODULES = [
+ { id: 1, place: 'place1', pos: 'left-140 top-65' },
+ { id: 2, place: 'place2', pos: 'left-332 top-65' },
+ { id: 3, place: 'place3', pos: 'left-80 top-108' },
+ { id: 4, place: 'place4', pos: 'left-275 top-112' },
+ { id: 5, place: 'place5', pos: 'left-120 top-162' },
+ { id: 6, place: 'place6', pos: 'left-320 top-162' },
+] as const
+
+const NieerReflectionBox = () => {
+ const navigate = useNavigate()
+ const { moduleName } = useParams<{ moduleName: string }>()
+ const visitedReflectionCursors = useNieerStore((state) => state.visitedReflectionCursors)
+ const reflectionWorks = useNieerStore((state) => state.reflectionWorks)
+
+ const completionByPlace = useMemo(() => {
+ const getVisitedCount = (placeName: string): number =>
+ visitedReflectionCursors[placeName]?.size ?? 0
+ return {
+ place1: getVisitedCount('place1') >= (PLACE_REQUIRED_COUNTS[1] ?? 0),
+ place2: getVisitedCount('place2') >= (PLACE_REQUIRED_COUNTS[2] ?? 0),
+ place3: getVisitedCount('place3') >= (PLACE_REQUIRED_COUNTS[3] ?? 0),
+ place4: getVisitedCount('place4') >= (PLACE_REQUIRED_COUNTS[4] ?? 0),
+ place5: getVisitedCount('place5') >= (PLACE_REQUIRED_COUNTS[5] ?? 0),
+ place6: getVisitedCount('place6') >= (PLACE_REQUIRED_COUNTS[6] ?? 0),
+ } as const
+ }, [visitedReflectionCursors])
+
+ if (!moduleName) return null
+
+ return (
+
+
navigate(`/${moduleName}/${starName}/reflection`)}
+ />
+
+
+ {MODULES.map((module) => {
+ const done = completionByPlace[module.place]
+ const work = reflectionWorks[module.id]
+ const imagePathOrDataUrl =
+ typeof work?.previewImageDataUrl === 'string'
+ ? work.previewImageDataUrl
+ : typeof work?.postcardImagePath === 'string'
+ ? work.postcardImagePath
+ : typeof work?.paradeImagePath === 'string'
+ ? work.paradeImagePath
+ : typeof work?.pianoImagePath === 'string'
+ ? work.pianoImagePath
+ : null
+ const imageSrc =
+ imagePathOrDataUrl && imagePathOrDataUrl.startsWith('data:image')
+ ? imagePathOrDataUrl
+ : imagePathOrDataUrl
+ ? getStaticImage(imagePathOrDataUrl)
+ : null
+ const shouldShowSloganImage =
+ done &&
+ typeof work?.sloganImagePath === 'string' &&
+ !(typeof work?.previewImageDataUrl === 'string')
+ const sloganImageSrc =
+ shouldShowSloganImage && typeof work?.sloganImagePath === 'string'
+ ? getStaticImage(work.sloganImagePath)
+ : null
+ const textContent =
+ typeof work?.reflectionText === 'string'
+ ? work.reflectionText
+ : typeof work?.lyricLabel === 'string'
+ ? work.lyricLabel
+ : typeof work?.quoteLabel === 'string'
+ ? work.quoteLabel
+ : null
+ return (
+
+ {!done && (
+
+ 暂未收集
+
+ )}
+ {done && imageSrc && (
+
+ )}
+ {shouldShowSloganImage && sloganImageSrc && (
+
+ )}
+ {done && textContent && (
+
{textContent}
+ )}
+ {done && (
+
+ )}
+
+ )
+ })}
+
+
+ )
+}
+
+export default NieerReflectionBox
diff --git a/src/pages/nieer/reflection/index.tsx b/src/pages/nieer/reflection/index.tsx
new file mode 100644
index 0000000..9b81406
--- /dev/null
+++ b/src/pages/nieer/reflection/index.tsx
@@ -0,0 +1,284 @@
+import { Image } from '@/components'
+import { cn, getBackgroundImage, getStaticImage } from '@/lib/utils'
+import { useMemo } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { LOBBY_COLUMNS, LOBBY_LAYOUT, LOBBY_TIMELINE, PLACE_REQUIRED_COUNTS } from './lobby.config'
+import { useNieerStore } from '@/store/nieerStore'
+import { useDialogReducer } from './useDialogReducer'
+import DialogLayer from './DialogLayer'
+
+const starName = 'nieer'
+
+const NieerReflection = () => {
+ const navigate = useNavigate()
+ const { moduleName } = useParams<{ moduleName: string }>()
+ const debugLobby = new URLSearchParams(window.location.search).get('debugLobby') === '1'
+ const visitedReflectionCursors = useNieerStore((state) => state.visitedReflectionCursors)
+ const markReflectionCursorVisited = useNieerStore((state) => state.markReflectionCursorVisited)
+ const setReflectionWork = useNieerStore((state) => state.setReflectionWork)
+
+ const {
+ state: dialogState,
+ openScene,
+ enterUnlock,
+ handleShare,
+ close,
+ } = useDialogReducer(markReflectionCursorVisited, setReflectionWork)
+
+ const placeCompletion = useMemo(() => {
+ const getVisitedCount = (placeName: string): number =>
+ visitedReflectionCursors[placeName]?.size ?? 0
+ return {
+ 1: getVisitedCount('place1') >= (PLACE_REQUIRED_COUNTS[1] ?? 0),
+ 2: getVisitedCount('place2') >= (PLACE_REQUIRED_COUNTS[2] ?? 0),
+ 3: getVisitedCount('place3') >= (PLACE_REQUIRED_COUNTS[3] ?? 0),
+ 4: getVisitedCount('place4') >= (PLACE_REQUIRED_COUNTS[4] ?? 0),
+ 5: getVisitedCount('place5') >= (PLACE_REQUIRED_COUNTS[5] ?? 0),
+ 6: getVisitedCount('place6') >= (PLACE_REQUIRED_COUNTS[6] ?? 0),
+ } as const
+ }, [visitedReflectionCursors])
+
+ const unlockedById = useMemo(
+ () =>
+ ({
+ 1: true,
+ 2: placeCompletion[1],
+ 3: placeCompletion[2],
+ 4: placeCompletion[3],
+ 5: placeCompletion[4],
+ 6: placeCompletion[5],
+ }) as const,
+ [placeCompletion]
+ )
+
+ const badgeUnlocked = useMemo(
+ () => ({
+ 1: placeCompletion[1] && placeCompletion[2],
+ 2: placeCompletion[3] && placeCompletion[4],
+ 3: placeCompletion[5] && placeCompletion[6],
+ }),
+ [placeCompletion]
+ )
+
+ const guideSceneId = useMemo(() => {
+ const nextScene = LOBBY_COLUMNS.find(
+ (scene) => !placeCompletion[scene.id as keyof typeof placeCompletion]
+ )
+ return nextScene?.id ?? 6
+ }, [placeCompletion])
+ const allPlacesCompleted = useMemo(
+ () => LOBBY_COLUMNS.every((scene) => placeCompletion[scene.id as keyof typeof placeCompletion]),
+ [placeCompletion]
+ )
+
+ if (!moduleName) return null
+
+ return (
+
+ {/* 返回按钮 */}
+
navigate(`/${moduleName}/${starName}/home`)}
+ />
+
+ {/* 六列时间轴内容 */}
+
+ {/* 琴弦层:在背景图之上、在顶部图标之下 */}
+
})
+ {LOBBY_COLUMNS.map((scene) => {
+ const isUnlocked = unlockedById[scene.id as keyof typeof unlockedById]
+ // 顶部图标只在“该模块已完成学习任务”时亮起;未完成(即当前进度)保持黑白
+ const isCompleted = placeCompletion[scene.id as keyof typeof placeCompletion]
+ const iconSrc = isCompleted ? scene.icon : scene.iconBw
+ const isClickable = isUnlocked || scene.id === guideSceneId
+ const showGuideState = !allPlacesCompleted && scene.id === guideSceneId
+
+ return (
+
+ )
+ })}
+
+
+ {/* 横跨年份层:只负责 1937 跨列展示 */}
+
+ {LOBBY_TIMELINE.map((item) => {
+ if (item.type === 'single') {
+ return (
+
+ {item.label}
+
+ )
+ }
+
+ return (
+
+ {item.withLine && }
+ {item.label}
+ {item.withLine && }
+
+ )
+ })}
+
+
+ {/* 底部 footer + 徽章区 */}
+
+
+ {[
+ {
+ id: 1,
+ className: 'top-0 left-36',
+ },
+ {
+ id: 2,
+ className: 'top-0 left-146',
+ },
+ {
+ id: 3,
+ className: 'top-0 right-36',
+ },
+ ].map((i) => {
+ const isLit = badgeUnlocked[i.id as keyof typeof badgeUnlocked]
+ return (
+
+
+ {isLit && (
+
+ )}
+
+ )
+ })}
+
+
+
+ {/* 右下角:旅行箱 */}
+ navigate(`/${moduleName}/${starName}/reflection/box`)}
+ />
+
+ {/* debug 参考线 */}
+ {debugLobby && (
+
+ )}
+
+
+
+ )
+}
+
+export default NieerReflection
diff --git a/src/pages/nieer/reflection/lobby.config.ts b/src/pages/nieer/reflection/lobby.config.ts
new file mode 100644
index 0000000..ed65303
--- /dev/null
+++ b/src/pages/nieer/reflection/lobby.config.ts
@@ -0,0 +1,74 @@
+export const LOBBY_LAYOUT = {
+ columnsLeft: 'left-75',
+ columnsTop: 'top-0',
+ columnsWidthPx: 1320,
+ columnCount: 6,
+ timelineTop: 'top-45',
+} as const
+
+export const LOBBY_COLUMNS = [
+ { id: 1, city: '上海', cityImage: '上海', icon: '话筒', iconBw: '黑白-话筒', active: true },
+ { id: 2, city: '贵州', cityImage: '贵州', icon: '喇叭', iconBw: '黑白-喇叭', active: false },
+ {
+ id: 3,
+ city: '欧洲·亚洲',
+ cityImage: '欧洲亚洲',
+ icon: '唱片',
+ iconBw: '黑白-唱片',
+ active: false,
+ },
+ { id: 4, city: '美国', cityImage: '美国', icon: '羽毛笔', iconBw: '黑白-羽毛', active: false },
+ { id: 5, city: '利比亚', cityImage: '利比亚', icon: '飞机', iconBw: '黑白-飞机', active: false },
+ { id: 6, city: '北京', cityImage: '北京', icon: '车', iconBw: '黑白-车', active: false },
+] as const
+
+type TimelineSingle = {
+ type: 'single'
+ col: number
+ label: string
+}
+
+type TimelineSpan = {
+ type: 'span'
+ colStart: number
+ colEnd: number
+ label: string
+ withLine?: boolean
+}
+
+export type TimelineItem = TimelineSingle | TimelineSpan
+
+export const LOBBY_TIMELINE: TimelineItem[] = [
+ { type: 'single', col: 0, label: '1936' },
+ { type: 'span', colStart: 1, colEnd: 2, label: '1937', withLine: true },
+ { type: 'single', col: 3, label: '1950' },
+ { type: 'single', col: 4, label: '2011' },
+ { type: 'single', col: 5, label: '2025' },
+]
+
+export const SCENE_TITLES: Record = {
+ 1: '抗战诞生·烽火铸歌',
+ 2: '抗战诞生·烽火铸歌',
+ 3: '海外传播·声动世界',
+ 4: '海外传播·声动世界',
+ 5: '当代传承·薪火不息',
+ 6: '当代传承·薪火不息',
+}
+
+export const PLACE_REQUIRED_COUNTS: Record = {
+ 1: 3,
+ 2: 2,
+ 3: 3,
+ 4: 2,
+ 5: 3,
+ 6: 2,
+}
+
+export const UNLOCK_TITLES: Record = {
+ 1: '在输入框中写下1句你想教给他人的国歌歌词,把爱国之声传递出去吧~输入完后,点击分享按钮,文字自动进入\u201C旅行箱\u201D',
+ 2: '了解了蓝运瑜老人的故事,请你在下方输入框中,写下1句10字以内的你的爱国感悟吧!输入完后,点击分享按钮,文字自动进入\u201C旅行箱\u201D',
+ 3: '请跟随光点在钢琴上弹奏出国歌的片段,为任光的演出伴奏吧~',
+ 4: '点击选择史沫特莱女士临终遗言最触动你的一句话。选择完成后,点击分享按钮,文字自动进入\u201C旅行箱\u201D',
+ 5: '请拖曳配齐图片要素制作接同胞回家的明信片!',
+ 6: '选一个你最喜欢的阅兵方阵,搭配一句词条,点击分享,定格那个瞬间。',
+}
diff --git a/src/pages/nieer/reflection/useDialogReducer.ts b/src/pages/nieer/reflection/useDialogReducer.ts
new file mode 100644
index 0000000..a14e817
--- /dev/null
+++ b/src/pages/nieer/reflection/useDialogReducer.ts
@@ -0,0 +1,93 @@
+import { useCallback, useReducer, useRef } from 'react'
+import { PLACE_REQUIRED_COUNTS } from './lobby.config'
+
+// ── State ──────────────────────────────────────────────
+
+export type DialogState =
+ | { mode: 'idle' }
+ | { mode: 'content'; sceneId: number; nonce: number }
+ | { mode: 'unlock'; sceneId: number; hasShare: boolean; nonce: number }
+ | { mode: 'result'; sceneId: number; nonce: number }
+
+// ── Actions ────────────────────────────────────────────
+
+type DialogAction =
+ | { type: 'OPEN_SCENE'; sceneId: number; unlocked: boolean; nonce: number }
+ | { type: 'ENTER_UNLOCK' }
+ | { type: 'SHOW_RESULT' }
+ | { type: 'CLOSE' }
+
+// ── Reducer ────────────────────────────────────────────
+
+function dialogReducer(state: DialogState, action: DialogAction): DialogState {
+ switch (action.type) {
+ case 'OPEN_SCENE':
+ return action.unlocked
+ ? { mode: 'content', sceneId: action.sceneId, nonce: action.nonce }
+ : { mode: 'unlock', sceneId: action.sceneId, hasShare: false, nonce: action.nonce }
+
+ case 'ENTER_UNLOCK':
+ if (state.mode !== 'content') return state
+ return { mode: 'unlock', sceneId: state.sceneId, hasShare: true, nonce: state.nonce }
+
+ case 'SHOW_RESULT':
+ if (state.mode !== 'unlock') return state
+ return { mode: 'result', sceneId: state.sceneId, nonce: state.nonce }
+
+ case 'CLOSE':
+ return { mode: 'idle' }
+
+ default:
+ return state
+ }
+}
+
+// ── Hook ───────────────────────────────────────────────
+
+export function useDialogReducer(
+ markExhibitVisited: (place: string, exhibitId: number) => void,
+ setReflectionWork: (sceneId: number, payload: Record) => void
+) {
+ const [state, dispatch] = useReducer(dialogReducer, { mode: 'idle' })
+ const shareGuardRef = useRef(false)
+ const nonceRef = useRef(0)
+
+ const openScene = useCallback((sceneId: number, unlocked: boolean) => {
+ shareGuardRef.current = false
+ nonceRef.current += 1
+ dispatch({ type: 'OPEN_SCENE', sceneId, unlocked, nonce: nonceRef.current })
+ }, [])
+
+ const enterUnlock = useCallback(() => {
+ dispatch({ type: 'ENTER_UNLOCK' })
+ }, [])
+
+ const handleShare = useCallback(
+ (payload?: Record) => {
+ if (shareGuardRef.current) return
+ if (state.mode !== 'unlock') return
+ shareGuardRef.current = true
+
+ const { sceneId } = state
+ const placeName = `place${sceneId}`
+ const needCount = PLACE_REQUIRED_COUNTS[sceneId] ?? 0
+ for (let i = 1; i <= needCount; i++) {
+ markExhibitVisited(placeName, i)
+ }
+
+ if (payload) {
+ setReflectionWork(sceneId, payload)
+ }
+
+ dispatch({ type: 'SHOW_RESULT' })
+ },
+ [state, markExhibitVisited, setReflectionWork]
+ )
+
+ const close = useCallback(() => {
+ shareGuardRef.current = false
+ dispatch({ type: 'CLOSE' })
+ }, [])
+
+ return { state, openScene, enterUnlock, handleShare, close }
+}
diff --git a/src/pages/rongdesheng/home.tsx b/src/pages/rongdesheng/home.tsx
new file mode 100644
index 0000000..0c4ee37
--- /dev/null
+++ b/src/pages/rongdesheng/home.tsx
@@ -0,0 +1,56 @@
+import { getBackgroundImage, getStaticImage } from '@/lib/utils'
+import { Image } from '@/components'
+import { useNavigate, useParams } from 'react-router-dom'
+import { useRongdeshengStore } from '@/store/rongdeshengStore'
+
+const RongdeshengHome = () => {
+ const navigate = useNavigate()
+ const { moduleName } = useParams<{ moduleName: string }>()
+
+ // starName 固定为 rongdesheng
+ const starName = 'rongdesheng'
+ const lastVisitedPlace = useRongdeshengStore((s) => s.lastVisitedPlace)
+
+ if (!moduleName) return null
+
+ return (
+
+
navigate(`/entrance/${moduleName}`)}
+ />
+
+
+ {/* 对话 */}
+ navigate(`/${moduleName}/${starName}/${lastVisitedPlace || 'place1'}`)}
+ />
+
+ {/* 体悟(占位) */}
+ navigate(`/${moduleName}/${starName}/reflection`)}
+ />
+
+ {/* 内化(占位) */}
+ navigate(`/${moduleName}/${starName}/internalization`)}
+ />
+
+
+ )
+}
+
+export default RongdeshengHome
diff --git a/src/pages/rongdesheng/internalization.tsx b/src/pages/rongdesheng/internalization.tsx
new file mode 100644
index 0000000..7606c19
--- /dev/null
+++ b/src/pages/rongdesheng/internalization.tsx
@@ -0,0 +1,489 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import {
+ DragQuizPanel,
+ EndingOverlay,
+ GuideSteps,
+ GuideStepsItem,
+ Image,
+ LetterToRongdesheng,
+ Narration,
+ Video,
+ type DragFillResult,
+ type VideoAnchor,
+ type VideoRef,
+} from '@/components'
+import { SuccessAlert } from '@/components/layout'
+import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/layout/quiz'
+import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
+
+const starName = 'rongdesheng'
+
+type QuizOption = {
+ value: string
+ label: string
+ isCorrect?: boolean
+ explanation?: string
+}
+
+type QuizAnchor = {
+ id: string
+ time: number
+ type: 'single' | 'multiple' | 'drag'
+ title: string
+ prompt?: string
+ promptSuffix?: string
+ options: QuizOption[]
+ blanks?: { id: string; label: string }[]
+ blankSeparators?: string[]
+ correctAnswer?: string | string[]
+ correctAnswersMap?: Record
+ successContent?: string
+ explanation?: string
+}
+
+const QUIZ_ANCHORS: QuizAnchor[] = [
+ {
+ id: 'quiz-1',
+ time: 60,
+ type: 'drag',
+ title: '',
+ prompt: '【拖拽】荣德生大力发展与国计民生密切相关的',
+ promptSuffix: '产业。',
+ blanks: [
+ { id: 'blank-1', label: '____' },
+ { id: 'blank-2', label: '____' },
+ ],
+ blankSeparators: ['产业和'],
+ options: [
+ { value: '面粉', label: 'A. 面粉', isCorrect: true },
+ { value: '纺织', label: 'B. 纺织', isCorrect: true },
+ {
+ value: '采矿',
+ label: 'C. 采矿',
+ explanation: '荣德生的实业布局并未涉及采矿业。请你再想想吧!',
+ },
+ ],
+ correctAnswersMap: {
+ 'blank-1': '面粉',
+ 'blank-2': '纺织',
+ },
+ successContent: '',
+ explanation: '荣德生的实业布局并未涉及采矿业。请你再想想吧!',
+ },
+ {
+ id: 'quiz-2',
+ time: 60 + 51,
+ type: 'single',
+ title: '【单选】荣德生的企业虽多次遭战火摧毁,但始终浴火重生,这体现了他哪种宝贵品质?',
+ options: [
+ {
+ value: 'A',
+ label: 'A. 坚韧不拔、锲而不舍',
+ isCorrect: true,
+ explanation:
+ '荣德生面对战火摧残,始终坚守实业,70岁高龄仍重建工厂,这份坚韧是民族实业家的宝贵品格。',
+ },
+ {
+ value: 'B',
+ label: 'B. 急流勇退、明哲保身',
+ explanation: '荣德生始终坚守实业救国初心,从未退缩,这并非他的品质。请你再想想吧!',
+ },
+ ],
+ correctAnswer: 'A',
+ },
+ {
+ id: 'quiz-3',
+ time: 3 * 60 + 29,
+ type: 'multiple',
+ title: '【多选】荣德生用实际行动诠释了个人与国家的紧密联系,下列哪些事迹体现了他的爱国情怀?',
+ options: [
+ { value: 'A', label: 'A. 淞沪抗战中捐钱捐物,一次捐助一万包面粉支援前线', isCorrect: true },
+ { value: 'B', label: 'B. 拒绝日军、汪伪政权的威逼利诱,不转让工厂产权', isCorrect: true },
+ { value: 'C', label: 'C. 1948年坚守大陆,将荣氏企业完整留给新中国', isCorrect: true },
+ {
+ value: 'D',
+ label: 'D. 专注企业盈利,不参与任何社会公益与爱国活动',
+ explanation:
+ '很遗憾选择有误,荣德生是爱国民族实业家,始终心系国家,不仅仅专注企业盈利,上述多项事迹均体现了他的爱国情怀。请你再想想吧!',
+ },
+ ],
+ correctAnswer: ['A', 'B', 'C'],
+ successContent: '荣德生始终将个人实业与国家命运绑定,用实际行动践行了实业救国的爱国初心。',
+ },
+ {
+ id: 'quiz-4',
+ time: 4 * 60 + 33,
+ type: 'single',
+ title: '【单选】抗战时期,荣德生为助力民族工业人才培养,做出了哪项举措?',
+ options: [
+ {
+ value: 'A',
+ label: 'A. 开办纺织专科学校',
+ isCorrect: true,
+ explanation: '荣德生重视实业人才培养,抗战时期开办纺织专科学校,为行业发展储备力量。',
+ },
+ {
+ value: 'B',
+ label: 'B. 设立全国性实业奖学金',
+ explanation: '荣德生彼时的举措是开办纺织专科学校,并非设立全国性实业奖学金。请你再想想吧!',
+ },
+ ],
+ correctAnswer: 'A',
+ },
+ {
+ id: 'quiz-5',
+ time: 5 * 60 + 22,
+ type: 'single',
+ title:
+ '【单选】荣德生先生提出的“崇俭立国”主张,以及造桥铺路、创办学校等善举,体现了他怎样的财富观?',
+ options: [
+ {
+ value: 'A',
+ label: 'A. 创造财富后回馈社会,勇担社会责任',
+ isCorrect: true,
+ explanation:
+ '荣德生不将财富用于个人享乐,而是投身公益、建设家乡,让财富成为推动社会发展的力量。',
+ },
+ {
+ value: 'B',
+ label: 'B. 将财富完全用于企业扩张,追求利益最大化',
+ explanation:
+ '荣德生重视企业发展,但更注重财富的社会价值,并非单纯追求利益最大化。请你再想想吧!',
+ },
+ ],
+ correctAnswer: 'A',
+ },
+]
+
+const RongdeshengInternalization = () => {
+ const navigate = useNavigate()
+ const { moduleName } = useParams<{ moduleName: string }>()
+
+ const [dragQuizValues, setDragQuizValues] = useState>({})
+ const [dragQuizErrors, setDragQuizErrors] = useState>({})
+ const [narrationVisible, setNarrationVisible] = useState(true)
+ const [preGuideVisible, setPreGuideVisible] = useState(false)
+ const [showVideo, setShowVideo] = useState(false)
+ const [postGuideVisible, setPostGuideVisible] = useState(false)
+ const [showLetter, setShowLetter] = useState(false)
+ const [showEnding, setShowEnding] = useState(false)
+ const [activeSuccess, setActiveSuccess] = useState(null)
+ const [successContent, setSuccessContent] = useState('')
+ const [resumeCallback, setResumeCallback] = useState<(() => void) | null>(null)
+
+ const videoRef = useRef(null)
+
+ useEffect(() => {
+ if (!narrationVisible) {
+ setPreGuideVisible(true)
+ }
+ }, [narrationVisible])
+
+ const handleVideoCanPlay = () => {
+ if (showVideo && videoRef.current) {
+ videoRef.current.play().catch(() => {})
+ }
+ }
+
+ const anchors: VideoAnchor[] = useMemo(() => {
+ return QUIZ_ANCHORS.map((item) => {
+ if (item.type === 'drag' && item.blanks) {
+ return {
+ id: item.id,
+ time: item.time,
+ once: true,
+ afterResume: 'remove',
+ render: ({ resume }) => {
+ const handleDragChange = (value: DragFillResult[]) => {
+ setDragQuizValues((prev) => ({ ...prev, [item.id]: value }))
+ setDragQuizErrors((prev) => ({ ...prev, [item.id]: '' }))
+
+ if (value.length < item.blanks!.length) return
+
+ const hasWrongOption = value.some((result) => {
+ const option = item.options.find((current) => current.value === result.optionValue)
+ return option && !option.isCorrect
+ })
+
+ if (hasWrongOption) {
+ setDragQuizErrors((prev) => ({
+ ...prev,
+ [item.id]: item.explanation || '请你再想想吧!',
+ }))
+ return
+ }
+
+ const isCorrect = item.blanks!.every((blank) => {
+ const filled = value.find((result) => result.blankId === blank.id)
+ return filled?.optionValue === item.correctAnswersMap?.[blank.id]
+ })
+
+ if (isCorrect) {
+ setActiveSuccess(item.id)
+ setSuccessContent(item.successContent || '')
+ setResumeCallback(() => resume)
+ } else {
+ setDragQuizErrors((prev) => ({
+ ...prev,
+ [item.id]: item.explanation || '请你再想想吧!',
+ }))
+ }
+ }
+
+ return (
+ <>
+ ({
+ value: option.value,
+ label: option.label,
+ }))}
+ value={dragQuizValues[item.id] || []}
+ error={dragQuizErrors[item.id]}
+ onChange={handleDragChange}
+ />
+ {
+ setActiveSuccess(null)
+ setSuccessContent('')
+ setDragQuizValues((prev) => {
+ const next = { ...prev }
+ delete next[item.id]
+ return next
+ })
+ setDragQuizErrors((prev) => {
+ const next = { ...prev }
+ delete next[item.id]
+ return next
+ })
+ const callback = resumeCallback
+ setResumeCallback(null)
+ callback?.()
+ }}
+ />
+ >
+ )
+ },
+ }
+ }
+
+ if (item.type === 'single') {
+ const correctAnswer = item.correctAnswer as string
+ return {
+ id: item.id,
+ time: item.time,
+ once: true,
+ afterResume: 'remove',
+ render: ({ resume }) => (
+ <>
+ ({
+ value: option.value,
+ label: option.label,
+ }))}
+ correctAnswer={correctAnswer}
+ onWrongSelect={(value: string) => {
+ const option = item.options.find((current) => current.value === value)
+ return option?.explanation || '再思考一下,选择正确答案。'
+ }}
+ onSubmit={(value: string, isCorrect: boolean) => {
+ if (isCorrect) {
+ const option = item.options.find((current) => current.value === value)
+ setActiveSuccess(item.id)
+ setSuccessContent(option?.explanation || item.successContent || '')
+ setResumeCallback(() => resume)
+ }
+ }}
+ />
+ {
+ setActiveSuccess(null)
+ setSuccessContent('')
+ const callback = resumeCallback
+ setResumeCallback(null)
+ callback?.()
+ }}
+ />
+ >
+ ),
+ }
+ }
+
+ const correctAnswer = item.correctAnswer as string[]
+ return {
+ id: item.id,
+ time: item.time,
+ once: true,
+ afterResume: 'remove',
+ render: ({ resume }) => (
+ <>
+ ({
+ value: option.value,
+ label: option.label,
+ }))}
+ correctAnswer={correctAnswer}
+ errorMessage={(selectedValues: string[]) => {
+ const selectedSet = new Set(selectedValues)
+ const wrongOption = item.options.find(
+ (option) => selectedSet.has(option.value) && option.explanation
+ )
+ return wrongOption?.explanation || '你的选择还不够全面哦~请你再想想吧!'
+ }}
+ onSubmit={(_values: string[], isCorrect: boolean) => {
+ if (isCorrect) {
+ setActiveSuccess(item.id)
+ setSuccessContent(item.successContent || '')
+ setResumeCallback(() => resume)
+ }
+ }}
+ />
+ {
+ setActiveSuccess(null)
+ setSuccessContent('')
+ const callback = resumeCallback
+ setResumeCallback(null)
+ callback?.()
+ }}
+ />
+ >
+ ),
+ }
+ })
+ }, [activeSuccess, dragQuizErrors, dragQuizValues, resumeCallback, successContent])
+
+ if (!moduleName) return null
+
+ return (
+
+ navigate(`/${moduleName}/${starName}/home`)}
+ />
+
+ {
+ setNarrationVisible(false)
+ }}
+ text="百年风云,实业兴邦;旧居留痕,精神永续。走过荣德生先生的旧居,我们读懂了一位民族实业家的赤诚与坚守,见证了“面粉大王”“纺织巨子”实业救国的初心与担当。历史的尘埃落定,但先生的精神从未远去。生活在新时代的青年,应该在先生精神的指引下,传承实业魂、续写报国志。"
+ />
+
+ {
+ setPreGuideVisible(visible)
+ if (!visible) {
+ setShowVideo(true)
+ handleVideoCanPlay()
+ }
+ }}
+ showMask={false}
+ >
+
+ 小沪,跟着你走完荣德生旧居的学习之旅,我真的被这位民族实业家的一生打动了!
+
+
+ 从钱庄学徒到“面粉大王”“纺织巨子”,从抗战守节到坚守大陆留存工业火种,他用一生践行实业救国的初心,那在新时代,我们该如何理解和传承荣先生的这份实业精神呢?
+
+
+ 针对你的这个问题,今天我们有幸邀请到了上海社会科学院经济研究所博士后,经济史研究室研究人员徐晶老师。关于荣德生先生的实业精神与新时代的传承,我想徐老师会给我们带来深刻的解读!
+
+
+
+ {showVideo && (
+ {
+ setShowVideo(false)
+ setPostGuideVisible(true)
+ }}
+ />
+ )}
+
+ {
+ setPostGuideVisible(visible)
+ if (!visible) {
+ setShowLetter(true)
+ }
+ }}
+ showMask={false}
+ >
+
+ 荣德生先生用一生诠释了“实业救国、经济报国”的初心——从钱庄学徒到实业巨子,他扎根民族大地,以面粉和棉纱筑牢国家经济根基;从战火守节到坚守大陆,他始终与国家同呼吸、共命运;
+
+
+ 从创办学校到造桥铺路,他用责任与担当诠释了企业家的社会价值。你不妨把这份触动凝练成几句心里话,既总结学习的收获,也想想未来该如何践行这份实业精神,用自己的行动传承这份初心吧!
+
+
+
+ {
+ setShowLetter(false)
+ }}
+ onAnimationEnd={() => {
+ setShowEnding(true)
+ }}
+ showVideo={true}
+ />
+
+ {
+ setShowEnding(false)
+ navigate(`/${moduleName}/${starName}/home`)
+ }}
+ />
+
+ )
+}
+
+export default RongdeshengInternalization
diff --git a/src/pages/rongdesheng/place/place1.tsx b/src/pages/rongdesheng/place/place1.tsx
new file mode 100644
index 0000000..269375b
--- /dev/null
+++ b/src/pages/rongdesheng/place/place1.tsx
@@ -0,0 +1,283 @@
+import {
+ ExhibitOverlay,
+ GuideSteps,
+ GuideStepsItem,
+ Image,
+ StudyGuideModal,
+ SingleChoiceQuizPanel,
+ Title,
+} from '@/components'
+import Cursor from '@/components/cursor'
+import { useCursorController } from '@/hooks/useCursorController'
+import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
+import { useMemo, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { useRongdeshengStore, saveRongdeshengStore } from '@/store/rongdeshengStore'
+
+const starName = 'rongdesheng'
+const place = 'place1'
+
+const RongdeshengPlace1 = () => {
+ const navigate = useNavigate()
+ const { moduleName } = useParams<{ moduleName: string }>()
+
+ const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useRongdeshengStore()
+
+ const persistedVisited = useMemo(
+ () => new Set(getVisitedExhibits(place) as (1 | 2)[]),
+ [getVisitedExhibits]
+ )
+ const [visited, setVisited] = useState>(() => new Set(persistedVisited))
+
+ const total = 2
+ const progressCount = visited.size
+ const completed = progressCount >= total
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
+
+ const { isEnabled } = useCursorController({
+ initialEnabled: [1],
+ dependencies: {
+ 2: [1],
+ },
+ visited,
+ })
+
+ const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null)
+
+ // 学习引导弹窗(开场)
+ const [showGuideModal, setShowGuideModal] = useState(true)
+ // 对话结束后的学习引导(说明框)
+ const [showAfterDialogGuide, setShowAfterDialogGuide] = useState(false)
+
+ // 展品2对话结束后的题目
+ const [quizVisible, setQuizVisible] = useState(false)
+
+ // 分开展品1、展品2对话状态,避免 fragment 条件渲染导致 speaker 识别异常
+ const [guide1Visible, setGuide1Visible] = useState(false)
+ const [guide1Step, setGuide1Step] = useState(0)
+ const [guide2Visible, setGuide2Visible] = useState(false)
+ const [guide2Step, setGuide2Step] = useState(0)
+
+ if (!moduleName) return null
+
+ const exhibitSrcMap: Record<1 | 2, string> = {
+ 1: 'rongdesheng/place1/场景1展品1',
+ 2: 'rongdesheng/place1/场景1展品2',
+ }
+
+ const handleOpenExhibit = (id: 1 | 2) => {
+ setActiveExhibitId(id)
+ setQuizVisible(false)
+ if (id === 1) {
+ setGuide1Step(0)
+ setGuide1Visible(true)
+ setGuide2Visible(false)
+ return
+ }
+ setGuide2Step(0)
+ setGuide2Visible(true)
+ setGuide1Visible(false)
+ }
+
+ const markVisited = (id: 1 | 2) => {
+ setVisited((prev) => {
+ const next = new Set(prev)
+ next.add(id)
+ markExhibitVisited(place, id)
+ saveRongdeshengStore()
+ return next
+ })
+ }
+
+ return (
+
+
+
+ navigate(`/${moduleName}/${starName}/home`)}
+ />
+
+ {/* cursor 1 */}
+ handleOpenExhibit(1)}
+ read={visited.has(1)}
+ disabled={!isEnabled(1)}
+ />
+ {/* cursor 2 */}
+ handleOpenExhibit(2)}
+ read={visited.has(2)}
+ disabled={!isEnabled(2)}
+ />
+
+ {completed && (
+ {
+ setLastVisitedPlace('place2')
+ saveRongdeshengStore()
+ navigate(`/${moduleName}/${starName}/place2`)
+ }}
+ />
+ )}
+
+ {
+ setActiveExhibitId(null)
+ }}
+ />
+
+ {/* 展品1对话(图1) */}
+ {
+ setGuide1Visible(nextVisible)
+ if (!nextVisible) {
+ markVisited(1)
+ }
+ }}
+ currentStep={guide1Step}
+ onStepChange={setGuide1Step}
+ showMask={false}
+ highlightSelectors={[]}
+ zIndex={10001}
+ >
+
+ 小沪,快看荣德生的照片吧,作为一个无锡人,当时是有什么机缘让他离开家乡,到上海闯荡的呢?
+
+
+ 如你所言,1875年,荣德生先生出生于无锡的商贾之家。凭借着殷实的家境,荣先生从小便由老先生教学,九岁的时候被送到私塾念书,毕业以后,就正式开始学习经商的道路。
+
+
+ 想要做生意,第一要走南闯北,第二要胆识过人。所以1889年,十几岁的荣先生离开无锡老家,来到上海通顺钱庄学做生意。当时的钱庄学徒要从打扫、记账、珠算等基础活干起,每天天不亮就起床,深夜才休息。
+
+
+ 荣先生格外刻苦,不仅熟练掌握了珠算、记账、汇兑等核心技能,还学会了察言观色、与人打交道的本事,也从繁重琐碎的钱业学习中逐渐培养起了稳健踏实的办事风格,为他后来创办自己的广生钱庄打下了坚实基础。
+
+
+
+ {/* 展品2对话(图2) */}
+ {
+ setGuide2Visible(nextVisible)
+ if (!nextVisible) {
+ setQuizVisible(true)
+ }
+ }}
+ currentStep={guide2Step}
+ onStepChange={setGuide2Step}
+ showMask={false}
+ highlightSelectors={[]}
+ zIndex={10001}
+ >
+
+ 原来荣德生先生后来自己开了钱庄!广生钱庄在他的创业路上起到了什么作用?
+
+
+ 1896年,荣德生先生与兄长荣宗敬和父亲与人合伙在上海开设广生钱庄,后在无锡设立分庄,这是荣氏兄弟携手创业的起点。
+
+
+ 广生钱庄开展的“收茧款项”就是他们的创新之举——利用钱庄的汇兑优势,在上海与无锡之间开展蚕茧收购业务,把无锡的蚕茧运到上海销售,再将货款通过钱庄汇回,赚取了可观的利润。
+
+
+ 广生钱庄后来转为荣家独资经营,成为荣家资本原始积累的重要来源,就像一座桥梁,连接起他后来的实业梦想。
+
+
+ 不过可惜的是,因为关联商号卷入投机活动失败,广生钱庄最终歇业了,但这段金融从业经历让荣德生摸清了市场规律,积累了宝贵的管理经验,为之后创办面粉厂、纺织厂埋下了伏笔。
+
+
+
+ {/* 图3:对话结束后弹出的单选题 */}
+ {
+ if (!isCorrect) return
+ // 交由成功弹窗继续按钮统一收口
+ }}
+ onSuccessContinue={() => {
+ setQuizVisible(false)
+ markVisited(2)
+ }}
+ />
+
+ {/* 学习引导词(开场) */}
+ {
+ setShowGuideModal(false)
+ setShowAfterDialogGuide(true)
+ }}
+ ailgn="left"
+ >
+ 1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
+ {'\n'}
+ 2.在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点开查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习。
+ {'\n'}
+ 3.所有动态光点的展品全部学习后才能进入下一个场景学习。
+
+
+ {/* 学习引导词(说明框) */}
+ setShowAfterDialogGuide(false)}
+ audioSrc={getPublicPath('audio/rongdesheng/mp3_rongdesheng_01.mp3')}
+ >
+ {` 同学你好,欢迎来到“走进故居·对话”学习模块!
+ 此刻你正伫立在荣德生旧居的石库门廊下,接下来请循着实业的足迹,一同探寻先生传奇人生里的4段壮阔征程:金融启蒙·资本筑基、实业拓疆·双业兴邦、山河危难·气节如钢、风雨坚守·初心不改。准备好共赴这场面粉与棉纱交织的民族工业史诗了吗?`}
+
+
+ )
+}
+
+export default RongdeshengPlace1
diff --git a/src/pages/rongdesheng/place/place2.tsx b/src/pages/rongdesheng/place/place2.tsx
new file mode 100644
index 0000000..f1bfef0
--- /dev/null
+++ b/src/pages/rongdesheng/place/place2.tsx
@@ -0,0 +1,457 @@
+import {
+ ExhibitOverlay,
+ GuideSteps,
+ GuideStepsItem,
+ Image,
+ SingleChoiceQuizPanel,
+ Title,
+ Video,
+} from '@/components'
+import Cursor from '@/components/cursor'
+import { useCursorController } from '@/hooks/useCursorController'
+import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
+import { useMemo, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { saveRongdeshengStore, useRongdeshengStore } from '@/store/rongdeshengStore'
+
+const starName = 'rongdesheng'
+const place = 'place2'
+
+const RongdeshengPlace2 = () => {
+ const navigate = useNavigate()
+ const { moduleName } = useParams<{ moduleName: string }>()
+
+ const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useRongdeshengStore()
+
+ const persistedVisited = useMemo(
+ () => new Set(getVisitedExhibits(place) as (1 | 2 | 3 | 4 | 5)[]),
+ [getVisitedExhibits]
+ )
+ const [visited, setVisited] = useState>(() => new Set(persistedVisited))
+
+ const total = 5
+ const progressCount = visited.size
+ const completed = progressCount >= total
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
+
+ const { isEnabled } = useCursorController({
+ initialEnabled: [1],
+ dependencies: {
+ 2: [1],
+ 3: [2],
+ 4: [3],
+ 5: [4],
+ },
+ visited,
+ })
+
+ const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | 4 | 5 | null>(null)
+
+ const [showVideo, setShowVideo] = useState(false)
+ const [videoForExhibitId, setVideoForExhibitId] = useState<1 | 2 | 3 | 4 | 5 | null>(null)
+
+ const [guide1Visible, setGuide1Visible] = useState(false)
+ const [guide1Step, setGuide1Step] = useState(0)
+ const [guide1AfterVideoVisible, setGuide1AfterVideoVisible] = useState(false)
+ const [guide1AfterVideoStep, setGuide1AfterVideoStep] = useState(0)
+ const [quiz1Visible, setQuiz1Visible] = useState(false)
+ const [guide3Visible, setGuide3Visible] = useState(false)
+ const [guide3Step, setGuide3Step] = useState(0)
+ const [quiz4Visible, setQuiz4Visible] = useState(false)
+ const [guide5BeforeVisible, setGuide5BeforeVisible] = useState(false)
+ const [guide5BeforeStep, setGuide5BeforeStep] = useState(0)
+ const [guide5AfterVisible, setGuide5AfterVisible] = useState(false)
+ const [guide5AfterStep, setGuide5AfterStep] = useState(0)
+
+ if (!moduleName) return null
+
+ const exhibitSrcMap: Record<1 | 2 | 3 | 4 | 5, string> = {
+ 1: 'rongdesheng/place2/场景2展品1',
+ 2: 'rongdesheng/place2/场景2展品2',
+ 3: 'rongdesheng/place2/场景2展品3',
+ 4: 'rongdesheng/place2/场景2展品4',
+ 5: 'rongdesheng/place2/场景2展品5',
+ }
+
+ const handleOpenExhibit = (id: 1 | 2 | 3 | 4 | 5) => {
+ setActiveExhibitId(id)
+ if (id === 1) {
+ setGuide1Step(0)
+ setGuide1Visible(true)
+ }
+ if (id === 3) {
+ setGuide3Step(0)
+ setGuide3Visible(true)
+ }
+ if (id === 5) {
+ setGuide5BeforeStep(0)
+ setGuide5BeforeVisible(true)
+ }
+ }
+
+ const handleVideoEnded = () => {
+ const id = videoForExhibitId
+ setShowVideo(false)
+ setVideoForExhibitId(null)
+ if (id === 1) {
+ setGuide1AfterVideoStep(0)
+ setGuide1AfterVideoVisible(true)
+ }
+ if (id === 2) {
+ setVisited((prev) => {
+ const next = new Set(prev)
+ if (!next.has(2)) {
+ next.add(2)
+ markExhibitVisited(place, 2)
+ saveRongdeshengStore()
+ }
+ return next
+ })
+ }
+ if (id === 4) {
+ setQuiz4Visible(true)
+ }
+ if (id === 5) {
+ setGuide5AfterStep(0)
+ setGuide5AfterVisible(true)
+ }
+ }
+
+ return (
+
+
+
+ navigate(`/${moduleName}/${starName}/place1`)}
+ />
+
+ {/* cursor order: 1 -> 2 -> 3 -> 4 -> 5 */}
+ handleOpenExhibit(1)}
+ read={visited.has(1)}
+ disabled={!isEnabled(1)}
+ />
+ handleOpenExhibit(2)}
+ read={visited.has(2)}
+ disabled={!isEnabled(2)}
+ />
+ handleOpenExhibit(3)}
+ read={visited.has(3)}
+ disabled={!isEnabled(3)}
+ />
+ handleOpenExhibit(4)}
+ read={visited.has(4)}
+ disabled={!isEnabled(4)}
+ />
+ handleOpenExhibit(5)}
+ read={visited.has(5)}
+ disabled={!isEnabled(5)}
+ />
+
+ {completed && (
+ {
+ setLastVisitedPlace('place3')
+ saveRongdeshengStore()
+ navigate(`/${moduleName}/${starName}/place3`)
+ }}
+ />
+ )}
+
+ {
+ if (activeExhibitId === null) return
+ const closingId = activeExhibitId
+ setActiveExhibitId(null)
+
+ if (closingId === 1 || closingId === 2 || closingId === 4 || closingId === 5) {
+ setVideoForExhibitId(closingId)
+ setShowVideo(true)
+ return
+ }
+
+ // 其余展品先保持关闭即完成
+ setVisited((prev) => {
+ const next = new Set(prev)
+ if (!next.has(closingId)) {
+ next.add(closingId)
+ markExhibitVisited(place, closingId)
+ saveRongdeshengStore()
+ }
+ return next
+ })
+ }}
+ />
+
+ {showVideo && videoForExhibitId === 1 && (
+
+ )}
+ {showVideo && videoForExhibitId === 2 && (
+
+ )}
+ {showVideo && videoForExhibitId === 4 && (
+
+ )}
+ {showVideo && videoForExhibitId === 5 && (
+
+ )}
+
+
+
+ 哇,这是福新面粉公司的老照片!荣德生先生为什么选择办面粉,面粉厂又为什么在上海创办?福新面粉厂后来发展得怎么样?
+
+
+ 办中国自己的面粉厂,这可以说是荣德生先生实业救国的一个初心。
+
+
+
+ {
+ setGuide1AfterVideoVisible(v)
+ if (!v) {
+ setQuiz1Visible(true)
+ }
+ }}
+ currentStep={guide1AfterVideoStep}
+ onStepChange={setGuide1AfterVideoStep}
+ showMask={false}
+ highlightSelectors={[]}
+ zIndex={10001}
+ >
+
+ 目睹洋面粉倾销的荣先生与兄长一起在无锡创办了保兴面粉厂,1912年,荣氏兄弟又看准了上海的区位优势——交通便利、人口密集、市场需求大,于是在上海创办了福新面粉厂,引进了当时最先进的生产设备。
+
+
+ 之后他们趁热打铁,又在上海接连创办了福新二厂、三厂,还收买或租办了中兴面粉厂等,改为福新四厂、六厂,形成了规模化的面粉生产体系。短短几年间,福新面粉厂的产量就占据了上海面粉市场的半壁江山。
+
+
+
+ {/* 展品3:打开即对话 */}
+
+
+ 荣德生先生在面粉业取得成功后,又进军纺织业了吗?申新纺织厂是怎么创办起来的呢?
+
+
+ 是的!1915年,他与兄长在上海招股创办了申新纺织无限公司,并开办申新一厂。他们之所以选择纺织业,是因为当时中国的棉纱市场被外资垄断,老百姓的棉布多依赖进口。
+
+
+ 申新纺织公司的创办,就是要打破这种垄断局面,为国人生产品质优良的棉纱、棉布。
+
+
+
+ {/* 展品4:视频后题目 */}
+ {
+ if (!isCorrect) return
+ }}
+ onSuccessContinue={() => {
+ setQuiz4Visible(false)
+ setVisited((prev) => {
+ const next = new Set(prev)
+ if (!next.has(4)) {
+ next.add(4)
+ markExhibitVisited(place, 4)
+ saveRongdeshengStore()
+ }
+ return next
+ })
+ }}
+ />
+
+ {
+ if (!isCorrect) return
+ }}
+ onSuccessContinue={() => {
+ setQuiz1Visible(false)
+ setVisited((prev) => {
+ const next = new Set(prev)
+ if (!next.has(1)) {
+ next.add(1)
+ markExhibitVisited(place, 1)
+ saveRongdeshengStore()
+ }
+ return next
+ })
+ }}
+ />
+
+ {/* 展品5:打开即对话 + 视频后对话并标记完成 */}
+
+
+ 劳工自治区!荣德生先生的工厂能在市场上竞争过外资企业,除了优质产品,是不是还有特别的管理秘诀呀?
+
+
+ 你观察得特别仔细!荣氏企业能站稳脚跟,秘诀之一就是这套超前的“劳工自治区”管理模式。
+
+
+
+ {
+ setGuide5AfterVisible(v)
+ if (!v) {
+ setVisited((prev) => {
+ const next = new Set(prev)
+ if (!next.has(5)) {
+ next.add(5)
+ markExhibitVisited(place, 5)
+ saveRongdeshengStore()
+ }
+ return next
+ })
+ }
+ }}
+ currentStep={guide5AfterStep}
+ onStepChange={setGuide5AfterStep}
+ showMask={false}
+ highlightSelectors={[]}
+ zIndex={10001}
+ >
+
+ 在荣德生先生推行的以“劳资双方利益要兼顾”为出发点的劳工自治区管理模式下,工人在企业里得到尊重和关怀,干事的积极性自然高涨。他们不仅严格把控生产环节,还主动参与技术改进。
+
+
+ 再加上荣先生坚持每天清晨用前一天的面粉做疙瘩汤亲自检验品质的严谨态度,形成了“人文管理+品质把控”的双重保障。
+
+
+ 这种立足创新、聚焦工人福利的全方位关照,让荣氏产品质量远超同行,成功抵御了洋货倾销,也让“兵船”牌面粉和“人钟牌”棉纱成为行业标准。
+
+
+
+ )
+}
+
+export default RongdeshengPlace2
diff --git a/src/pages/rongdesheng/place/place3.tsx b/src/pages/rongdesheng/place/place3.tsx
new file mode 100644
index 0000000..da356ef
--- /dev/null
+++ b/src/pages/rongdesheng/place/place3.tsx
@@ -0,0 +1,350 @@
+import {
+ ExhibitOverlay,
+ GuideSteps,
+ GuideStepsItem,
+ Image,
+ SingleChoiceQuizPanel,
+ Title,
+ Video,
+} from '@/components'
+import Cursor from '@/components/cursor'
+import { useCursorController } from '@/hooks/useCursorController'
+import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
+import { useMemo, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { saveRongdeshengStore, useRongdeshengStore } from '@/store/rongdeshengStore'
+
+const starName = 'rongdesheng'
+const place = 'place3'
+
+const RongdeshengPlace3 = () => {
+ const navigate = useNavigate()
+ const { moduleName } = useParams<{ moduleName: string }>()
+
+ const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useRongdeshengStore()
+
+ const persistedVisited = useMemo(
+ () => new Set(getVisitedExhibits(place) as (1 | 2 | 3)[]),
+ [getVisitedExhibits]
+ )
+ const [visited, setVisited] = useState>(() => new Set(persistedVisited))
+
+ const total = 3
+ const progressCount = visited.size
+ const completed = progressCount >= total
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
+
+ // cursor:1 -> 2 -> 3
+ const { isEnabled } = useCursorController({
+ initialEnabled: [1],
+ dependencies: {
+ 2: [1],
+ 3: [2],
+ },
+ visited,
+ })
+
+ const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(null)
+
+ const [showVideo, setShowVideo] = useState(false)
+
+ const [guide1Visible, setGuide1Visible] = useState(false)
+ const [guide1Step, setGuide1Step] = useState(0)
+ const [guide2Visible, setGuide2Visible] = useState(false)
+ const [guide2Step, setGuide2Step] = useState(0)
+ const [guide3BeforeVisible, setGuide3BeforeVisible] = useState(false)
+ const [guide3BeforeStep, setGuide3BeforeStep] = useState(0)
+ const [guide3AfterVisible, setGuide3AfterVisible] = useState(false)
+ const [guide3AfterStep, setGuide3AfterStep] = useState(0)
+ const [quiz1Visible, setQuiz1Visible] = useState(false)
+ const [quiz3Visible, setQuiz3Visible] = useState(false)
+
+ if (!moduleName) return null
+
+ const exhibitSrcMap: Record<1 | 2 | 3, string> = {
+ 1: 'rongdesheng/place3/场景3展品1',
+ 2: 'rongdesheng/place3/场景3展品2',
+ 3: 'rongdesheng/place3/场景3展品3',
+ }
+
+ const handleOpenExhibit = (id: 1 | 2 | 3) => {
+ setActiveExhibitId(id)
+ if (id === 1) {
+ setGuide1Step(0)
+ setGuide1Visible(true)
+ }
+ if (id === 2) {
+ setGuide2Step(0)
+ setGuide2Visible(true)
+ }
+ if (id === 3) {
+ setGuide3BeforeStep(0)
+ setGuide3BeforeVisible(true)
+ }
+ }
+
+ const handleVideoEnded = () => {
+ setShowVideo(false)
+ setGuide3AfterStep(0)
+ setGuide3AfterVisible(true)
+ }
+
+ return (
+
+
+
+ navigate(`/${moduleName}/${starName}/place2`)}
+ />
+
+ handleOpenExhibit(1)}
+ read={visited.has(1)}
+ disabled={!isEnabled(1)}
+ />
+ handleOpenExhibit(2)}
+ read={visited.has(2)}
+ disabled={!isEnabled(2)}
+ />
+ handleOpenExhibit(3)}
+ read={visited.has(3)}
+ disabled={!isEnabled(3)}
+ />
+
+ {completed && (
+ {
+ setLastVisitedPlace('place4')
+ saveRongdeshengStore()
+ navigate(`/${moduleName}/${starName}/place4`)
+ }}
+ />
+ )}
+
+ {
+ if (activeExhibitId === null) return
+ const closingId = activeExhibitId
+ setActiveExhibitId(null)
+
+ // 展品1、2:关闭展品后直接完成
+ if (closingId === 1 || closingId === 2) {
+ setVisited((prev) => {
+ const next = new Set(prev)
+ next.add(closingId)
+ markExhibitVisited(place, closingId)
+ saveRongdeshengStore()
+ return next
+ })
+ }
+
+ if (closingId === 1) {
+ setQuiz1Visible(true)
+ }
+ if (closingId === 3) {
+ setShowVideo(true)
+ }
+ }}
+ />
+
+ {showVideo && (
+
+ )}
+
+ {
+ setGuide1Visible(v)
+ }}
+ currentStep={guide1Step}
+ onStepChange={setGuide1Step}
+ showMask={false}
+ highlightSelectors={[]}
+ zIndex={10001}
+ >
+
+ 上海申新一厂和八厂于1937年10月27日被日本飞机炸毁了,在战乱中荣氏又是如何发展工业的呢?
+
+
+ 1937年抗日战争全面爆发,日军的炮火轰炸了荣氏在上海、无锡的多家工厂,设备被毁,生产停滞。为了保住民族工业的火种,支援抗战,荣德生做出了艰难的决定——将工厂内迁。
+
+
+ 迁厂要从上海、无锡出发,穿越日军的封锁线,沿着崎岖的道路前往重庆、成都等地,路途遥远且危险重重。
+
+
+ 很多设备在运输过程中遭到损坏,不少工人也付出了生命的代价,但他们始终没有放弃,因为荣德生知道,后方的生产关乎前线的军需和民众的生计。
+
+
+
+
+
+ 内迁后的工厂在那么艰苦的条件下还能坚持生产吗?他们主要生产什么?为抗战做出了哪些贡献?
+
+
+ 是的!内迁后的重庆申新纱厂、茂新面粉厂等,在简陋的条件下迅速恢复生产。虽然产量不及战前,但从未中断。生产的面粉、棉纱,一部分供应给前线的抗日军队,制作军装、被褥、急救包;另一部分供应给后方民众,保障基本生活需求。
+
+
+ 这篇报道,就见证了荣氏企业用实业支援抗战的壮举,为抗日战争的物资供应提供了有力支持,成为民族救亡运动中不可或缺的力量。
+
+
+
+
+
+ “心正思无邪 意诚言必中”,这是荣德生先生的题字吗?他为什么写这两句话?
+
+
+ 这发生于1941年太平洋战争爆发、上海公共租界沦陷之后。日军以经济提携为名,通过行政命令强迫中国企业交由日本公司或个人管理,企图将上海的民族工业纳入其战时经济体系。
+
+
+ 日商丰田纱厂多次上门威逼利诱,想收购上海申新一、八厂;汪伪政权的外交部长也出面邀谈,企图逼迫他妥协。但荣德生始终坚守民族底线,不合作、不妥协。
+
+
+
+ {
+ setGuide3AfterVisible(v)
+ if (!v) {
+ setQuiz3Visible(true)
+ }
+ }}
+ currentStep={guide3AfterStep}
+ onStepChange={setGuide3AfterStep}
+ showMask={false}
+ highlightSelectors={[]}
+ zIndex={10001}
+ >
+
+ 生死存亡之际,他用行动诠释了“民族气节”四个字。即便工厂被占据,也绝不与侵略者合作。
+
+
+
+ {/* 展品1:对话结束后的单选题(图2) */}
+ {
+ if (!isCorrect) return
+ }}
+ onSuccessContinue={() => {
+ setQuiz1Visible(false)
+ }}
+ />
+
+ {/* 展品3:视频后对话结束的单选题 */}
+ {
+ if (!isCorrect) return
+ }}
+ onSuccessContinue={() => {
+ setQuiz3Visible(false)
+ // 展品3:答题成功后才算完成,更新 cursor 状态
+ setVisited((prev) => {
+ const next = new Set(prev)
+ if (!next.has(3)) {
+ next.add(3)
+ markExhibitVisited(place, 3)
+ saveRongdeshengStore()
+ }
+ return next
+ })
+ }}
+ />
+
+ )
+}
+
+export default RongdeshengPlace3
diff --git a/src/pages/rongdesheng/place/place4.tsx b/src/pages/rongdesheng/place/place4.tsx
new file mode 100644
index 0000000..5489eea
--- /dev/null
+++ b/src/pages/rongdesheng/place/place4.tsx
@@ -0,0 +1,308 @@
+import { ExhibitOverlay, GuideSteps, GuideStepsItem, Image, Title, Video } from '@/components'
+import Cursor from '@/components/cursor'
+import { useCursorController } from '@/hooks/useCursorController'
+import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
+import { useMemo, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { saveRongdeshengStore, useRongdeshengStore } from '@/store/rongdeshengStore'
+
+const starName = 'rongdesheng'
+const place = 'place4'
+
+const RongdeshengPlace4 = () => {
+ const navigate = useNavigate()
+ const { moduleName } = useParams<{ moduleName: string }>()
+
+ const { markExhibitVisited, getVisitedExhibits, setLastVisitedPlace } = useRongdeshengStore()
+
+ const persistedVisited = useMemo(
+ () => new Set(getVisitedExhibits(place) as (1 | 2 | 3 | 4)[]),
+ [getVisitedExhibits]
+ )
+ const [visited, setVisited] = useState>(() => new Set(persistedVisited))
+
+ const total = 4
+ const progressCount = visited.size
+ const completed = progressCount >= total
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
+
+ // cursor:1 -> 2 -> 3 -> 4
+ const { isEnabled } = useCursorController({
+ initialEnabled: [1],
+ dependencies: {
+ 2: [1],
+ 3: [2],
+ 4: [3],
+ },
+ visited,
+ })
+
+ const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | 4 | null>(null)
+
+ const [showVideo, setShowVideo] = useState(false)
+ const [guide1Visible, setGuide1Visible] = useState(false)
+ const [guide1Step, setGuide1Step] = useState(0)
+ const [guide1AfterVideoVisible, setGuide1AfterVideoVisible] = useState(false)
+ const [guide1AfterVideoStep, setGuide1AfterVideoStep] = useState(0)
+ const [guide2Visible, setGuide2Visible] = useState(false)
+ const [guide2Step, setGuide2Step] = useState(0)
+ const [guide3Visible, setGuide3Visible] = useState(false)
+ const [guide3Step, setGuide3Step] = useState(0)
+ const [guide4Visible, setGuide4Visible] = useState(false)
+ const [guide4Step, setGuide4Step] = useState(0)
+
+ if (!moduleName) return null
+
+ const exhibitSrcMap: Record<1 | 2 | 3 | 4, string> = {
+ 1: 'rongdesheng/place4/场景4展品1',
+ 2: 'rongdesheng/place4/场景4展品2',
+ 3: 'rongdesheng/place4/场景4展品3',
+ 4: 'rongdesheng/place4/场景4展品4',
+ }
+
+ const handleOpenExhibit = (id: 1 | 2 | 3 | 4) => {
+ setActiveExhibitId(id)
+ if (id === 1) {
+ setGuide1Step(0)
+ setGuide1Visible(true)
+ }
+ if (id === 2) {
+ setGuide2Step(0)
+ setGuide2Visible(true)
+ }
+ if (id === 3) {
+ setGuide3Step(0)
+ setGuide3Visible(true)
+ }
+ if (id === 4) {
+ setGuide4Step(0)
+ setGuide4Visible(true)
+ }
+ }
+
+ const handleVideoEnded = () => {
+ setShowVideo(false)
+ setGuide1AfterVideoStep(0)
+ setGuide1AfterVideoVisible(true)
+ }
+
+ return (
+
+
+
+ navigate(`/${moduleName}/${starName}/place3`)}
+ />
+
+ handleOpenExhibit(1)}
+ read={visited.has(1)}
+ disabled={!isEnabled(1)}
+ />
+ handleOpenExhibit(2)}
+ read={visited.has(2)}
+ disabled={!isEnabled(2)}
+ />
+ handleOpenExhibit(3)}
+ read={visited.has(3)}
+ disabled={!isEnabled(3)}
+ />
+
+ {/* 展品 */}
+ handleOpenExhibit(4)}
+ read={visited.has(4)}
+ disabled={!isEnabled(4)}
+ />
+
+ {completed && (
+ {
+ setLastVisitedPlace('place4')
+ saveRongdeshengStore()
+ navigate(`/${moduleName}/${starName}/home`)
+ }}
+ />
+ )}
+
+ {
+ if (activeExhibitId === null) return
+ const closingId = activeExhibitId
+ setActiveExhibitId(null)
+
+ // 展品1在视频后对话结束再改状态;2/3/4关闭展品即改状态
+ if (closingId === 2 || closingId === 3 || closingId === 4) {
+ setVisited((prev) => {
+ const next = new Set(prev)
+ next.add(closingId)
+ markExhibitVisited(place, closingId)
+ saveRongdeshengStore()
+ return next
+ })
+ }
+
+ if (closingId === 1) {
+ setShowVideo(true)
+ }
+ }}
+ />
+
+ {showVideo && (
+
+ )}
+
+
+
+ 荣德生先生竟然遭遇过绑架?这是怎么回事?他是怎么度过这次危机的?
+
+
+ 1946年,荣德生先生在上海高恩路(现高安路)住所门前遭到绑架,绑匪勒索巨额款项,将他囚禁了35天。这是当时的新闻报道。面对突如其来的变故,荣氏企业内部沉着应对,在保障人身安全的前提下,积极与绑匪谈判,最终筹集款项将他赎回。
+
+
+
+ {
+ setGuide1AfterVideoVisible(v)
+ if (!v) {
+ setVisited((prev) => {
+ const next = new Set(prev)
+ if (!next.has(1)) {
+ next.add(1)
+ markExhibitVisited(place, 1)
+ saveRongdeshengStore()
+ }
+ return next
+ })
+ }
+ }}
+ currentStep={guide1AfterVideoStep}
+ onStepChange={setGuide1AfterVideoStep}
+ showMask={false}
+ highlightSelectors={[]}
+ zIndex={10001}
+ >
+
+ 这次绑架事件让荣先生身心受到巨大创伤,但也让他更加看清了当时社会的动荡不安,坚定了他期盼国家安定、民族工业能平稳发展的愿望。
+
+
+
+
+
+ “荣氏工业机构,绝不迁赴国外”,当时荣德生先生为什么阻止企业迁往国外啊?
+
+
+ 1948年,解放战争接近尾声,很多实业家纷纷撤离大陆,前往台湾或海外。但荣德生先生专程从无锡来到上海,发表声明,明确表态绝不离开大陆。他在给海外亲友的信中写道:“民族工业的根基在大陆,离开这片土地,实业救国便成空谈。”
+
+
+ 他不仅自己留下,还力排众议,阻止了荣氏企业迁台的计划,保住了大量的工厂设备、技术人才和生产资料。这份坚守,为新中国成立后的民族工业留存了重要力量,也体现了一位民族实业家的家国情怀。
+
+
+
+
+
+ 新中国成立后,荣氏的企业很快就复工了吗?政府对他的贡献给了肯定吗?
+
+
+ 是的!新中国成立后,在政府的支持下,荣氏企业迅速恢复生产。荣德生先生一生致力于实业救国,为民族工业的发展作出了巨大贡献,政府也对他给予了高度肯定。
+
+
+
+
+
+ 晚年的荣先生依然关注民族工业的发展,积极建言献策,他创办的企业后来成为新中国轻工业的重要骨干,为国家的经济建设贡献了力量。
+
+
+
+ )
+}
+
+export default RongdeshengPlace4
diff --git a/src/pages/rongdesheng/reflection/components/FinalScene.tsx b/src/pages/rongdesheng/reflection/components/FinalScene.tsx
new file mode 100644
index 0000000..87d1fe3
--- /dev/null
+++ b/src/pages/rongdesheng/reflection/components/FinalScene.tsx
@@ -0,0 +1,62 @@
+import { Image, Video } from '@/components'
+import { cn, getBackgroundImage, getStaticImage } from '@/lib/utils'
+import { FINAL_VIDEO_SRC } from '../modules.data'
+import { HINT_TEXTS } from '../constants'
+import type { FinalVideoStage } from '../types'
+
+interface FinalSceneProps {
+ visible: boolean
+ finalVideoStage: FinalVideoStage
+ onStart: () => void
+ onVideoEnded: () => void
+ onExit: () => void
+}
+
+export const FinalScene = ({
+ visible,
+ finalVideoStage,
+ onStart,
+ onVideoEnded,
+ onExit,
+}: FinalSceneProps) => {
+ if (!visible) return null
+
+ return (
+ <>
+
+
+ {finalVideoStage !== 'idle' && (
+
+
+ {finalVideoStage === 'ended' && (
+
+
+
+ 小提示
+
+
+ {HINT_TEXTS.finalEnded}
+
+
+
+ )}
+
+ )}
+ >
+ )
+}
diff --git a/src/pages/rongdesheng/reflection/components/HintPanel.tsx b/src/pages/rongdesheng/reflection/components/HintPanel.tsx
new file mode 100644
index 0000000..d25cdfb
--- /dev/null
+++ b/src/pages/rongdesheng/reflection/components/HintPanel.tsx
@@ -0,0 +1,49 @@
+import type { ReactNode } from 'react'
+import { useEffect } from 'react'
+import { cn, getBackgroundImage } from '@/lib/utils'
+import { useAudioManager } from '@/hooks/useAudioManager'
+import { HINT_AUDIO_CHANNEL } from '../constants'
+
+interface HintPanelProps {
+ visible: boolean
+ text: ReactNode
+ audioSrc?: string
+}
+
+export const HintPanel = ({ visible, text, audioSrc }: HintPanelProps) => {
+ const { play, stop, currentSrc } = useAudioManager({
+ mode: 'channel',
+ channelId: HINT_AUDIO_CHANNEL,
+ })
+
+ useEffect(() => {
+ if (visible && audioSrc) {
+ play(audioSrc)
+ return
+ }
+ // 不可见或无音频时,若当前正在播放的是上一条 hint 音频则停掉
+ if (currentSrc) stop()
+ }, [visible, audioSrc, play, stop, currentSrc])
+
+ if (!visible) return null
+
+ return (
+
+ )
+}
diff --git a/src/pages/rongdesheng/reflection/components/ModuleNavigation.tsx b/src/pages/rongdesheng/reflection/components/ModuleNavigation.tsx
new file mode 100644
index 0000000..088db73
--- /dev/null
+++ b/src/pages/rongdesheng/reflection/components/ModuleNavigation.tsx
@@ -0,0 +1,69 @@
+import { cn, getBackgroundImage } from '@/lib/utils'
+import { MODULES } from '../modules.data'
+import type { ModuleId, ModuleState } from '../types'
+
+interface ModuleNavigationProps {
+ currentModuleId: ModuleId
+ showFinalScene: boolean
+ persistedCompleted: Record
+ modulesState: Record
+ onSelect: (id: ModuleId) => void
+}
+
+const getVideoWatchedCount = (state: ModuleState | undefined): number => {
+ if (!state) return 0
+ return Object.values(state.videoViewed).filter(Boolean).length
+}
+
+export const ModuleNavigation = ({
+ currentModuleId,
+ showFinalScene,
+ persistedCompleted,
+ modulesState,
+ onSelect,
+}: ModuleNavigationProps) => {
+ return (
+
+ {MODULES.map((module, index) => {
+ const prev = MODULES[index - 1]
+ const isActive = !showFinalScene && currentModuleId === module.id
+ const isUnlocked =
+ index === 0 ||
+ (!!prev && (!!persistedCompleted[prev.id] || modulesState[prev.id].completed)) ||
+ showFinalScene
+
+ const statusText = (() => {
+ if (!isUnlocked) return '未解锁'
+ const watched = getVideoWatchedCount(modulesState[module.id])
+ return watched >= 2 ? '已完成' : `${watched}/2`
+ })()
+
+ return (
+
{
+ if (!isUnlocked) return
+ onSelect(module.id)
+ }}
+ >
+
+
{module.title}
+
{statusText}
+
+
+ )
+ })}
+
+ )
+}
diff --git a/src/pages/rongdesheng/reflection/components/QuestionPanel.tsx b/src/pages/rongdesheng/reflection/components/QuestionPanel.tsx
new file mode 100644
index 0000000..6b2bc7c
--- /dev/null
+++ b/src/pages/rongdesheng/reflection/components/QuestionPanel.tsx
@@ -0,0 +1,121 @@
+import { Image } from '@/components'
+import { cn, getBackgroundImage, getStaticImage } from '@/lib/utils'
+import type { Module, ModuleState } from '../types'
+
+interface QuestionPanelProps {
+ module: Module
+ sceneTitle: string
+ currentState: ModuleState
+ allSelectedVideosViewed: boolean
+ onOptionSelect: (optionId: string) => void
+ onRetry: () => void
+ onSubmit: () => void
+}
+
+export const QuestionPanel = ({
+ module,
+ sceneTitle,
+ currentState,
+ allSelectedVideosViewed,
+ onOptionSelect,
+ onRetry,
+ onSubmit,
+}: QuestionPanelProps) => {
+ return (
+ <>
+
+
+
+ {module.question}
+
+
+
+ {module.options.map((option) => {
+ const isSelected = currentState.selectedOptions.includes(option.id)
+ return (
+
onOptionSelect(option.id)}
+ >
+
+
{option.text}
+
+ {isSelected && option.isCorrect && (
+
+ )}
+ {isSelected && !option.isCorrect && (
+
+ )}
+
+
+
+ )
+ })}
+
+
+ {currentState.showRetryButton && (
+
+ )}
+
+ {currentState.hasAnswered && allSelectedVideosViewed && (
+
+ )}
+ >
+ )
+}
+
+interface SceneHeaderProps {
+ sceneTitle: string
+ moduleTitle: string
+}
+
+export const SceneHeader = ({ sceneTitle, moduleTitle }: SceneHeaderProps) => (
+
+
+ {sceneTitle}
+
+ {moduleTitle}
+
+)
+
+interface ActionButtonProps {
+ label: string
+ onClick: () => void
+ tracking?: boolean
+}
+
+const ActionButton = ({ label, onClick, tracking = true }: ActionButtonProps) => (
+
+
+ {label}
+
+
+)
diff --git a/src/pages/rongdesheng/reflection/components/ResultPanel.tsx b/src/pages/rongdesheng/reflection/components/ResultPanel.tsx
new file mode 100644
index 0000000..2caae48
--- /dev/null
+++ b/src/pages/rongdesheng/reflection/components/ResultPanel.tsx
@@ -0,0 +1,36 @@
+import { getBackgroundImage } from '@/lib/utils'
+import { SceneHeader } from './QuestionPanel'
+import type { Module } from '../types'
+
+interface ResultPanelProps {
+ module: Module
+ sceneTitle: string
+ onNext: () => void
+}
+
+export const ResultPanel = ({ module, sceneTitle, onNext }: ResultPanelProps) => {
+ return (
+ <>
+
+
+
+
+
+
+ {module.resultContent}
+
+
+
+ 下一场景
+
+
+ >
+ )
+}
diff --git a/src/pages/rongdesheng/reflection/components/VideoBubbles.tsx b/src/pages/rongdesheng/reflection/components/VideoBubbles.tsx
new file mode 100644
index 0000000..2966cab
--- /dev/null
+++ b/src/pages/rongdesheng/reflection/components/VideoBubbles.tsx
@@ -0,0 +1,91 @@
+import { Image } from '@/components'
+import { cn, getStaticImage } from '@/lib/utils'
+import type { Module } from '../types'
+
+interface VideoBubblesProps {
+ module: Module
+ selectedOptions: string[]
+ videoViewed: Record
+ onWatch: (videoId: string, videoSrc: string) => void
+}
+
+export const VideoBubbles = ({
+ module,
+ selectedOptions,
+ videoViewed,
+ onWatch,
+}: VideoBubblesProps) => {
+ return (
+ <>
+ {module.videoResources.map((video) => {
+ const shouldShow = module.options.some(
+ (option) =>
+ option.videoResourceId === video.id &&
+ option.isCorrect &&
+ selectedOptions.includes(option.id)
+ )
+ if (!shouldShow) return null
+
+ const isViewed = videoViewed[video.id] || false
+
+ return (
+ onWatch(video.id, video.videoSrc)}
+ >
+
+
+ {!isViewed && (
+
+ )}
+
+ {isViewed && (
+ <>
+
+
+
+ 已浏览
+
+
+ >
+ )}
+
+ )
+ })}
+ >
+ )
+}
diff --git a/src/pages/rongdesheng/reflection/constants.ts b/src/pages/rongdesheng/reflection/constants.ts
new file mode 100644
index 0000000..2dcc6d2
--- /dev/null
+++ b/src/pages/rongdesheng/reflection/constants.ts
@@ -0,0 +1,13 @@
+export const HINT_TEXTS = {
+ answeredPending: '选择成功,在场景中点击对应气泡查看决策影响',
+ answeredAllViewed: '已了解所有决策影响,请点击提交查看结果',
+ partialCorrect: '还有合适的决策未选择,请继续补充。',
+ finalScene: '恭喜你成功闯过所有难关!点击合成按钮,解锁申新纺织厂从初创到自强的完整红色故事吧~',
+ finalEnded: '恭喜你完成推演!保存勋章,和同学分享申新纺织厂从初创到自强的红色实业故事吧~',
+} as const
+
+export type HintKey = keyof typeof HINT_TEXTS
+
+export const HINT_AUDIOS: Partial> = {}
+
+export const HINT_AUDIO_CHANNEL = 'rongdesheng-reflection-hint'
diff --git a/src/pages/rongdesheng/reflection/index.tsx b/src/pages/rongdesheng/reflection/index.tsx
new file mode 100644
index 0000000..134bc45
--- /dev/null
+++ b/src/pages/rongdesheng/reflection/index.tsx
@@ -0,0 +1,418 @@
+import { Image, StudyGuideModal, Video } from '@/components'
+import { useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { getBackgroundImage, getStaticImage } from '@/lib/utils'
+import { useRongdeshengStore } from '@/store/rongdeshengStore'
+import { FinalScene } from './components/FinalScene'
+import { HintPanel } from './components/HintPanel'
+import { ModuleNavigation } from './components/ModuleNavigation'
+import { QuestionPanel } from './components/QuestionPanel'
+import { ResultPanel } from './components/ResultPanel'
+import { VideoBubbles } from './components/VideoBubbles'
+import { HINT_AUDIOS, HINT_TEXTS } from './constants'
+import { MODULES, getSceneTitle } from './modules.data'
+import type { ModuleId, ModuleState, VideoPlayingState } from './types'
+
+const RongdeshengReflection = () => {
+ const navigate = useNavigate()
+ const { moduleName } = useParams<{ moduleName: string }>()
+ const starName = 'rongdesheng'
+
+ // 持久化 store
+ const persistedCompleted = useRongdeshengStore((s) => s.reflectionCompleted)
+ const persistedVideoViewed = useRongdeshengStore((s) => s.reflectionVideoViewed)
+ const persistedFinalCompleted = useRongdeshengStore((s) => s.reflectionFinalCompleted)
+ const persistedGuideShown = useRongdeshengStore((s) => s.reflectionGuideShown)
+ const markReflectionVideoViewed = useRongdeshengStore((s) => s.markReflectionVideoViewed)
+ const markReflectionCompleted = useRongdeshengStore((s) => s.markReflectionCompleted)
+ const markReflectionFinalCompleted = useRongdeshengStore((s) => s.markReflectionFinalCompleted)
+ const markReflectionGuideShown = useRongdeshengStore((s) => s.markReflectionGuideShown)
+
+ // 学习引导弹窗:第一次进入时显示
+ const [showGuide, setShowGuide] = useState(!persistedGuideShown)
+
+ // 当前选中的模块ID:默认选中第一个未完成的模块(断点续学)
+ const [currentModuleId, setCurrentModuleId] = useState(() => {
+ const firstUncompleted = MODULES.find((m) => !persistedCompleted[m.id])
+ return (firstUncompleted?.id ?? MODULES[MODULES.length - 1]!.id) as ModuleId
+ })
+
+ // 是否进入最终场景(场景6 合成坚守图谱)
+ const [showFinalScene, setShowFinalScene] = useState(persistedFinalCompleted)
+
+ // 最终合成视频:'idle' 未播放 | 'playing' 播放中 | 'ended' 播放结束停在最后一帧
+ const [finalVideoStage, setFinalVideoStage] = useState<'idle' | 'playing' | 'ended'>('idle')
+
+ // 视频播放状态
+ const [videoPlayingState, setVideoPlayingState] = useState({
+ isPlaying: false,
+ currentVideoId: null,
+ currentVideoSrc: null,
+ })
+
+ // 所有模块的状态
+ const [modulesState, setModulesState] = useState>(() => {
+ const initial = {} as Record
+ MODULES.forEach((module) => {
+ initial[module.id] = {
+ selectedOptions: [],
+ videoViewed: { ...(persistedVideoViewed[module.id] || {}) },
+ completed: !!persistedCompleted[module.id],
+ hasAnswered: false,
+ hasSelectedWrong: false,
+ showRetryButton: false,
+ }
+ })
+ return initial
+ })
+
+ const currentModule = MODULES.find((m) => m.id === currentModuleId)!
+ const currentState = modulesState[currentModuleId]
+
+ // 答对后,当前选中的正确选项所关联的视频是否都已观看(用于控制是否允许提交)
+ const allSelectedVideosViewed = currentState.selectedOptions.every((optionId) => {
+ const option = currentModule.options.find((o) => o.id === optionId)
+ if (!option?.videoResourceId) return true
+ return !!currentState.videoViewed[option.videoResourceId]
+ })
+
+ if (!moduleName) return null
+
+ // 处理模块切换:已完成模块支持回看再学习(重置交互状态,保留 videoViewed)
+ const handleModuleSelect = (moduleId: ModuleId) => {
+ if (showFinalScene) setShowFinalScene(false)
+ setCurrentModuleId(moduleId)
+ setModulesState((prev) => {
+ const target = prev[moduleId]
+ if (!target.completed) return prev
+ return {
+ ...prev,
+ [moduleId]: {
+ ...target,
+ selectedOptions: [],
+ completed: false,
+ hasAnswered: false,
+ hasSelectedWrong: false,
+ showRetryButton: false,
+ },
+ }
+ })
+ }
+
+ // 处理选项选择
+ const handleOptionSelect = (optionId: string) => {
+ const state = currentState
+ if (state.completed || state.hasAnswered) return
+
+ const option = currentModule.options.find((opt) => opt.id === optionId)
+ if (!option) return
+
+ // 已选中则取消
+ if (state.selectedOptions.includes(optionId)) {
+ setModulesState((prev) => ({
+ ...prev,
+ [currentModuleId]: {
+ ...prev[currentModuleId],
+ selectedOptions: prev[currentModuleId].selectedOptions.filter((id) => id !== optionId),
+ hasSelectedWrong: false,
+ showRetryButton: false,
+ },
+ }))
+ return
+ }
+
+ if (state.selectedOptions.length >= 2) return
+
+ const newSelectedOptions = [...state.selectedOptions, optionId]
+ setModulesState((prev) => ({
+ ...prev,
+ [currentModuleId]: { ...prev[currentModuleId], selectedOptions: newSelectedOptions },
+ }))
+
+ // 选择了错误选项
+ if (!option.isCorrect) {
+ setModulesState((prev) => ({
+ ...prev,
+ [currentModuleId]: {
+ ...prev[currentModuleId],
+ hasSelectedWrong: true,
+ showRetryButton: true,
+ },
+ }))
+ return
+ }
+
+ // 选满两个选项后,判断是否全对
+ if (newSelectedOptions.length === 2) {
+ const [o1, o2] = newSelectedOptions.map((id) =>
+ currentModule.options.find((opt) => opt.id === id)
+ )
+ const allCorrect = !!o1?.isCorrect && !!o2?.isCorrect
+ setModulesState((prev) => ({
+ ...prev,
+ [currentModuleId]: {
+ ...prev[currentModuleId],
+ hasAnswered: allCorrect,
+ hasSelectedWrong: !allCorrect,
+ showRetryButton: !allCorrect,
+ },
+ }))
+ } else {
+ // 只选了一个正确选项,等继续补充
+ setModulesState((prev) => ({
+ ...prev,
+ [currentModuleId]: {
+ ...prev[currentModuleId],
+ hasAnswered: false,
+ hasSelectedWrong: false,
+ showRetryButton: false,
+ },
+ }))
+ }
+ }
+
+ // 重做:只清空答错的选项,保留已答对的
+ const handleRetry = () => {
+ setModulesState((prev) => {
+ const keptCorrectOptions = prev[currentModuleId].selectedOptions.filter((optionId) => {
+ const option = currentModule.options.find((opt) => opt.id === optionId)
+ return option?.isCorrect
+ })
+ return {
+ ...prev,
+ [currentModuleId]: {
+ ...prev[currentModuleId],
+ selectedOptions: keptCorrectOptions,
+ hasAnswered: false,
+ hasSelectedWrong: false,
+ showRetryButton: false,
+ },
+ }
+ })
+ }
+
+ // 开始播放气泡视频
+ const handleVideoWatch = (videoId: string, videoSrc: string) => {
+ setVideoPlayingState({ isPlaying: true, currentVideoId: videoId, currentVideoSrc: videoSrc })
+ }
+
+ // 气泡视频播放结束 -> 标记已观看
+ const handleVideoEnded = () => {
+ const videoId = videoPlayingState.currentVideoId
+ if (!videoId) return
+
+ setModulesState((prev) => ({
+ ...prev,
+ [currentModuleId]: {
+ ...prev[currentModuleId],
+ videoViewed: { ...prev[currentModuleId].videoViewed, [videoId]: true },
+ },
+ }))
+ markReflectionVideoViewed(currentModuleId, videoId)
+
+ setVideoPlayingState({ isPlaying: false, currentVideoId: null, currentVideoSrc: null })
+ }
+
+ // 提交答题 -> 进入当前场景结算页
+ const handleSubmit = () => {
+ setModulesState((prev) => ({
+ ...prev,
+ [currentModuleId]: { ...prev[currentModuleId], completed: true },
+ }))
+ markReflectionCompleted(currentModuleId)
+ }
+
+ // 结算页"下一场景":切到下一场景或进入最终合成场景
+ const handleNextScene = () => {
+ const currentIndex = MODULES.findIndex((m) => m.id === currentModuleId)
+ const nextModule = MODULES[currentIndex + 1]
+ if (nextModule) {
+ setModulesState((prev) => ({
+ ...prev,
+ [currentModuleId]: {
+ ...prev[currentModuleId],
+ selectedOptions: [],
+ hasAnswered: false,
+ hasSelectedWrong: false,
+ showRetryButton: false,
+ },
+ }))
+ setCurrentModuleId(nextModule.id)
+ } else {
+ setShowFinalScene(true)
+ }
+ }
+
+ // 计算小提示板显示内容与音频
+ const hint = resolveHint({
+ showFinalScene,
+ currentState,
+ currentModule,
+ allSelectedVideosViewed,
+ })
+
+ return (
+
+ {/* 返回按钮 */}
+
navigate(`/${moduleName}/${starName}/home`)}
+ />
+
+ {/* 视频气泡(仅非最终场景展示) */}
+ {!showFinalScene && (
+
+ )}
+
+ {/* 气泡视频播放器 */}
+ {videoPlayingState.isPlaying && videoPlayingState.currentVideoSrc && (
+
+ )}
+
+ {/* 顶部模块目录 */}
+
+
+ {/* 底部小提示板(含音频) */}
+
+
+ {/* 右侧题目或结果面板(最终场景时隐藏) */}
+
+
+ {!currentState.completed ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* 最终合成场景 */}
+ setFinalVideoStage('playing')}
+ onVideoEnded={() => {
+ setFinalVideoStage('ended')
+ markReflectionFinalCompleted()
+ }}
+ onExit={() => navigate(`/${moduleName}/${starName}/home`)}
+ />
+
+ {/* 学习引导弹窗:首次进入时显示 */}
+ {
+ setShowGuide(false)
+ markReflectionGuideShown()
+ }}
+ >
+ 同学你好,欢迎来到“跨越时空·体悟”学习模块!{'\n'}
+ 请登陆“时空回溯・亲历时代”体验仓,下一站:1915年,上海。{'\n'}
+ 欢迎来到申新纺织厂!荣氏兄弟创办的申新纺织厂,从无到有、历经磨难,却始终坚守爱国初心。
+ 接下来请你化身决策助手,和他们一起闯过重重难关,守护民族实业的未来!{'\n'}
+ 温馨提示:每个难关的突破都需要叠加多重因素助力哦!
+
+
+ )
+}
+
+// ---- helpers ----
+
+interface ResolveHintArgs {
+ showFinalScene: boolean
+ currentState: ModuleState
+ currentModule: (typeof MODULES)[number]
+ allSelectedVideosViewed: boolean
+}
+
+interface HintResult {
+ visible: boolean
+ text: string
+ audioSrc?: string
+}
+
+function resolveHint({
+ showFinalScene,
+ currentState,
+ currentModule,
+ allSelectedVideosViewed,
+}: ResolveHintArgs): HintResult {
+ if (showFinalScene) {
+ return { visible: true, text: HINT_TEXTS.finalScene, audioSrc: HINT_AUDIOS.finalScene }
+ }
+ if (currentState.hasSelectedWrong) {
+ return {
+ visible: true,
+ text: currentModule.wrongHint,
+ audioSrc: currentModule.audio?.wrongHint,
+ }
+ }
+ if (currentState.hasAnswered) {
+ return allSelectedVideosViewed
+ ? {
+ visible: true,
+ text: HINT_TEXTS.answeredAllViewed,
+ audioSrc: HINT_AUDIOS.answeredAllViewed,
+ }
+ : {
+ visible: true,
+ text: HINT_TEXTS.answeredPending,
+ audioSrc: HINT_AUDIOS.answeredPending,
+ }
+ }
+ const selectedCorrectCount = currentState.selectedOptions.filter((optionId) => {
+ const option = currentModule.options.find((opt) => opt.id === optionId)
+ return option?.isCorrect
+ }).length
+ if (selectedCorrectCount === 1) {
+ return { visible: true, text: HINT_TEXTS.partialCorrect, audioSrc: HINT_AUDIOS.partialCorrect }
+ }
+ return { visible: false, text: '' }
+}
+
+export default RongdeshengReflection
diff --git a/src/pages/rongdesheng/reflection/modules.data.ts b/src/pages/rongdesheng/reflection/modules.data.ts
new file mode 100644
index 0000000..7a12d12
--- /dev/null
+++ b/src/pages/rongdesheng/reflection/modules.data.ts
@@ -0,0 +1,219 @@
+import type { Module } from './types'
+
+export const MODULES: Module[] = [
+ {
+ id: 'scene1',
+ title: '建厂拓业',
+ backgroundImage: 'rongdesheng/reflection/场景1选中',
+ question:
+ '1915年,荣氏兄弟计划在上海创办申新纱厂,却面临三大难题:租界建厂受列强限制、资金短缺、原料运输不便。上海作为远东商贸中心,机遇与挑战并存,如何在这里建起属于中国人的纱厂?',
+ options: [
+ { id: 'opt1-1', text: '创办申新纱厂', isCorrect: true, videoResourceId: 'video1-1' },
+ { id: 'opt1-2', text: '引进国外先进设备', isCorrect: true, videoResourceId: 'video1-2' },
+ { id: 'opt1-3', text: '建立工会组织', isCorrect: false },
+ ],
+ videoResources: [
+ {
+ id: 'video1-1',
+ thumbnail: 'rongdesheng/reflection/视频1-1',
+ videoSrc: '/video/rongdesheng/rongdesheng_2_1.mp4',
+ position: { top: '16%', left: '8%' },
+ flipY: false,
+ },
+ {
+ id: 'video1-2',
+ thumbnail: 'rongdesheng/reflection/视频1-2',
+ videoSrc: '/video/rongdesheng/rongdesheng_2_2.mp4',
+ position: { top: '25%', left: '29%' },
+ flipY: true,
+ },
+ ],
+ resultContent:
+ '择址临港、凝聚华商力量,申新在上海扎根生长,成为民族工业冲破列强封锁的一面旗帜!',
+ resultImage: 'rongdesheng/reflection/场景1结算',
+ wrongHint:
+ '荣氏兄弟坚守"实业救国"初心,拒绝洋行干涉企业经营,不愿民族产业沦为外资附庸!试试其他方案吧~',
+ audio: { wrongHint: '' },
+ },
+ {
+ id: 'scene2',
+ title: '护厂抗争',
+ backgroundImage: 'rongdesheng/reflection/场景2选中',
+ question:
+ '1934年,汇丰银行以申新七厂借款到期为由,欲低价拍卖给日商。申新发展遭遇重创,该如何守护来之不易的民族产业?',
+ options: [
+ { id: 'opt2-1', text: 'A.妥协退让,出售部分厂房抵债', isCorrect: false },
+ {
+ id: 'opt2-2',
+ text: 'B.联合全厂职工护厂,抵制拍卖',
+ isCorrect: true,
+ videoResourceId: 'video2-1',
+ },
+ {
+ id: 'opt2-3',
+ text: 'C.联络华商群体与总工会,寻求社会声援',
+ isCorrect: true,
+ videoResourceId: 'video2-2',
+ },
+ ],
+ videoResources: [
+ {
+ id: 'video2-1',
+ thumbnail: 'rongdesheng/reflection/视频2-1',
+ videoSrc: '/video/rongdesheng/rongdesheng_2_3.mp4',
+ position: { top: '14%', left: '11%' },
+ },
+ {
+ id: 'video2-2',
+ thumbnail: 'rongdesheng/reflection/视频2-2',
+ videoSrc: '/video/rongdesheng/rongdesheng_2_4.mp4',
+ position: { top: '21%', left: '22.2%' },
+ flipY: true,
+ },
+ ],
+ resultContent:
+ '护厂抗争的胜利,是民族工业坚守爱国初心的体现,也是国人联合"反抗压迫、团结抗争"的印证!',
+ resultImage: 'rongdesheng/reflection/场景2结算',
+ wrongHint:
+ '荣氏兄弟坚守"实业救国"初心,拒绝洋行干涉企业经营,不愿民族产业沦为外资附庸!试试其他方案吧~',
+ audio: { wrongHint: '' },
+ },
+ {
+ id: 'scene3',
+ title: '民生坚守',
+ backgroundImage: 'rongdesheng/reflection/场景3选中',
+ question:
+ '时局动荡,棉花原料涨价,工厂盈利下滑,职工生活面临困难。荣德生面对既要维持生产,又要保障职工生活,该怎么做?',
+ options: [
+ { id: 'opt3-1', text: 'A.削减职工工资,降低运营成本', isCorrect: false },
+ {
+ id: 'opt3-2',
+ text: 'B.开设职工福利社,提供平价粮油',
+ isCorrect: true,
+ videoResourceId: 'video3-1',
+ },
+ {
+ id: 'opt3-3',
+ text: 'C.坚持生产平价棉布,保障市场民生供应',
+ isCorrect: true,
+ videoResourceId: 'video3-2',
+ },
+ ],
+ videoResources: [
+ {
+ id: 'video3-1',
+ thumbnail: 'rongdesheng/reflection/视频3-1',
+ videoSrc: '/video/rongdesheng/rongdesheng_2_5.mp4',
+ position: { top: '15%', left: '44%' },
+ },
+ {
+ id: 'video3-2',
+ thumbnail: 'rongdesheng/reflection/视频3-2',
+ videoSrc: '/video/rongdesheng/rongdesheng_2_6.mp4',
+ position: { bottom: '50%', left: '55%' },
+ flipY: true,
+ },
+ ],
+ resultContent: '在艰难时局中坚守民生初心,让民族工业成为稳定社会的重要力量!',
+ resultImage: 'rongdesheng/reflection/场景3结算',
+ wrongHint: '荣德生始终重视职工权益,认为"职工是工厂的根基",不会削减工资哦!换个方案试试~',
+ audio: { wrongHint: '' },
+ },
+ {
+ id: 'scene4',
+ title: '抗战支援',
+ backgroundImage: 'rongdesheng/reflection/场景4选中',
+ question:
+ '抗战爆发后,棉花原料运输受阻,前线急需大量棉布、军毯支援,如何在保生产的同时,践行民族企业的爱国担当?',
+ options: [
+ { id: 'opt4-1', text: 'A.停工避战,将设备转移至安全地区', isCorrect: false },
+ {
+ id: 'opt4-2',
+ text: 'B.寻找江南本地棉农,保障原料供应',
+ isCorrect: true,
+ videoResourceId: 'video4-1',
+ },
+ {
+ id: 'opt4-3',
+ text: 'C.调整生产结构,优先生产棉布、军毯支援前线',
+ isCorrect: true,
+ videoResourceId: 'video4-2',
+ },
+ ],
+ videoResources: [
+ {
+ id: 'video4-1',
+ thumbnail: 'rongdesheng/reflection/视频4-1',
+ videoSrc: '/video/rongdesheng/rongdesheng_2_7.mp4',
+ position: { top: '42%', left: '41%' },
+ flipY: true,
+ },
+ {
+ id: 'video4-2',
+ thumbnail: 'rongdesheng/reflection/视频4-2',
+ videoSrc: '/video/rongdesheng/rongdesheng_2_8.mp4',
+ position: { bottom: '26%', right: '44%' },
+ },
+ ],
+ resultContent: '战乱中坚守生产、支援前线,申新用实际行动诠释了民族工业的红色担当!',
+ resultImage: 'rongdesheng/reflection/场景4结算',
+ wrongHint: '荣氏兄弟秉持爱国担当,在抗战期间坚持生产支援前线,不会轻易停工!试试其他方案吧~',
+ audio: { wrongHint: '' },
+ },
+ {
+ id: 'scene5',
+ title: '革新突围',
+ backgroundImage: 'rongdesheng/reflection/场景5选中',
+ question:
+ '抗战胜利后,洋纱、洋布卷土重来,申新设备陈旧、技术落后,面临产品竞争力不足的困境,荣德生如何通过技术革新让民族纱厂站稳脚跟?',
+ options: [
+ {
+ id: 'opt5-1',
+ text: 'A.仿制外国先进设备,改良生产工艺',
+ isCorrect: false,
+ videoResourceId: 'video5-1',
+ },
+ {
+ id: 'opt5-2',
+ text: 'B.设立职员养成所,培养本土技术人才',
+ isCorrect: true,
+ videoResourceId: 'video5-2',
+ },
+ { id: 'opt5-3', text: 'C.降价促销,以低价抢占市场', isCorrect: true },
+ ],
+ videoResources: [
+ {
+ id: 'video5-1',
+ thumbnail: 'rongdesheng/reflection/视频5-1',
+ videoSrc: '/video/rongdesheng/rongdesheng_2_9.mp4',
+ position: { top: '25%', left: '42%' },
+ },
+ {
+ id: 'video5-2',
+ thumbnail: 'rongdesheng/reflection/视频5-2',
+ videoSrc: '/video/rongdesheng/rongdesheng_2_10.mp4',
+ position: { bottom: '50%', left: '55%' },
+ flipY: true,
+ },
+ ],
+ resultContent:
+ '自主革新、培育人才,申新以技术突破打破洋货垄断,体现了红色实业"自立自强"的奋斗精神!',
+ resultImage: 'rongdesheng/reflection/场景5结算',
+ wrongHint:
+ '单纯降价会挤压利润空间,无法从根本上解决技术短板,荣氏兄弟更注重以技术提质增效!换个方案试试~',
+ audio: { wrongHint: '' },
+ },
+]
+
+export const FINAL_VIDEO_SRC = '/video/rongdesheng/rongdesheng_2_11.mp4'
+
+const SCENE_NUMBER_MAP: Record = {
+ scene1: '一',
+ scene2: '二',
+ scene3: '三',
+ scene4: '四',
+ scene5: '五',
+}
+
+export const getSceneTitle = (moduleId: Module['id']): string =>
+ `推演场景${SCENE_NUMBER_MAP[moduleId] || ''}`
diff --git a/src/pages/rongdesheng/reflection/types.ts b/src/pages/rongdesheng/reflection/types.ts
new file mode 100644
index 0000000..f75019f
--- /dev/null
+++ b/src/pages/rongdesheng/reflection/types.ts
@@ -0,0 +1,53 @@
+export type ModuleId = 'scene1' | 'scene2' | 'scene3' | 'scene4' | 'scene5'
+
+export interface VideoResource {
+ id: string
+ thumbnail: string
+ videoSrc: string
+ position: {
+ top?: string
+ left?: string
+ right?: string
+ bottom?: string
+ }
+ flipY?: boolean
+}
+
+export interface ModuleOption {
+ id: string
+ text: string
+ isCorrect: boolean
+ videoResourceId?: string
+}
+
+export interface Module {
+ id: ModuleId
+ title: string
+ backgroundImage: string
+ question: string
+ options: ModuleOption[]
+ videoResources: VideoResource[]
+ resultContent: string
+ resultImage: string
+ wrongHint: string
+ audio?: {
+ wrongHint?: string
+ }
+}
+
+export interface ModuleState {
+ selectedOptions: string[]
+ videoViewed: Record
+ completed: boolean
+ hasAnswered: boolean
+ hasSelectedWrong: boolean
+ showRetryButton: boolean
+}
+
+export interface VideoPlayingState {
+ isPlaying: boolean
+ currentVideoId: string | null
+ currentVideoSrc: string | null
+}
+
+export type FinalVideoStage = 'idle' | 'playing' | 'ended'
diff --git a/src/pages/sunzhongshan/home.tsx b/src/pages/sunzhongshan/home.tsx
index edf0a28..a8b4947 100644
--- a/src/pages/sunzhongshan/home.tsx
+++ b/src/pages/sunzhongshan/home.tsx
@@ -1,6 +1,24 @@
+import { useEffect } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components'
+import { getUserDefine } from '@/lib/utils/api-common'
+import {
+ useSunzhongshanStore,
+ SUNZHONGSHAN_LESSON_ID,
+ SUNZHONGSHAN_KEY,
+} from '@/store/sunzhongshanStore'
+
+const deserializeVisitedExhibits = (
+ data: Record | undefined
+): Record> => {
+ if (!data || typeof data !== 'object') return {}
+ const visitedExhibits: Record> = {}
+ for (const [place, exhibits] of Object.entries(data)) {
+ visitedExhibits[place] = new Set(Array.isArray(exhibits) ? exhibits : [])
+ }
+ return visitedExhibits
+}
const Sunzhongshan = () => {
const navigate = useNavigate()
@@ -11,6 +29,40 @@ const Sunzhongshan = () => {
// starName 固定为 sunzhongshan
const starName = 'sunzhongshan'
+ const lastVisitedPlace = useSunzhongshanStore((state) => state.lastVisitedPlace)
+ const reflectionRoute = useSunzhongshanStore((state) => state.reflectionRoute)
+
+ useEffect(() => {
+ ;(async () => {
+ try {
+ const raw = await getUserDefine(SUNZHONGSHAN_LESSON_ID, SUNZHONGSHAN_KEY)
+ if (!raw) return
+
+ const data = JSON.parse(raw) as {
+ visitedExhibits?: Record
+ lastVisitedPlace?: string | null
+ reflectionRoute?: string
+ }
+
+ useSunzhongshanStore.setState((prev) => ({
+ ...prev,
+ visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
+ lastVisitedPlace:
+ data.lastVisitedPlace !== undefined ? data.lastVisitedPlace : prev.lastVisitedPlace,
+ reflectionRoute:
+ data.reflectionRoute &&
+ ['reflection', 'reflection1', 'reflection2', 'reflection3'].includes(
+ data.reflectionRoute
+ )
+ ? data.reflectionRoute
+ : (prev.reflectionRoute ?? 'reflection'),
+ }))
+ } catch (error) {
+ console.error('Failed to load Sunzhongshan store', error)
+ }
+ })()
+ }, [])
+
if (!moduleName) {
return null
}
@@ -34,13 +86,13 @@ const Sunzhongshan = () => {
navigate(`/${moduleName}/${starName}/place1`)}
+ onClick={() => navigate(`/${moduleName}/${starName}/${lastVisitedPlace || 'place1'}`)}
/>
{/* 体悟 */}
navigate(`/${moduleName}/${starName}/reflection`)}
+ onClick={() => navigate(`/${moduleName}/${starName}/${reflectionRoute || 'reflection'}`)}
/>
{/* 内化 */}
{
+const SunzhongshanInternalization = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{ moduleName: string }>()
@@ -218,11 +219,8 @@ const ChenyunInternalization = () => {
const [showVideo, setShowVideo] = useState(false)
const [postGuideVisible, setPostGuideVisible] = useState(false)
const [showLetter, setShowLetter] = useState(false)
- const [showLetterVideo, setShowLetterVideo] = useState(false)
const [showStudyGuide, setShowStudyGuide] = useState(false)
const videoRef = useRef(null)
- const letterVideoRef = useRef(null)
- const audioRef = useRef(null)
// 旁白关闭后显示对话引导
useEffect(() => {
@@ -231,17 +229,6 @@ const ChenyunInternalization = () => {
}
}, [showNarration])
- // 组件卸载时清理背景音乐
- useEffect(() => {
- const audioEl = audioRef.current
- return () => {
- if (audioEl) {
- audioEl.pause()
- audioEl.currentTime = 0
- }
- }
- }, [])
-
// 视频加载完成后自动播放
const handleVideoCanPlay = () => {
if (showVideo && videoRef.current) {
@@ -251,14 +238,6 @@ const ChenyunInternalization = () => {
}
}
- // 信件视频加载完成后自动播放
- const handleLetterVideoCanPlay = () => {
- if (showLetterVideo && letterVideoRef.current) {
- letterVideoRef.current.play().catch((error) => {
- console.warn('信件视频播放失败:', error)
- })
- }
- }
const anchors: VideoAnchor[] = useMemo(
() =>
QUIZ_ANCHORS.map((item) => {
@@ -337,7 +316,7 @@ const ChenyunInternalization = () => {
{
setActiveSuccess(null)
setDragQuizValues((prev) => {
@@ -479,9 +458,6 @@ const ChenyunInternalization = () => {
onClick={() => navigate(`/${moduleName}/${starName}/home`)}
/>
- {/* 内化结束后的背景音乐 */}
-
-
{showVideo && (
{
onEnded={() => {
setShowVideo(false)
setPostGuideVisible(true)
- if (audioRef.current) {
- audioRef.current.currentTime = 0
- audioRef.current.play().catch((error) => {
- console.warn('背景音乐播放失败:', error)
- })
- }
}}
/>
)}
@@ -564,40 +534,23 @@ const ChenyunInternalization = () => {
{/* 信件组件:视频引导结束后弹出,带飞入和放大动画 */}
{
- // 提交信件的逻辑(values 为多个填空的内容)
- console.log('提交信件', values)
+ onClose={() => {
setShowLetter(false)
- setShowLetterVideo(true)
}}
+ onAnimationEnd={() => {
+ setShowStudyGuide(true)
+ }}
+ showVideo={true}
/>
- {/* 信件关闭后的视频 */}
- {showLetterVideo && (
- {
- setShowLetterVideo(false)
- setShowStudyGuide(true)
- }}
- />
- )}
-
{
- if (audioRef.current) {
- audioRef.current.pause()
- audioRef.current.currentTime = 0
- }
setShowStudyGuide(false)
+ setCourseTimeProgress(6)
navigate(`/${moduleName}/${starName}/home`)
}}
/>
@@ -605,4 +558,4 @@ const ChenyunInternalization = () => {
)
}
-export default ChenyunInternalization
+export default SunzhongshanInternalization
diff --git a/src/pages/sunzhongshan/place/place1.tsx b/src/pages/sunzhongshan/place/place1.tsx
index 88a9762..d9936d7 100644
--- a/src/pages/sunzhongshan/place/place1.tsx
+++ b/src/pages/sunzhongshan/place/place1.tsx
@@ -17,7 +17,7 @@ import { SuccessAlert, DragQuizPanel } from '@/components/layout'
import { MultipleChoiceQuizPanel } from '@/components/layout/quiz'
import { useCursorController } from '@/hooks/useCursorController'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
-import { useSunzhongshanStore } from '@/store/sunzhongshanStore'
+import { useSunzhongshanStore, saveSunzhongshanStore } from '@/store/sunzhongshanStore'
// starName 固定为 sunzhongshan
const starName = 'sunzhongshan'
@@ -127,9 +127,7 @@ const SunzhongshanPlace1 = () => {
const progressCount = visited.size
const total = 5
const completed = progressCount >= total
- const subtitle = completed
- ? '探索孙中山故居纪念馆老宅堂屋(已完成)'
- : `探索孙中山故居纪念馆老宅堂屋(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
@@ -203,6 +201,9 @@ const SunzhongshanPlace1 = () => {
src={getStaticImage('components/place/r-3d前进')}
button
onClick={() => {
+ const { setLastVisitedPlace } = useSunzhongshanStore.getState()
+ setLastVisitedPlace('place2')
+ saveSunzhongshanStore()
navigate(`/${moduleName}/${starName}/place2`)
}}
/>
@@ -273,10 +274,14 @@ const SunzhongshanPlace1 = () => {
setShowGuideModal(false)
setVisible(true)
}}
- audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_01.mp3')}
+ audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_02.mp3')}
+ ailgn="left"
>
- {`同学你好,欢迎来到“走进故居·对话”学习模块!
- 此刻你正置身于孙中山故居堂屋,接下来请随时光的脚步,一同走进他人生中四段激情燃烧的岁月:立志救国·推翻帝制、讨袁护法·捍卫共和、与时俱进·探索新路、抱病北上·鞠躬尽瘁。做好准备了吗?开始探索这段波澜壮阔的历史征程吧!`}
+ 1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
+ {'\n'}
+ 2.在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点开查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习。
+ {'\n'}
+ 3.所有动态光点的展品全部学习后才能进入下一个场景学习。
{/* 引导 */}
@@ -310,14 +315,10 @@ const SunzhongshanPlace1 = () => {
setShowAfterDialogGuide(false)}
- ailgn="left"
- audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_02.mp3')}
+ audioSrc={getPublicPath('audio/sunzhongshan/mp3_sunzhongshan_01.mp3')}
>
- 1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
- {'\n'}
- 2.在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习。
- {'\n'}
- 3.所有动态光点的展品全部学习后才能进入下一个场景学习。
+ {`同学你好,欢迎来到“走进故居·对话”学习模块!
+ 此刻你正置身于孙中山故居堂屋,接下来请随时光的脚步,一同走进他人生中四段激情燃烧的岁月:立志救国·推翻帝制、讨袁护法·捍卫共和、与时俱进·探索新路、抱病北上·鞠躬尽瘁。做好准备了吗?开始探索这段波澜壮阔的历史征程吧!`}
{/* 光标2的对话 */}
@@ -446,7 +447,7 @@ const SunzhongshanPlace1 = () => {
{
{/* 光标4的成功提示 */}
{
- setSuccessAlert4Visible(false)
- // setShowVideo4Second(true);
- markExhibitVisited(place, 4)
- }}
+ // onContinue={() => {
+ // setSuccessAlert4Visible(false)
+ // setShowVideo4Second(true);
+ // }}
onClose={() => {
setSuccessAlert4Visible(false)
+ markExhibitVisited(place, 4)
}}
/>
@@ -518,7 +519,7 @@ const SunzhongshanPlace1 = () => {
{
const progressCount = visited.size
const total = 4
const completed = progressCount >= total
- const subtitle = completed
- ? '探索孙中山故居纪念馆老宅堂屋(已完成)'
- : `探索孙中山故居纪念馆老宅堂屋(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
@@ -150,6 +148,9 @@ const SunzhongshanPlace2 = () => {
src={getStaticImage('components/place/r-前进')}
button
onClick={() => {
+ const { setLastVisitedPlace } = useSunzhongshanStore.getState()
+ setLastVisitedPlace('place3')
+ saveSunzhongshanStore()
navigate(`/${moduleName}/${starName}/place3`)
}}
/>
diff --git a/src/pages/sunzhongshan/place/place3.tsx b/src/pages/sunzhongshan/place/place3.tsx
index f86997a..f90c7ce 100644
--- a/src/pages/sunzhongshan/place/place3.tsx
+++ b/src/pages/sunzhongshan/place/place3.tsx
@@ -15,7 +15,7 @@ import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import { SuccessAlert, DragQuizPanel } from '@/components/layout'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
-import { useSunzhongshanStore } from '@/store/sunzhongshanStore'
+import { useSunzhongshanStore, saveSunzhongshanStore } from '@/store/sunzhongshanStore'
const starName = 'sunzhongshan'
@@ -98,9 +98,7 @@ const SunzhongshanPlace3 = () => {
const progressCount = visited.size
const total = 4
const completed = progressCount >= total
- const subtitle = completed
- ? '探索孙中山故居纪念馆老宅堂屋(已完成)'
- : `探索孙中山故居纪念馆老宅堂屋(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
@@ -162,6 +160,9 @@ const SunzhongshanPlace3 = () => {
src={getStaticImage('components/place/r-前进')}
button
onClick={() => {
+ const { setLastVisitedPlace } = useSunzhongshanStore.getState()
+ setLastVisitedPlace('place4')
+ saveSunzhongshanStore()
navigate(`/${moduleName}/${starName}/place4`)
}}
/>
diff --git a/src/pages/sunzhongshan/place/place4.tsx b/src/pages/sunzhongshan/place/place4.tsx
index 433af56..c2ba584 100644
--- a/src/pages/sunzhongshan/place/place4.tsx
+++ b/src/pages/sunzhongshan/place/place4.tsx
@@ -14,7 +14,8 @@ import { useCursorController } from '@/hooks/useCursorController'
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
-import { useSunzhongshanStore } from '@/store/sunzhongshanStore'
+import { useSunzhongshanStore, saveSunzhongshanStore } from '@/store/sunzhongshanStore'
+import { setCourseTimeProgress } from '@/lib/utils/api-common'
const starName = 'sunzhongshan'
@@ -79,9 +80,7 @@ const SunzhongshanPlace4 = () => {
const progressCount = visited.size
const total = 2
const completed = progressCount >= total
- const subtitle = completed
- ? '探索孙中山故居纪念馆老宅堂屋(已完成)'
- : `探索孙中山故居纪念馆老宅堂屋(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const subtitleClassName = completed
? 'text-[#fff998] [text-shadow:_0_8px_8px_rgba(0,0,0,0.35),_0_0_16px_rgba(0,0,0,0.25)]'
: undefined
@@ -130,6 +129,10 @@ const SunzhongshanPlace4 = () => {
src={getStaticImage('components/place/l-前进')}
button
onClick={() => {
+ const { setLastVisitedPlace } = useSunzhongshanStore.getState()
+ setLastVisitedPlace('place4')
+ setCourseTimeProgress(4)
+ saveSunzhongshanStore()
navigate(`/${moduleName}/${starName}`)
}}
/>
@@ -162,7 +165,7 @@ const SunzhongshanPlace4 = () => {
{
const handleStartExplore = () => {
if (selectedScene && moduleName && starName) {
- navigate(`/${moduleName}/${starName}/reflection${selectedScene}`)
+ const { setReflectionRoute } = useSunzhongshanStore.getState()
+ const route = `reflection${selectedScene}`
+ setReflectionRoute(route)
+ saveSunzhongshanStore()
+ navigate(`/${moduleName}/${starName}/${route}`)
}
}
diff --git a/src/pages/sunzhongshan/reflection/scene1.tsx b/src/pages/sunzhongshan/reflection/scene1.tsx
index 96680db..01a8605 100644
--- a/src/pages/sunzhongshan/reflection/scene1.tsx
+++ b/src/pages/sunzhongshan/reflection/scene1.tsx
@@ -12,10 +12,9 @@ import {
import { SuccessAlert } from '@/components/layout'
import { SingleChoiceQuizPanel } from '@/components/layout/quiz'
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
-import { useClueStore } from '@/store/clueStore'
import type { ClueHint } from '@/components/clue'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
-import { useSunzhongshanStore } from '@/store/sunzhongshanStore'
+import { useSunzhongshanStore, saveSunzhongshanStore } from '@/store/sunzhongshanStore'
const starName = 'sunzhongshan'
const sceneId = `${starName}/reflection/scene1`
@@ -87,7 +86,7 @@ const SunzhongshanReflectionScene1 = () => {
const [showCompleteGuide, setShowCompleteGuide] = useState(false)
const [pendingQuiz, setPendingQuiz] = useState<'quiz2' | 'quiz3' | 'complete' | null>(null)
- const { collectClue, isClueCollected } = useClueStore()
+ const { collectClue, isClueCollected } = useSunzhongshanStore()
const containerRef = useRef(null)
const clueBookButtonRef = useRef(null)
// 为每个线索创建 ref
@@ -238,6 +237,9 @@ const SunzhongshanReflectionScene1 = () => {
// 保存场景1完成状态
setReflectionFlag(`${starName}-reflection-scene1-completed`, true)
if (moduleName && starName) {
+ const { setReflectionRoute } = useSunzhongshanStore.getState()
+ setReflectionRoute('reflection2')
+ saveSunzhongshanStore()
navigate(`/${moduleName}/${starName}/reflection2`)
}
}
@@ -273,7 +275,7 @@ const SunzhongshanReflectionScene1 = () => {
>
{/* 视频播放 - 覆盖在页面内容之上 */}
{showVideo && (
-
+
{
const [showCompleteGuide, setShowCompleteGuide] = useState(false)
const [pendingQuiz, setPendingQuiz] = useState<'quiz2' | 'quiz3' | 'complete' | null>(null)
- const { collectClue, isClueCollected } = useClueStore()
+ const { collectClue, isClueCollected } = useSunzhongshanStore()
const containerRef = useRef(null)
const clueBookButtonRef = useRef(null)
// 为每个线索创建 ref
@@ -248,6 +247,9 @@ const SunzhongshanReflectionScene2 = () => {
// 保存场景2完成状态
setReflectionFlag(`${starName}-reflection-scene2-completed`, true)
if (moduleName && starName) {
+ const { setReflectionRoute } = useSunzhongshanStore.getState()
+ setReflectionRoute('reflection3')
+ saveSunzhongshanStore()
navigate(`/${moduleName}/${starName}/reflection3`)
}
}
@@ -297,7 +299,7 @@ const SunzhongshanReflectionScene2 = () => {
>
{/* 视频播放 - 覆盖在页面内容之上 */}
{showVideo && (
-
+
{
// 控制 Cursor 显示:获取线索1后显示,点击后进入主场景
const [showCursor, setShowCursor] = useState(false)
- const { collectClue, isClueCollected } = useClueStore()
+ const { collectClue, isClueCollected } = useSunzhongshanStore()
const containerRef = useRef(null)
const clueBookButtonRef = useRef(null)
// 为每个线索创建 ref
@@ -258,6 +258,10 @@ const SunzhongshanReflectionScene3 = () => {
// 保存场景3完成状态
setReflectionFlag(`${starName}-reflection-scene3-completed`, true)
if (moduleName && starName) {
+ const { setReflectionRoute } = useSunzhongshanStore.getState()
+ setReflectionRoute('reflection3')
+ setCourseTimeProgress(5)
+ saveSunzhongshanStore()
navigate(`/${moduleName}/${starName}/home`)
}
}
@@ -327,7 +331,7 @@ const SunzhongshanReflectionScene3 = () => {
>
{/* 视频播放 - 覆盖在页面内容之上 */}
{showVideo && (
-
+
void
+ currentStep?: number
+ onStepChange?: (step: number) => void
+ /** 每个步骤对应的高亮选择器数组,索引对应步骤序号 */
+ highlightSelectors?: (string | string[])[]
+ /** 控制是否显示遮罩层(包括背景蒙版和 Overlay 遮罩,默认:true) */
+ showMask?: boolean
+ /** 自定义 z-index,用于控制层级(默认:50) */
+ zIndex?: number
+ /** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
+ audioChannelId?: string
+}
+
+interface SanmaoDialogueItemProps {
+ children: ReactNode
+ /** 当前步骤对应的音频地址 */
+ audioSrc?: string
+ /** 进入该步骤时是否自动播放音频(有 audioSrc 且未显式设置时,默认自动播放) */
+ autoPlayOnEnter?: boolean
+}
+
+const BG_PATH = 'components/三毛对话底板'
+
+const SanmaoDialogueSteps = ({
+ children,
+ className,
+ visible,
+ onVisibleChange,
+ currentStep: controlledStep,
+ onStepChange,
+ showMask = true,
+ zIndex = 50,
+ audioChannelId,
+}: SanmaoDialogueStepsProps) => {
+ const [internalStep, setInternalStep] = useState(0)
+ const [internalVisible, setInternalVisible] = useState(true)
+
+ const videoContext = useVideoContextSafe()
+ const { play, stop } = useAudioManager(
+ audioChannelId ? { mode: 'channel', channelId: audioChannelId } : { mode: 'global' }
+ )
+
+ const stepsData = Children.toArray(children).map((child) => {
+ if (isValidElement(child) && child.type === SanmaoDialogueItem) {
+ const hasAudio = !!child.props.audioSrc
+ return {
+ content: child.props.children,
+ audioSrc: child.props.audioSrc,
+ autoPlayOnEnter:
+ child.props.autoPlayOnEnter !== undefined ? child.props.autoPlayOnEnter : hasAudio,
+ }
+ }
+ return {
+ content: child as ReactNode,
+ audioSrc: undefined,
+ autoPlayOnEnter: false,
+ }
+ })
+
+ const steps = stepsData.map((s) => s.content)
+ const totalSteps = steps.length
+
+ const isControlled = controlledStep !== undefined
+ const currentStep = isControlled ? controlledStep : internalStep
+ const baseVisible = visible !== undefined ? visible : internalVisible
+
+ const isVisible =
+ baseVisible &&
+ (!videoContext || (!videoContext.isVideoPlaying && videoContext.isContentVisible))
+
+ const currentStepData = stepsData[currentStep]
+ const currentAudioSrc = currentStepData?.audioSrc
+ const currentAutoPlayOnEnter = currentStepData?.autoPlayOnEnter ?? false
+
+ useEffect(() => {
+ if (!isVisible) return
+ if (currentAutoPlayOnEnter && currentAudioSrc) {
+ play(currentAudioSrc)
+ }
+ }, [isVisible, currentStep, currentAutoPlayOnEnter, currentAudioSrc, play])
+
+ const updateStep = (newStep: number) => {
+ if (!isControlled) {
+ setInternalStep(newStep)
+ }
+ onStepChange?.(newStep)
+ }
+
+ const updateVisible = (newVisible: boolean) => {
+ if (visible === undefined) {
+ setInternalVisible(newVisible)
+ }
+ if (!newVisible) {
+ stop()
+ }
+ onVisibleChange?.(newVisible)
+ }
+
+ useEffect(() => {
+ if (!isVisible) {
+ stop()
+ }
+ }, [isVisible, stop])
+
+ const handleNext = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ if (currentStep < totalSteps - 1) {
+ updateStep(currentStep + 1)
+ }
+ }
+
+ const handleTextClick = () => {
+ if (currentStep < totalSteps - 1) {
+ updateStep(currentStep + 1)
+ } else {
+ updateVisible(false)
+ }
+ }
+
+ const dialogueContent = (
+
+ {isVisible && (
+
+ {showMask && }
+
+
+
+
+
+
+
+
+ {steps[currentStep]}
+
+
+ {currentStep < totalSteps - 1 && (
+
+
+ 继续
+
+ →
+
+ )}
+
+
+
+
+
+ )}
+
+ )
+
+ return dialogueContent
+}
+
+const SanmaoDialogueItem = ({ children }: SanmaoDialogueItemProps) => <>{children}>
+
+export { SanmaoDialogueSteps, SanmaoDialogueItem }
diff --git a/src/pages/zhangleping/components/index.ts b/src/pages/zhangleping/components/index.ts
new file mode 100644
index 0000000..2761508
--- /dev/null
+++ b/src/pages/zhangleping/components/index.ts
@@ -0,0 +1 @@
+export { SanmaoDialogueSteps, SanmaoDialogueItem } from './SanmaoDialogueSteps'
diff --git a/src/pages/zhangleping/draw/TopMenuPanel.tsx b/src/pages/zhangleping/draw/TopMenuPanel.tsx
new file mode 100644
index 0000000..326ab28
--- /dev/null
+++ b/src/pages/zhangleping/draw/TopMenuPanel.tsx
@@ -0,0 +1,197 @@
+import { Image } from '@/components'
+import { cn, getBackgroundImage, getStaticImage } from '@/lib/utils'
+import {
+ type BackgroundKey,
+ type ClothesKey,
+ type MaterialKey,
+ type SidebarBlockKey,
+ backgroundOptions,
+ clothesOptions,
+ getTopBarSrc,
+ materialOptions,
+} from './draw1.config'
+
+interface TopMenuPanelProps {
+ activeBlock: SidebarBlockKey
+ selectedClothes: ClothesKey
+ selectedBackground: BackgroundKey
+ selectedMaterials: Set
+ allowedMaterialKeys: MaterialKey[]
+ onSelectClothes: (key: ClothesKey) => void
+ onSelectBackground: (key: BackgroundKey) => void
+ onToggleMaterial: (key: MaterialKey) => void
+}
+
+const containerClassName = 'h-32 bg-[100%_auto] bg-no-repeat flex items-center justify-center'
+
+function ClothesPanel({
+ selectedClothes,
+ onSelectClothes,
+ barSrc,
+}: {
+ selectedClothes: ClothesKey
+ onSelectClothes: (key: ClothesKey) => void
+ barSrc: string
+}) {
+ return (
+
+
+ {clothesOptions.map((opt) => {
+ const active = selectedClothes === opt.key
+ return (
+
+ )
+ })}
+
+
+ )
+}
+
+function BackgroundPanel({
+ selectedBackground,
+ onSelectBackground,
+ barSrc,
+}: {
+ selectedBackground: BackgroundKey
+ onSelectBackground: (key: BackgroundKey) => void
+ barSrc: string
+}) {
+ return (
+
+
+ {backgroundOptions.map((opt) => {
+ const active = selectedBackground === opt.key
+ return (
+
+ )
+ })}
+
+
+ )
+}
+
+function MaterialPanel({
+ selectedMaterials,
+ allowedMaterialKeys,
+ onToggleMaterial,
+ barSrc,
+}: {
+ selectedMaterials: Set
+ allowedMaterialKeys: MaterialKey[]
+ onToggleMaterial: (key: MaterialKey) => void
+ barSrc: string
+}) {
+ return (
+
+
+ {materialOptions.map((opt) => {
+ const isAllowed = allowedMaterialKeys.includes(opt.key)
+ const active = isAllowed && selectedMaterials.has(opt.key)
+
+ // 顶栏素材展示“贴纸”版本:真实资源在
+ // `.../素材库/贴纸/` 下,且文件名通常为 `贴纸${原素材名}`,
+ // 少数 key 还需要特殊映射(如“水”实际是“矿泉水瓶”)。
+ const parts = opt.assetSrc.split('/')
+ const rawName = parts[parts.length - 1] ?? opt.key
+ const baseDir = parts.slice(0, -1).join('/')
+ const stickerBaseDir = `${baseDir}/贴纸`
+ const stickerNameMap: Partial> = {
+ 水: '矿泉水瓶',
+ 安全帽: '帽子',
+ 遥控器: '遥控',
+ }
+ const stickerName = `贴纸${stickerNameMap[opt.key] ?? rawName}`
+ const newAssetSrc = `${stickerBaseDir}/${stickerName}`
+ return (
+
+ )
+ })}
+
+
+ )
+}
+
+export function TopMenuPanel(props: TopMenuPanelProps) {
+ const { activeBlock } = props
+ const barSrc = getTopBarSrc(activeBlock)
+
+ if (activeBlock === '衣柜') {
+ return (
+
+ )
+ }
+
+ if (activeBlock === '替换布景') {
+ return (
+
+ )
+ }
+
+ if (activeBlock === '素材库') {
+ return (
+
+ )
+ }
+
+ return null
+}
diff --git a/src/pages/zhangleping/draw/draw1.config.ts b/src/pages/zhangleping/draw/draw1.config.ts
new file mode 100644
index 0000000..5c8d008
--- /dev/null
+++ b/src/pages/zhangleping/draw/draw1.config.ts
@@ -0,0 +1,237 @@
+import { toPng } from 'html-to-image'
+
+export const STAR_NAME = 'zhangleping'
+
+// ─── 类型定义 ───────────────────────────────────────────────
+
+export type SidebarBlockKey = '衣柜' | '替换布景' | '素材库' | '任务提示'
+export type ClothesKey = '默认' | '校服' | '休闲'
+
+export type BackgroundKey =
+ | '城际高铁'
+ | '云端航迹'
+ | '野外旅途'
+ | '助农直播'
+ | '智能实训'
+ | '天际航拍'
+ | '匠心赛场'
+ | '青春展台'
+ | '绿色出行'
+ | '暖心社区'
+ | '绿意街角'
+ | '垃圾分类'
+
+export type MaterialKey = string
+
+export interface MaterialOption {
+ key: MaterialKey
+ assetSrc: string
+ position: { top?: string; left?: string; bottom?: string; right?: string }
+}
+
+export interface SidebarBlock {
+ key: SidebarBlockKey
+ barSrc?: string
+ iconSrc: string
+}
+
+// ─── 素材冲突映射(右手冲突:手机/矿泉水瓶/飞机票共用同一只手) ────
+
+export const materialConflicts: Partial> = {
+ 手机: ['水', '飞机票'],
+ 水: ['手机'],
+ 飞机票: ['手机', '水'],
+ 宣传单: ['手机'],
+}
+
+// ─── 布景 → 可用素材映射 ────────────────────────────────────
+
+export const backgroundToAllowedMaterials: Partial> = {
+ 城际高铁: ['火车票', '背包', '耳机'],
+ 云端航迹: ['飞机票', '行李箱', '手机'],
+ 绿意街角: ['垃圾', '志愿者'],
+ 野外旅途: ['手机', '背包', 'c'],
+ 助农直播: ['苹果', '话筒'],
+ 智能实训: ['安全帽'],
+ 天际航拍: ['遥控器', '无人机', '耳机'],
+ 匠心赛场: ['奖杯', '奖状'],
+ 青春展台: ['水', '宣传单'],
+ 绿色出行: ['车', '背包', '耳机'],
+ 暖心社区: ['志愿者', '水'],
+}
+
+// ─── 选项配置 ───────────────────────────────────────────────
+
+export const clothesOptions: { key: ClothesKey }[] = [
+ { key: '默认' },
+ { key: '校服' },
+ { key: '休闲' },
+]
+
+export const backgroundOptions: { key: BackgroundKey }[] = [
+ { key: '城际高铁' },
+ { key: '云端航迹' },
+ { key: '绿意街角' },
+ { key: '野外旅途' },
+ { key: '助农直播' },
+ { key: '智能实训' },
+ { key: '天际航拍' },
+ { key: '匠心赛场' },
+ { key: '青春展台' },
+ { key: '绿色出行' },
+ { key: '暖心社区' },
+]
+
+export const sidebarBlocks: SidebarBlock[] = [
+ {
+ key: '衣柜',
+ barSrc: `${STAR_NAME}/reflection/draw/衣柜条`,
+ iconSrc: `${STAR_NAME}/reflection/draw/衣柜`,
+ },
+ {
+ key: '替换布景',
+ barSrc: `${STAR_NAME}/reflection/draw/布景条`,
+ iconSrc: `${STAR_NAME}/reflection/draw/替换布景`,
+ },
+ {
+ key: '素材库',
+ barSrc: `${STAR_NAME}/reflection/draw/素材库条`,
+ iconSrc: `${STAR_NAME}/reflection/draw/素材库`,
+ },
+ {
+ key: '任务提示',
+ barSrc: `${STAR_NAME}/reflection/draw/素材库条`,
+ iconSrc: `${STAR_NAME}/reflection/draw/任务提示`,
+ },
+]
+
+export const materialOptions: MaterialOption[] = [
+ {
+ key: '火车票',
+ assetSrc: `${STAR_NAME}/reflection/draw/素材库/火车票`,
+ position: { top: '56%', left: '42%' },
+ },
+ {
+ key: '背包',
+ assetSrc: `${STAR_NAME}/reflection/draw/素材库/背包`,
+ position: { top: '66%', left: '25%' },
+ },
+ {
+ key: '耳机',
+ assetSrc: `${STAR_NAME}/reflection/draw/素材库/耳机`,
+ position: { top: '49%', left: '28%' },
+ },
+ {
+ key: '飞机票',
+ assetSrc: `${STAR_NAME}/reflection/draw/素材库/飞机票`,
+ position: { top: '55.5%', left: '41.5%' },
+ },
+ {
+ key: '行李箱',
+ assetSrc: `${STAR_NAME}/reflection/draw/素材库/行李箱`,
+ position: { bottom: '0%', left: '18%' },
+ },
+ {
+ key: '手机',
+ assetSrc: `${STAR_NAME}/reflection/draw/素材库/手机`,
+ position: { top: '56%', left: '42.5%' },
+ },
+ {
+ key: '垃圾',
+ assetSrc: `${STAR_NAME}/reflection/draw/素材库/垃圾`,
+ position: { bottom: '-1%', left: '27.5%' },
+ },
+ {
+ key: '志愿者',
+ assetSrc: `${STAR_NAME}/reflection/draw/素材库/志愿者`,
+ position: { bottom: '18%', left: '27.5%' },
+ },
+ {
+ key: '水',
+ assetSrc: `${STAR_NAME}/reflection/draw/素材库/水`,
+ position: { top: '54%', left: '42.3%' },
+ },
+ {
+ key: '宣传单',
+ assetSrc: `${STAR_NAME}/reflection/draw/素材库/宣传单`,
+ position: { top: '87.6%', left: '28.3%' },
+ },
+ {
+ key: '苹果',
+ assetSrc: `${STAR_NAME}/reflection/draw/素材库/苹果`,
+ position: { top: '55%', left: '42%' },
+ },
+ {
+ key: '话筒',
+ assetSrc: `${STAR_NAME}/reflection/draw/素材库/话筒`,
+ position: { bottom: '6.5%', left: '27%' },
+ },
+ {
+ key: '安全帽',
+ assetSrc: `${STAR_NAME}/reflection/draw/素材库/安全帽`,
+ position: { top: '45%', left: '28%' },
+ },
+ {
+ key: '遥控器',
+ assetSrc: `${STAR_NAME}/reflection/draw/素材库/遥控器`,
+ position: { top: '51%', left: '42%' },
+ },
+ {
+ key: '无人机',
+ assetSrc: `${STAR_NAME}/reflection/draw/素材库/无人机`,
+ position: { top: '8%', right: '12%' },
+ },
+ {
+ key: '奖杯',
+ assetSrc: `${STAR_NAME}/reflection/draw/素材库/奖杯`,
+ position: { top: '48%', left: '41%' },
+ },
+ {
+ key: '奖状',
+ assetSrc: `${STAR_NAME}/reflection/draw/素材库/奖状`,
+ position: { bottom: '7.5%', left: '24.7%' },
+ },
+ {
+ key: '车',
+ assetSrc: `${STAR_NAME}/reflection/draw/素材库/车`,
+ position: { bottom: '13%', left: '14%' },
+ },
+]
+
+// ─── 资源路径工具 ─────────────────────────────────────────────
+
+export function getBackgroundAssetKey(bg: BackgroundKey): BackgroundKey {
+ return bg === '绿意街角' ? '垃圾分类' : bg
+}
+
+export function getTopBarSrc(block: SidebarBlockKey): string {
+ if (block === '衣柜') return `${STAR_NAME}/reflection/draw/衣柜条`
+ if (block === '替换布景') return `${STAR_NAME}/reflection/draw/布景条`
+ return `${STAR_NAME}/reflection/draw/素材库条`
+}
+
+// ─── DOM 截图生成 ─────────────────────────────────────────────
+
+export async function buildCompositeDataUrl(compositionEl: HTMLElement): Promise {
+ // 临时移除定位偏移,避免截图出现留白/偏移
+ const { position, left, right, top, bottom } = compositionEl.style
+ Object.assign(compositionEl.style, {
+ position: 'fixed',
+ left: '0',
+ right: 'auto',
+ top: '0',
+ bottom: 'auto',
+ })
+
+ try {
+ return await toPng(compositionEl, {
+ cacheBust: false,
+ skipFonts: true,
+ width: compositionEl.offsetWidth,
+ height: compositionEl.offsetHeight,
+ })
+ } finally {
+ // 恢复原始样式
+ Object.assign(compositionEl.style, { position, left, right, top, bottom })
+ }
+}
diff --git a/src/pages/zhangleping/draw/draw1.tsx b/src/pages/zhangleping/draw/draw1.tsx
new file mode 100644
index 0000000..08b3da1
--- /dev/null
+++ b/src/pages/zhangleping/draw/draw1.tsx
@@ -0,0 +1,233 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { Image } from '@/components'
+import { cn, getBackgroundImage, getStaticImage } from '@/lib/utils'
+import { useNavigate, useParams } from 'react-router-dom'
+import { saveZhanglepingStore, useZhanglepingStore } from '@/store/zhanglepingStore'
+import {
+ type BackgroundKey,
+ type ClothesKey,
+ type MaterialKey,
+ type SidebarBlockKey,
+ STAR_NAME,
+ backgroundToAllowedMaterials,
+ buildCompositeDataUrl,
+ getBackgroundAssetKey,
+ materialConflicts,
+ materialOptions,
+ sidebarBlocks,
+} from './draw1.config'
+import { TopMenuPanel } from './TopMenuPanel'
+
+const ZhanglepingDraw1 = () => {
+ const navigate = useNavigate()
+ const { moduleName } = useParams<{ moduleName: string }>()
+ const { setDrawArtwork } = useZhanglepingStore()
+
+ const [activeBlock, setActiveBlock] = useState('衣柜')
+ const [selectedClothes, setSelectedClothes] = useState('默认')
+ const [selectedBackground, setSelectedBackground] = useState('城际高铁')
+ const [selectedMaterials, setSelectedMaterials] = useState>(new Set())
+ const [showCaptionInput, setShowCaptionInput] = useState(false)
+ const [captionText, setCaptionText] = useState('')
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ const captionInputRef = useRef(null)
+ const compositionRef = useRef(null)
+
+ const allowedMaterialKeys = useMemo(
+ () => backgroundToAllowedMaterials[selectedBackground] ?? [],
+ [selectedBackground]
+ )
+ const backgroundAssetKey = getBackgroundAssetKey(selectedBackground)
+
+ const scenePreviewSrc = `${STAR_NAME}/reflection/draw/布景/${backgroundAssetKey}`
+ const clothingSrc = `${STAR_NAME}/reflection/draw/衣柜/${selectedClothes}`
+ const boardBgSrc = `${STAR_NAME}/reflection/draw/画板底图`
+
+ // 切换布景后:移除不允许的素材
+ useEffect(() => {
+ setSelectedMaterials((prev) => {
+ const next = new Set([...prev].filter((k) => allowedMaterialKeys.includes(k)))
+ return next.size === prev.size ? prev : next
+ })
+ }, [allowedMaterialKeys])
+
+ useEffect(() => {
+ if (showCaptionInput) captionInputRef.current?.focus()
+ }, [showCaptionInput])
+
+ const handleToggleMaterial = (key: MaterialKey) => {
+ setSelectedMaterials((prev) => {
+ const next = new Set(prev)
+ if (next.has(key)) {
+ next.delete(key)
+ return next
+ }
+ const conflicts = materialConflicts[key] ?? []
+ for (const c of conflicts) next.delete(c)
+ if (next.size < 3) next.add(key)
+ return next
+ })
+ }
+
+ const handleSubmitArtwork = async () => {
+ if (!showCaptionInput) {
+ setShowCaptionInput(true)
+ return
+ }
+ if (isSubmitting) return
+
+ setIsSubmitting(true)
+ try {
+ const dataUrl = await buildCompositeDataUrl(compositionRef.current!)
+ setDrawArtwork(dataUrl, captionText.trim())
+ saveZhanglepingStore()
+ navigate(`/${moduleName}/${STAR_NAME}/reflection5`)
+ } catch (error) {
+ console.error('Failed to submit draw artwork', error)
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ const isPreviewVisible = activeBlock !== '任务提示'
+
+ if (!moduleName) return null
+
+ return (
+
+ {/* 中央预览区 */}
+
+
+ {isPreviewVisible && (
+
+
+
+ )}
+
+ {isPreviewVisible &&
+ materialOptions
+ .filter((opt) => selectedMaterials.has(opt.key))
+ .map((opt) => (
+
+
+
+ ))}
+
+
+
+ {/* 顶部菜单 */}
+
+
+
+
+ {/* 任务提示窗口 */}
+ {activeBlock === '任务提示' && (
+
+
+
+
+
①为三毛换装
+
②搭配1个新时代场景
+
③融入1-3个新时代元素
+
④配1句简短正能量文字
+
+
+
+
+ )}
+
+ {/* 右侧侧边栏 */}
+
+
+ {sidebarBlocks.map((blk) => (
+
+ ))}
+
+
+
+ {/* 底部输入框 */}
+ {showCaptionInput && (
+
+
+ setCaptionText(Array.from(e.target.value).slice(0, 15).join(''))}
+ className={cn(
+ 'w-200 h-24 bg-transparent',
+ 'text-4xl! text-[#8B6914] leading-none placeholder:text-3xl!'
+ )}
+ autoComplete="off"
+ placeholder="提交之前,请给你创作的我配一句简短的正能量文字吧!"
+ />
+
+
+ )}
+
+ {/* 底部按钮 */}
+
+ navigate(`/${moduleName}/${STAR_NAME}/draw`)}
+ />
+
+
+
+
+
+ )
+}
+
+export default ZhanglepingDraw1
diff --git a/src/pages/zhangleping/draw/index.tsx b/src/pages/zhangleping/draw/index.tsx
new file mode 100644
index 0000000..f5dbcfa
--- /dev/null
+++ b/src/pages/zhangleping/draw/index.tsx
@@ -0,0 +1,61 @@
+import { useNavigate, useParams } from 'react-router-dom'
+import { Image } from '@/components'
+import { getBackgroundImage, getStaticImage } from '@/lib/utils'
+
+const starName = 'zhangleping'
+
+const ZhanglepingDraw = () => {
+ const navigate = useNavigate()
+ const { moduleName } = useParams<{ moduleName: string }>()
+
+ if (!moduleName) return null
+
+ return (
+
+ {/* 任务提示(右侧纸片图标) */}
+
})
+
+ {/* 创作任务面板(含标题和列表) */}
+
+
+
+
①为三毛换装
+
②搭配1个新时代场景
+
③融入1-3个新时代元素
+
④配1句简短正能量文字
+
+
+
+
+ {/* 底部按钮:返回 / 开始创作 */}
+
+ navigate(`/${moduleName}/${starName}/reflection5`)}
+ />
+ navigate(`/${moduleName}/${starName}/draw1`)}
+ />
+
+
+ )
+}
+
+export default ZhanglepingDraw
diff --git a/src/pages/zhangleping/home.tsx b/src/pages/zhangleping/home.tsx
index 1501d8e..8a2bfe5 100644
--- a/src/pages/zhangleping/home.tsx
+++ b/src/pages/zhangleping/home.tsx
@@ -35,8 +35,16 @@ const ZhanglepingHome = () => {
onClick={() => navigate(`/${moduleName}/${starName}/place1`)}
/>
{/* 体悟 / 内化 预留 */}
-
-
+ navigate(`/${moduleName}/${starName}/reflection`)}
+ />
+ navigate(`/${moduleName}/${starName}/internalization`)}
+ />
)
diff --git a/src/pages/zhangleping/internalization.tsx b/src/pages/zhangleping/internalization.tsx
new file mode 100644
index 0000000..55f7387
--- /dev/null
+++ b/src/pages/zhangleping/internalization.tsx
@@ -0,0 +1,485 @@
+import { useEffect, useRef, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import {
+ Image,
+ Video,
+ type VideoAnchor,
+ type VideoRef,
+ LetterToZhangleping,
+ Narration,
+ EndingOverlay,
+} from '@/components'
+import { SuccessAlert } from '@/components/layout'
+import { MultipleChoiceQuizPanel, SingleChoiceQuizPanel } from '@/components/layout/quiz'
+import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
+import { GuideSteps, GuideStepsItem } from '@/components'
+
+const starName = 'zhangleping'
+
+const ZhanglepingInternalization = () => {
+ const navigate = useNavigate()
+ const { moduleName } = useParams<{ moduleName: string }>()
+
+ const [narrationVisible, setNarrationVisible] = useState(true)
+ const [visible, setVisible] = useState(false) // 对话引导面板是否可见
+ const [showVideo, setShowVideo] = useState(false) // 内化主视频是否展示
+ const [showLetter, setShowLetter] = useState(false)
+ const [showStudyGuide, setShowStudyGuide] = useState(false)
+ const [postGuideVisible, setPostGuideVisible] = useState(false)
+
+ const videoRef = useRef(null)
+
+ const [showQuiz, setShowQuiz] = useState(false)
+ const [quizSuccessVisible, setQuizSuccessVisible] = useState(false)
+
+ // 视频锚点题目状态
+ const [activeSuccess, setActiveSuccess] = useState(null)
+ const [successContent, setSuccessContent] = useState('')
+ const [resumeCallback, setResumeCallback] = useState<(() => void) | null>(null)
+
+ // 旁白关闭后显示题目(答对后再显示对话引导)
+ useEffect(() => {
+ if (!narrationVisible) {
+ setShowQuiz(true)
+ }
+ }, [narrationVisible])
+
+ const handleVideoCanPlay = () => {
+ if (showVideo && videoRef.current) {
+ videoRef.current.play().catch(() => {})
+ }
+ }
+
+ type QuizOption = {
+ value: string
+ label: string
+ isCorrect?: boolean
+ explanation?: string
+ }
+
+ type QuizAnchor = {
+ id: string
+ time: number
+ type: 'single' | 'multiple'
+ title: string
+ options: QuizOption[]
+ correctAnswer: string | string[]
+ }
+
+ // 视频内题目锚点(时间单位:秒)
+ const QUIZ_ANCHORS: QuizAnchor[] = [
+ {
+ id: 'quiz-1',
+ time: 60 * 1 + 49, // 1:49
+ type: 'multiple',
+ title: '【多选】张乐平的漫画能够比直白的道理说教更吸引人、更易传递美好品质的核心原因是:',
+ options: [
+ {
+ value: 'A',
+ label: 'A. 绘画技巧高超,色彩搭配丰富',
+ explanation:
+ '张乐平的漫画魅力不在于复杂的技巧和色彩,而在于内容的真实性哦!请你再想想吧!',
+ },
+ {
+ value: 'B',
+ label: 'B. 源于生活实际,以贴近现实的“小切片”传递情感与品质',
+ explanation:
+ '艺术源于生活,张乐平的漫画捕捉生活中的真实场景与人物,没有口号式宣传,做到了潜移默化、寓教于乐。',
+ },
+ {
+ value: 'C',
+ label: 'C. 故事情节离奇,充满奇幻色彩',
+ explanation: '张乐平的漫画聚焦普通民众的生活与苦难,并非离奇的奇幻故事。请你再想想吧!',
+ },
+ ],
+ correctAnswer: ['A', 'B', 'C'],
+ },
+ {
+ id: 'quiz-2',
+ time: 60 * 3 + 22, // 3:22
+ type: 'multiple',
+ title: '【多选】张乐平“化繁为简、以简为上”的艺术风格主要体现在以下哪些方面?',
+ options: [
+ {
+ value: 'A',
+ label: 'A. 抓住人物表情、动作等细节刻画人物,不事无巨细描摹服装',
+ },
+ {
+ value: 'B',
+ label: 'B. 场景刻画化繁为简,聚焦核心道具与场景,寥寥数笔呈现完整画面',
+ },
+ {
+ value: 'C',
+ label: 'C. 刻意简化故事逻辑,降低读者理解难度',
+ explanation:
+ '张乐平的漫画是“形式简”而非“逻辑简”,核心是通过简洁形式传递深刻内涵。请你再想想吧!',
+ },
+ ],
+ correctAnswer: ['A', 'B'],
+ },
+ {
+ id: 'quiz-3',
+ time: 60 * 5 + 1, // 5:01
+ type: 'single',
+ title:
+ '【判断】张乐平创作的“无字漫画”完全依靠人物的动态表情和场景刻画完成叙事,没有任何说明性文字和对话框。',
+ options: [
+ {
+ value: 'A',
+ label: 'A. 正确',
+ explanation: '无字漫画的特点是无说明性文字、无对话框,仅靠画面完成叙事。',
+ },
+ {
+ value: 'B',
+ label: 'B. 错误',
+ explanation:
+ '请回顾汪教授对“无字漫画”的解读,张乐平从1935年塑造三毛起就坚持这种创作形式哦!请你再想想吧!',
+ },
+ ],
+ correctAnswer: 'A',
+ },
+ {
+ id: 'quiz-4',
+ time: 60 * 5 + 1, // 5:01,同一时间出现(对应图3、4)
+ type: 'multiple',
+ title: '【多选】以下哪些属于张乐平先生的创作理念与艺术追求?',
+ options: [
+ {
+ value: 'A',
+ label: 'A. 艺术源于生活,注重观察日常细节',
+ },
+ {
+ value: 'B',
+ label: 'B. 化繁为简,追求“以少胜多”的艺术境界',
+ },
+ {
+ value: 'C',
+ label: 'C. 用作品传递善良、正义与真善美',
+ },
+ {
+ value: 'D',
+ label: 'D. 仅专注于个人艺术表达,不关心社会现实',
+ explanation:
+ '张乐平始终以画笔担当社会责任,关注社会现实与民众疾苦,并非只专注个人表达。请你再想想吧!',
+ },
+ ],
+ correctAnswer: ['A', 'B', 'C'],
+ },
+ ]
+
+ const anchors: VideoAnchor[] = QUIZ_ANCHORS.map((item) => {
+ if (item.type === 'single') {
+ const correct = item.correctAnswer as string
+
+ return {
+ id: item.id,
+ time: item.time,
+ once: true,
+ afterResume: 'remove',
+ render: ({ resume }) => (
+ <>
+ ({
+ value: opt.value,
+ label: opt.label,
+ }))}
+ correctAnswer={correct}
+ onWrongSelect={(value: string) => {
+ const opt = item.options.find((o) => o.value === value)
+ return opt?.explanation || '再思考一下,选择正确答案。'
+ }}
+ onSubmit={(value: string, isCorrect: boolean) => {
+ if (isCorrect) {
+ const opt = item.options.find((o) => o.value === value)
+ setActiveSuccess(item.id)
+ setSuccessContent(opt?.explanation || '恭喜你,选择正确!')
+ setResumeCallback(() => resume)
+ }
+ }}
+ />
+ {
+ setActiveSuccess(null)
+ setSuccessContent('')
+ const cb = resumeCallback
+ setResumeCallback(null)
+ cb?.()
+ }}
+ />
+ >
+ ),
+ }
+ }
+
+ // 多选题
+ const correctAnswers = item.correctAnswer as string[]
+
+ return {
+ id: item.id,
+ time: item.time,
+ once: true,
+ afterResume: 'remove',
+ render: ({ resume }) => (
+ <>
+ ({
+ value: opt.value,
+ label: opt.label,
+ }))}
+ correctAnswer={correctAnswers}
+ errorMessage={(selectedValues: string[]) => {
+ const picked = new Set(selectedValues)
+
+ if (item.id === 'quiz-1') {
+ if (picked.has('A')) {
+ const optA = item.options.find((o) => o.value === 'A')
+ return (
+ optA?.explanation ||
+ '张乐平的漫画魅力不在于复杂的技巧和色彩,而在于内容的真实性哦!请你再想想吧!'
+ )
+ }
+ if (picked.has('C')) {
+ const optC = item.options.find((o) => o.value === 'C')
+ return (
+ optC?.explanation ||
+ '张乐平的漫画聚焦普通民众的生活与苦难,并非离奇的奇幻故事。请你再想想吧!'
+ )
+ }
+ return '你的选择还不够全面哦~请你再想想吧!'
+ }
+
+ if (item.id === 'quiz-2') {
+ if (picked.has('C')) {
+ const optC = item.options.find((o) => o.value === 'C')
+ return (
+ optC?.explanation ||
+ '张乐平的漫画是“形式简”而非“逻辑简”,核心是通过简洁形式传递深刻内涵。请你再想想吧!'
+ )
+ }
+ return '你的选择还不够全面哦~请你再想想吧!'
+ }
+
+ if (item.id === 'quiz-4') {
+ if (picked.has('D')) {
+ const optD = item.options.find((o) => o.value === 'D')
+ return (
+ optD?.explanation ||
+ '张乐平始终以画笔担当社会责任,关注社会现实与民众疾苦,并非只专注个人表达。请你再想想吧!'
+ )
+ }
+ return '你的选择还不够全面哦~请你再想想吧!'
+ }
+
+ return '你的选择还不够全面哦~请你再想想吧!'
+ }}
+ onSubmit={(values: string[], isCorrect: boolean) => {
+ if (isCorrect) {
+ setActiveSuccess(item.id)
+ if (item.id === 'quiz-1') {
+ setSuccessContent(
+ '艺术源于生活,张乐平的漫画捕捉生活中的真实场景与人物,没有口号式宣传,做到了潜移默化、寓教于乐。'
+ )
+ } else if (item.id === 'quiz-2') {
+ setSuccessContent(
+ '先生善于捕捉核心细节展现人物特质,化繁为简的场景刻画让漫画更具感染力,这正是“以少胜多”艺术境界的体现。'
+ )
+ } else if (item.id === 'quiz-4') {
+ setSuccessContent(
+ '张乐平先生坚持艺术扎根生活,以简洁笔墨刻画时代,用作品传递温暖与正义,这些都是他的核心创作理念。'
+ )
+ } else {
+ setSuccessContent('')
+ }
+ setResumeCallback(() => resume)
+ }
+ }}
+ />
+ {
+ setActiveSuccess(null)
+ setSuccessContent('')
+ const cb = resumeCallback
+ setResumeCallback(null)
+ cb?.()
+ }}
+ />
+ >
+ ),
+ }
+ })
+
+ if (!moduleName) return null
+
+ return (
+
+ {/* 离开按钮 */}
+ navigate(`/${moduleName}/${starName}/home`)}
+ />
+
+ {/* 过度语 */}
+ {
+ setNarrationVisible(false)
+ }}
+ text="从弄堂里的三毛记忆到画室中的笔墨传承,张乐平先生以一支画笔为刃,划破黑暗现实的迷雾;以一颗赤诚之心,守护万千儿童的童真。他笔下的三毛,从流浪儿到新中国少年,是一个民族的成长缩影;他坚持的艺术,源于生活、归于真诚,是跨越时空的精神纽带。今天,我们站在张乐平故居的画桌前,不仅要回望先生以笔为业、以爱为怀的一生,更要让这份艺术精神内化于心、外化于行,成为新时代中职学子践行初心、传递美好的力量。"
+ />
+
+ {/* 题目弹窗(旁白结束后出现) */}
+ {
+ if (isCorrect) {
+ setQuizSuccessVisible(true)
+ }
+ }}
+ />
+ {
+ setQuizSuccessVisible(false)
+ setShowQuiz(false)
+ setVisible(true)
+ }}
+ />
+
+ {/* 视频前引导 */}
+ {
+ setVisible(v)
+ if (!v) {
+ setShowVideo(true)
+ handleVideoCanPlay()
+ }
+ }}
+ showMask={false}
+ >
+
+ 小沪,刚刚完成“三毛寻忆”的时空任务太有收获了!从童年学艺的老画桌到新中国的画架,我跟着三毛的记忆碎片,真切感受到张乐平先生用画笔记录时代、守护童心的坚守。
+
+
+ 尤其是先生笔下三毛的形象从流浪儿到少先队员的转变,还有不同年代里“画笔如刀枪、如镜子、如园丁”的力量,都让我特别触动。但我还想从美术这一艺术创作角度出发更多了解张乐平先生,你能给我讲讲吗?
+
+
+ 你的感悟特别深刻!关于你想了解的美术创作,今天我们有幸邀请到了上海大学美术学院设计系副主任、副教授汪宁,汪教授对张乐平漫画艺术开展过专门研究,对先生的创作理念与时代价值有着深入的理解。
+
+
+ 接下来,就让我们跟随汪教授一起,探寻张乐平艺术作品和艺术精神的当代意义吧!
+
+
+
+ {/* 内化主视频 */}
+ {showVideo && (
+ {
+ setShowVideo(false)
+ setPostGuideVisible(true)
+ }}
+ onCanPlay={handleVideoCanPlay}
+ onEnded={() => {
+ setShowVideo(false)
+ // 学习视频结束后,先展示对话,再进入写信
+ setPostGuideVisible(true)
+ }}
+ />
+ )}
+
+ {/* 学习视频结束后的对话引导 */}
+ {
+ setPostGuideVisible(v)
+ if (!v) {
+ setShowLetter(true)
+ }
+ }}
+ showMask={false}
+ zIndex={60}
+ >
+
+ 张乐平先生的精神,不是挂在墙上的口号,而是“源于生活”的真诚、“化繁为简”的智慧、“向善向美”的坚守。这份精神,藏在三毛的三根头发里,藏在先生的笔墨春秋中,更藏在我们每一个人的日常行动里。
+
+
+ 现在,就让我们站在这座承载着温暖与力量的故居中,把对先生的崇敬、对精神的体悟,浓缩进你想对先生说的话中吧!
+
+
+
+ {/* 写信组件 */}
+ {
+ setShowLetter(false)
+ }}
+ onSubmit={() => {}}
+ onAnimationEnd={() => {
+ setShowLetter(false)
+ setShowStudyGuide(true)
+ }}
+ showVideo={true}
+ />
+
+ {/* 内化结束 */}
+ {
+ setShowStudyGuide(false)
+ navigate(`/${moduleName}/${starName}/home`)
+ }}
+ />
+
+ )
+}
+
+export default ZhanglepingInternalization
diff --git a/src/pages/zhangleping/place/place1.tsx b/src/pages/zhangleping/place/place1.tsx
index 1fa35e0..0c61ac1 100644
--- a/src/pages/zhangleping/place/place1.tsx
+++ b/src/pages/zhangleping/place/place1.tsx
@@ -7,10 +7,11 @@ import {
StudyGuideModal,
} from '@/components'
import { useNavigate, useParams } from 'react-router-dom'
-import { getBackgroundImage, getStaticImage } from '@/lib/utils'
-import { useState } from 'react'
+import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
+import { useMemo, useState } from 'react'
import Cursor from '@/components/cursor'
import { useCursorController } from '@/hooks/useCursorController'
+import { saveZhanglepingStore, useZhanglepingStore } from '@/store/zhanglepingStore'
// starName 固定为 zhangleping
const starName = 'zhangleping'
@@ -28,10 +29,16 @@ const ZhanglepingPlace1 = () => {
moduleName: string
}>()
+ // 接入持久化:place1 展品访问与断点
+ const markVisited = useZhanglepingStore((s) => s.markExhibitVisited)
+ const getVisited = useZhanglepingStore((s) => s.getVisitedExhibits)
+ const setLastVisitedPlace = useZhanglepingStore((s) => s.setLastVisitedPlace)
+ const persistedVisited = useMemo(() => new Set(getVisited('place1') as (1 | 2)[]), [getVisited])
+
// 当前激活的展品 ID
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null)
// 已访问的展品(本页内状态;如需持久化可接入 store)
- const [visited, setVisited] = useState>(() => new Set())
+ const [visited, setVisited] = useState>(() => new Set(persistedVisited))
// 展品1的对话引导状态
const [guideVisible1, setGuideVisible1] = useState(false)
@@ -61,9 +68,7 @@ const ZhanglepingPlace1 = () => {
const total = 2
const progressCount = visited.size
const completed = progressCount >= total
- const subtitle = completed
- ? '搜索该阶段相关资料(已完成)'
- : `搜索该阶段相关资料(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const handleOpenExhibit = (id: 1 | 2) => {
setActiveExhibitId(id)
@@ -125,6 +130,8 @@ const ZhanglepingPlace1 = () => {
src={getStaticImage('components/place/继续前进')}
button
onClick={() => {
+ setLastVisitedPlace('place2')
+ saveZhanglepingStore()
navigate(`/${moduleName}/${starName}/place2`)
}}
/>
@@ -140,10 +147,16 @@ const ZhanglepingPlace1 = () => {
highlightSelectors={[]}
zIndex={10001}
>
-
+
小沪,张乐平先生画的这幅《童年的回忆》漫画真灵动!这是张先生回忆自己在浙江老家的生活场景吗?
-
+
是的。张乐平先生1910年生于浙江农村,母亲是他绘画的启蒙人。你看这里的绣品,就源自张先生的母亲。
@@ -158,7 +171,10 @@ const ZhanglepingPlace1 = () => {
highlightSelectors={[]}
zIndex={10001}
>
-
+
她能字画、工女红,尤擅剪纸、刺绣。张先生自幼耳濡目染,受到启蒙,在小学校师陆寅生指导下,表现出不凡的绘画才能。因为有绘画的陪伴,张先生的童年还是过得非常愉快的,正如你看到的《童年的回忆》这幅画。
@@ -178,6 +194,7 @@ const ZhanglepingPlace1 = () => {
setVisited((prev) => {
const next = new Set(prev)
next.add(activeExhibitId)
+ markVisited('place1', activeExhibitId)
return next
})
}
@@ -188,10 +205,25 @@ const ZhanglepingPlace1 = () => {
{/* 学习引导词 */}
{
setShowGuideModal(false)
setShowAfterDialogGuide(true)
}}
+ ailgn="left"
+ >
+ 1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
+ {'\n'}
+ 2.在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点开查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习。
+ {'\n'}
+ 3.所有动态光点的展品全部学习后才能进入下一个场景学习。
+
+
+ {/* 对话结束后的学习引导(内容沿用原有说明) */}
+ setShowAfterDialogGuide(false)}
>
同学你好,欢迎来到“走进故居·对话”学习模块!
{'\n'}
@@ -199,19 +231,6 @@ const ZhanglepingPlace1 = () => {
{'\n'}
准备好共赴这场画笔与心灵的温柔邂逅了吗?
-
- {/* 对话结束后的学习引导(内容沿用原有说明) */}
- setShowAfterDialogGuide(false)}
- ailgn="left"
- >
- 1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
- {'\n'}
- 2.在学习过程中会掉落拼图碎片,拼图碎片会自动收入拼图盒中,拼图盒可以点击查看当前拼图收集情况,点击单个拼图可以回到获得拼图的场景再次学习。
- {'\n'}
- 3.所有动态光点的展品全部学习后才能进入下一个场景学习。
-
)
}
diff --git a/src/pages/zhangleping/place/place2.tsx b/src/pages/zhangleping/place/place2.tsx
index 2338573..e0a972f 100644
--- a/src/pages/zhangleping/place/place2.tsx
+++ b/src/pages/zhangleping/place/place2.tsx
@@ -10,11 +10,12 @@ import {
} from '@/components'
import { useNavigate, useParams } from 'react-router-dom'
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
-import { useRef, useState } from 'react'
+import { useMemo, useRef, useState } from 'react'
import Cursor from '@/components/cursor'
import { useCursorController } from '@/hooks/useCursorController'
import SuccessAlert from '@/components/layout/alert/success-alert'
import ScaledPortal from '@/components/layout/ScaledPortal'
+import { saveZhanglepingStore, useZhanglepingStore } from '@/store/zhanglepingStore'
// starName 固定为 zhangleping
const starName = 'zhangleping'
@@ -34,10 +35,22 @@ const ZhanglepingPlace2 = () => {
}>()
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(null)
- const [visited, setVisited] = useState
>(() => new Set())
+ // 持久化接入
+ const markVisited = useZhanglepingStore((s) => s.markExhibitVisited)
+ const getVisited = useZhanglepingStore((s) => s.getVisitedExhibits)
+ const setLastVisitedPlace = useZhanglepingStore((s) => s.setLastVisitedPlace)
+ const persistedVisited = useMemo(
+ () => new Set(getVisited('place2') as (1 | 2 | 3)[]),
+ [getVisited]
+ )
+ const [visited, setVisited] = useState>(() => new Set(persistedVisited))
- const [guideVisible2, setGuideVisible2] = useState(false)
- const [guideStep2, setGuideStep2] = useState(0)
+ const [guideVisible1, setGuideVisible1] = useState(false)
+ const [guideStep1, setGuideStep1] = useState(0)
+
+ // 展品2目前无引导对话(保留 setter 以便未来扩展)
+ const [_guideVisible2, _setGuideVisible2] = useState(false)
+ const [_guideStep2, _setGuideStep2] = useState(0)
const [guideVisible3, setGuideVisible3] = useState(false)
const [guideStep3, setGuideStep3] = useState(0)
@@ -51,6 +64,10 @@ const ZhanglepingPlace2 = () => {
const [showVideo1, setShowVideo1] = useState(false)
const video1Ref = useRef(null)
+ // 展品2:关闭后播放的视频
+ const [showVideo2, setShowVideo2] = useState(false)
+ const video2Ref = useRef(null)
+
// Cursor 顺序控制:先学习展品3,再依次解锁展品2、展品1
const { isEnabled } = useCursorController({
initialEnabled: [3],
@@ -66,17 +83,16 @@ const ZhanglepingPlace2 = () => {
const total = 3
const progressCount = visited.size
const completed = progressCount >= total
- const subtitle = completed
- ? '搜索该阶段相关资料(已完成)'
- : `搜索该阶段相关资料(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const handleOpenExhibit = (id: 1 | 2 | 3) => {
setActiveExhibitId(id)
if (id === 1) {
- // 展品1仅展示大图,无引导对话
+ // 展品1:打开弹窗时显示引导对话
+ setGuideVisible1(true)
+ setGuideStep1(0)
} else if (id === 2) {
- setGuideVisible2(true)
- setGuideStep2(0)
+ // 展品2:无引导对话
} else {
setGuideVisible3(true)
setGuideStep3(0)
@@ -142,30 +158,37 @@ const ZhanglepingPlace2 = () => {
src={getStaticImage('components/place/继续前进')}
button
onClick={() => {
+ setLastVisitedPlace('place3')
+ saveZhanglepingStore()
navigate(`/${moduleName}/${starName}/place3`)
}}
/>
)}
- {/* 展品1:仅图片,无对话,引导通过关闭后的视频讲解 */}
- {/* 展品2:代表作品与历史背景 */}
+ {/* 展品1:代表作品与历史背景(打开展品1弹窗时显示对话) */}
-
- 张先生还在街头做过画,这样的画作能直面民众,唤起国人的抗战热情,应该就是你说的画笔如“刀枪”吧!
+
+ 这幅发表在《抗战漫画》第12期上的《侵略者的坟墓》与刚才那幅《穷凶极恶的结果》一样,当时的国人看起来肯定特别提振精神!
+
+
+ 这就是张乐平先生画作在当时发挥的最重要的作用!他的幅幅作品犹如刺刀钢枪,揭露日军暴行,抨击汉奸敌伪的卖国嘴脸,直至抗战的最后胜利。那时他常说“漫画是流动的宣传队”。
- 不错。你看到的正是抗战时期的场景,也是张乐平先生艺术生涯中最具历史使命感和时代感的时期。1937年震惊中外的“七七事变”爆发,日本侵略行径激起了全中国人民的强烈愤慨。
-
-
- 淞沪会战当月,张先生以抗日救亡漫画宣传队副领队身份北上宣传抗日,后任队长,他带领宣传队足迹遍及沪、苏、皖、鄂、浙、湘、桂、赣、闽、粤等地,配合抗战形势创作了大量的抗日漫画。
+ 他的漫画还被译成多国文字,印成传单,空投敌占区,宣传正义,呼唤良知,这正是“笔铸刀枪”的力量!
@@ -185,10 +208,16 @@ const ZhanglepingPlace2 = () => {
highlightSelectors={[]}
zIndex={10001}
>
-
+
这幅“穷凶极恶的结果”的漫画太形象了,小沪,聂耳先生用音乐唤醒大家的爱国情怀,那张乐平先生的画笔在不同年代里,是不是也为大家带来了不一样的力量呀?
-
+
这是个很好的问题!张乐平先生的画笔在不同的历史时期发挥着不同作用,比如抗日战争时是“刀枪”,揭露日寇暴行;解放战争时是“镜子”,映照社会现实;新中国成立后是“园丁”,为万千儿童种下真善美的种子。
@@ -201,10 +230,23 @@ const ZhanglepingPlace2 = () => {
src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ''}
onClose={() => {
if (activeExhibitId !== null) {
- // 展品1:关闭后播放一段视频,视频结束再标记已访问
+ // 展品1:关闭弹窗时(对话可能还在进行),先不关闭弹窗,等待对话完全结束
if (activeExhibitId === 1) {
+ // 如果对话还在显示,先关闭对话
+ if (guideVisible1) {
+ setGuideVisible1(false)
+ } else {
+ // 对话已经结束,关闭弹窗并播放视频
+ setActiveExhibitId(null)
+ setShowVideo1(true)
+ }
+ return
+ }
+
+ // 展品2:关闭后播放一段视频,视频结束再标记已访问
+ if (activeExhibitId === 2) {
setActiveExhibitId(null)
- setShowVideo1(true)
+ setShowVideo2(true)
return
}
@@ -214,16 +256,17 @@ const ZhanglepingPlace2 = () => {
setVisited((prev) => {
const next = new Set(prev)
next.add(3)
+ markVisited('place2', 3)
return next
})
}
- } else {
- setVisited((prev) => {
- const next = new Set(prev)
- next.add(activeExhibitId)
- return next
- })
}
+ // setVisited((prev) => {
+ // const next = new Set(prev)
+ // next.add(activeExhibitId)
+ // markVisited('place2', activeExhibitId)
+ // return next
+ // })
}
setActiveExhibitId(null)
}}
@@ -233,7 +276,7 @@ const ZhanglepingPlace2 = () => {
{showVideo1 && (
{
setVisited((prev) => {
const next = new Set(prev)
next.add(1)
+ markVisited('place2', 1)
return next
})
}}
@@ -252,6 +296,30 @@ const ZhanglepingPlace2 = () => {
/>
)}
+ {/* 展品2关闭后的讲解视频 */}
+ {showVideo2 && (
+ {
+ setShowVideo2(false)
+ setVisited((prev) => {
+ const next = new Set(prev)
+ next.add(2)
+ markVisited('place2', 2)
+ return next
+ })
+ }}
+ onSkip={() => {
+ setShowVideo2(false)
+ }}
+ />
+ )}
+
{/* 展品3对话结束后的连线题 + 成功提示(使用 ScaledPortal) */}
<>
diff --git a/src/pages/zhangleping/place/place3.tsx b/src/pages/zhangleping/place/place3.tsx
index 51046c3..572018a 100644
--- a/src/pages/zhangleping/place/place3.tsx
+++ b/src/pages/zhangleping/place/place3.tsx
@@ -10,11 +10,12 @@ import {
} from '@/components'
import { useNavigate, useParams } from 'react-router-dom'
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
-import { useRef, useState } from 'react'
+import { useMemo, useRef, useState } from 'react'
import Cursor from '@/components/cursor'
import { useCursorController } from '@/hooks/useCursorController'
import SuccessAlert from '@/components/layout/alert/success-alert'
import ScaledPortal from '@/components/layout/ScaledPortal'
+import { saveZhanglepingStore, useZhanglepingStore } from '@/store/zhanglepingStore'
// starName 固定为 zhangleping
const starName = 'zhangleping'
@@ -34,13 +35,22 @@ const ZhanglepingPlace3 = () => {
}>()
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(null)
- const [visited, setVisited] = useState>(() => new Set())
+ // 持久化接入
+ const markVisited = useZhanglepingStore((s) => s.markExhibitVisited)
+ const getVisited = useZhanglepingStore((s) => s.getVisitedExhibits)
+ const setLastVisitedPlace = useZhanglepingStore((s) => s.setLastVisitedPlace)
+ const persistedVisited = useMemo(
+ () => new Set(getVisited('place3') as (1 | 2 | 3)[]),
+ [getVisited]
+ )
+ const [visited, setVisited] = useState>(() => new Set(persistedVisited))
const [guideVisible1, setGuideVisible1] = useState(false)
const [guideStep1, setGuideStep1] = useState(0)
- const [guideVisible2, setGuideVisible2] = useState(false)
- const [guideStep2, setGuideStep2] = useState(0)
+ // 展品2对话已移除(保留占位,避免未来扩展时大改)
+ const [_guideVisible2, _setGuideVisible2] = useState(false)
+ const [_guideStep2, _setGuideStep2] = useState(0)
const [guideVisible3, setGuideVisible3] = useState(false)
const [guideStep3, setGuideStep3] = useState(0)
@@ -52,7 +62,9 @@ const ZhanglepingPlace3 = () => {
const [successAlert2Visible, setSuccessAlert2Visible] = useState(false)
const [successAlert2Content, setSuccessAlert2Content] = useState('')
- // 展品3对话结束后的两道单选题
+ // 展品3对话结束后的讲解视频 + 两道单选题
+ const [showVideo3, setShowVideo3] = useState(false)
+ const video3Ref = useRef(null)
const [quiz3Visible, setQuiz3Visible] = useState(false)
const [quiz3Step, setQuiz3Step] = useState<1 | 2>(1)
const [quiz3Finished, setQuiz3Finished] = useState(false)
@@ -73,9 +85,7 @@ const ZhanglepingPlace3 = () => {
const total = 3
const progressCount = visited.size
const completed = progressCount >= total
- const subtitle = completed
- ? '搜索该阶段相关资料(已完成)'
- : `搜索该阶段相关资料(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const handleOpenExhibit = (id: 1 | 2 | 3) => {
setActiveExhibitId(id)
@@ -83,8 +93,8 @@ const ZhanglepingPlace3 = () => {
setGuideVisible1(true)
setGuideStep1(0)
} else if (id === 2) {
- setGuideVisible2(true)
- setGuideStep2(0)
+ // 展品2:去掉对话,打开弹窗同时直接播放讲解视频(其余逻辑保持不变)
+ setShowVideo3(true)
} else {
setGuideVisible3(true)
setGuideStep3(0)
@@ -147,6 +157,8 @@ const ZhanglepingPlace3 = () => {
src={getStaticImage('components/place/继续前进')}
button
onClick={() => {
+ setLastVisitedPlace('place4')
+ saveZhanglepingStore()
navigate(`/${moduleName}/${starName}/place4`)
}}
/>
@@ -162,10 +174,16 @@ const ZhanglepingPlace3 = () => {
highlightSelectors={[]}
zIndex={10001}
>
-
+
那我明白了!这个戴上红领巾的三毛体现的应该是已经摆脱苦难的中国人民了。
-
+
确实如此。这幅名叫《向日葵》的画选自于1950—1965年的《三毛日记》。像这样的“新三毛”形象再次发生重要转变是自1949年新中国成立之后,三毛戴上红领巾,成为新中国的少先队员。
@@ -176,26 +194,8 @@ const ZhanglepingPlace3 = () => {
- {/* 展品2:三毛形象的特点 */}
-
-
- 早期三毛以一个滑稽幽默、调皮活泼的上海弄堂儿童形象出现。抗战胜利后,张先生重返上海开启了新的创作历程,自1950年6月起开始居住在五原路288弄3号,直至1992年9月辞世,张先生在这里度过了他艺术生涯中极为重要的创作时期。
-
-
- 他先是根据抗战前线的经历见闻创作了《三毛从军记》,后又以自己耳闻目睹的社会现实,创作了《三毛流浪记》,三毛的形象也逐步转变为承载民族苦难与底层儿童命运的象征。
-
-
- 三毛身上的每一处伤痕、每一次遭遇,可以说都真实折射出了当时社会的困顿与民众的疾苦,也是为什么说这个时期,先生的画作更像是一面“镜子”。
-
-
+ {/* 展品2:三毛形象的特点(原对话已移除) */}
+ {/* 现改为:打开展品窗口的同时直接播放讲解视频 */}
{/* 展品3:三毛的社会影响 */}
{
highlightSelectors={[]}
zIndex={10001}
>
-
+
小沪,我一直知道张乐平先生被称为“三毛之父”,但是今天才发现张先生笔下成长时期的三毛看起来比早期的三毛多了一些磨难,张先生塑造的这样一个小流浪儿也在跟着时代一起成长吗?
-
+
你的感受很准确。三毛初次亮相于1935年的上海,他的创作者张乐平先生也开创了中国无文字儿童连环漫画之先河。
@@ -240,6 +246,7 @@ const ZhanglepingPlace3 = () => {
setVisited((prev) => {
const next = new Set(prev)
next.add(3)
+ markVisited('place3', 3)
return next
})
}
@@ -247,6 +254,7 @@ const ZhanglepingPlace3 = () => {
setVisited((prev) => {
const next = new Set(prev)
next.add(activeExhibitId)
+ markVisited('place3', activeExhibitId)
return next
})
}
@@ -259,7 +267,7 @@ const ZhanglepingPlace3 = () => {
{showVideo2 && (
{
>
+ {/* 展品2对话结束后的讲解视频 */}
+ {showVideo3 && (
+
+ {
+ setShowVideo3(false)
+ }}
+ onSkip={() => {
+ setShowVideo3(false)
+ }}
+ />
+
+ )}
+
{/* 展品3对话结束后的两道单选题 + 每题答对后的提示(使用 ScaledPortal) */}
<>
diff --git a/src/pages/zhangleping/place/place4.tsx b/src/pages/zhangleping/place/place4.tsx
index f059867..76d3653 100644
--- a/src/pages/zhangleping/place/place4.tsx
+++ b/src/pages/zhangleping/place/place4.tsx
@@ -7,12 +7,13 @@ import {
SingleChoiceQuizPanel,
} from '@/components'
import { useNavigate, useParams } from 'react-router-dom'
-import { getBackgroundImage, getStaticImage } from '@/lib/utils'
-import { useState } from 'react'
+import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
+import { useMemo, useState } from 'react'
import Cursor from '@/components/cursor'
import { useCursorController } from '@/hooks/useCursorController'
import SuccessAlert from '@/components/layout/alert/success-alert'
import ScaledPortal from '@/components/layout/ScaledPortal'
+import { saveZhanglepingStore, useZhanglepingStore } from '@/store/zhanglepingStore'
// starName 固定为 zhangleping
const starName = 'zhangleping'
@@ -30,7 +31,12 @@ const ZhanglepingPlace4 = () => {
}>()
const [activeExhibitId, setActiveExhibitId] = useState<1 | null>(null)
- const [visited, setVisited] = useState>(() => new Set())
+ // 持久化接入
+ const markVisited = useZhanglepingStore((s) => s.markExhibitVisited)
+ const getVisited = useZhanglepingStore((s) => s.getVisitedExhibits)
+ const setLastVisitedPlace = useZhanglepingStore((s) => s.setLastVisitedPlace)
+ const persistedVisited = useMemo(() => new Set(getVisited('place4') as 1[]), [getVisited])
+ const [visited, setVisited] = useState>(() => new Set(persistedVisited))
const [guideVisible1, setGuideVisible1] = useState(false)
const [guideStep1, setGuideStep1] = useState(0)
@@ -53,9 +59,7 @@ const ZhanglepingPlace4 = () => {
const total = 1
const progressCount = visited.size
const completed = progressCount >= total
- const subtitle = completed
- ? '搜索该阶段相关资料(已完成)'
- : `搜索该阶段相关资料(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const handleOpenExhibit = (id: 1) => {
setActiveExhibitId(id)
@@ -104,6 +108,8 @@ const ZhanglepingPlace4 = () => {
src={getStaticImage('components/place/继续前进')}
button
onClick={() => {
+ setLastVisitedPlace('place5')
+ saveZhanglepingStore()
navigate(`/${moduleName}/${starName}/place5`)
}}
/>
@@ -126,10 +132,16 @@ const ZhanglepingPlace4 = () => {
highlightSelectors={[]}
zIndex={10001}
>
-
+
小沪,三毛系列漫画真的太深入人心了!张乐平先生还会和“小三毛迷”们互通书信,张先生真的是非常喜欢儿童呀!
-
+
张乐平先生将其一生都献给了中国漫画和儿童事业。他说:“大树是从苗苗长大的,对苗苗我们要注意浇灌。”新中国成立后,张先生像“园丁”一样,经常深入学校、少年宫等辅导孩子们画画。
@@ -149,6 +161,7 @@ const ZhanglepingPlace4 = () => {
setVisited((prev) => {
const next = new Set(prev)
next.add(activeExhibitId)
+ markVisited('place4', activeExhibitId)
return next
})
}
diff --git a/src/pages/zhangleping/place/place5.tsx b/src/pages/zhangleping/place/place5.tsx
index 37821ff..ad8dbef 100644
--- a/src/pages/zhangleping/place/place5.tsx
+++ b/src/pages/zhangleping/place/place5.tsx
@@ -7,10 +7,11 @@ import {
Title,
} from '@/components'
import { useNavigate, useParams } from 'react-router-dom'
-import { getBackgroundImage, getStaticImage } from '@/lib/utils'
-import { useEffect, useState } from 'react'
+import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
+import { useEffect, useMemo, useState } from 'react'
import Cursor from '@/components/cursor'
import { useCursorController } from '@/hooks/useCursorController'
+import { useZhanglepingStore } from '@/store/zhanglepingStore'
// starName 固定为 zhangleping
const starName = 'zhangleping'
@@ -30,7 +31,19 @@ const ZhanglepingPlace5 = () => {
}>()
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>(null)
- const [visited, setVisited] = useState>(() => new Set())
+
+ // 展品3:打开展品窗口时自动播放话外音
+ const [exhibit3AudioKey, setExhibit3AudioKey] = useState(0)
+ const exhibit3AudioSrc = getPublicPath('audio/zhangleping/mp3_zhangleping_18.mp3')
+
+ // 持久化接入
+ const markVisited = useZhanglepingStore((s) => s.markExhibitVisited)
+ const getVisited = useZhanglepingStore((s) => s.getVisitedExhibits)
+ const persistedVisited = useMemo(
+ () => new Set(getVisited('place5') as (1 | 2 | 3)[]),
+ [getVisited]
+ )
+ const [visited, setVisited] = useState>(() => new Set(persistedVisited))
// 光标1+光标2均查看后的总结对话
const [guide12Visible, setGuide12Visible] = useState(false)
@@ -52,12 +65,16 @@ const ZhanglepingPlace5 = () => {
const total = 3
const progressCount = visited.size
const completed = progressCount >= total
- const subtitle = completed
- ? '搜索该阶段相关资料(已完成)'
- : `搜索该阶段相关资料(${progressCount}/${total})`
+ const subtitle = completed ? '已完成' : `${progressCount}/${total}`
const handleOpenExhibit = (id: 1 | 2 | 3) => {
setActiveExhibitId(id)
+
+ // 展品3:打开弹窗立即播放话外音
+ if (id === 3) {
+ // 通过 key 强制 Audio 组件重新挂载,确保每次打开都从头播放
+ setExhibit3AudioKey((k) => k + 1)
+ }
}
// 光标1和光标2都已查看后,自动弹出一次总结对话
@@ -69,13 +86,7 @@ const ZhanglepingPlace5 = () => {
setGuide12Shown(true)
}, [visited, guide12Shown])
- // 所有光标已读后,自动弹出一次结束语
- useEffect(() => {
- if (endingShown) return
- if (!completed) return
- setEndingVisible(true)
- setEndingShown(true)
- }, [completed, endingShown])
+ // 结束语:不再在全部完成时自动出现;改为“查看并关闭最后一个展品3后”触发
if (!moduleName) return null
@@ -147,10 +158,16 @@ const ZhanglepingPlace5 = () => {
highlightSelectors={[]}
zIndex={10001}
>
-
+
小沪,三毛系列漫画真的太深入人心了!张乐平先生还会和“小三毛迷”们互通书信,张先生真的是非常喜欢儿童呀!
-
+
是呀!这面汇聚了五十二位著名漫画家创作的瓷板画墙,堪称中国漫画界一次意义非凡的“集体致敬”,每一位参与者都以自己独特的艺术语言,重新诠释了三毛与张乐平先生之间的故事。
@@ -172,16 +189,27 @@ const ZhanglepingPlace5 = () => {
setVisited((prev) => {
const next = new Set(prev)
next.add(activeExhibitId)
+ markVisited('place5', activeExhibitId)
return next
})
}
+ // 关闭展品3后触发结束语(仅触发一次)
+ if (activeExhibitId === 3 && !endingShown) {
+ setEndingVisible(true)
+ setEndingShown(true)
+ }
+
setActiveExhibitId(null)
}}
/>
+ {/* 展品3:打开展品窗口时自动播放话外音 */}
+ {activeExhibitId === 3 && }
+
{/* 结束语:所有光标已读后出现 */}
{
setEndingVisible(false)
diff --git a/src/pages/zhangleping/reflection/index.tsx b/src/pages/zhangleping/reflection/index.tsx
new file mode 100644
index 0000000..f86b3e3
--- /dev/null
+++ b/src/pages/zhangleping/reflection/index.tsx
@@ -0,0 +1,226 @@
+import { useEffect, useMemo, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { Image, StudyGuideModal } from '@/components'
+import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
+import { useZhanglepingStore, saveZhanglepingStore } from '@/store/zhanglepingStore'
+
+const starName = 'zhangleping'
+
+type SceneConfig = {
+ id: 1 | 2 | 3 | 4 | 5
+ title: string
+ totalFragments: number
+ entranceImage: string
+ position: { top: string; left: string }
+}
+
+type SceneId = SceneConfig['id']
+
+// 导览图上 5 个入口的大致位置(可按实际效果图再微调)
+const sceneConfig: SceneConfig[] = [
+ {
+ id: 1,
+ title: '童年学艺',
+ totalFragments: 3,
+ entranceImage: `${starName}/reflection/home/场景1入口`,
+ position: { top: '48%', left: '15%' },
+ },
+ {
+ id: 2,
+ title: '抗战烽火',
+ totalFragments: 3,
+ entranceImage: `${starName}/reflection/home/场景2入口`,
+ position: { top: '35%', left: '26%' },
+ },
+ {
+ id: 3,
+ title: '流浪岁月',
+ totalFragments: 2,
+ entranceImage: `${starName}/reflection/home/场景3入口`,
+ position: { top: '25%', left: '43%' },
+ },
+ {
+ id: 4,
+ title: '新中国',
+ totalFragments: 2,
+ entranceImage: `${starName}/reflection/home/场景4入口`,
+ position: { top: '58%', left: '44%' },
+ },
+ {
+ id: 5,
+ title: '创作心路',
+ totalFragments: 1,
+ entranceImage: `${starName}/reflection/home/场景5入口`,
+ position: { top: '64%', left: '60%' },
+ },
+]
+
+const ZhanglepingReflection = () => {
+ const navigate = useNavigate()
+ const { moduleName } = useParams<{ moduleName: string }>()
+ const [showGuideModal, setShowGuideModal] = useState(false)
+
+ const { getVisitedReflectionCursors, setReflectionRoute, hasSeenStudyGuide, markStudyGuideSeen } =
+ useZhanglepingStore()
+
+ // 只在第一次进入时显示引导窗口
+ useEffect(() => {
+ if (!hasSeenStudyGuide) {
+ setShowGuideModal(true)
+ }
+ }, [hasSeenStudyGuide])
+
+ const sceneProgress = useMemo>(() => {
+ const getCount = (sceneId: SceneId) => getVisitedReflectionCursors(`scene${sceneId}`).length
+ return {
+ 1: getCount(1),
+ 2: getCount(2),
+ 3: getCount(3),
+ 4: getCount(4),
+ 5: getCount(5),
+ }
+ }, [getVisitedReflectionCursors])
+
+ const isSceneCompleted = (sceneId: SceneId) => {
+ const cfg = sceneConfig.find((s) => s.id === sceneId)
+ if (!cfg) return false
+ return sceneProgress[sceneId] >= cfg.totalFragments
+ }
+
+ // 场景逐步解锁:1 默认解锁;N 需要 N-1 完成
+ const isSceneUnlocked = (sceneId: SceneId) => {
+ if (sceneId === 1) return true
+ return isSceneCompleted((sceneId - 1) as SceneId)
+ }
+
+ // 当前引导的入口:第一个“已解锁但未完成”的场景
+ const nextSceneToEnter = useMemo(() => {
+ for (const scene of sceneConfig) {
+ if (isSceneUnlocked(scene.id) && !isSceneCompleted(scene.id)) return scene
+ }
+ return null
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [sceneProgress])
+
+ if (!moduleName) return null
+
+ return (
+ <>
+ {/* 学习引导窗口 */}
+ {
+ setShowGuideModal(false)
+ markStudyGuideSeen()
+ }}
+ ailgn="center"
+ zIndex={10000}
+ >
+ 同学你好,欢迎来到“跨越时空·体悟”学习模块!
+ 请登陆“时空回溯・亲历时代”体验仓,下一站:1930年,上海。
+ 三毛向你发送求助——他的“成长记忆碎片”散落在张乐平故居的5个关键场景中。你需要化身“时光探员”,收集记忆碎片,拼出三毛的成长故事。时空门已开,请做好准备吧!
+
+
+
+ {/* 离开 */}
+
navigate(`/${moduleName}/${starName}/home`)}
+ />
+
+ {/* 导览图入口(点击区域) */}
+ {sceneConfig.map((scene) => {
+ const unlocked = isSceneUnlocked(scene.id)
+ // 注释掉未使用的变量
+ // const _subtitle = `解锁该场景记忆碎片(${Math.min(sceneProgress[scene.id], scene.totalFragments)}/${scene.totalFragments})`
+ return (
+
+
+ {/* 未解锁覆盖 */}
+ {!unlocked ? (
+
+ ) : (
+
{
+ if (!unlocked) return
+ const route = `reflection${scene.id}`
+ setReflectionRoute(route)
+ saveZhanglepingStore()
+ navigate(`/${moduleName}/${starName}/${route}`)
+ }}
+ />
+ )}
+
+ {/* 场景标题 + subtitle(贴在入口下方) */}
+ {/*
+
{scene.title}
+
{subtitle}
+
*/}
+
+ {/* 点击进入引导(只引导“下一步可进入”的入口) */}
+ {nextSceneToEnter?.id === scene.id && unlocked && (
+
+ )}
+
+
+ )
+ })}
+
+ {/* 碎片收集 */}
+
+ {/* 5 个碎片位(按效果图纵向排列,可继续微调坐标) */}
+ {([1, 2, 3, 4, 5] as const).map((id) => {
+ const unlocked = isSceneCompleted(id)
+ const imgPath = `${starName}/reflection/clue/碎片${id}`
+ const positions: Record
= {
+ 1: { top: '12%', left: '45%', transform: 'rotate(-20deg)' },
+ 2: { top: '32%', left: '80%', transform: 'rotate(20deg)' },
+ 3: { top: '50%', left: '42%' },
+ 4: { top: '68%', left: '72%' },
+ 5: { top: '84%', left: '38%' },
+ }
+ const pos = positions[id]
+ return (
+
+ )
+ })}
+
+
+ >
+ )
+}
+
+export default ZhanglepingReflection
diff --git a/src/pages/zhangleping/reflection/scene1.tsx b/src/pages/zhangleping/reflection/scene1.tsx
new file mode 100644
index 0000000..17a549d
--- /dev/null
+++ b/src/pages/zhangleping/reflection/scene1.tsx
@@ -0,0 +1,402 @@
+import { useCallback, useMemo, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { Image, Title, Cursor, ExhibitFlipOverlay, DragFill, SceneHint } from '@/components'
+import type { DragFillResult } from '@/components'
+import { cn, getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
+import { useZhanglepingStore, saveZhanglepingStore } from '@/store/zhanglepingStore'
+import { SanmaoDialogueItem, SanmaoDialogueSteps } from '@/pages/zhangleping/components'
+import { useCursorController } from '@/hooks'
+import { useSceneHintController } from './useSceneHintController'
+
+const STAR_NAME = 'zhangleping'
+const SCENE = 'scene1'
+const EMPTY_SET = new Set()
+
+// 第一段:解锁相关资源(2 个)
+const RESOURCE_TOTAL = 2
+// 第二段:解锁记忆碎片(1 个)
+const FRAGMENT_TOTAL = 1
+
+type ExhibitId = 1 | 2
+type FragmentId = 3
+
+// 第二阶段:唯一正确物品(其余都算“选择题错误”)
+const CORRECT_DRAG_OPTION = 'drag-1'
+const DRAG_ERROR_HINT =
+ '这些物品虽然与绘画有关,但并不是解锁记忆碎片的关键。试试找找与"眼里看到的真东西"相关的物品吧!'
+const PERSISTENT_DRAG_HINT = '拖拽关联的物品至虚框处解锁记忆碎片'
+const DRAG_ERROR_HINT_DURATION_MS = 3200
+const DRAG_ERROR_HINT_RESET_DELAY_MS = 3300
+
+const exhibitAssets: Record = {
+ 1: {
+ src: getStaticImage('zhangleping/reflection/scene1/场景1展品1'),
+ },
+ 2: {
+ src: getStaticImage('zhangleping/reflection/scene1/场景1展品2正面'),
+ flipSrc: getStaticImage('zhangleping/reflection/scene1/场景1展品2反面'),
+ },
+}
+
+const DRAG_ITEMS = [
+ {
+ id: 'drag-1',
+ className: 'absolute left-[21%] top-[66.5%]',
+ src: 'zhangleping/reflection/scene1/场景1拖拽1',
+ },
+ {
+ id: 'drag-2',
+ className: 'absolute right-[28%] bottom-[26%]',
+ src: 'zhangleping/reflection/scene1/场景1拖拽2',
+ },
+ {
+ id: 'drag-3',
+ className: 'absolute right-[22%] bottom-[14%]',
+ src: 'zhangleping/reflection/scene1/场景1拖拽3',
+ },
+] as const
+
+type DragOptionValue = (typeof DRAG_ITEMS)[number]['id']
+
+const DRAG_OPTION_ASSET_MAP: Record =
+ DRAG_ITEMS.reduce(
+ (acc, item) => {
+ acc[item.id] = { className: item.className, src: item.src }
+ return acc
+ },
+ {} as Record
+ )
+
+const DRAG_OPTIONS = DRAG_ITEMS.map((i) => ({ value: i.id, label: i.id }))
+
+type DragBlankId = 'drop-1'
+const DRAG_BLANKS = [{ id: 'drop-1' as DragBlankId, label: '', required: true }]
+const DRAG_BLANK_POSITION: Record = {
+ 'drop-1': 'absolute left-[41.3%] top-[67%] w-[12%] h-[26%] perspective-1000',
+}
+
+const ZhanglepingReflectionScene1 = () => {
+ const navigate = useNavigate()
+ const { moduleName } = useParams<{ moduleName: string }>()
+
+ const { markReflectionCursorVisited } = useZhanglepingStore()
+ const visited = useZhanglepingStore((state) => state.visitedReflectionCursors[SCENE] ?? EMPTY_SET)
+
+ const [activeExhibit, setActiveExhibit] = useState(null)
+ const [showExhibit1Dialogue, setShowExhibit1Dialogue] = useState(false)
+
+ const markVisitedAndSave = useCallback(
+ (id: ExhibitId | FragmentId) => {
+ markReflectionCursorVisited(SCENE, id)
+ saveZhanglepingStore()
+ },
+ [markReflectionCursorVisited]
+ )
+
+ const resourceProgress = useMemo(
+ () => Array.from(visited).filter((id) => id === 1 || id === 2).length,
+ [visited]
+ )
+ const resourcesCompleted = resourceProgress >= RESOURCE_TOTAL
+
+ const fragmentCompleted = useMemo(() => visited.has(3), [visited])
+ const fragmentProgress = fragmentCompleted ? 1 : 0
+ const completed = fragmentCompleted
+
+ const subtitle = resourcesCompleted
+ ? `${fragmentProgress}/${FRAGMENT_TOTAL}`
+ : `${Math.min(resourceProgress, RESOURCE_TOTAL)}/${RESOURCE_TOTAL}`
+
+ const { isEnabled } = useCursorController({
+ unlockRules: [
+ { id: 1, initialEnabled: true },
+ { id: 2, dependsOn: [1] },
+ ],
+ visited,
+ })
+
+ // 第二阶段:拖拽填空
+ const [dragFillValue, setDragFillValue] = useState([])
+
+ const isDragPhase = resourcesCompleted && !fragmentCompleted
+ const [showFragmentOverlay, setShowFragmentOverlay] = useState(false)
+ const [showFragmentDialogue, setShowFragmentDialogue] = useState(false)
+ const [successHintKey, setSuccessHintKey] = useState(0)
+
+ const hint = useSceneHintController(isDragPhase, PERSISTENT_DRAG_HINT)
+
+ const handleDragValueChange = useCallback(
+ (newValue: DragFillResult[]) => {
+ setDragFillValue(newValue)
+ if (!isDragPhase) return
+
+ const answer = newValue.find((v) => v.blankId === 'drop-1')
+ if (!answer) return
+
+ if (answer.optionValue !== CORRECT_DRAG_OPTION) {
+ // 放错:退回并提示
+ setDragFillValue([])
+ hint.showTempHint(
+ DRAG_ERROR_HINT,
+ DRAG_ERROR_HINT_DURATION_MS,
+ DRAG_ERROR_HINT_RESET_DELAY_MS
+ )
+ return
+ }
+
+ // 正确:弹出展品与对话(碎片在关闭碎片展品窗口时才解锁)
+ hint.clearAndReset()
+ setShowFragmentOverlay(true)
+ setShowFragmentDialogue(true)
+ },
+ [hint, isDragPhase]
+ )
+
+ const handleLeave = () => navigate(`/${moduleName}/${STAR_NAME}/reflection`)
+ const openExhibit = (id: ExhibitId) => {
+ setActiveExhibit(id)
+ setShowExhibit1Dialogue(false)
+ }
+
+ const closeExhibit = () => {
+ if (activeExhibit !== null) {
+ markVisitedAndSave(activeExhibit)
+ }
+ setActiveExhibit(null)
+ setShowExhibit1Dialogue(false)
+ }
+
+ if (!moduleName) return null
+
+ return (
+