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

+61 -1
View File
@@ -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) {
+61
View File
@@ -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 = () => {
+3
View File
@@ -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;
-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;
/** 提交回调,返回用户选择和是否正确 */
+260 -92
View File
@@ -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>
</>
+30 -18
View File
@@ -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();
};
+119
View File
@@ -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>
);
};
+2
View File
@@ -1,3 +1,5 @@
export { default as Overlay } from "./Overlay";
export type { OverlayProps } from "./Overlay";
export { EndingOverlay } from "./EndingOverlay";
export type { EndingOverlayProps } from "./EndingOverlay";