diff --git a/src/components/layout/quiz/MatchQuizPanel.tsx b/src/components/layout/quiz/MatchQuizPanel.tsx new file mode 100644 index 0000000..f30896d --- /dev/null +++ b/src/components/layout/quiz/MatchQuizPanel.tsx @@ -0,0 +1,337 @@ +import { useState, useEffect, useRef } from "react"; +import { cn } from "@/lib/utils"; +import type { MatchQuizPanelProps } from "./types"; + +/** + * 连线题面板组件 + * 支持移动端触摸操作 + * + * @example + * ```tsx + * { + * console.log("匹配结果:", matches, "是否正确:", isCorrect); + * }} + * /> + * ``` + */ +const MatchQuizPanel = ({ + visible, + zIndex = 10000, + title, + leftItems, + rightItems, + correctMatches, + errorMessage, + onSubmit, + onError, + disabled = false, +}: MatchQuizPanelProps) => { + const [matches, setMatches] = useState>([]); + const [selectedLeftId, setSelectedLeftId] = useState(null); + const [error, setError] = useState(""); + const containerRef = useRef(null); + + // 当窗口关闭时,重置状态 + useEffect(() => { + if (!visible) { + setMatches([]); + setSelectedLeftId(null); + setError(""); + } + }, [visible]); + + // 处理左列项点击 + const handleLeftItemClick = (leftId: string) => { + if (disabled) return; + + // 如果已经匹配过,先取消匹配 + const existingMatch = matches.find((m) => m.leftId === leftId); + if (existingMatch) { + setMatches(matches.filter((m) => m.leftId !== leftId)); + setSelectedLeftId(null); + setError(""); + return; + } + + // 如果已经选中了另一个左项,取消之前的选中 + if (selectedLeftId && selectedLeftId !== leftId) { + setSelectedLeftId(leftId); + setError(""); + return; + } + + // 切换选中状态 + setSelectedLeftId(selectedLeftId === leftId ? null : leftId); + setError(""); + }; + + // 处理右列项点击 + const handleRightItemClick = (rightId: string) => { + if (disabled) return; + + // 如果没有选中左项,直接返回 + if (!selectedLeftId) return; + + // 如果已经匹配过,先取消匹配 + const existingMatch = matches.find((m) => m.rightId === rightId); + if (existingMatch) { + setMatches(matches.filter((m) => m.rightId !== rightId)); + setSelectedLeftId(null); + setError(""); + return; + } + + // 创建新的匹配 + const newMatch = { leftId: selectedLeftId, rightId }; + const newMatches = [...matches, newMatch]; + setMatches(newMatches); + setSelectedLeftId(null); + setError(""); + + // 检查是否所有项都已匹配 + if (newMatches.length === leftItems.length && newMatches.length === rightItems.length) { + // 验证答案 + const isCorrect = validateMatches(newMatches); + if (isCorrect) { + onSubmit?.(newMatches, true); + } else { + const errorMsg = errorMessage || "连线不正确,请重新尝试!"; + setError(errorMsg); + onError?.(errorMsg); + // 清空匹配,允许重新尝试 + setTimeout(() => { + setMatches([]); + }, 2000); + } + } + }; + + // 验证匹配是否正确 + const validateMatches = (matchesToValidate: Array<{ leftId: string; rightId: string }>) => { + if (!correctMatches || matchesToValidate.length !== correctMatches.length) { + return false; + } + + // 检查每个匹配是否正确 + for (const match of matchesToValidate) { + const isCorrect = correctMatches.some( + (cm) => cm.leftId === match.leftId && cm.rightId === match.rightId + ); + if (!isCorrect) { + return false; + } + } + + return true; + }; + + // 获取左项是否已匹配 + const isLeftItemMatched = (leftId: string) => { + return matches.some((m) => m.leftId === leftId); + }; + + // 获取右项是否已匹配 + const isRightItemMatched = (rightId: string) => { + return matches.some((m) => m.rightId === rightId); + }; + + // 获取左项对应的右项ID + const getMatchedRightId = (leftId: string) => { + const match = matches.find((m) => m.leftId === leftId); + return match?.rightId || null; + }; + + // 获取右项对应的左项ID + const getMatchedLeftId = (rightId: string) => { + const match = matches.find((m) => m.rightId === rightId); + return match?.leftId || null; + }; + + if (!visible) return null; + + return ( +
+
+ {/* 题干卡片 */} +
+
+ {title} +
+ + {/* 答案反馈 */} + {error && ( +
+ {error} +
+ )} +
+ + {/* 连线区域 */} +
+ {/* 左列:日期 */} +
+ {leftItems.map((item) => { + const isMatched = isLeftItemMatched(item.id); + const isSelected = selectedLeftId === item.id; + const matchedRightId = getMatchedRightId(item.id); + + return ( +
handleLeftItemClick(item.id)} + onTouchStart={(e) => { + // 移动端触摸优化 + e.currentTarget.classList.add("scale-105"); + }} + onTouchEnd={(e) => { + e.currentTarget.classList.remove("scale-105"); + }} + > + {item.label} + {isMatched && ( +
+ ✓ +
+ )} +
+ ); + })} +
+ + {/* 中间连线区域(移动端隐藏,桌面端显示) */} +
+ {/* 绘制连线 */} + + {matches.map((match, index) => { + const leftItem = leftItems.find((item) => item.id === match.leftId); + const rightItem = rightItems.find((item) => item.id === match.rightId); + if (!leftItem || !rightItem) return null; + + const leftIndex = leftItems.findIndex((item) => item.id === match.leftId); + const rightIndex = rightItems.findIndex((item) => item.id === match.rightId); + + // 计算连线起点和终点(基于项目高度和间距) + const itemHeight = 80; // 每个项目的大概高度 + const gap = 16; // 项目之间的间距 + const startY = leftIndex * (itemHeight + gap) + itemHeight / 2; + const endY = rightIndex * (itemHeight + gap) + itemHeight / 2; + + return ( + + ); + })} + {/* 箭头标记定义 */} + + + + + + +
+ + {/* 右列:事件 */} +
+ {rightItems.map((item) => { + const isMatched = isRightItemMatched(item.id); + const matchedLeftId = getMatchedLeftId(item.id); + + return ( +
handleRightItemClick(item.id)} + onTouchStart={(e) => { + if (selectedLeftId) { + e.currentTarget.classList.add("scale-105"); + } + }} + onTouchEnd={(e) => { + e.currentTarget.classList.remove("scale-105"); + }} + > + {item.label} + {isMatched && ( +
+ ✓ +
+ )} +
+ ); + })} +
+
+ + {/* 移动端连线提示 */} +
+ 请先点击左侧日期,再点击右侧对应的事件进行连线 +
+
+
+ ); +}; + +export default MatchQuizPanel; + diff --git a/src/components/layout/quiz/index.ts b/src/components/layout/quiz/index.ts index 7f5dd83..61f2c69 100644 --- a/src/components/layout/quiz/index.ts +++ b/src/components/layout/quiz/index.ts @@ -2,10 +2,14 @@ export { default as SingleChoiceQuizPanel } from "./SingleChoiceQuizPanel"; export { default as MultipleChoiceQuizPanel } from "./MultipleChoiceQuizPanel"; export { default as DragQuizPanel } from "./DragQuizPanel"; +export { default as MatchQuizPanel } from "./MatchQuizPanel"; export type { SingleChoiceQuizPanelProps, MultipleChoiceQuizPanelProps, DragQuizPanelProps, + MatchQuizPanelProps, + MatchQuizItem, + MatchQuizMatch, } from "./types"; // 保留旧组件以保持向后兼容 diff --git a/src/components/layout/quiz/types.ts b/src/components/layout/quiz/types.ts index 1ff9287..cf969d0 100644 --- a/src/components/layout/quiz/types.ts +++ b/src/components/layout/quiz/types.ts @@ -85,6 +85,46 @@ export interface MultipleChoiceQuizPanelProps { submitText?: string; } +/** 连线题项 */ +export interface MatchQuizItem { + /** 唯一标识 */ + id: string; + /** 显示文本 */ + label: string; +} + +/** 连线题匹配 */ +export interface MatchQuizMatch { + /** 左列项ID */ + leftId: string; + /** 右列项ID */ + rightId: string; +} + +/** 连线题面板配置 */ +export interface MatchQuizPanelProps { + /** 是否可见 */ + visible: boolean; + /** z-index 层级 */ + zIndex?: number; + /** 题目 */ + title: string; + /** 左列项列表(日期) */ + leftItems: MatchQuizItem[]; + /** 右列项列表(事件) */ + rightItems: MatchQuizItem[]; + /** 正确答案匹配列表 */ + correctMatches: MatchQuizMatch[]; + /** 自定义错误提示信息(答案错误时显示) */ + errorMessage?: string; + /** 提交回调,返回用户匹配和是否正确 */ + onSubmit?: (matches: MatchQuizMatch[], isCorrect: boolean) => void; + /** 错误回调(可选) */ + onError?: (error: string) => void; + /** 是否禁用 */ + disabled?: boolean; +} + /** @deprecated 使用 SingleChoiceQuizPanelProps 替代 */ export interface QuizPanelProps { visible: boolean; diff --git a/src/components/video/video.tsx b/src/components/video/video.tsx index 9ee99af..dcebd15 100644 --- a/src/components/video/video.tsx +++ b/src/components/video/video.tsx @@ -91,6 +91,8 @@ const Video = forwardRef( const [isPlaying, setIsPlaying] = useState(false); const [isDragging, setIsDragging] = useState(false); const [isVideoReady, setIsVideoReady] = useState(false); + // 跟踪是否应该停在锚点处(用于 seek 时强制暂停) + const shouldStopAtAnchorRef = useRef<{ time: number; anchorId: string } | null>(null); useImperativeHandle(ref, () => ({ video: videoRef.current, @@ -250,11 +252,151 @@ const Video = forwardRef( } }; - // Seek 时重置隐藏状态(非 once 的锚点可以重新触发) + // 检查并触发锚点的辅助函数 + 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秒的容差) + 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?.(); + + break; + } + } + }, [anchors, removedAnchorIds, triggeredOnceIds, hiddenAnchorIds, activeAnchorId]); + + // 查找目标时间之前最近的未触发锚点 + const findNearestAnchorBeforeTime = useCallback((targetTime: number): VideoAnchor | null => { + let nearestAnchor: VideoAnchor | null = null; + let nearestTime = -1; + + 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; + } + } + + return nearestAnchor; + }, [anchors, removedAnchorIds, triggeredOnceIds, hiddenAnchorIds]); + + // 开始跳转时,如果应该停在锚点处,立即暂停 + const handleSeeking = (): void => { + if (!videoRef.current) return; + // 如果设置了应该停在锚点处,立即暂停 + if (shouldStopAtAnchorRef.current) { + videoRef.current.pause(); + // 同步检查并再次暂停(Safari 兼容) + if (!videoRef.current.paused) { + videoRef.current.pause(); + } + } + }; + + // Seek 时重置隐藏状态(非 once 的锚点可以重新触发),并检查是否跳过了锚点 const handleSeeked = (): void => { if (!videoRef.current) return; const currentTime = 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 (!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?.(); + } + } + + // 清除标记 + shouldStopAtAnchorRef.current = null; + return; + } + // 如果时间不匹配,清除标记 + shouldStopAtAnchorRef.current = null; + } + // 清除当前时间之后的隐藏状态(允许重新触发) setHiddenAnchorIds((prev) => { const next = new Set(prev); @@ -267,11 +409,13 @@ const Video = forwardRef( return next; }); - // 重置锚点处理锁 - if (activeAnchorId) { - setActiveAnchorId(null); + // 重置锚点处理锁(如果当前没有激活的锚点) + if (!activeAnchorId) { + isHandlingAnchor.current = false; } - isHandlingAnchor.current = false; + + // 检查是否跳过了锚点时间,如果是则触发锚点 + checkAndTriggerAnchors(currentTime); }; // 获取当前激活的锚点 @@ -300,10 +444,53 @@ const Video = forwardRef( }; const handleSkipClick = (): void => { - // 优先暂停当前视频 - if (videoRef.current) { - videoRef.current.pause(); + if (!videoRef.current) return; + + const currentTime = 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; + } } + + // 如果有未触发的锚点,跳转到第一个未触发的锚点时间并触发 + 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) { @@ -331,8 +518,28 @@ const Video = forwardRef( if (!videoRef.current || !progressRef.current) return; const rect = progressRef.current.getBoundingClientRect(); const percent = (e.clientX - rect.left) / rect.width; - const newTime = percent * duration; - videoRef.current.currentTime = newTime; + 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; resetControlsTimer(); }; @@ -345,8 +552,28 @@ const Video = forwardRef( 0, Math.min(1, (e.clientX - rect.left) / rect.width) ); - const newTime = percent * duration; - videoRef.current.currentTime = newTime; + 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; }; // 全局鼠标移动和释放事件处理拖拽 @@ -360,12 +587,33 @@ const Video = forwardRef( 0, Math.min(1, (e.clientX - rect.left) / rect.width) ); - const newTime = percent * duration; - videoRef.current.currentTime = newTime; + 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; }; const handleMouseUp = () => { setIsDragging(false); + // 拖拽结束后,handleSeeked 会自动处理锚点触发 }; document.addEventListener("mousemove", handleMouseMove); @@ -375,7 +623,7 @@ const Video = forwardRef( document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; - }, [isDragging, duration]); + }, [isDragging, duration, checkAndTriggerAnchors, findNearestAnchorBeforeTime]); // 音量控制 const toggleMute = () => { @@ -434,6 +682,7 @@ const Video = forwardRef( className="w-full h-full object-cover" controls={false} onTimeUpdate={handleTimeUpdate} + onSeeking={handleSeeking} onSeeked={handleSeeked} onEnded={handleEnded} onPlay={handlePlay} diff --git a/src/pages/huangyanpei/internalization.tsx b/src/pages/huangyanpei/internalization.tsx index 0fbc9f4..9ebf5e8 100644 --- a/src/pages/huangyanpei/internalization.tsx +++ b/src/pages/huangyanpei/internalization.tsx @@ -247,7 +247,7 @@ const HuangyanpeiInternalization = () => { } else { setDragQuizErrors((prev) => ({ ...prev, - [item.id]: "再想一想,确保选择的都是中国国产企业/品牌~", + [item.id]: "特斯拉是美国品牌。请你再想想吧!", })); } }; diff --git a/src/pages/huangyanpei/place/place3.tsx b/src/pages/huangyanpei/place/place3.tsx index d15a565..4fcc888 100644 --- a/src/pages/huangyanpei/place/place3.tsx +++ b/src/pages/huangyanpei/place/place3.tsx @@ -96,6 +96,14 @@ const HuangyanpeiPlace3 = () => { // 光标1、2的引导对话状态(在两者都浏览后显示) const [guide12Visible, setGuide12Visible] = useState(false); const [guide12Shown, setGuide12Shown] = useState(false); + // 题目3(开群女学)显示状态 + const [showQuiz3_12, setShowQuiz3_12] = useState(false); + // 题目3成功提示状态 + const [showQuiz3Success, setShowQuiz3Success] = useState(false); + // 题目4(浦东中学)显示状态 + const [showQuiz4, setShowQuiz4] = useState(false); + // 题目4成功提示状态 + const [showQuiz4Success, setShowQuiz4Success] = useState(false); const handleOpenExhibit = (index: 1 | 2 | 3) => { if (index === 3) { @@ -187,6 +195,50 @@ const HuangyanpeiPlace3 = () => { } }, [visited, guide12Shown]); + // 处理guide12Visible变化,对话结束后显示题目3 + const handleGuide12VisibleChange = (visible: boolean) => { + setGuide12Visible(visible); + // 当对话关闭时,显示题目3 + if (!visible && guide12Shown) { + setShowQuiz3_12(true); + } + }; + + // 处理题目3提交 + const handleQuiz3_12Submit = (value: string, isCorrect: boolean) => { + if (isCorrect) { + setShowQuiz3_12(false); + setShowQuiz3Success(true); + } + }; + + // 处理题目3成功提示继续 + const handleQuiz3SuccessContinue = () => { + setShowQuiz3Success(false); + // 显示题目4 + setShowQuiz4(true); + }; + + // 处理题目4提交 + const handleQuiz4Submit = (value: string, isCorrect: boolean) => { + if (isCorrect) { + setShowQuiz4(false); + setShowQuiz4Success(true); + } + }; + + // 处理题目4成功提示继续 + const handleQuiz4SuccessContinue = () => { + setShowQuiz4Success(false); + // 标记展品1和2为已访问,触发继续前进按钮显示 + if (!visited.has(1)) { + markExhibitVisited(place, 1); + } + if (!visited.has(2)) { + markExhibitVisited(place, 2); + } + }; + const progressCount = visited.size; const total = 3; const completed = progressCount >= total; @@ -373,10 +425,70 @@ const HuangyanpeiPlace3 = () => { onClose={handleDragQuizSuccessContinue} /> + {/* 题目3(开群女学) */} + { + switch (value) { + case "B": + return "上海女子师范学校是上海市区的女子教育机构,并非由黄炎培等人在浦东地区创办。请你再想想吧!"; + case "C": + return "浦东中学是一所面向男女学生的综合性中学,并非专门的女子学校。请你再想想吧!"; + default: + return "请你再想想吧!"; + } + }} + onSubmit={handleQuiz3_12Submit} + /> + + {/* 题目3成功提示 */} + + + {/* 题目4(浦东中学) */} + { + switch (value) { + case "A": + return "浦东中学是一所男女同校的综合性中学,并非女子中学。请你再想想吧!"; + case "B": + return "浦东中学以普通中学教育为主,其定位是培养全面发展的人才,并非职业技术学校。请你再想想吧!"; + default: + return "请你再想想吧!"; + } + }} + onSubmit={handleQuiz4Submit} + /> + + {/* 题目4成功提示 */} + + {/* 光标1、2的引导对话 */} diff --git a/src/pages/huangyanpei/place/place5.tsx b/src/pages/huangyanpei/place/place5.tsx index d88a94e..dacc844 100644 --- a/src/pages/huangyanpei/place/place5.tsx +++ b/src/pages/huangyanpei/place/place5.tsx @@ -41,7 +41,11 @@ const HuangyanpeiPlace5 = () => { // 展品2视频后的对话引导状态 const [guideVisible, setGuideVisible] = useState(false); const [guideStep, setGuideStep] = useState(0); - // 题目显示状态 + // 题目1(光标1对话后)显示状态 + const [showQuiz1, setShowQuiz1] = useState(false); + // 题目1成功提示状态 + const [showQuiz1Success, setShowQuiz1Success] = useState(false); + // 题目显示状态(展品2视频后) const [showQuiz, setShowQuiz] = useState(false); // 成功提示状态 const [showSuccessAlert, setShowSuccessAlert] = useState(false); @@ -177,7 +181,15 @@ const HuangyanpeiPlace5 = () => { }} onClose={() => { if (activeExhibitId !== null) { - markExhibitVisited(place, activeExhibitId); + // 如果是展品1关闭,显示题目1 + if (activeExhibitId === 1) { + setShowQuiz1(true); + } + // 注意:展品1的已访问标记在题目1成功后处理,这里不标记 + // 展品2正常标记 + if (activeExhibitId === 2) { + markExhibitVisited(place, activeExhibitId); + } } setActiveExhibitId(null); }} @@ -188,9 +200,7 @@ const HuangyanpeiPlace5 = () => { visible={guide1Visible} currentStep={guide1Step} onStepChange={setGuide1Step} - onVisibleChange={(visible) => { - setGuide1Visible(visible); - }} + onVisibleChange={setGuide1Visible} showMask={false} zIndex={10000} > @@ -217,6 +227,50 @@ const HuangyanpeiPlace5 = () => { + {/* 题目1(光标1对话后) */} + { + switch (value) { + case "B": + return '国民党政府坚持"攘外必先安内"的政策导致国共关系紧张,黄炎培正是因为反对这种政策、希望推动和平,才发起组建了民主政团同盟。请你再想想吧!'; + case "C": + return "黄炎培创建民主政团同盟的出发点是民族大义与国家前途,并非为了个人政治利益。请你再想想吧!"; + default: + return "请你再想想吧!"; + } + }} + onSubmit={(value, isCorrect) => { + if (isCorrect) { + setShowQuiz1(false); + setShowQuiz1Success(true); + } + }} + zIndex={10001} + /> + + {/* 题目1成功提示 */} + { + setShowQuiz1Success(false); + // 标记展品1为已访问,修改cursor状态 + markExhibitVisited(place, 1); + }} + onClose={() => { + setShowQuiz1Success(false); + // 标记展品1为已访问,修改cursor状态 + markExhibitVisited(place, 1); + }} + /> + {/* 光标2的引导对话 */} { const [choice2, setChoice2] = useState<"铁工科" | "商科" | null>(null); const [showCompleteGuide, setShowCompleteGuide] = useState(false); const [completeGuideShown, setCompleteGuideShown] = useState(false); + // 题目显示状态(ChoiceDialog1关闭后) + const [showQuiz, setShowQuiz] = useState(false); + // 题目成功提示状态 + const [showQuizSuccess, setShowQuizSuccess] = useState(false); // 使用持久化的reflection cursor状态 const scene = "scene2"; @@ -129,7 +134,11 @@ const HuangyanpeiReflectionScene2 = () => { {/* 第一个选择弹窗 */} setShowDialog1(false)} + onClose={() => { + setShowDialog1(false); + // 关闭后显示题目 + setShowQuiz(true); + }} onSelect={() => { markReflectionCursorVisited(scene, 1); }} @@ -145,6 +154,42 @@ const HuangyanpeiReflectionScene2 = () => { }} /> + {/* 题目(ChoiceDialog1关闭后) */} + { + return "是这个数目吗?你要不要再算一下!"; + }} + onSubmit={(value, isCorrect) => { + if (isCorrect) { + setShowQuiz(false); + setShowQuizSuccess(true); + } + }} + zIndex={10001} + /> + + {/* 题目成功提示 */} + { + setShowQuizSuccess(false); + }} + onClose={() => { + setShowQuizSuccess(false); + }} + /> + {/* 完成提示 */} { if (isCorrect) { setActiveSuccess(item.id); - setSuccessContent("恭喜你,匹配正确!"); + setSuccessContent(""); setResumeCallback(() => resume); } else { setDragQuizErrors((prev) => ({ @@ -245,7 +245,7 @@ const LuxunInternalization = () => { if (isCorrect) { setActiveSuccess(item.id); - setSuccessContent("恭喜你,匹配正确!"); + setSuccessContent(""); setResumeCallback(() => resume); } else { setDragQuizErrors((prev) => ({ @@ -276,7 +276,7 @@ const LuxunInternalization = () => { /> { setActiveSuccess(null); @@ -354,7 +354,7 @@ const LuxunInternalization = () => { /> { setActiveSuccess(null); @@ -400,7 +400,7 @@ const LuxunInternalization = () => { /> { setActiveSuccess(null); diff --git a/src/pages/luxun/place/place1.tsx b/src/pages/luxun/place/place1.tsx index aa0a324..b3038eb 100644 --- a/src/pages/luxun/place/place1.tsx +++ b/src/pages/luxun/place/place1.tsx @@ -7,9 +7,11 @@ import { Cursor, StudyGuideModal, } from "@/components"; +import { MatchQuizPanel } from "@/components/layout/quiz"; +import { SuccessAlert } from "@/components/layout"; import { useNavigate, useParams } from "react-router-dom"; import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import { useCursorController } from "@/hooks/useCursorController"; import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext"; @@ -47,6 +49,10 @@ const LuxunPlace1 = () => { const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null); // 已浏览展品的 ID 集合 const [visited, setVisited] = useState>(new Set()); + // 连线题显示状态(展品1关闭后) + const [showMatchQuiz, setShowMatchQuiz] = useState(false); + // 连线题成功提示状态 + const [showMatchQuizSuccess, setShowMatchQuizSuccess] = useState(false); // Cursor 顺序控制:1 -> 2 const { isEnabled } = useCursorController({ @@ -160,11 +166,17 @@ const LuxunPlace1 = () => { // } onClose={() => { if (activeExhibitId !== null) { - setVisited((prev) => { - const next = new Set(prev); - next.add(activeExhibitId); - return next; - }); + // 如果是展品1关闭,显示连线题 + if (activeExhibitId === 1) { + setShowMatchQuiz(true); + } else { + // 展品2正常标记为已访问 + setVisited((prev) => { + const next = new Set(prev); + next.add(activeExhibitId); + return next; + }); + } } setActiveExhibitId(null); }} @@ -270,6 +282,61 @@ const LuxunPlace1 = () => { 这个过程的确十分曲折。 + + {/* 连线题(展品1关闭后) */} + { + if (isCorrect) { + setShowMatchQuiz(false); + setShowMatchQuizSuccess(true); + } + }} + zIndex={10001} + /> + + {/* 连线题成功提示 */} + { + setShowMatchQuizSuccess(false); + // 标记展品1为已访问 + setVisited((prev) => { + const next = new Set(prev); + next.add(1); + return next; + }); + }} + onClose={() => { + setShowMatchQuizSuccess(false); + // 标记展品1为已访问 + setVisited((prev) => { + const next = new Set(prev); + next.add(1); + return next; + }); + }} + /> ); }; diff --git a/src/pages/luxun/place/place2.tsx b/src/pages/luxun/place/place2.tsx index 06d6520..6eb7a86 100644 --- a/src/pages/luxun/place/place2.tsx +++ b/src/pages/luxun/place/place2.tsx @@ -6,11 +6,15 @@ import { GuideSteps, GuideStepsItem, } from "@/components"; +import { DragQuizPanel } from "@/components/layout/quiz"; +import { SuccessAlert } from "@/components/layout"; +import ScaledPortal from "@/components/layout/ScaledPortal"; import { useNavigate, useParams } from "react-router-dom"; import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils"; import { useState } from "react"; import { useCursorController } from "@/hooks/useCursorController"; import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext"; +import type { DragFillResult } from "@/components"; // starName 固定为 luxun const starName = "luxun"; @@ -57,6 +61,16 @@ const LuxunPlace2 = () => { // 光标3的引导对话状态 const [guide3Visible, setGuide3Visible] = useState(false); const [guide3Step, setGuide3Step] = useState(0); + // 拖拽题显示状态(包含那句话的步骤结束后) + const [showDragQuiz, setShowDragQuiz] = useState(false); + // 标记是否已经显示过题目(避免重复显示) + const [quizShown, setQuizShown] = useState(false); + // 拖拽题的值 + const [dragQuizValue, setDragQuizValue] = useState([]); + // 拖拽题错误提示 + const [dragQuizError, setDragQuizError] = useState(""); + // 拖拽题成功提示状态 + const [showDragQuizSuccess, setShowDragQuizSuccess] = useState(false); const handleOpenExhibit = (index: 1 | 2 | 3) => { if (index === 1) { @@ -73,6 +87,7 @@ const LuxunPlace2 = () => { setActiveExhibitId(3); setGuide3Visible(true); setGuide3Step(0); + setQuizShown(false); // 重置题目显示标记 } else { // 光标3:直接打开 setActiveExhibitId(index); @@ -99,6 +114,18 @@ const LuxunPlace2 = () => { setGuide2Visible(visible); }; + // 监听光标3的引导对话步骤变化 + const handleGuide3StepChange = (step: number) => { + // 在步骤2(包含那句话的步骤)结束后,进入步骤3前先弹题 + if (step === 3 && !quizShown) { + setGuide3Step(2); // 保持在步骤2 + setShowDragQuiz(true); + setQuizShown(true); + return; + } + setGuide3Step(step); + }; + const progressCount = visited.size; const total = 3; const completed = progressCount >= total; @@ -245,12 +272,12 @@ const LuxunPlace2 = () => { 这是鲁迅的自嘲,他曾说"我不会说绵软的苏白,不会打响亮的京腔,不入调,不入流",所以戏称自己的文章是"南腔北调"。这本书里的《〈守常全集》题记》,赞美了李大钊同志,说"他的遗文却将永驻,因为这是先驱者的遗产,革命史上的丰碑。" - \{/* 光标3的引导对话 */} + {/* 光标3的引导对话 */} { 文字如利刃般戳破虚假论调,揭露了反动派掩盖自身无能、麻痹民众的阴谋,唤醒国人的民族自信,尽显战斗文字的锋芒与力量。 + + {/* 拖拽题和成功提示(使用 ScaledPortal 保证层级与缩放) */} + + <> + { + setDragQuizValue(value); + setDragQuizError(""); + + // 检查是否所有空白都已填充 + if (value.length < 4) return; + + // 验证顺序:必须是 ABCD 顺序 + const correctOrder = [ + "埋头苦干的人", + "拼命硬干的人", + "为民请命的人", + "舍身求法的人", + ]; + + const isCorrect = value.every((v, index) => { + return v.optionValue === correctOrder[index]; + }); + + if (isCorrect) { + // 答题成功 + setShowDragQuiz(false); + setShowDragQuizSuccess(true); + } else { + // 答题失败:顺序不对 + setDragQuizError( + "请你按照鲁迅先生《中国人失掉自信力了吗》的原文拖拽哦~" + ); + } + }} + zIndex={10000} + /> + + { + setShowDragQuizSuccess(false); + setDragQuizValue([]); + setDragQuizError(""); + // 题目完成后,继续对话到步骤3 + setGuide3Visible(true); + setGuide3Step(3); + }} + onClose={() => { + setShowDragQuizSuccess(false); + setDragQuizValue([]); + setDragQuizError(""); + // 题目完成后,继续对话到步骤3 + setGuide3Visible(true); + setGuide3Step(3); + }} + /> + + ); }; diff --git a/src/pages/luxun/place/place4.tsx b/src/pages/luxun/place/place4.tsx index 0169eb0..2a0235a 100644 --- a/src/pages/luxun/place/place4.tsx +++ b/src/pages/luxun/place/place4.tsx @@ -7,10 +7,12 @@ import { Cursor, SingleChoiceQuizPanel, Video, + type VideoAnchor, } from "@/components"; +import type { VideoRef } from "@/components/video"; import { useNavigate, useParams } from "react-router-dom"; import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils"; -import { useState } from "react"; +import { useState, useRef, useMemo } from "react"; import SuccessAlert from "@/components/layout/alert/success-alert"; import { useCursorController } from "@/hooks/useCursorController"; import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext"; @@ -27,10 +29,14 @@ const exhibitSrcMap: Record<1 | 2 | 3, string> = { const quizDefaultError = "请结合刚刚的旁白内容,再思考一下吧!"; const quizExplanations: Record = { - A: "陈独秀与鲁迅有思想交往,但他并未在这间“红色庇护所”中避难、与鲁迅夜谈革命议题。", - B: "恭喜你,选对啦。", - C: "李大钊于1927年牺牲,时间上不符合这间客房被用作“红色庇护所”的背景。", - D: "周恩来虽长期在上海从事革命活动,但并未在这里避难并与鲁迅夜谈。", + // A: "陈独秀与鲁迅有思想交往,但他并未在这间“红色庇护所”中避难、与鲁迅夜谈革命议题。", + // B: "恭喜你,选对啦。", + // C: "李大钊于1927年牺牲,时间上不符合这间客房被用作“红色庇护所”的背景。", + // D: "周恩来虽长期在上海从事革命活动,但并未在这里避难并与鲁迅夜谈。", + A: "请你结合刚刚学习的内容,再思考一下吧!", + B: "恭喜你,选择正确!", + C: "请你结合刚刚学习的内容,再思考一下吧!", + D: "请你结合刚刚学习的内容,再思考一下吧!", }; const LuxunPlace5 = () => { @@ -72,6 +78,10 @@ const LuxunPlace5 = () => { // 结尾视频与对话 const [showEndingVideo, setShowEndingVideo] = useState(false); const [endingGuideVisible, setEndingGuideVisible] = useState(false); + // 视频锚点相关状态 + const [activeVideoSuccess, setActiveVideoSuccess] = useState(false); + const [resumeVideoCallback, setResumeVideoCallback] = useState<(() => void) | null>(null); + const videoRef = useRef(null); const handleOpenExhibit = (index: 1 | 2 | 3) => { if (index === 3) { @@ -83,6 +93,66 @@ const LuxunPlace5 = () => { setActiveExhibitId(index); }; + // 视频锚点配置 + const videoAnchors: VideoAnchor[] = useMemo( + () => [ + { + id: "quiz-anchor", + time: 5, + once: true, + afterResume: "remove", + render: ({ resume }) => ( + <> + { + switch (value) { + case "B": + return "这不是毛泽东主席的原话。请你再想想吧!"; + case "C": + return "这不是毛泽东主席的原话。请你再想想吧!"; + default: + return "请你再想想吧!"; + } + }} + onSubmit={(value: string, isCorrect: boolean) => { + if (isCorrect) { + setActiveVideoSuccess(true); + setResumeVideoCallback(() => resume); + } + }} + zIndex={20000} + /> + + { + setActiveVideoSuccess(false); + const cb = resumeVideoCallback; + setResumeVideoCallback(null); + cb?.(); + }} + onClose={() => { + setActiveVideoSuccess(false); + const cb = resumeVideoCallback; + setResumeVideoCallback(null); + cb?.(); + }} + /> + + ), + }, + ], + [activeVideoSuccess, resumeVideoCallback] + ); + const progressCount = visited.size; const total = 3; const completed = progressCount >= total; @@ -304,11 +374,14 @@ const LuxunPlace5 = () => { {/* 结尾视频播放 */} {showEndingVideo && (