feat: 移除不再使用的配置文件和更新组件,提升代码整洁性

- 删除 .claude/settings.local.json 配置文件,清理不必要的代码。
- 更新多个组件以引入音频资源,增强用户互动体验。
- 调整引导对话文本,确保信息准确性和一致性。
- 更新图像资源,提升视觉内容的准确性和一致性。
This commit is contained in:
zhongyidian committed 2026-01-20 18:06:55 +08:00
1 parent cf64e7ec50
commit 8ba36a4a1a
132 files changed
+546 -454

No files matched your search

@@ -1,14 +1,19 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { useEffect } from "react";
import { cn, getBackgroundImage } from "@/lib/utils";
import { AnimatePresence, motion } from "framer-motion";
import { useAudioManager } from "@/hooks/useAudioManager";
export interface ExhibitDescriptionNarrationProps {
/** 描述文本 */
text?: string;
/** 是否可见 */
visible?: boolean;
/** 是否自动播放语音 */
/** 是否自动播放音频(默认 true) */
autoPlay?: boolean;
/** 音频文件地址 */
audioSrc?: string;
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
audioChannelId?: string;
/** 额外的自定义类 */
className?: string;
}
@@ -16,114 +21,41 @@ export interface ExhibitDescriptionNarrationProps {
/**
* 展品描述播报组件
* - 显示展品描述文本
* - 支持文本转语音(TTS)播报
* - 提供播放/暂停控制
* - 支持音频文件播放(通过 useAudioManager)
* - 提供播放/停止控制
*/
const ExhibitDescriptionNarration = ({
text,
visible = true,
autoPlay = false,
autoPlay = true,
audioSrc,
audioChannelId,
className,
}: ExhibitDescriptionNarrationProps) => {
const [isPlaying, setIsPlaying] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
const synthRef = useRef<typeof window.speechSynthesis | null>(null);
// 音频管理:默认全局互斥,如果传了 audioChannelId 则按 channel 互斥
const { currentSrc, isPlaying, play, stop } = useAudioManager(
audioChannelId
? { mode: "channel", channelId: audioChannelId }
: { mode: "global" }
);
// 初始化语音合成
// 当前组件对应的音频是否正在播放
const isCurrentAudioPlaying =
audioSrc && currentSrc === audioSrc && isPlaying;
// 自动播放音频
useEffect(() => {
if (typeof window !== "undefined" && "speechSynthesis" in window) {
synthRef.current = window.speechSynthesis;
if (visible && autoPlay && audioSrc) {
play(audioSrc);
}
}, []);
}, [visible, autoPlay, audioSrc, play]);
// 播放语音
const playSpeech = useCallback(() => {
if (!text || !synthRef.current) return;
// 如果已有正在播放的语音,先停止
if (utteranceRef.current) {
synthRef.current.cancel();
}
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = "zh-CN";
utterance.rate = 0.9; // 语速稍慢
utterance.pitch = 1; // 音调
utterance.volume = 1; // 音量
utterance.onstart = () => {
setIsPlaying(true);
setIsPaused(false);
};
utterance.onend = () => {
setIsPlaying(false);
setIsPaused(false);
utteranceRef.current = null;
};
utterance.onerror = () => {
setIsPlaying(false);
setIsPaused(false);
utteranceRef.current = null;
};
utteranceRef.current = utterance;
synthRef.current.speak(utterance);
}, [text]);
// 暂停语音
const pauseSpeech = useCallback(() => {
if (synthRef.current && isPlaying) {
synthRef.current.pause();
setIsPaused(true);
}
}, [isPlaying]);
// 继续播放语音
const resumeSpeech = useCallback(() => {
if (synthRef.current && isPaused) {
synthRef.current.resume();
setIsPaused(false);
}
}, [isPaused]);
// 停止语音
const stopSpeech = useCallback(() => {
if (synthRef.current) {
synthRef.current.cancel();
setIsPlaying(false);
setIsPaused(false);
utteranceRef.current = null;
}
}, []);
// 自动播放
// 组件不可见时停止音频(仅当当前播放的是该组件的 audioSrc)
useEffect(() => {
if (visible && autoPlay && text && synthRef.current) {
// 延迟一下确保组件已渲染
const timer = setTimeout(() => {
playSpeech();
}, 300);
return () => {
clearTimeout(timer);
stopSpeech();
};
if (!visible && audioSrc && currentSrc === audioSrc) {
stop();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, autoPlay, text]);
// 组件卸载或不可见时停止语音
useEffect(() => {
if (!visible) {
stopSpeech();
}
return () => {
stopSpeech();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible]);
}, [visible, audioSrc, currentSrc, stop]);
if (!text) return null;
@@ -166,12 +98,12 @@ const ExhibitDescriptionNarration = ({
{text}
</div>
{/* 控制按钮 */}
{synthRef.current && (
{/* 控制按钮 - 仅在提供了 audioSrc 时显示 */}
{audioSrc && (
<div className="flex items-center justify-end gap-2 flex-shrink-0">
{!isPlaying && !isPaused && (
{!isCurrentAudioPlaying && (
<button
onClick={playSpeech}
onClick={() => play(audioSrc)}
className="w-10 h-10 flex items-center justify-center rounded-full bg-white/30 hover:bg-white/50 transition-colors shadow-lg"
aria-label="播放"
>
@@ -184,39 +116,9 @@ const ExhibitDescriptionNarration = ({
</svg>
</button>
)}
{isPlaying && !isPaused && (
{isCurrentAudioPlaying && (
<button
onClick={pauseSpeech}
className="w-10 h-10 flex items-center justify-center rounded-full bg-white/30 hover:bg-white/50 transition-colors shadow-lg"
aria-label="暂停"
>
<svg
className="w-4 h-4 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M5.75 3a.75.75 0 00-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 00.75-.75V3.75A.75.75 0 007.25 3h-1.5zM12.75 3a.75.75 0 00-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 00.75-.75V3.75a.75.75 0 00-.75-.75h-1.5z" />
</svg>
</button>
)}
{isPaused && (
<button
onClick={resumeSpeech}
className="w-10 h-10 flex items-center justify-center rounded-full bg-white/30 hover:bg-white/50 transition-colors shadow-lg"
aria-label="继续播放"
>
<svg
className="w-4 h-4 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z" />
</svg>
</button>
)}
{(isPlaying || isPaused) && (
<button
onClick={stopSpeech}
onClick={stop}
className="w-10 h-10 flex items-center justify-center rounded-full bg-white/30 hover:bg-white/50 transition-colors shadow-lg"
aria-label="停止"
>
+9 -1
View File
@@ -26,6 +26,10 @@ export interface ExhibitOverlayProps {
description?: string;
/** 是否自动播放描述语音,默认 false */
autoPlayDescription?: boolean;
/** 展品描述对应的音频地址(可选) */
audioSrc?: string;
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
audioChannelId?: string;
/** 点击展示 */
onClick?: () => void;
/** 打开回调 */
@@ -48,7 +52,9 @@ const ExhibitOverlay = ({
visible = true,
className,
description,
autoPlayDescription = false,
autoPlayDescription = true,
audioSrc,
audioChannelId,
onClick,
onOpen,
}: ExhibitOverlayProps) => {
@@ -515,6 +521,8 @@ const ExhibitOverlay = ({
text={description}
visible={visible}
autoPlay={autoPlayDescription}
audioSrc={audioSrc}
audioChannelId={audioChannelId}
className="absolute left-20 top-1/2 -translate-y-1/2"
/>
)}
+1 -1
View File
@@ -152,7 +152,7 @@ export const StudyGuideModal = ({
"[text-shadow:_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4)]"
)}
style={{
marginTop: verticalAlign === "center" ? "100px" : "",
marginTop: verticalAlign === "center" ? "85px" : "",
}}
>
{children}
@@ -1,6 +1,7 @@
import { Image } from "@/components";
import { getStaticImage, getBackgroundImage } from "@/lib/utils";
import type { MouseEvent } from "react";
import { useEffect } from "react";
import { useAudioManager } from "@/hooks/useAudioManager";
export interface SuccessAlertProps {
/** 标题 */
@@ -9,6 +10,12 @@ export interface SuccessAlertProps {
content?: string;
/** 是否可见 */
visible?: boolean;
/** 成功提示音频地址(可选) */
audioSrc?: string;
/** 弹窗出现时是否自动播放音频(默认 true) */
autoPlayAudio?: boolean;
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
audioChannelId?: string;
/** 点击遮罩层回调 */
onMaskClick?: () => void;
/** 继续按钮回调 */
@@ -25,15 +32,44 @@ const SuccessAlert = ({
title = "恭喜你, 选择正确!",
content,
visible = true,
audioSrc,
autoPlayAudio = true,
audioChannelId,
onMaskClick,
onContinue,
onClose,
}: SuccessAlertProps) => {
const { currentSrc, play, stop } = useAudioManager(
audioChannelId
? { mode: "channel", channelId: audioChannelId }
: { mode: "global" }
);
// 弹窗显示时自动播放音频
useEffect(() => {
if (!visible) return;
if (!autoPlayAudio) return;
if (!audioSrc) return;
play(audioSrc);
}, [visible, autoPlayAudio, audioSrc, play]);
// 弹窗关闭时停止音频(仅当当前播放的是该弹窗的 audioSrc)
useEffect(() => {
if (visible) return;
if (audioSrc && currentSrc === audioSrc) {
stop();
}
}, [visible, audioSrc, currentSrc, stop]);
if (!visible) return null;
const handleContinue = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// 点击继续时停止音频
if (audioSrc && currentSrc === audioSrc) {
stop();
}
// 只调用 onContinue,它会处理关闭逻辑
if (onContinue) {
onContinue();
+3 -40
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useRef } from "react";
import { useEffect, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { getStaticImage } from "@/lib/utils";
import { Image, Select } from "@/components";
@@ -30,9 +30,6 @@ const LetterToHuangyanpei = ({
// 是否已提交:提交后隐藏编辑信件,展示只读信件
const [submitted, setSubmitted] = useState(false);
// 用于跟踪是否已经自动填入过答案,避免重复填入
const autoFilledRef = useRef(false);
// 填空类型定义:0=填空1(限填),1=填空2(任填),2=填空3(任填),3=填空4(限填)
const BLANK_1_ANSWER = "手脑并用";
const BLANK_4_ANSWER = "有业者乐业";
@@ -40,6 +37,7 @@ const LetterToHuangyanpei = ({
// 填空1的选项
const BLANK_1_OPTIONS = [
"手脑并用",
"有业者乐业",
// "知行合一",
// "学以致用",
// "理论实践"
@@ -47,47 +45,13 @@ const LetterToHuangyanpei = ({
// 填空4的选项
const BLANK_4_OPTIONS = [
"手脑并用",
"有业者乐业",
// "有业者敬业",
// "有业者精业",
// "有业者成业",
];
// 自动浮现答案:填空1、4空白时自动填入正确答案
useEffect(() => {
// 只在组件可见且未自动填入过时检查
if (!visible) {
autoFilledRef.current = false;
return;
}
if (autoFilledRef.current) return;
setBlankValues((prevValues) => {
const nextValues = [...prevValues];
let changed = false;
// 填空1(索引0):如果为空,自动填入正确答案
if (!nextValues[0] || nextValues[0].trim() === "") {
nextValues[0] = BLANK_1_ANSWER;
changed = true;
}
// 填空4(索引3):如果为空,自动填入正确答案
if (!nextValues[3] || nextValues[3].trim() === "") {
nextValues[3] = BLANK_4_ANSWER;
changed = true;
}
if (changed || nextValues.length > 0) {
// 如果已经有值或已更改,标记为已处理,避免重复检查
autoFilledRef.current = true;
}
return nextValues;
});
}, [visible]);
// 校验输入:确保所有填空符合标准
const validateBlanks = () => {
// 计算占位总数,防止有些没被输入过而是 undefined
@@ -137,7 +101,6 @@ const LetterToHuangyanpei = ({
setSubmitted(false);
setBlankValues([]);
setBlankErrors([]);
autoFilledRef.current = false; // 重置自动填入标记
}
}, [visible]);
+3 -1
View File
@@ -36,11 +36,13 @@ const LetterToLuxun = ({
// 填空1的选项
const BLANK_1_OPTIONS = [
"首在立人",
"批判求真",
// 可以添加其他选项
];
// 填空2的选项
const BLANK_2_OPTIONS = [
"首在立人",
"批判求真",
// 可以添加其他选项
];
@@ -262,7 +264,7 @@ const LetterToLuxun = ({
// 任填:文本输入
<input
name={`blank-${currentIndex}`}
className={`inline-block w-[100px] h-[36px] mx-2 align-middle bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
className={`inline-block w-[100px] h-[36px] mx-2 mb-2 align-middle bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
blankErrors[currentIndex]
? "border-red-500"
: "border-[#4e402e]"
+28 -28
View File
@@ -39,7 +39,7 @@ const LetterToSunzhongshan = ({
const BLANK_1_ANSWER = "天下为公";
const BLANK_2_ANSWER = "与时俱进";
// 最后一个空的正确答案
const LAST_BLANK_ANSWER = "仍须努力";
const LAST_BLANK_ANSWER = "仍需努力";
// 前三个空的选项(所有空都用这三个选项)
const SELECT_OPTIONS = [
@@ -48,7 +48,7 @@ const LetterToSunzhongshan = ({
{ value: "与时俱进", label: "与时俱进" },
];
// 校验输入:前三个空必须选择正确答案,最后一个空必须是"仍须努力",其他空不能为空且不超过 10 个字符
// 校验输入:前三个空必须选择正确答案,最后一个空必须是"仍需努力",其他空不能为空且不超过 10 个字符
const validateBlanks = () => {
// 计算占位总数,防止有些没被输入过而是 undefined
const placeholderCount = (letterContent.match(/____/g) || []).length;
@@ -69,7 +69,7 @@ const LetterToSunzhongshan = ({
hasError = true;
}
} else if (i === placeholderCount - 1) {
// 最后一个空:必须是"仍须努力"
// 最后一个空:必须是"仍需努力"
if (!v || v !== LAST_BLANK_ANSWER) {
nextValues[i] = "";
nextErrors[i] = true;
@@ -251,31 +251,31 @@ const LetterToSunzhongshan = ({
}
// 立即验证选择是否正确
let correctAnswer = "";
if (currentIndex === 0) {
correctAnswer = BLANK_0_ANSWER;
} else if (currentIndex === 1) {
correctAnswer = BLANK_1_ANSWER;
} else if (currentIndex === 2) {
correctAnswer = BLANK_2_ANSWER;
}
// let correctAnswer = "";
// if (currentIndex === 0) {
// correctAnswer = BLANK_0_ANSWER;
// } else if (currentIndex === 1) {
// correctAnswer = BLANK_1_ANSWER;
// } else if (currentIndex === 2) {
// correctAnswer = BLANK_2_ANSWER;
// }
if (
correctAnswer &&
selectedValue !== correctAnswer
) {
// 选择错误,显示错误提示
setErrorAlertText(
"请选择正确答案"
);
setShowErrorAlert(true);
// 清空选择
next[currentIndex] = "";
setBlankValues(next);
const nextErrs = [...blankErrors];
nextErrs[currentIndex] = true;
setBlankErrors(nextErrs);
}
// if (
// correctAnswer &&
// selectedValue !== correctAnswer
// ) {
// // 选择错误,显示错误提示
// setErrorAlertText(
// "请选择正确答案"
// );
// setShowErrorAlert(true);
// // 清空选择
// next[currentIndex] = "";
// setBlankValues(next);
// const nextErrs = [...blankErrors];
// nextErrs[currentIndex] = true;
// setBlankErrors(nextErrs);
// }
}}
placeholder="请选择"
error={blankErrors[currentIndex]}
@@ -286,7 +286,7 @@ const LetterToSunzhongshan = ({
// 其他空:文本输入
<input
name={`blank-${currentIndex}`}
className={`inline-block w-[110px] h-[36px] mx-2 mb-2 align-middle bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
className={`inline-block w-[110px] 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]"
+1 -1
View File
@@ -126,7 +126,7 @@ export const EndingOverlay = ({
<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"
className="relative z-10 flex items-center max-w-full transition-all py-50 px-24 h-[825px] bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: `url(${getStaticImage("components/overlay/bg")})`,
}}
+1
View File
@@ -322,6 +322,7 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
width: `${
dropdownPosition.width > 0 ? dropdownPosition.width : "auto"
}px`,
minWidth: "80px",
zIndex: zIndex,
}}
role="listbox"