feat: 更新音频和引导对话,优化用户体验

- 在多个场景中添加音频资源,增强学习互动性。
- 更新引导对话文本,确保信息准确性和一致性。
- 调整组件结构,清理不必要的代码,提升可维护性。
- 引入结束窗口,鼓励用户深入了解历史背景。
This commit is contained in:
zhongyidian committed 2026-01-15 19:30:17 +08:00
1 parent d7ff7d3a45
commit 6e106032fd
141 files changed
+1375 -479

No files matched your search

@@ -1,71 +0,0 @@
import { Image } from "@/components";
import { getStaticImage } from "@/lib/utils";
import { ClueData } from "./types";
import DialogueContent from "./DialogueContent";
export interface ClueContentProps {
/** 线索数据 */
clue: ClueData;
}
/**
* 线索内容组件
* 根据线索类型渲染不同的内容
*/
const ClueContent = ({ clue }: ClueContentProps) => {
switch (clue.type) {
case "text":
return (
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
<h2 className="text-4xl font-bold text-[#896a27] mb-4 text-center">
{clue.title}
</h2>
{typeof clue.content === "string" ? (
<div className="text-3xl leading-16 text-[#896a27] whitespace-pre-line">
{clue.content}
</div>
) : (
<div className="text-3xl leading-16 text-[#896a27]">
{clue.content}
</div>
)}
</div>
);
case "map":
return (
<div className="w-full h-full flex items-center justify-center">
<Image
src={getStaticImage(clue.mapImage)}
className="max-w-full max-h-full object-contain"
alt="地图"
/>
</div>
);
case "audio":
return (
<div className="w-full h-full flex items-center justify-center">
<div className="text-5xl leading-relaxed text-white whitespace-pre-line text-center ">
{clue.text}
</div>
<Image
className="absolute left-0 bottom-10 w-full "
src={getStaticImage(clue.audioImage)}
alt="录音"
/>
</div>
);
case "dialogue":
return <DialogueContent clue={clue} />;
case "custom":
return <>{clue.content}</>;
default:
return null;
}
};
export default ClueContent;
@@ -1,130 +0,0 @@
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import { Image } from "@/components";
import { getStaticImage } from "@/lib/utils";
import { DialogueClueData } from "./types";
export interface DialogueContentProps {
/** 对话线索数据 */
clue: DialogueClueData;
}
/**
* 对话内容组件
* 依次显示多条对话,所有对话同时展示,带有动画效果
*/
const DialogueContent = ({ clue }: DialogueContentProps) => {
const [visibleIndices, setVisibleIndices] = useState<number[]>([]);
// 依次显示对话:先显示第一条,延迟后显示第二条
useEffect(() => {
if (clue.dialogues.length === 0) return;
// 立即显示第一条对话
setVisibleIndices([0]);
// 延迟显示后续对话
if (clue.dialogues.length > 1) {
const timer = setTimeout(() => {
setVisibleIndices((prev) => [...prev, 1]);
}, 1500); // 第一条显示1.5秒后显示第二条
return () => clearTimeout(timer);
}
}, [clue.dialogues.length]);
return (
<div className="w-full h-full flex flex-col items-center gap-30 relative px-10">
{clue.dialogues.map((dialogue, index) => {
const isVisible = visibleIndices.includes(index);
return (
<motion.div
key={index}
initial={{ opacity: 0, y: 30, scale: 0.95 }}
animate={
isVisible
? { opacity: 1, y: 0, scale: 1 }
: { opacity: 0, y: 30, scale: 0.95 }
}
transition={{
duration: 0.6,
ease: [0.16, 1, 0.3, 1], // 自定义缓动函数,更流畅
}}
className="flex items-center gap-8 w-full max-w-7xl"
>
{/* 角色头像 */}
{dialogue.avatar && (
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={
isVisible ? { opacity: 1, x: 0 } : { opacity: 0, x: -20 }
}
transition={{
duration: 0.5,
delay: 0.1,
ease: "easeOut",
}}
className="flex-shrink-0 flex flex-col items-center"
>
<Image
src={getStaticImage(dialogue.avatar)}
className="w-60 h-60 object-contain"
alt={dialogue.character}
/>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={
isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 10 }
}
transition={{
duration: 0.4,
delay: 0.3,
ease: "easeOut",
}}
className="mt-3 px-5 py-2 bg-gray-800/90 rounded-lg"
>
<span className="text-white text-base font-medium">
{dialogue.character}
</span>
</motion.div>
</motion.div>
)}
{/* 对话气泡 */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={isVisible ? { opacity: 1, x: 0 } : { opacity: 0, x: 20 }}
transition={{
duration: 0.5,
delay: 0.2,
ease: "easeOut",
}}
className="flex-1 relative"
>
<div className="bg-gray-800/95 rounded-3xl px-20 py-8 shadow-2xl">
<motion.div
initial={{ opacity: 0 }}
animate={isVisible ? { opacity: 1 } : { opacity: 0 }}
transition={{
duration: 0.4,
delay: 0.4,
}}
className="text-white text-4xl leading-relaxed"
>
{dialogue.text}
</motion.div>
</div>
{/* 对话气泡箭头 - 指向左侧头像 */}
<div className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-3">
<div className="w-0 h-0 border-t-[12px] border-t-transparent border-r-[12px] border-r-gray-800/95 border-b-[12px] border-b-transparent" />
</div>
</motion.div>
</motion.div>
);
})}
</div>
);
};
export default DialogueContent;
-167
View File
@@ -1,167 +0,0 @@
import { useEffect, useState, useMemo, MouseEvent, ReactNode } from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "framer-motion";
import { cn, getStaticImage } from "@/lib/utils";
import { Image } from "@/components";
import { useScale } from "@/contexts/ScaleContext";
import ClueContent from "./ClueContent";
import { ClueData } from "./types";
export interface ClueOverlayProps {
/** 是否可见 */
visible?: boolean;
/** 关闭回调 */
onClose?: () => void;
/** 线索数据(如果提供,会优先使用) */
clue?: ClueData;
/** 自定义内容(文本或其他内容,当 clue 未提供时使用) */
children?: ReactNode;
/** 额外的自定义类 */
className?: string;
}
/**
* 线索展示框组件
* - 中间内容区域可自定义(文本或其他内容)
* - 使用背景资源:components/layout/纸(地图和音频类型除外)
*/
const ClueOverlay = ({
visible = true,
onClose,
clue,
children,
className,
}: ClueOverlayProps) => {
const maskSrc = getStaticImage("components/蒙版遮罩");
const backSrc = getStaticImage("components/关闭");
const paperBgSrc = getStaticImage("components/layout/纸");
// 获取缩放参数,用于视觉对齐
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
const [mounted, setMounted] = useState(false);
// 判断是否需要显示纸张背景(地图、音频和对话类型不显示纸张背景)
const shouldShowPaperBg =
clue?.type !== "map" && clue?.type !== "audio" && clue?.type !== "dialogue";
// SSR 兼容:只在客户端挂载
useEffect(() => {
setMounted(true);
}, []);
const handleClose = (e: MouseEvent<HTMLImageElement>) => {
e.stopPropagation();
onClose?.();
};
// 视觉对齐样式:确保与 App 内容对齐
const overlayStyle = useMemo<React.CSSProperties>(
() => ({
position: "fixed",
inset: 0,
zIndex: 9999,
pointerEvents: "none",
}),
[]
);
const scaledContainerStyle = useMemo<React.CSSProperties>(
() => ({
position: "absolute",
width: `${DESIGN_WIDTH}px`,
height: `${DESIGN_HEIGHT}px`,
left: `${offsetX}px`,
top: `${offsetY}px`,
transform: `scale(${scale})`,
transformOrigin: "top left",
overflow: "hidden",
}),
[scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT]
);
if (!mounted) return null;
const content = (
<AnimatePresence>
{visible && (
<motion.div
key="clue-overlay"
style={overlayStyle}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
<div style={scaledContainerStyle}>
<div
className={cn(
"absolute inset-0 flex items-center justify-center",
"bg-black/70",
className
)}
style={{ pointerEvents: "auto" }}
>
{/* 背景蒙版 */}
{maskSrc && (
<Image
src={maskSrc}
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
aria-hidden
/>
)}
{/* 关闭按钮 */}
{onClose && (
<Image
src={backSrc}
onClick={handleClose}
alt="关闭"
className="absolute top-5 right-5 z-20"
button
/>
)}
{/* 内容区域 */}
<div className="relative z-10 flex items-center justify-center w-full h-full">
{shouldShowPaperBg ? (
// 文本类型:显示纸张背景
<div
className="relative"
style={{ minWidth: "600px", minHeight: "400px" }}
>
{/* 纸张背景图片 */}
<Image
src={paperBgSrc}
className="w-full h-full object-contain pointer-events-none select-none"
alt=""
aria-hidden
/>
{/* 自定义内容 - 绝对定位在纸张上方 */}
<div
className="absolute inset-0 flex items-center justify-center"
style={{ padding: "60px 80px" }}
>
<div className="w-full h-full flex flex-col items-center justify-center">
{clue ? <ClueContent clue={clue} /> : children}
</div>
</div>
</div>
) : (
// 地图和音频类型:直接显示内容,无纸张背景
<div className="max-w-full max-h-full w-full flex items-center justify-center">
{clue ? <ClueContent clue={clue} /> : children}
</div>
)}
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
// 直接使用 Portal 渲染到 document.body
return createPortal(content, document.body);
};
export default ClueOverlay;
-58
View File
@@ -1,58 +0,0 @@
import { ReactNode } from "react";
/** 线索类型 */
export type ClueType = "text" | "map" | "audio" | "dialogue" | "custom";
/** 文本线索数据 */
export interface TextClueData {
type: "text";
/** 标题 */
title: string;
/** 内容文本(支持字符串或 ReactNode,字符串可使用 \n 换行) */
content: string | ReactNode;
}
/** 地图线索数据 */
export interface MapClueData {
type: "map";
/** 地图图片资源路径 */
mapImage: string;
}
/** 录音线索数据 */
export interface AudioClueData {
type: "audio";
/** 文本内容 */
text: string;
/** 录音图片资源路径 */
audioImage: string;
}
/** 对话线索数据 */
export interface DialogueClueData {
type: "dialogue";
/** 对话列表 */
dialogues: Array<{
/** 角色名称 */
character: string;
/** 角色头像资源路径 */
avatar?: string;
/** 对话文本 */
text: string;
}>;
}
/** 自定义线索数据 */
export interface CustomClueData {
type: "custom";
/** 自定义内容 */
content: ReactNode;
}
/** 线索数据联合类型 */
export type ClueData =
| TextClueData
| MapClueData
| AudioClueData
| DialogueClueData
| CustomClueData;
+11 -11
View File
@@ -1,6 +1,6 @@
export { default as ResidenceLayout } from "./ResidenceLayout";
export { default as ClueOverlay } from "./clue";
export type { ClueOverlayProps } from "./clue";
export { default as ClueOverlay } from "../../pages/chenyun/reflection/clue";
export type { ClueOverlayProps } from "../../pages/chenyun/reflection/clue";
export { default as Alert } from "./alert/title-alert";
export type { AlertProps } from "./alert/title-alert";
export { default as SuccessAlert } from "./alert/success-alert";
@@ -9,12 +9,12 @@ export { default as ErrorAlert } from "./alert/error-alert";
export type { ErrorAlertProps } from "./alert/error-alert";
export { DragQuizPanel } from "./quiz";
export type { DragQuizPanelProps } from "./quiz";
export type {
ClueData,
ClueType,
TextClueData,
MapClueData,
AudioClueData,
DialogueClueData,
CustomClueData,
} from "./clue/types";
// export type {
// ClueData,
// ClueType,
// TextClueData,
// MapClueData,
// AudioClueData,
// DialogueClueData,
// CustomClueData,
// } from "../../pages/chenyun/reflection/clue/types";
@@ -90,8 +90,12 @@ const MultipleChoiceQuizPanel = ({
onSubmit?.(submittedValues, true);
} else {
// 答题失败:清空状态,执行 onError,允许重新选择
const submittedValues = [...selectedValues];
setSelectedValues([]);
const errorMsg = errorMessage || "很抱歉,你选错了!请你再思考一下吧!";
const errorMsg =
typeof errorMessage === "function"
? errorMessage(submittedValues)
: errorMessage || "很抱歉,你选错了!请你再思考一下吧!";
setError(errorMsg);
onError?.(errorMsg);
}
+2 -2
View File
@@ -65,8 +65,8 @@ export interface MultipleChoiceQuizPanelProps {
options: MultipleChoiceOption[];
/** 正确答案数组(可选,用于验证) */
correctAnswer?: string[];
/** 自定义错误提示信息(答案错误时显示) */
errorMessage?: string;
/** 自定义错误提示信息(答案错误时显示),可以是字符串或函数(根据用户选择返回不同提示) */
errorMessage?: string | ((selectedValues: string[]) => string);
/** 自定义最小选择数量不足时的提示信息 */
minSelectionsErrorMessage?: string;
/** 提交回调,返回用户选择和是否正确 */