feat: 更新音频和引导对话,优化用户体验
- 在多个场景中添加音频资源,增强学习互动性。 - 更新引导对话文本,确保信息准确性和一致性。 - 调整组件结构,清理不必要的代码,提升可维护性。 - 引入结束窗口,鼓励用户深入了解历史背景。
This commit is contained in:
1 parent
d7ff7d3a45
commit
6e106032fd
141 files changed
+1375
-479
No files matched your search
@@ -1,9 +1,24 @@
|
||||
import { ReactNode, useState, Children, isValidElement } from "react";
|
||||
import {
|
||||
ReactNode,
|
||||
useState,
|
||||
Children,
|
||||
isValidElement,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { cn, getBackgroundImage } from "@/lib/utils";
|
||||
import { Overlay } from "@/components/overlay";
|
||||
import { useVideoContextSafe } from "@/contexts/VideoContext";
|
||||
import { useAudioManager } from "@/hooks/useAudioManager";
|
||||
|
||||
/**
|
||||
* 使用示例:
|
||||
* <GuideSteps audioChannelId="guide-chenyun" visible>
|
||||
* <GuideStepsItem audioSrc="/audios/step1.mp3" autoPlayOnEnter>
|
||||
* 第一步内容...
|
||||
* </GuideStepsItem>
|
||||
* </GuideSteps>
|
||||
*/
|
||||
interface GuideStepsProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
@@ -17,11 +32,17 @@ interface GuideStepsProps {
|
||||
showMask?: boolean;
|
||||
/** 自定义 z-index,用于控制层级(默认:50) */
|
||||
zIndex?: number;
|
||||
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
|
||||
audioChannelId?: string;
|
||||
}
|
||||
|
||||
interface GuideStepsItemProps {
|
||||
children: ReactNode;
|
||||
speaker?: "guide" | "student";
|
||||
/** 当前步骤对应的音频地址 */
|
||||
audioSrc?: string;
|
||||
/** 进入该步骤时是否自动播放音频(有 audioSrc 且未显式设置时,默认自动播放) */
|
||||
autoPlayOnEnter?: boolean;
|
||||
}
|
||||
|
||||
const GuideSteps = ({
|
||||
@@ -34,6 +55,7 @@ const GuideSteps = ({
|
||||
highlightSelectors,
|
||||
showMask = true,
|
||||
zIndex = 50,
|
||||
audioChannelId,
|
||||
}: GuideStepsProps) => {
|
||||
const [internalStep, setInternalStep] = useState(0);
|
||||
const [internalVisible, setInternalVisible] = useState(true);
|
||||
@@ -41,21 +63,37 @@ const GuideSteps = ({
|
||||
// 安全地获取视频上下文(如果不在 VideoProvider 中,返回 null)
|
||||
const videoContext = useVideoContextSafe();
|
||||
|
||||
// 音频管理:默认全局互斥,如果传了 audioChannelId 则按 channel 互斥
|
||||
const { play, stop } = useAudioManager(
|
||||
audioChannelId
|
||||
? { mode: "channel", channelId: audioChannelId }
|
||||
: { mode: "global" }
|
||||
);
|
||||
|
||||
// 收集每个步骤的内容和 speaker 信息
|
||||
const stepsData = Children.toArray(children).map((child) => {
|
||||
if (
|
||||
isValidElement<GuideStepsItemProps>(child) &&
|
||||
child.type === GuideStepsItem
|
||||
) {
|
||||
const hasAudio = !!child.props.audioSrc;
|
||||
return {
|
||||
content: child.props.children,
|
||||
speaker: child.props.speaker || "guide", // 默认为 "guide" 保持向后兼容
|
||||
audioSrc: child.props.audioSrc,
|
||||
// 如果没有显式设置 autoPlayOnEnter,则在有 audioSrc 时默认自动播放
|
||||
autoPlayOnEnter:
|
||||
child.props.autoPlayOnEnter !== undefined
|
||||
? child.props.autoPlayOnEnter
|
||||
: hasAudio,
|
||||
};
|
||||
}
|
||||
// 如果不是 GuideStepsItem,保持原有行为
|
||||
return {
|
||||
content: child,
|
||||
speaker: "guide" as const,
|
||||
audioSrc: undefined,
|
||||
autoPlayOnEnter: false,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -76,6 +114,17 @@ const GuideSteps = ({
|
||||
|
||||
// 获取当前步骤的 speaker
|
||||
const currentSpeaker = stepsData[currentStep]?.speaker || "guide";
|
||||
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) {
|
||||
@@ -88,9 +137,20 @@ const GuideSteps = ({
|
||||
if (visible === undefined) {
|
||||
setInternalVisible(newVisible);
|
||||
}
|
||||
// 隐藏引导时,停止当前 channel 的音频
|
||||
if (!newVisible) {
|
||||
stop();
|
||||
}
|
||||
onVisibleChange?.(newVisible);
|
||||
};
|
||||
|
||||
// 受控 visible 场景:当外部把 visible 置为 false 时,也需要停止音频
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
stop();
|
||||
}
|
||||
}, [isVisible, stop]);
|
||||
|
||||
const handleNext = (e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // 阻止事件冒泡
|
||||
if (currentStep < totalSteps - 1) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { createPortal } from "react-dom";
|
||||
import { Image } from "@/components";
|
||||
import { cn, getStaticImage } from "@/lib/utils";
|
||||
import { useScale } from "@/contexts/ScaleContext";
|
||||
import { useAudioManager } from "@/hooks/useAudioManager";
|
||||
import { useVideoContextSafe } from "@/contexts/VideoContext";
|
||||
|
||||
export interface StudyGuideModalProps {
|
||||
/** 是否可见 */
|
||||
@@ -22,11 +24,27 @@ export interface StudyGuideModalProps {
|
||||
ailgn?: "center" | "left" | "right";
|
||||
// 垂直对齐方式,默认 center
|
||||
verticalAlign?: "center" | "top" | "bottom";
|
||||
/** 引导语音地址(可选) */
|
||||
audioSrc?: string;
|
||||
/** 弹窗出现时是否自动播放引导语音(默认 true) */
|
||||
autoPlayAudio?: boolean;
|
||||
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
|
||||
audioChannelId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用学习引导弹窗
|
||||
* 使用 images/components/guide 下的引导框 + 继续按钮资源
|
||||
*
|
||||
* 使用示例:
|
||||
* <StudyGuideModal
|
||||
* visible
|
||||
* audioSrc="/audios/modal-intro.mp3"
|
||||
* autoPlayAudio
|
||||
* audioChannelId="guide-chenyun"
|
||||
* >
|
||||
* 文案内容...
|
||||
* </StudyGuideModal>
|
||||
*/
|
||||
export const StudyGuideModal = ({
|
||||
visible = false,
|
||||
@@ -37,15 +55,58 @@ export const StudyGuideModal = ({
|
||||
zIndex = 120,
|
||||
ailgn = "center",
|
||||
verticalAlign = "top",
|
||||
audioSrc,
|
||||
autoPlayAudio = true,
|
||||
audioChannelId,
|
||||
}: StudyGuideModalProps) => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale();
|
||||
const videoContext = useVideoContextSafe();
|
||||
const { currentSrc, play, stop } = useAudioManager(
|
||||
audioChannelId
|
||||
? {
|
||||
mode: "channel",
|
||||
channelId: audioChannelId,
|
||||
}
|
||||
: { mode: "global" }
|
||||
);
|
||||
|
||||
// SSR 兼容:只在客户端挂载
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// 当视频正在播放或内容未展示时,不应播放引导语音
|
||||
const canPlayAudio =
|
||||
!videoContext ||
|
||||
(!videoContext.isVideoPlaying && videoContext.isContentVisible);
|
||||
|
||||
// 弹窗显隐联动音频播放/停止
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
|
||||
if (visible && autoPlayAudio && audioSrc && canPlayAudio) {
|
||||
play(audioSrc);
|
||||
}
|
||||
// 注意:这里不依赖 currentSrc,避免 play() 更新状态后重复触发自动播放
|
||||
}, [visible, mounted, autoPlayAudio, audioSrc, play, canPlayAudio]);
|
||||
|
||||
// 关闭弹窗时停止(仅当当前播放的是该弹窗的 audioSrc)
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
if (!visible && audioSrc && currentSrc === audioSrc) {
|
||||
stop();
|
||||
}
|
||||
}, [visible, mounted, audioSrc, currentSrc, stop]);
|
||||
|
||||
// 当视频开始播放时,如果当前正在播放该弹窗音频,则停止,避免视频音频与弹窗音频重叠
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
if (!canPlayAudio && audioSrc && currentSrc === audioSrc) {
|
||||
stop();
|
||||
}
|
||||
}, [mounted, canPlayAudio, audioSrc, currentSrc, stop]);
|
||||
|
||||
if (!visible || !mounted) return null;
|
||||
|
||||
const handleMaskClick = () => {
|
||||
|
||||
@@ -24,6 +24,9 @@ export type { ExhibitOverlayProps } from "./exhibit";
|
||||
export { Overlay } from "./overlay";
|
||||
export type { OverlayProps } from "./overlay";
|
||||
|
||||
export { EndingOverlay } from "./overlay";
|
||||
export type { EndingOverlayProps } from "./overlay";
|
||||
|
||||
// Spotlight component
|
||||
export { Spotlight } from "./spotlight";
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -65,8 +65,8 @@ export interface MultipleChoiceQuizPanelProps {
|
||||
options: MultipleChoiceOption[];
|
||||
/** 正确答案数组(可选,用于验证) */
|
||||
correctAnswer?: string[];
|
||||
/** 自定义错误提示信息(答案错误时显示) */
|
||||
errorMessage?: string;
|
||||
/** 自定义错误提示信息(答案错误时显示),可以是字符串或函数(根据用户选择返回不同提示) */
|
||||
errorMessage?: string | ((selectedValues: string[]) => string);
|
||||
/** 自定义最小选择数量不足时的提示信息 */
|
||||
minSelectionsErrorMessage?: string;
|
||||
/** 提交回调,返回用户选择和是否正确 */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { getStaticImage } from "@/lib/utils";
|
||||
import { Image } from "@/components";
|
||||
@@ -23,6 +23,47 @@ const LetterToHuangyanpei = ({
|
||||
}: LetterToHuangyanpeiProps) => {
|
||||
// 下划线可填写内容
|
||||
const [blankValues, setBlankValues] = useState<string[]>([]);
|
||||
// 每个空的校验状态:true 表示该空当前不符合要求(为空或超过 10 个字)
|
||||
const [blankErrors, setBlankErrors] = useState<boolean[]>([]);
|
||||
|
||||
// 是否已提交:提交后隐藏编辑信件,展示只读信件
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
// 校验输入:不能为空、每个不超过 10 个字符
|
||||
const validateBlanks = () => {
|
||||
// 计算占位总数,防止有些没被输入过而是 undefined
|
||||
const placeholderCount = (letterContent.match(/____/g) || []).length;
|
||||
|
||||
const nextErrors: boolean[] = Array(placeholderCount).fill(false);
|
||||
const nextValues: string[] = [...blankValues];
|
||||
let hasError = false;
|
||||
|
||||
for (let i = 0; i < placeholderCount; i++) {
|
||||
const raw = blankValues[i] || "";
|
||||
const v = raw.trim();
|
||||
|
||||
// 为空 或 超过 10 个字,都视为不符合要求:清空并标红
|
||||
if (!v || v.length > 10) {
|
||||
nextValues[i] = "";
|
||||
nextErrors[i] = true;
|
||||
hasError = true;
|
||||
}
|
||||
}
|
||||
|
||||
setBlankValues(nextValues);
|
||||
setBlankErrors(nextErrors);
|
||||
|
||||
return !hasError;
|
||||
};
|
||||
|
||||
// 弹窗关闭后重置内容,方便下次重新填写
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setSubmitted(false);
|
||||
setBlankValues([]);
|
||||
setBlankErrors([]);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// 信件内容模板(根据设计稿)
|
||||
const letterContent = `敬爱的黄炎培先生:
|
||||
@@ -67,112 +108,239 @@ const LetterToHuangyanpei = ({
|
||||
<div
|
||||
className="relative w-[1200px] h-full pointer-events-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ perspective: "1200px" }}
|
||||
>
|
||||
{/* 信纸背景 */}
|
||||
<img
|
||||
src={getStaticImage("components/letter/信底")}
|
||||
alt="信纸"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
|
||||
{/* 标题 - 垂直显示在左侧 */}
|
||||
<div className="absolute left-[160px] top-20 bottom-0 [text-shadow:_0px_0px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921]">
|
||||
{/* 编辑态 / 只读态:Y 轴翻转切换 */}
|
||||
<motion.div
|
||||
className="relative w-full h-full"
|
||||
initial={false}
|
||||
animate={{ rotateY: submitted ? 180 : 0 }}
|
||||
transition={{ duration: 0.7, ease: "easeInOut" }}
|
||||
style={{ transformStyle: "preserve-3d" }}
|
||||
>
|
||||
{/* 前面:编辑态 */}
|
||||
<div
|
||||
className="text-white text-6xl font-bold tracking-[20px]"
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
writingMode: "vertical-rl",
|
||||
textOrientation: "upright",
|
||||
backfaceVisibility: "hidden",
|
||||
// 编辑态展示时才响应事件,避免与背面点击冲突
|
||||
pointerEvents: submitted ? "none" : "auto",
|
||||
}}
|
||||
>
|
||||
写给黄炎培先生的信
|
||||
</div>
|
||||
</div>
|
||||
{/* 编辑态:信纸背景 */}
|
||||
<img
|
||||
src={getStaticImage("components/letter/信底")}
|
||||
alt="信纸"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
|
||||
{/* 信件内容区域 */}
|
||||
<div className="absolute w-[700px] left-[320px] right-[180px] top-[80px] bottom-[180px] z-99">
|
||||
<div className="text-[#4e402e] text-[26px] leading-relaxed">
|
||||
{(() => {
|
||||
let placeholderIndex = 0;
|
||||
let foundNiHao = false;
|
||||
return letterContent.split("\n").map((line, lineIndex) => {
|
||||
// 检测是否已经找到"您好"行
|
||||
if (line.includes("您好")) {
|
||||
foundNiHao = true;
|
||||
}
|
||||
{/* 标题 - 垂直显示在左侧 */}
|
||||
<div className="absolute left-[160px] top-20 bottom-0 [text-shadow:_0px_0px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921]">
|
||||
<div
|
||||
className="text-white text-6xl font-bold tracking-[20px]"
|
||||
style={{
|
||||
writingMode: "vertical-rl",
|
||||
textOrientation: "upright",
|
||||
}}
|
||||
>
|
||||
写给黄炎培先生的信
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// "您好"行之后的非空行需要首行缩进
|
||||
const needsIndent =
|
||||
foundNiHao &&
|
||||
line.trim() !== "" &&
|
||||
!line.includes("您好");
|
||||
|
||||
const segments = line.split("____");
|
||||
return (
|
||||
<div
|
||||
key={lineIndex}
|
||||
className="mb-0"
|
||||
style={needsIndent ? { textIndent: "2em" } : {}}
|
||||
>
|
||||
{segments.map((part, partIndex) => {
|
||||
const isLast = partIndex === segments.length - 1;
|
||||
if (isLast) {
|
||||
return (
|
||||
<span key={`part-${lineIndex}-${partIndex}`}>
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
{/* 信件内容区域(可编辑) */}
|
||||
<div className="absolute w-[700px] left-[320px] right-[180px] top-[80px] bottom-[180px] z-99">
|
||||
<div className="text-[#4e402e] text-[26px] leading-relaxed">
|
||||
{(() => {
|
||||
let placeholderIndex = 0;
|
||||
let foundNiHao = false;
|
||||
return letterContent
|
||||
.split("\n")
|
||||
.map((line, lineIndex) => {
|
||||
// 检测是否已经找到"您好"行
|
||||
if (line.includes("您好")) {
|
||||
foundNiHao = true;
|
||||
}
|
||||
|
||||
const currentIndex = placeholderIndex++;
|
||||
// "您好"行之后的非空行需要首行缩进
|
||||
const needsIndent =
|
||||
foundNiHao &&
|
||||
line.trim() !== "" &&
|
||||
!line.includes("您好");
|
||||
|
||||
const segments = line.split("____");
|
||||
return (
|
||||
<span key={`part-${lineIndex}-${partIndex}`}>
|
||||
{part}
|
||||
<input
|
||||
className="inline-block w-[100px] h-[36px] mx-2 align-middle bg-transparent border-b-2 border-dashed border-[#4e402e] text-[24px] text-center outline-none"
|
||||
value={blankValues[currentIndex] ?? ""}
|
||||
onChange={(e) => {
|
||||
const next = [...blankValues];
|
||||
next[currentIndex] = e.target.value;
|
||||
setBlankValues(next);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
key={lineIndex}
|
||||
style={needsIndent ? { textIndent: "2em" } : {}}
|
||||
>
|
||||
{segments.map((part, partIndex) => {
|
||||
const isLast =
|
||||
partIndex === segments.length - 1;
|
||||
if (isLast) {
|
||||
return (
|
||||
<span
|
||||
key={`part-${lineIndex}-${partIndex}`}
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const currentIndex = placeholderIndex++;
|
||||
return (
|
||||
<span
|
||||
key={`part-${lineIndex}-${partIndex}`}
|
||||
>
|
||||
{part}
|
||||
<input
|
||||
name={`blank-${currentIndex}`}
|
||||
className={`inline-block w-[100px] h-[36px] mx-2 bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
|
||||
blankErrors[currentIndex]
|
||||
? "border-red-500"
|
||||
: "border-[#4e402e]"
|
||||
}`}
|
||||
value={blankValues[currentIndex] ?? ""}
|
||||
onChange={(e) => {
|
||||
const next = [...blankValues];
|
||||
next[currentIndex] = e.target.value;
|
||||
setBlankValues(next);
|
||||
|
||||
// 用户再次输入时,清除该空的错误高亮
|
||||
if (blankErrors[currentIndex]) {
|
||||
const nextErrs = [...blankErrors];
|
||||
nextErrs[currentIndex] = false;
|
||||
setBlankErrors(nextErrs);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 笔的图片 */}
|
||||
<div className="absolute bottom-[150px] -right-30">
|
||||
<img
|
||||
src={getStaticImage("components/letter/笔")}
|
||||
alt="笔"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 提交按钮:切换到只读信件态 */}
|
||||
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
|
||||
<Image
|
||||
src={getStaticImage("components/letter/可提交状态")}
|
||||
alt="可提交状态"
|
||||
onClick={() => {
|
||||
if (!validateBlanks()) return;
|
||||
setSubmitted(true);
|
||||
}}
|
||||
button
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 笔的图片 */}
|
||||
<div className="absolute bottom-[150px] -right-30">
|
||||
<img
|
||||
src={getStaticImage("components/letter/笔")}
|
||||
alt="笔"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
{/* 背面:只读态 */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
transform: "rotateY(180deg)",
|
||||
backfaceVisibility: "hidden",
|
||||
// 只读态展示时才响应事件
|
||||
pointerEvents: submitted ? "auto" : "none",
|
||||
}}
|
||||
>
|
||||
{/* 只读态:新背景 */}
|
||||
<img
|
||||
src={getStaticImage("components/letter/信")}
|
||||
alt="信件"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
<Image
|
||||
src={getStaticImage("components/close")}
|
||||
alt="信纸"
|
||||
className="absolute right-38 top-0"
|
||||
button
|
||||
onClick={() => {
|
||||
setSubmitted(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
|
||||
<Image
|
||||
src={getStaticImage("components/letter/可提交状态")}
|
||||
alt="可提交状态"
|
||||
onClick={() => onSubmit?.(blankValues)}
|
||||
button
|
||||
/>
|
||||
</div>
|
||||
{/* 信件内容区域(只读) */}
|
||||
<div className="absolute w-[700px] left-[260px] right-[180px] top-[80px] bottom-[180px] z-99">
|
||||
<div className="text-[#4e402e] text-[26px] leading-relaxed">
|
||||
{(() => {
|
||||
let placeholderIndex = 0;
|
||||
let foundNiHao = false;
|
||||
return letterContent
|
||||
.split("\n")
|
||||
.map((line, lineIndex) => {
|
||||
if (line.includes("您好")) {
|
||||
foundNiHao = true;
|
||||
}
|
||||
|
||||
{/* 寄信图标(可选,如果需要的话) */}
|
||||
<div className="absolute top-[50px] right-[50px] w-[100px] h-[100px] opacity-80">
|
||||
<img
|
||||
src={getStaticImage("components/letter/寄信图标")}
|
||||
alt="寄信"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
const needsIndent =
|
||||
foundNiHao &&
|
||||
line.trim() !== "" &&
|
||||
!line.includes("您好");
|
||||
|
||||
const segments = line.split("____");
|
||||
return (
|
||||
<div
|
||||
key={lineIndex}
|
||||
style={needsIndent ? { textIndent: "2em" } : {}}
|
||||
>
|
||||
{segments.map((part, partIndex) => {
|
||||
const isLast =
|
||||
partIndex === segments.length - 1;
|
||||
if (isLast) {
|
||||
return (
|
||||
<span
|
||||
key={`part-${lineIndex}-${partIndex}`}
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const currentIndex = placeholderIndex++;
|
||||
const value = blankValues[currentIndex] ?? "";
|
||||
|
||||
return (
|
||||
<span
|
||||
key={`part-${lineIndex}-${partIndex}`}
|
||||
>
|
||||
{part}
|
||||
{value || " "}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 寄出按钮:关闭窗口 */}
|
||||
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
|
||||
<Image
|
||||
src={getStaticImage("components/letter/寄出")}
|
||||
alt="寄出"
|
||||
onClick={() => {
|
||||
onSubmit?.(blankValues);
|
||||
}}
|
||||
button
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAudioManager } from "@/hooks/useAudioManager";
|
||||
|
||||
export interface NarrationProps {
|
||||
/** 旁白文本 */
|
||||
@@ -16,6 +17,10 @@ export interface NarrationProps {
|
||||
enableAnimation?: boolean;
|
||||
/** 文字动画速度(毫秒/字),默认 100ms */
|
||||
animationSpeed?: number;
|
||||
/** 覆盖层出现时是否自动播放音频(默认 true) */
|
||||
autoPlayAudio?: boolean;
|
||||
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
|
||||
audioChannelId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,18 +39,33 @@ const Narration = ({
|
||||
className,
|
||||
enableAnimation = true,
|
||||
animationSpeed = 100,
|
||||
autoPlayAudio = true,
|
||||
audioChannelId,
|
||||
}: NarrationProps) => {
|
||||
const [displayedText, setDisplayedText] = useState("");
|
||||
const [isAnimating, setIsAnimating] = useState(true);
|
||||
const [audio, setAudio] = useState<HTMLAudioElement | null>(null);
|
||||
const [isSkipped, setIsSkipped] = useState(false);
|
||||
|
||||
// 旁白音频:默认自动播放,支持按 channel 分组互斥
|
||||
const { currentSrc, play, stop } = useAudioManager(
|
||||
audioChannelId
|
||||
? {
|
||||
mode: "channel",
|
||||
channelId: audioChannelId,
|
||||
}
|
||||
: { mode: "global" }
|
||||
);
|
||||
|
||||
// 文字动画效果
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setDisplayedText("");
|
||||
setIsAnimating(true);
|
||||
setIsSkipped(false);
|
||||
// 旁白关闭时,如果当前正在播放该旁白音频,则停止
|
||||
if (audioSrc && currentSrc === audioSrc) {
|
||||
stop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -76,7 +96,7 @@ const Narration = ({
|
||||
}, animationSpeed);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [text, visible, enableAnimation, animationSpeed, isSkipped]);
|
||||
}, [text, visible, enableAnimation, animationSpeed, isSkipped, audioSrc, currentSrc, stop]);
|
||||
|
||||
// 跳过动画
|
||||
const handleSkip = () => {
|
||||
@@ -85,27 +105,19 @@ const Narration = ({
|
||||
setIsSkipped(true);
|
||||
};
|
||||
|
||||
// 语音播报
|
||||
// 旁白出现时自动播放音频
|
||||
useEffect(() => {
|
||||
if (!visible || !audioSrc) return;
|
||||
if (!visible) return;
|
||||
if (!autoPlayAudio) return;
|
||||
if (!audioSrc) return;
|
||||
|
||||
const audioElement = new Audio(audioSrc);
|
||||
audioElement.play().catch((error) => {
|
||||
console.warn("Audio playback failed:", error);
|
||||
});
|
||||
setAudio(audioElement);
|
||||
|
||||
return () => {
|
||||
audioElement.pause();
|
||||
audioElement.currentTime = 0;
|
||||
};
|
||||
}, [visible, audioSrc]);
|
||||
play(audioSrc);
|
||||
}, [visible, autoPlayAudio, audioSrc, play]);
|
||||
|
||||
// 停止音频
|
||||
const handleClose = () => {
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
if (audioSrc && currentSrc === audioSrc) {
|
||||
stop();
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getStaticImage } from "@/lib/utils";
|
||||
import { useAudioManager } from "@/hooks/useAudioManager";
|
||||
|
||||
export interface EndingOverlayProps {
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
/** 中间显示的文本内容 */
|
||||
text: string;
|
||||
/** 点击继续回调 */
|
||||
onContinue: () => void;
|
||||
/** 文字动画速度(毫秒/字),默认 60ms */
|
||||
animationSpeed?: number;
|
||||
/** 结语对应的音频地址(可选) */
|
||||
audioSrc?: string;
|
||||
/** 覆盖层出现时是否自动播放音频(默认 true) */
|
||||
autoPlayAudio?: boolean;
|
||||
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
|
||||
audioChannelId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 反思模块统一的结语覆盖层
|
||||
* 使用 components/overlay/bg 作为背景
|
||||
*/
|
||||
export const EndingOverlay = ({
|
||||
visible,
|
||||
text,
|
||||
onContinue,
|
||||
animationSpeed = 60,
|
||||
audioSrc,
|
||||
autoPlayAudio = true,
|
||||
audioChannelId,
|
||||
}: EndingOverlayProps) => {
|
||||
const [displayedText, setDisplayedText] = useState("");
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
// 结语音频:默认自动播放,支持按 channel 分组互斥
|
||||
const { currentSrc, play, stop } = useAudioManager(
|
||||
audioChannelId
|
||||
? {
|
||||
mode: "channel",
|
||||
channelId: audioChannelId,
|
||||
}
|
||||
: { mode: "global" }
|
||||
);
|
||||
|
||||
// 文字逐字动画
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setDisplayedText("");
|
||||
setIsAnimating(false);
|
||||
// 结语关闭时,如果当前正在播放该结语音频,则停止
|
||||
if (audioSrc && currentSrc === audioSrc) {
|
||||
stop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let currentIndex = 0;
|
||||
setDisplayedText("");
|
||||
setIsAnimating(true);
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (currentIndex < text.length) {
|
||||
setDisplayedText(text.slice(0, currentIndex + 1));
|
||||
currentIndex++;
|
||||
} else {
|
||||
setIsAnimating(false);
|
||||
clearInterval(timer);
|
||||
}
|
||||
}, animationSpeed);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [visible, text, animationSpeed, audioSrc, currentSrc, stop]);
|
||||
|
||||
// 覆盖层显示时自动播放音频
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
if (!autoPlayAudio) return;
|
||||
if (!audioSrc) return;
|
||||
|
||||
play(audioSrc);
|
||||
}, [visible, autoPlayAudio, audioSrc, play]);
|
||||
|
||||
// 全部文字出现后 1 秒自动关闭
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
if (isAnimating) return;
|
||||
if (!text || displayedText !== text) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
onContinue();
|
||||
}, 800);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [visible, isAnimating, displayedText, text, onContinue]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-transparent">
|
||||
{/* 文本内容层 */}
|
||||
<div
|
||||
className="relative z-10 flex items-center max-w-8xl py-50 px-24 h-[825px] bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: `url(${getStaticImage("components/overlay/bg")})`,
|
||||
}}
|
||||
>
|
||||
<div className="text-4xl leading-relaxed text-[#7b3f00] font-semibold tracking-wide">
|
||||
{displayedText}
|
||||
</div>
|
||||
|
||||
{/* 底部渐隐提示条,仅做视觉平衡 */}
|
||||
{/* <div className="mt-16 h-4 w-full bg-gradient-to-r from-transparent via-[rgba(0,0,0,0.35)] to-transparent rounded-full" /> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
export { default as Overlay } from "./Overlay";
|
||||
export type { OverlayProps } from "./Overlay";
|
||||
|
||||
export { EndingOverlay } from "./EndingOverlay";
|
||||
export type { EndingOverlayProps } from "./EndingOverlay";
|
||||
Reference in new issue
Block a user