feat: 添加连线题和选择题功能,增强互动性
- 在多个组件中引入 MatchQuizPanel 和 SingleChoiceQuizPanel,提升用户参与度。 - 更新 HuangyanpeiPlace1、HuangyanpeiPlace3、LuxunPlace1、LuxunPlace2 组件,增加新的选择题和连线题,丰富学习内容。 - 调整成功提示逻辑,确保用户体验流畅。 - 优化视频播放中的锚点逻辑,提升互动性和学习效果。
This commit is contained in:
1 parent
02a3f00a9b
commit
966b47ede3
12 files changed
+1122
-36
No files matched your search
@@ -0,0 +1,337 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MatchQuizPanelProps } from "./types";
|
||||
|
||||
/**
|
||||
* 连线题面板组件
|
||||
* 支持移动端触摸操作
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <MatchQuizPanel
|
||||
* visible={true}
|
||||
* title="请将日期与对应的事件连线"
|
||||
* leftItems={[
|
||||
* { id: "1", label: "1892-1898年" },
|
||||
* { id: "2", label: "1919-1923年" },
|
||||
* ]}
|
||||
* rightItems={[
|
||||
* { id: "a", label: "在绍兴三味书屋求学积淀" },
|
||||
* { id: "b", label: "于北京八道湾参加新文化运动" },
|
||||
* ]}
|
||||
* correctMatches={[
|
||||
* { leftId: "1", rightId: "a" },
|
||||
* { leftId: "2", rightId: "b" },
|
||||
* ]}
|
||||
* onSubmit={(matches, isCorrect) => {
|
||||
* console.log("匹配结果:", matches, "是否正确:", isCorrect);
|
||||
* }}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
const MatchQuizPanel = ({
|
||||
visible,
|
||||
zIndex = 10000,
|
||||
title,
|
||||
leftItems,
|
||||
rightItems,
|
||||
correctMatches,
|
||||
errorMessage,
|
||||
onSubmit,
|
||||
onError,
|
||||
disabled = false,
|
||||
}: MatchQuizPanelProps) => {
|
||||
const [matches, setMatches] = useState<Array<{ leftId: string; rightId: string }>>([]);
|
||||
const [selectedLeftId, setSelectedLeftId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string>("");
|
||||
const containerRef = useRef<HTMLDivElement>(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 (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/40"
|
||||
style={{ zIndex }}
|
||||
ref={containerRef}
|
||||
>
|
||||
<div className="relative w-full max-w-[1728px] h-full max-h-[972px] flex flex-col items-center justify-center p-4 md:p-8">
|
||||
{/* 题干卡片 */}
|
||||
<div className="w-full max-w-[1700px] rounded-[32px] bg-black/90 px-6 md:px-20 py-6 md:py-8 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9] mb-4 md:mb-8">
|
||||
<div className="text-[24px] md:text-[34px] leading-relaxed text-white text-center">
|
||||
{title}
|
||||
</div>
|
||||
|
||||
{/* 答案反馈 */}
|
||||
{error && (
|
||||
<div className="mt-4 md:mt-5 text-center text-[20px] md:text-[26px] text-[#fcbe53]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 连线区域 */}
|
||||
<div className="w-full max-w-[1600px] flex flex-col md:flex-row gap-4 md:gap-8 items-start md:items-center justify-center">
|
||||
{/* 左列:日期 */}
|
||||
<div className="w-full md:w-1/2 flex flex-col gap-3 md:gap-4">
|
||||
{leftItems.map((item) => {
|
||||
const isMatched = isLeftItemMatched(item.id);
|
||||
const isSelected = selectedLeftId === item.id;
|
||||
const matchedRightId = getMatchedRightId(item.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"relative px-4 md:px-6 py-3 md:py-4 rounded-xl border-2 transition-all duration-200",
|
||||
"text-[20px] md:text-[28px] leading-relaxed cursor-pointer",
|
||||
"touch-manipulation", // 优化移动端触摸
|
||||
disabled || isMatched
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
: "cursor-pointer",
|
||||
isSelected
|
||||
? "bg-[#d4a574] text-[#5c3e2e] border-[#f0be75] shadow-lg scale-105"
|
||||
: isMatched
|
||||
? "bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]"
|
||||
: "bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-gradient-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]"
|
||||
)}
|
||||
onClick={() => handleLeftItemClick(item.id)}
|
||||
onTouchStart={(e) => {
|
||||
// 移动端触摸优化
|
||||
e.currentTarget.classList.add("scale-105");
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.classList.remove("scale-105");
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
{isMatched && (
|
||||
<div className="absolute -right-2 md:-right-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-green-500 rounded-full flex items-center justify-center">
|
||||
<span className="text-white text-xs md:text-sm">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 中间连线区域(移动端隐藏,桌面端显示) */}
|
||||
<div className="hidden md:block w-16 h-full min-h-[300px] relative flex-shrink-0">
|
||||
{/* 绘制连线 */}
|
||||
<svg className="absolute inset-0 w-full h-full pointer-events-none overflow-visible">
|
||||
{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 (
|
||||
<line
|
||||
key={`${match.leftId}-${match.rightId}-${index}`}
|
||||
x1="0"
|
||||
y1={startY}
|
||||
x2="100%"
|
||||
y2={endY}
|
||||
stroke="#f0be75"
|
||||
strokeWidth="3"
|
||||
strokeDasharray="5,5"
|
||||
markerEnd="url(#arrowhead)"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{/* 箭头标记定义 */}
|
||||
<defs>
|
||||
<marker
|
||||
id="arrowhead"
|
||||
markerWidth="10"
|
||||
markerHeight="10"
|
||||
refX="9"
|
||||
refY="3"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon points="0 0, 10 3, 0 6" fill="#f0be75" />
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* 右列:事件 */}
|
||||
<div className="w-full md:w-1/2 flex flex-col gap-3 md:gap-4">
|
||||
{rightItems.map((item) => {
|
||||
const isMatched = isRightItemMatched(item.id);
|
||||
const matchedLeftId = getMatchedLeftId(item.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"relative px-4 md:px-6 py-3 md:py-4 rounded-xl border-2 transition-all duration-200",
|
||||
"text-[20px] md:text-[28px] leading-relaxed",
|
||||
"touch-manipulation", // 优化移动端触摸
|
||||
disabled || isMatched
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
: selectedLeftId
|
||||
? "cursor-pointer"
|
||||
: "cursor-default",
|
||||
isMatched
|
||||
? "bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]"
|
||||
: selectedLeftId
|
||||
? "bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-gradient-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]"
|
||||
: "bg-[#d7b888]/60 text-[#5c3e2e]/60 border-[#d4c5a9]/60"
|
||||
)}
|
||||
onClick={() => handleRightItemClick(item.id)}
|
||||
onTouchStart={(e) => {
|
||||
if (selectedLeftId) {
|
||||
e.currentTarget.classList.add("scale-105");
|
||||
}
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.classList.remove("scale-105");
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
{isMatched && (
|
||||
<div className="absolute -left-2 md:-left-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-green-500 rounded-full flex items-center justify-center">
|
||||
<span className="text-white text-xs md:text-sm">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 移动端连线提示 */}
|
||||
<div className="md:hidden mt-4 text-center text-[18px] text-white/80">
|
||||
请先点击左侧日期,再点击右侧对应的事件进行连线
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MatchQuizPanel;
|
||||
|
||||
@@ -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";
|
||||
|
||||
// 保留旧组件以保持向后兼容
|
||||
|
||||
@@ -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;
|
||||
|
||||
+264
-15
@@ -91,6 +91,8 @@ const Video = forwardRef<VideoRef, VideoProps>(
|
||||
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<VideoRef, VideoProps>(
|
||||
}
|
||||
};
|
||||
|
||||
// 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<VideoRef, VideoProps>(
|
||||
return next;
|
||||
});
|
||||
|
||||
// 重置锚点处理锁
|
||||
if (activeAnchorId) {
|
||||
setActiveAnchorId(null);
|
||||
}
|
||||
// 重置锚点处理锁(如果当前没有激活的锚点)
|
||||
if (!activeAnchorId) {
|
||||
isHandlingAnchor.current = false;
|
||||
}
|
||||
|
||||
// 检查是否跳过了锚点时间,如果是则触发锚点
|
||||
checkAndTriggerAnchors(currentTime);
|
||||
};
|
||||
|
||||
// 获取当前激活的锚点
|
||||
@@ -300,10 +444,53 @@ const Video = forwardRef<VideoRef, VideoProps>(
|
||||
};
|
||||
|
||||
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<VideoRef, VideoProps>(
|
||||
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<VideoRef, VideoProps>(
|
||||
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<VideoRef, VideoProps>(
|
||||
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<VideoRef, VideoProps>(
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [isDragging, duration]);
|
||||
}, [isDragging, duration, checkAndTriggerAnchors, findNearestAnchorBeforeTime]);
|
||||
|
||||
// 音量控制
|
||||
const toggleMute = () => {
|
||||
@@ -434,6 +682,7 @@ const Video = forwardRef<VideoRef, VideoProps>(
|
||||
className="w-full h-full object-cover"
|
||||
controls={false}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onSeeking={handleSeeking}
|
||||
onSeeked={handleSeeked}
|
||||
onEnded={handleEnded}
|
||||
onPlay={handlePlay}
|
||||
|
||||
@@ -247,7 +247,7 @@ const HuangyanpeiInternalization = () => {
|
||||
} else {
|
||||
setDragQuizErrors((prev) => ({
|
||||
...prev,
|
||||
[item.id]: "再想一想,确保选择的都是中国国产企业/品牌~",
|
||||
[item.id]: "特斯拉是美国品牌。请你再想想吧!",
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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(开群女学) */}
|
||||
<SingleChoiceQuizPanel
|
||||
visible={showQuiz3_12}
|
||||
title="【单选】黄炎培和堂兄黄洪培、堂嫂陆开群一起,策划创建的开创了浦东地区女子教育的先河的学校为:"
|
||||
options={[
|
||||
{ value: "A", label: "A. 开群女学" },
|
||||
{ value: "B", label: "B. 上海女子师范学校" },
|
||||
{ value: "C", label: "C. 浦东中学" },
|
||||
]}
|
||||
correctAnswer="A"
|
||||
onWrongSelect={(value) => {
|
||||
switch (value) {
|
||||
case "B":
|
||||
return "上海女子师范学校是上海市区的女子教育机构,并非由黄炎培等人在浦东地区创办。请你再想想吧!";
|
||||
case "C":
|
||||
return "浦东中学是一所面向男女学生的综合性中学,并非专门的女子学校。请你再想想吧!";
|
||||
default:
|
||||
return "请你再想想吧!";
|
||||
}
|
||||
}}
|
||||
onSubmit={handleQuiz3_12Submit}
|
||||
/>
|
||||
|
||||
{/* 题目3成功提示 */}
|
||||
<SuccessAlert
|
||||
visible={showQuiz3Success}
|
||||
onContinue={handleQuiz3SuccessContinue}
|
||||
onClose={handleQuiz3SuccessContinue}
|
||||
/>
|
||||
|
||||
{/* 题目4(浦东中学) */}
|
||||
<SingleChoiceQuizPanel
|
||||
visible={showQuiz4}
|
||||
title="【单选】由黄炎培担任首任校长的浦东中学是:"
|
||||
options={[
|
||||
{ value: "A", label: "A. 近代上海第一所女子中学" },
|
||||
{ value: "B", label: "B. 近代上海第一所职业技术学校" },
|
||||
{ value: "C", label: "C. 近代上海第一所完全中学" },
|
||||
]}
|
||||
correctAnswer="C"
|
||||
onWrongSelect={(value) => {
|
||||
switch (value) {
|
||||
case "A":
|
||||
return "浦东中学是一所男女同校的综合性中学,并非女子中学。请你再想想吧!";
|
||||
case "B":
|
||||
return "浦东中学以普通中学教育为主,其定位是培养全面发展的人才,并非职业技术学校。请你再想想吧!";
|
||||
default:
|
||||
return "请你再想想吧!";
|
||||
}
|
||||
}}
|
||||
onSubmit={handleQuiz4Submit}
|
||||
/>
|
||||
|
||||
{/* 题目4成功提示 */}
|
||||
<SuccessAlert
|
||||
visible={showQuiz4Success}
|
||||
onContinue={handleQuiz4SuccessContinue}
|
||||
onClose={handleQuiz4SuccessContinue}
|
||||
/>
|
||||
|
||||
{/* 光标1、2的引导对话 */}
|
||||
<GuideSteps
|
||||
visible={guide12Visible}
|
||||
onVisibleChange={setGuide12Visible}
|
||||
onVisibleChange={handleGuide12VisibleChange}
|
||||
showMask={false}
|
||||
zIndex={10000}
|
||||
>
|
||||
|
||||
@@ -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,8 +181,16 @@ const HuangyanpeiPlace5 = () => {
|
||||
}}
|
||||
onClose={() => {
|
||||
if (activeExhibitId !== null) {
|
||||
// 如果是展品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 = () => {
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
|
||||
{/* 题目1(光标1对话后) */}
|
||||
<SingleChoiceQuizPanel
|
||||
visible={showQuiz1}
|
||||
title="【单选】黄炎培先生创建民主政团同盟等组织是为了:"
|
||||
options={[
|
||||
{ value: "A", label: "A. 为国家寻求一条和平发展的道路" },
|
||||
{ value: "B", label: 'B. 支持国民党"攘外必先安内"的政策' },
|
||||
{ value: "C", label: "C. 扩大个人政治影响力,争夺权力" },
|
||||
]}
|
||||
correctAnswer="A"
|
||||
onWrongSelect={(value) => {
|
||||
switch (value) {
|
||||
case "B":
|
||||
return '国民党政府坚持"攘外必先安内"的政策导致国共关系紧张,黄炎培正是因为反对这种政策、希望推动和平,才发起组建了民主政团同盟。请你再想想吧!';
|
||||
case "C":
|
||||
return "黄炎培创建民主政团同盟的出发点是民族大义与国家前途,并非为了个人政治利益。请你再想想吧!";
|
||||
default:
|
||||
return "请你再想想吧!";
|
||||
}
|
||||
}}
|
||||
onSubmit={(value, isCorrect) => {
|
||||
if (isCorrect) {
|
||||
setShowQuiz1(false);
|
||||
setShowQuiz1Success(true);
|
||||
}
|
||||
}}
|
||||
zIndex={10001}
|
||||
/>
|
||||
|
||||
{/* 题目1成功提示 */}
|
||||
<SuccessAlert
|
||||
visible={showQuiz1Success}
|
||||
onContinue={() => {
|
||||
setShowQuiz1Success(false);
|
||||
// 标记展品1为已访问,修改cursor状态
|
||||
markExhibitVisited(place, 1);
|
||||
}}
|
||||
onClose={() => {
|
||||
setShowQuiz1Success(false);
|
||||
// 标记展品1为已访问,修改cursor状态
|
||||
markExhibitVisited(place, 1);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 光标2的引导对话 */}
|
||||
<GuideSteps
|
||||
visible={guideVisible}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Title, Image, Cursor, StudyGuideModal } from "@/components";
|
||||
import { Title, Image, Cursor, StudyGuideModal, SingleChoiceQuizPanel } 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";
|
||||
@@ -20,6 +21,10 @@ const HuangyanpeiReflectionScene2 = () => {
|
||||
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 = () => {
|
||||
{/* 第一个选择弹窗 */}
|
||||
<ChoiceDialog1
|
||||
visible={showDialog1}
|
||||
onClose={() => setShowDialog1(false)}
|
||||
onClose={() => {
|
||||
setShowDialog1(false);
|
||||
// 关闭后显示题目
|
||||
setShowQuiz(true);
|
||||
}}
|
||||
onSelect={() => {
|
||||
markReflectionCursorVisited(scene, 1);
|
||||
}}
|
||||
@@ -145,6 +154,42 @@ const HuangyanpeiReflectionScene2 = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 题目(ChoiceDialog1关闭后) */}
|
||||
<SingleChoiceQuizPanel
|
||||
visible={showQuiz}
|
||||
title="【单选】杨斯盛先生近期监工浦东中学建设,有一份上月的工人工钱支出表需要核对,你来告诉我答案吧:"
|
||||
options={[
|
||||
{ value: "A", label: "A. 800 两白银" },
|
||||
{ value: "B", label: "B. 815 两白银" },
|
||||
{ value: "C", label: "C. 820 两白银" },
|
||||
{ value: "D", label: "D. 836 两白银" },
|
||||
]}
|
||||
correctAnswer="C"
|
||||
onWrongSelect={() => {
|
||||
return "是这个数目吗?你要不要再算一下!";
|
||||
}}
|
||||
onSubmit={(value, isCorrect) => {
|
||||
if (isCorrect) {
|
||||
setShowQuiz(false);
|
||||
setShowQuizSuccess(true);
|
||||
}
|
||||
}}
|
||||
zIndex={10001}
|
||||
/>
|
||||
|
||||
{/* 题目成功提示 */}
|
||||
<SuccessAlert
|
||||
visible={showQuizSuccess}
|
||||
title=""
|
||||
content="恭喜你!选择正确。不愧是从川沙小学堂走出来!"
|
||||
onContinue={() => {
|
||||
setShowQuizSuccess(false);
|
||||
}}
|
||||
onClose={() => {
|
||||
setShowQuizSuccess(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 完成提示 */}
|
||||
<StudyGuideModal
|
||||
visible={showCompleteGuide}
|
||||
|
||||
@@ -226,7 +226,7 @@ const LuxunInternalization = () => {
|
||||
|
||||
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 = () => {
|
||||
/>
|
||||
<SuccessAlert
|
||||
visible={activeSuccess === item.id}
|
||||
title="恭喜你, 选择正确!"
|
||||
// title="恭喜你, 选择正确!"
|
||||
content={successContent}
|
||||
onContinue={() => {
|
||||
setActiveSuccess(null);
|
||||
@@ -354,7 +354,7 @@ const LuxunInternalization = () => {
|
||||
/>
|
||||
<SuccessAlert
|
||||
visible={activeSuccess === item.id}
|
||||
title="恭喜你, 选择正确!"
|
||||
// title="恭喜你, 选择正确!"
|
||||
content={successContent}
|
||||
onContinue={() => {
|
||||
setActiveSuccess(null);
|
||||
@@ -400,7 +400,7 @@ const LuxunInternalization = () => {
|
||||
/>
|
||||
<SuccessAlert
|
||||
visible={activeSuccess === item.id}
|
||||
title="恭喜你, 选择正确!"
|
||||
// title="恭喜你, 选择正确!"
|
||||
content={successContent}
|
||||
onContinue={() => {
|
||||
setActiveSuccess(null);
|
||||
|
||||
@@ -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<Set<number>>(new Set());
|
||||
// 连线题显示状态(展品1关闭后)
|
||||
const [showMatchQuiz, setShowMatchQuiz] = useState(false);
|
||||
// 连线题成功提示状态
|
||||
const [showMatchQuizSuccess, setShowMatchQuizSuccess] = useState(false);
|
||||
|
||||
// Cursor 顺序控制:1 -> 2
|
||||
const { isEnabled } = useCursorController({
|
||||
@@ -160,12 +166,18 @@ const LuxunPlace1 = () => {
|
||||
// }
|
||||
onClose={() => {
|
||||
if (activeExhibitId !== null) {
|
||||
// 如果是展品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 = () => {
|
||||
这个过程的确十分曲折。
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
|
||||
{/* 连线题(展品1关闭后) */}
|
||||
<MatchQuizPanel
|
||||
visible={showMatchQuiz}
|
||||
title="【连线】请将左侧的日期与右侧对应的事件进行连线:"
|
||||
leftItems={[
|
||||
{ id: "1", label: "1892-1898年" },
|
||||
{ id: "2", label: "1919-1923年" },
|
||||
{ id: "3", label: "1926年9-12月" },
|
||||
{ id: "4", label: "1927年3-9月" },
|
||||
]}
|
||||
rightItems={[
|
||||
{ id: "a", label: "在绍兴三味书屋求学积淀" },
|
||||
{ id: "b", label: "于北京八道湾参加新文化运动、酝酿《阿Q正传》" },
|
||||
{ id: "c", label: "赴厦门大学任教,聚焦底层创作杂文" },
|
||||
{ id: "d", label: "在广州白云楼整理旧作定稿《朝花夕拾》部分篇章" },
|
||||
]}
|
||||
correctMatches={[
|
||||
{ leftId: "1", rightId: "a" },
|
||||
{ leftId: "2", rightId: "b" },
|
||||
{ leftId: "3", rightId: "c" },
|
||||
{ leftId: "4", rightId: "d" },
|
||||
]}
|
||||
errorMessage="连线不正确,请重新尝试!"
|
||||
onSubmit={(matches, isCorrect) => {
|
||||
if (isCorrect) {
|
||||
setShowMatchQuiz(false);
|
||||
setShowMatchQuizSuccess(true);
|
||||
}
|
||||
}}
|
||||
zIndex={10001}
|
||||
/>
|
||||
|
||||
{/* 连线题成功提示 */}
|
||||
<SuccessAlert
|
||||
visible={showMatchQuizSuccess}
|
||||
onContinue={() => {
|
||||
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;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<DragFillResult[]>([]);
|
||||
// 拖拽题错误提示
|
||||
const [dragQuizError, setDragQuizError] = useState<string>("");
|
||||
// 拖拽题成功提示状态
|
||||
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 = () => {
|
||||
这是鲁迅的自嘲,他曾说"我不会说绵软的苏白,不会打响亮的京腔,不入调,不入流",所以戏称自己的文章是"南腔北调"。这本书里的《〈守常全集》题记》,赞美了李大钊同志,说"他的遗文却将永驻,因为这是先驱者的遗产,革命史上的丰碑。"
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
\{/* 光标3的引导对话 */}
|
||||
{/* 光标3的引导对话 */}
|
||||
<GuideSteps
|
||||
visible={guide3Visible}
|
||||
onVisibleChange={setGuide3Visible}
|
||||
currentStep={guide3Step}
|
||||
onStepChange={setGuide3Step}
|
||||
onStepChange={handleGuide3StepChange}
|
||||
showMask={false}
|
||||
highlightSelectors={[]}
|
||||
zIndex={10000}
|
||||
@@ -274,6 +301,84 @@ const LuxunPlace2 = () => {
|
||||
文字如利刃般戳破虚假论调,揭露了反动派掩盖自身无能、麻痹民众的阴谋,唤醒国人的民族自信,尽显战斗文字的锋芒与力量。
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
|
||||
{/* 拖拽题和成功提示(使用 ScaledPortal 保证层级与缩放) */}
|
||||
<ScaledPortal enabled={showDragQuiz || showDragQuizSuccess} zIndex={10050}>
|
||||
<>
|
||||
<DragQuizPanel
|
||||
visible={showDragQuiz}
|
||||
title=""
|
||||
questionPrefix="【拖拽】我们从古以来,就有"
|
||||
questionSuffix="......这就是中国的脊梁。"
|
||||
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: "" },
|
||||
{ id: "blank-4", label: "" },
|
||||
]}
|
||||
blankSeparators={[",有", ",有", ",有"]}
|
||||
value={dragQuizValue}
|
||||
error={dragQuizError}
|
||||
onChange={(value) => {
|
||||
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}
|
||||
/>
|
||||
|
||||
<SuccessAlert
|
||||
visible={showDragQuizSuccess}
|
||||
onContinue={() => {
|
||||
setShowDragQuizSuccess(false);
|
||||
setDragQuizValue([]);
|
||||
setDragQuizError("");
|
||||
// 题目完成后,继续对话到步骤3
|
||||
setGuide3Visible(true);
|
||||
setGuide3Step(3);
|
||||
}}
|
||||
onClose={() => {
|
||||
setShowDragQuizSuccess(false);
|
||||
setDragQuizValue([]);
|
||||
setDragQuizError("");
|
||||
// 题目完成后,继续对话到步骤3
|
||||
setGuide3Visible(true);
|
||||
setGuide3Step(3);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
</ScaledPortal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<VideoRef>(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 }) => (
|
||||
<>
|
||||
<SingleChoiceQuizPanel
|
||||
visible={!activeVideoSuccess}
|
||||
title='【单选】毛泽东主席称赞鲁迅先生为"最伟大和最英勇的___"。'
|
||||
options={[
|
||||
{ value: "A", label: "A. 旗手" },
|
||||
{ value: "B", label: "B. 作家" },
|
||||
{ value: "C", label: "C. 战士" },
|
||||
]}
|
||||
correctAnswer="A"
|
||||
onWrongSelect={(value: string) => {
|
||||
switch (value) {
|
||||
case "B":
|
||||
return "这不是毛泽东主席的原话。请你再想想吧!";
|
||||
case "C":
|
||||
return "这不是毛泽东主席的原话。请你再想想吧!";
|
||||
default:
|
||||
return "请你再想想吧!";
|
||||
}
|
||||
}}
|
||||
onSubmit={(value: string, isCorrect: boolean) => {
|
||||
if (isCorrect) {
|
||||
setActiveVideoSuccess(true);
|
||||
setResumeVideoCallback(() => resume);
|
||||
}
|
||||
}}
|
||||
zIndex={20000}
|
||||
/>
|
||||
|
||||
<SuccessAlert
|
||||
visible={activeVideoSuccess}
|
||||
onContinue={() => {
|
||||
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 && (
|
||||
<Video
|
||||
ref={videoRef}
|
||||
src={getPublicPath("video/luxun/luxun_06.mp4")}
|
||||
className="fixed inset-0 z-[20000]"
|
||||
controls
|
||||
autoPlay
|
||||
playsInline
|
||||
showSkipButton
|
||||
anchors={videoAnchors}
|
||||
onEnded={() => {
|
||||
setEndingGuideVisible(true);
|
||||
}}
|
||||
|
||||
Reference in new issue
Block a user