feat: 更新组件和引导对话,增强用户体验
- 在多个组件中添加新的选择和填空功能,提升互动性。 - 更新引导对话文本,确保信息准确性和一致性。 - 引入新的选择组件,优化用户填写体验。 - 清理冗余代码,提升可维护性。
This commit is contained in:
1 parent
a8723ab4aa
commit
5bdfdadede
27 files changed
+831
-1203
No files matched your search
Binary file not shown.
Binary file not shown.
@@ -62,15 +62,6 @@ export type {
|
|||||||
export { Narration } from "./narration";
|
export { Narration } from "./narration";
|
||||||
export type { NarrationProps } from "./narration";
|
export type { NarrationProps } from "./narration";
|
||||||
|
|
||||||
// Toast component
|
|
||||||
export { toast, Toast, useToast, ToastProvider } from "./toast";
|
|
||||||
export type {
|
|
||||||
ToastType,
|
|
||||||
ToastPosition,
|
|
||||||
ToastOptions,
|
|
||||||
ToastInstance,
|
|
||||||
} from "./toast";
|
|
||||||
|
|
||||||
// Clue components
|
// Clue components
|
||||||
export { ClueHintModal, ClueBookModal } from "./clue";
|
export { ClueHintModal, ClueBookModal } from "./clue";
|
||||||
export type { ClueHintModalProps, ClueHint, ClueBookModalProps } from "./clue";
|
export type { ClueHintModalProps, ClueHint, ClueBookModalProps } from "./clue";
|
||||||
@@ -79,6 +70,10 @@ export type { ClueHintModalProps, ClueHint, ClueBookModalProps } from "./clue";
|
|||||||
export { flyToAnimation } from "./animation";
|
export { flyToAnimation } from "./animation";
|
||||||
export type { FlyToAnimationOptions, FlyToPosition } from "./animation";
|
export type { FlyToAnimationOptions, FlyToPosition } from "./animation";
|
||||||
|
|
||||||
|
// Select component
|
||||||
|
export { Select } from "./select";
|
||||||
|
export type { SelectProps, SelectOption } from "./select";
|
||||||
|
|
||||||
// Letter components
|
// Letter components
|
||||||
export {
|
export {
|
||||||
LetterToChenyun,
|
LetterToChenyun,
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ import { Image, PuzzleSystem, flyToAnimation } from "@/components";
|
|||||||
import { usePuzzleStore } from "@/store/puzzleStore";
|
import { usePuzzleStore } from "@/store/puzzleStore";
|
||||||
import PuzzlePiecePortal from "@/components/puzzle/PuzzlePiecePortal";
|
import PuzzlePiecePortal from "@/components/puzzle/PuzzlePiecePortal";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支持拼图盒功能的故居列表
|
||||||
|
* 后续如需为其他故居添加拼图盒,只需在此数组中添加对应的故居名称即可
|
||||||
|
*/
|
||||||
|
const RESIDENCES_WITH_PUZZLE = ["chenyun"] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 故居布局组件
|
* 故居布局组件
|
||||||
* 用于所有故居页面的统一布局容器
|
* 用于所有故居页面的统一布局容器
|
||||||
@@ -48,112 +54,131 @@ const ResidenceLayoutContent = () => {
|
|||||||
return "";
|
return "";
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
// 处理碎片动画触发
|
// 判断当前故居是否支持拼图盒功能
|
||||||
|
const hasPuzzleFeature = useMemo(
|
||||||
|
() => RESIDENCES_WITH_PUZZLE.includes(residence as any),
|
||||||
|
[residence]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 动画配置常量
|
||||||
|
const ANIMATION_CONFIG = {
|
||||||
|
APPEAR_DURATION: 800, // 初始出现动画持续时间(3D旋转+放大,毫秒)
|
||||||
|
GLOW_DURATION: 800, // 发光动画持续时间(毫秒)
|
||||||
|
FLY_DURATION: 1400, // 飞行动画持续时间(毫秒)
|
||||||
|
RETRY_DELAY: 100, // 重试延迟(毫秒)
|
||||||
|
MAX_RETRIES: 3, // 最大重试次数
|
||||||
|
SCALE: { from: 1, to: 0.25 }, // 缩放比例(更小的目标尺寸,效果更明显)
|
||||||
|
OPACITY: [1, 1, 0.6, 0] as number[], // 透明度变化(更平滑的淡出)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清理动画状态
|
||||||
|
const cleanupAnimation = () => {
|
||||||
|
setIsAnimationPlaying(false);
|
||||||
|
setShowPuzzlePiece(false);
|
||||||
|
setPuzzlePieceId(null);
|
||||||
|
setIsFlying(false);
|
||||||
|
animationInProgressRef.current = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 执行飞行动画
|
||||||
|
const executeFlyAnimation = (pieceId: number) => {
|
||||||
|
if (!residence || !puzzleBoxRef.current || !puzzlePieceRef.current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsFlying(true);
|
||||||
|
|
||||||
|
// 使用 requestAnimationFrame 确保元素已完全渲染
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!puzzlePieceRef.current || !residence) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
flyToAnimation({
|
||||||
|
fromRef: puzzlePieceRef,
|
||||||
|
toRef: puzzleBoxRef,
|
||||||
|
targetPosition: "center",
|
||||||
|
duration: ANIMATION_CONFIG.FLY_DURATION,
|
||||||
|
scale: ANIMATION_CONFIG.SCALE,
|
||||||
|
opacity: ANIMATION_CONFIG.OPACITY,
|
||||||
|
ease: [0.25, 0.1, 0.25, 1], // 自定义缓动函数,更自然的加速和减速
|
||||||
|
onComplete: () => {
|
||||||
|
if (residence) {
|
||||||
|
unlockPuzzle(residence, pieceId);
|
||||||
|
}
|
||||||
|
cleanupAnimation();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 等待元素准备就绪并执行动画
|
||||||
|
const waitForElementsAndAnimate = async (
|
||||||
|
pieceId: number,
|
||||||
|
retryCount = 0
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!residence) {
|
||||||
|
cleanupAnimation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查元素是否已准备好
|
||||||
|
if (puzzleBoxRef.current && puzzlePieceRef.current) {
|
||||||
|
const success = executeFlyAnimation(pieceId);
|
||||||
|
if (success) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果元素未准备好且未超过最大重试次数,则重试
|
||||||
|
if (retryCount < ANIMATION_CONFIG.MAX_RETRIES) {
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, ANIMATION_CONFIG.RETRY_DELAY)
|
||||||
|
);
|
||||||
|
return waitForElementsAndAnimate(pieceId, retryCount + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超过最大重试次数,直接解锁并清理
|
||||||
|
console.warn("Puzzle animation: 元素未准备好,直接解锁");
|
||||||
|
unlockPuzzle(residence, pieceId);
|
||||||
|
cleanupAnimation();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理碎片动画触发(仅支持拼图功能的故居)
|
||||||
const handleTriggerPuzzleAnimation = (pieceId: number) => {
|
const handleTriggerPuzzleAnimation = (pieceId: number) => {
|
||||||
if (!residence) return;
|
if (!residence || !hasPuzzleFeature) return;
|
||||||
|
|
||||||
// 防止重复执行动画
|
// 防止重复执行动画
|
||||||
if (animationInProgressRef.current) return;
|
if (animationInProgressRef.current) return;
|
||||||
|
|
||||||
// 检查碎片是否已解锁
|
// 检查碎片是否已解锁
|
||||||
if (!isPuzzleUnlocked(residence, pieceId)) {
|
if (isPuzzleUnlocked(residence, pieceId)) return;
|
||||||
|
|
||||||
// 标记动画正在进行
|
// 标记动画正在进行
|
||||||
animationInProgressRef.current = true;
|
animationInProgressRef.current = true;
|
||||||
|
|
||||||
// 开始动画流程:禁用所有操作
|
// 开始动画流程:禁用所有操作
|
||||||
setIsAnimationPlaying(true);
|
setIsAnimationPlaying(true);
|
||||||
setPuzzlePieceId(pieceId);
|
setPuzzlePieceId(pieceId);
|
||||||
|
|
||||||
// 显示碎片在屏幕正中间
|
|
||||||
setShowPuzzlePiece(true);
|
setShowPuzzlePiece(true);
|
||||||
|
|
||||||
// 使用双重 requestAnimationFrame 确保 Portal 渲染完成
|
// 使用双重 requestAnimationFrame 确保 Portal 渲染完成
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(async () => {
|
||||||
// 延迟执行飞行动画,让发光动画先显示
|
// 等待初始出现动画(旋转+放大)完成
|
||||||
setTimeout(() => {
|
await new Promise((resolve) =>
|
||||||
if (puzzleBoxRef.current && puzzlePieceRef.current) {
|
setTimeout(resolve, ANIMATION_CONFIG.APPEAR_DURATION)
|
||||||
// 标记开始飞行,隐藏原始碎片
|
);
|
||||||
setIsFlying(true);
|
|
||||||
|
|
||||||
// 使用 requestAnimationFrame 确保隐藏操作在下一帧执行,并且元素已完全渲染
|
// 等待发光动画显示一段时间,让用户看到碎片
|
||||||
requestAnimationFrame(() => {
|
await new Promise((resolve) =>
|
||||||
// 再次确保元素已渲染并获取准确尺寸
|
setTimeout(resolve, ANIMATION_CONFIG.GLOW_DURATION)
|
||||||
if (puzzlePieceRef.current) {
|
);
|
||||||
flyToAnimation({
|
|
||||||
fromRef: puzzlePieceRef,
|
// 等待元素准备就绪并执行飞行动画
|
||||||
toRef: puzzleBoxRef,
|
await waitForElementsAndAnimate(pieceId);
|
||||||
targetPosition: "center",
|
|
||||||
duration: 1200,
|
|
||||||
scale: { from: 1, to: 0.3 },
|
|
||||||
opacity: [1, 0.8, 0],
|
|
||||||
onComplete: () => {
|
|
||||||
// 动画完成后解锁碎片并恢复交互
|
|
||||||
unlockPuzzle(residence, pieceId);
|
|
||||||
setShowPuzzlePiece(false);
|
|
||||||
setPuzzlePieceId(null);
|
|
||||||
setIsAnimationPlaying(false);
|
|
||||||
setIsFlying(false);
|
|
||||||
animationInProgressRef.current = false;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// 如果 ref 不存在,直接解锁并恢复
|
|
||||||
unlockPuzzle(residence, pieceId);
|
|
||||||
setIsAnimationPlaying(false);
|
|
||||||
setShowPuzzlePiece(false);
|
|
||||||
setPuzzlePieceId(null);
|
|
||||||
setIsFlying(false);
|
|
||||||
animationInProgressRef.current = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// 如果 ref 不存在,等待一下再试
|
|
||||||
setTimeout(() => {
|
|
||||||
if (puzzleBoxRef.current && puzzlePieceRef.current) {
|
|
||||||
setIsFlying(true);
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (puzzlePieceRef.current) {
|
|
||||||
flyToAnimation({
|
|
||||||
fromRef: puzzlePieceRef,
|
|
||||||
toRef: puzzleBoxRef,
|
|
||||||
targetPosition: "center",
|
|
||||||
duration: 1200,
|
|
||||||
scale: { from: 1, to: 0.3 },
|
|
||||||
opacity: [1, 0.8, 0],
|
|
||||||
onComplete: () => {
|
|
||||||
unlockPuzzle(residence, pieceId);
|
|
||||||
setShowPuzzlePiece(false);
|
|
||||||
setPuzzlePieceId(null);
|
|
||||||
setIsAnimationPlaying(false);
|
|
||||||
setIsFlying(false);
|
|
||||||
animationInProgressRef.current = false;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
unlockPuzzle(residence, pieceId);
|
|
||||||
setIsAnimationPlaying(false);
|
|
||||||
setShowPuzzlePiece(false);
|
|
||||||
setPuzzlePieceId(null);
|
|
||||||
setIsFlying(false);
|
|
||||||
animationInProgressRef.current = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// 如果还是不存在,直接解锁并恢复
|
|
||||||
unlockPuzzle(residence, pieceId);
|
|
||||||
setIsAnimationPlaying(false);
|
|
||||||
setShowPuzzlePiece(false);
|
|
||||||
setPuzzlePieceId(null);
|
|
||||||
setIsFlying(false);
|
|
||||||
animationInProgressRef.current = false;
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}, 500); // 发光动画显示 500ms 后开始飞行
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取当前路由的视频路径
|
// 获取当前路由的视频路径
|
||||||
@@ -262,7 +287,8 @@ const ResidenceLayoutContent = () => {
|
|||||||
} transition-opacity duration-300 ease-in`}
|
} transition-opacity duration-300 ease-in`}
|
||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
{/* 拼图盒 - 独立于内容层,确保始终可点击 */}
|
{/* 拼图盒 - 独立于内容层,确保始终可点击(仅支持拼图功能的故居显示) */}
|
||||||
|
{hasPuzzleFeature && (
|
||||||
<div
|
<div
|
||||||
ref={puzzleBoxRef}
|
ref={puzzleBoxRef}
|
||||||
className="absolute left-4 bottom-4 z-[1] cursor-pointer"
|
className="absolute left-4 bottom-4 z-[1] cursor-pointer"
|
||||||
@@ -275,6 +301,7 @@ const ResidenceLayoutContent = () => {
|
|||||||
>
|
>
|
||||||
<Image src={getStaticImage("components/拼图盒")} button />
|
<Image src={getStaticImage("components/拼图盒")} button />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 视频层 - 只在播放时渲染,z-index 动态调整 */}
|
{/* 视频层 - 只在播放时渲染,z-index 动态调整 */}
|
||||||
@@ -300,8 +327,8 @@ const ResidenceLayoutContent = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 拼图系统弹窗 */}
|
{/* 拼图系统弹窗(仅支持拼图功能的故居显示) */}
|
||||||
{residence && (
|
{hasPuzzleFeature && residence && (
|
||||||
<PuzzleSystem
|
<PuzzleSystem
|
||||||
visible={isPuzzleSystemVisible}
|
visible={isPuzzleSystemVisible}
|
||||||
onClose={() => setIsPuzzleSystemVisible(false)}
|
onClose={() => setIsPuzzleSystemVisible(false)}
|
||||||
@@ -309,8 +336,11 @@ const ResidenceLayoutContent = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 碎片显示 - 用于飞行动画,显示在屏幕正中间 */}
|
{/* 碎片显示 - 用于飞行动画,显示在屏幕正中间(仅支持拼图功能的故居显示) */}
|
||||||
{showPuzzlePiece && puzzlePieceId && residence && (
|
{hasPuzzleFeature &&
|
||||||
|
showPuzzlePiece &&
|
||||||
|
puzzlePieceId &&
|
||||||
|
residence && (
|
||||||
<PuzzlePiecePortal
|
<PuzzlePiecePortal
|
||||||
ref={puzzlePieceRef}
|
ref={puzzlePieceRef}
|
||||||
residence={residence}
|
residence={residence}
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ const LetterToChenyun = ({
|
|||||||
textOrientation: "upright",
|
textOrientation: "upright",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
寄给陈云爷爷的信
|
寄给陈云的信
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { getStaticImage } from "@/lib/utils";
|
import { getStaticImage } from "@/lib/utils";
|
||||||
import { Image } from "@/components";
|
import { Image, Select } from "@/components";
|
||||||
|
import { filterInput } from "@/lib/utils/sensitiveWords";
|
||||||
|
|
||||||
export interface LetterToHuangyanpeiProps {
|
export interface LetterToHuangyanpeiProps {
|
||||||
/** 是否可见 */
|
/** 是否可见 */
|
||||||
@@ -23,13 +24,71 @@ const LetterToHuangyanpei = ({
|
|||||||
}: LetterToHuangyanpeiProps) => {
|
}: LetterToHuangyanpeiProps) => {
|
||||||
// 下划线可填写内容
|
// 下划线可填写内容
|
||||||
const [blankValues, setBlankValues] = useState<string[]>([]);
|
const [blankValues, setBlankValues] = useState<string[]>([]);
|
||||||
// 每个空的校验状态:true 表示该空当前不符合要求(为空或超过 10 个字)
|
// 每个空的校验状态:true 表示该空当前不符合要求
|
||||||
const [blankErrors, setBlankErrors] = useState<boolean[]>([]);
|
const [blankErrors, setBlankErrors] = useState<boolean[]>([]);
|
||||||
|
|
||||||
// 是否已提交:提交后隐藏编辑信件,展示只读信件
|
// 是否已提交:提交后隐藏编辑信件,展示只读信件
|
||||||
const [submitted, setSubmitted] = useState(false);
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
|
||||||
// 校验输入:不能为空、每个不超过 10 个字符
|
// 用于跟踪是否已经自动填入过答案,避免重复填入
|
||||||
|
const autoFilledRef = useRef(false);
|
||||||
|
|
||||||
|
// 填空类型定义:0=填空1(限填),1=填空2(任填),2=填空3(任填),3=填空4(限填)
|
||||||
|
const BLANK_1_ANSWER = "手脑并用";
|
||||||
|
const BLANK_4_ANSWER = "有业者乐业";
|
||||||
|
|
||||||
|
// 填空1的选项
|
||||||
|
const BLANK_1_OPTIONS = [
|
||||||
|
"手脑并用",
|
||||||
|
// "知行合一",
|
||||||
|
// "学以致用",
|
||||||
|
// "理论实践"
|
||||||
|
];
|
||||||
|
|
||||||
|
// 填空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 = () => {
|
const validateBlanks = () => {
|
||||||
// 计算占位总数,防止有些没被输入过而是 undefined
|
// 计算占位总数,防止有些没被输入过而是 undefined
|
||||||
const placeholderCount = (letterContent.match(/____/g) || []).length;
|
const placeholderCount = (letterContent.match(/____/g) || []).length;
|
||||||
@@ -39,15 +98,31 @@ const LetterToHuangyanpei = ({
|
|||||||
let hasError = false;
|
let hasError = false;
|
||||||
|
|
||||||
for (let i = 0; i < placeholderCount; i++) {
|
for (let i = 0; i < placeholderCount; i++) {
|
||||||
const raw = blankValues[i] || "";
|
const raw = nextValues[i] || "";
|
||||||
const v = raw.trim();
|
const v = raw.trim();
|
||||||
|
|
||||||
// 为空 或 超过 10 个字,都视为不符合要求:清空并标红
|
if (i === 0) {
|
||||||
if (!v || v.length > 10) {
|
// 填空1:限填,必须选择"手脑并用"
|
||||||
|
if (!v || v !== BLANK_1_ANSWER) {
|
||||||
nextValues[i] = "";
|
nextValues[i] = "";
|
||||||
nextErrors[i] = true;
|
nextErrors[i] = true;
|
||||||
hasError = true;
|
hasError = true;
|
||||||
}
|
}
|
||||||
|
} else if (i === 1 || i === 2) {
|
||||||
|
// 填空2、3:任填,不能为空,不超过20个字
|
||||||
|
if (!v || v.length > 20) {
|
||||||
|
nextValues[i] = "";
|
||||||
|
nextErrors[i] = true;
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
} else if (i === 3) {
|
||||||
|
// 填空4:限填,必须选择"有业者乐业"
|
||||||
|
if (!v || v !== BLANK_4_ANSWER) {
|
||||||
|
nextValues[i] = "";
|
||||||
|
nextErrors[i] = true;
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setBlankValues(nextValues);
|
setBlankValues(nextValues);
|
||||||
@@ -62,6 +137,7 @@ const LetterToHuangyanpei = ({
|
|||||||
setSubmitted(false);
|
setSubmitted(false);
|
||||||
setBlankValues([]);
|
setBlankValues([]);
|
||||||
setBlankErrors([]);
|
setBlankErrors([]);
|
||||||
|
autoFilledRef.current = false; // 重置自动填入标记
|
||||||
}
|
}
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
@@ -187,11 +263,48 @@ const LetterToHuangyanpei = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentIndex = placeholderIndex++;
|
const currentIndex = placeholderIndex++;
|
||||||
|
const isLimitedFill =
|
||||||
|
currentIndex === 0 || currentIndex === 3; // 填空1和填空4为限填
|
||||||
|
const options =
|
||||||
|
currentIndex === 0
|
||||||
|
? BLANK_1_OPTIONS
|
||||||
|
: BLANK_4_OPTIONS;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={`part-${lineIndex}-${partIndex}`}
|
key={`part-${lineIndex}-${partIndex}`}
|
||||||
>
|
>
|
||||||
{part}
|
{part}
|
||||||
|
{isLimitedFill ? (
|
||||||
|
// 限填:下拉选择
|
||||||
|
<Select
|
||||||
|
className="inline"
|
||||||
|
options={options.map((opt) => ({
|
||||||
|
value: opt,
|
||||||
|
label: opt,
|
||||||
|
}))}
|
||||||
|
value={
|
||||||
|
blankValues[currentIndex] ?? ""
|
||||||
|
}
|
||||||
|
onChange={(selectedValue) => {
|
||||||
|
const next = [...blankValues];
|
||||||
|
next[currentIndex] = selectedValue;
|
||||||
|
setBlankValues(next);
|
||||||
|
|
||||||
|
// 用户再次选择时,清除该空的错误高亮
|
||||||
|
if (blankErrors[currentIndex]) {
|
||||||
|
const nextErrs = [...blankErrors];
|
||||||
|
nextErrs[currentIndex] = false;
|
||||||
|
setBlankErrors(nextErrs);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="请选择"
|
||||||
|
error={blankErrors[currentIndex]}
|
||||||
|
zIndex={10000}
|
||||||
|
fitContent={true}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// 任填:文本输入,限制20个字,敏感词过滤
|
||||||
<input
|
<input
|
||||||
name={`blank-${currentIndex}`}
|
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 ${
|
className={`inline-block w-[100px] h-[36px] mx-2 bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
|
||||||
@@ -199,10 +312,19 @@ const LetterToHuangyanpei = ({
|
|||||||
? "border-red-500"
|
? "border-red-500"
|
||||||
: "border-[#4e402e]"
|
: "border-[#4e402e]"
|
||||||
}`}
|
}`}
|
||||||
value={blankValues[currentIndex] ?? ""}
|
autoComplete="off"
|
||||||
|
value={
|
||||||
|
blankValues[currentIndex] ?? ""
|
||||||
|
}
|
||||||
|
maxLength={20}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
const rawValue = e.target.value;
|
||||||
|
// 应用敏感词过滤
|
||||||
|
const filteredValue =
|
||||||
|
filterInput(rawValue);
|
||||||
|
|
||||||
const next = [...blankValues];
|
const next = [...blankValues];
|
||||||
next[currentIndex] = e.target.value;
|
next[currentIndex] = filteredValue;
|
||||||
setBlankValues(next);
|
setBlankValues(next);
|
||||||
|
|
||||||
// 用户再次输入时,清除该空的错误高亮
|
// 用户再次输入时,清除该空的错误高亮
|
||||||
@@ -213,6 +335,7 @@ const LetterToHuangyanpei = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -334,7 +457,10 @@ const LetterToHuangyanpei = ({
|
|||||||
src={getStaticImage("components/letter/寄出")}
|
src={getStaticImage("components/letter/寄出")}
|
||||||
alt="寄出"
|
alt="寄出"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
// 寄出前再次校验,确保所有填空符合标准
|
||||||
|
if (validateBlanks()) {
|
||||||
onSubmit?.(blankValues);
|
onSubmit?.(blankValues);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
button
|
button
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ const LetterToSunzhongshan = ({
|
|||||||
textOrientation: "upright",
|
textOrientation: "upright",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
写给孙中山爷爷的信
|
写给孙中山先生的信
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -41,11 +41,7 @@ const PuzzlePieceDisplay = forwardRef<HTMLDivElement, PuzzlePieceDisplayProps>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
<img
|
<img src={pieceImage} alt={`碎片 ${pieceId}`} className="w-full" />
|
||||||
src={pieceImage}
|
|
||||||
alt={`碎片 ${pieceId}`}
|
|
||||||
className="w-full h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -54,4 +50,3 @@ const PuzzlePieceDisplay = forwardRef<HTMLDivElement, PuzzlePieceDisplayProps>(
|
|||||||
PuzzlePieceDisplay.displayName = "PuzzlePieceDisplay";
|
PuzzlePieceDisplay.displayName = "PuzzlePieceDisplay";
|
||||||
|
|
||||||
export default PuzzlePieceDisplay;
|
export default PuzzlePieceDisplay;
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { forwardRef } from "react";
|
import { forwardRef, useEffect, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
import PuzzlePieceDisplay from "./PuzzlePieceDisplay";
|
import PuzzlePieceDisplay from "./PuzzlePieceDisplay";
|
||||||
|
|
||||||
interface PuzzlePiecePortalProps {
|
interface PuzzlePiecePortalProps {
|
||||||
@@ -12,21 +13,82 @@ interface PuzzlePiecePortalProps {
|
|||||||
/**
|
/**
|
||||||
* 碎片 Portal 组件
|
* 碎片 Portal 组件
|
||||||
* 将碎片渲染到 document.body,确保在最上层显示
|
* 将碎片渲染到 document.body,确保在最上层显示
|
||||||
|
* 支持旋转+放大的初始出现动画
|
||||||
*/
|
*/
|
||||||
const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
|
const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
|
||||||
({ residence, pieceId, hidden = false }, ref) => {
|
({ residence, pieceId, hidden = false }, ref) => {
|
||||||
|
const [isAnimating, setIsAnimating] = useState(true);
|
||||||
|
|
||||||
|
// 当组件首次渲染时,标记动画开始
|
||||||
|
useEffect(() => {
|
||||||
|
setIsAnimating(true);
|
||||||
|
// 动画完成后,重置状态以便下次出现时重新播放
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsAnimating(false);
|
||||||
|
}, 800); // 与动画时长一致
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [pieceId]); // 当 pieceId 变化时重新播放动画
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
|
||||||
className="fixed z-[99999] pointer-events-none"
|
|
||||||
style={{
|
style={{
|
||||||
|
position: "fixed",
|
||||||
left: "50%",
|
left: "50%",
|
||||||
top: "50%",
|
top: "50%",
|
||||||
transform: "translate(-50%, -50%)",
|
|
||||||
width: "120px",
|
width: "120px",
|
||||||
height: "120px",
|
height: "120px",
|
||||||
opacity: hidden ? 0 : 1,
|
transform: "translate(-50%, -50%)",
|
||||||
visibility: hidden ? "hidden" : "visible",
|
perspective: "1000px", // 添加透视,让3D效果更明显
|
||||||
|
zIndex: 99999,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
ref={ref}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
transformStyle: "preserve-3d", // 保持3D变换
|
||||||
|
}}
|
||||||
|
initial={
|
||||||
|
isAnimating
|
||||||
|
? {
|
||||||
|
scale: 0,
|
||||||
|
rotateY: -180,
|
||||||
|
opacity: 0,
|
||||||
|
}
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
animate={
|
||||||
|
hidden
|
||||||
|
? { opacity: 0, visibility: "hidden" as const }
|
||||||
|
: isAnimating
|
||||||
|
? {
|
||||||
|
scale: 1,
|
||||||
|
rotateY: 0,
|
||||||
|
opacity: 1,
|
||||||
|
visibility: "visible" as const,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
opacity: 1,
|
||||||
|
visibility: "visible" as const,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transition={{
|
||||||
|
duration: 0.8, // 稍微延长动画时间,让效果更舒缓
|
||||||
|
ease: [0.25, 0.46, 0.45, 0.94], // 平滑的缓动函数,缓缓出现
|
||||||
|
scale: {
|
||||||
|
duration: 0.8,
|
||||||
|
ease: [0.25, 0.46, 0.45, 0.94],
|
||||||
|
},
|
||||||
|
rotateY: {
|
||||||
|
duration: 0.8,
|
||||||
|
ease: [0.25, 0.46, 0.45, 0.94],
|
||||||
|
},
|
||||||
|
opacity: {
|
||||||
|
duration: 0.6,
|
||||||
|
ease: "easeOut",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PuzzlePieceDisplay
|
<PuzzlePieceDisplay
|
||||||
@@ -35,6 +97,7 @@ const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
|
|||||||
showGlow={true}
|
showGlow={true}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
/>
|
/>
|
||||||
|
</motion.div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { default } from "./select";
|
||||||
|
export { default as Select } from "./select";
|
||||||
|
export type { SelectProps, SelectOption } from "./select.types";
|
||||||
|
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
import { forwardRef, useState, useRef, useEffect, useCallback } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { SelectProps } from "./select.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select - 下拉选择组件
|
||||||
|
* 使用 Portal 将下拉菜单渲染到 body 下,避免受到父容器缩放影响
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <Select
|
||||||
|
* options={[
|
||||||
|
* { value: "1", label: "选项1" },
|
||||||
|
* { value: "2", label: "选项2" },
|
||||||
|
* ]}
|
||||||
|
* value={value}
|
||||||
|
* onChange={(val) => setValue(val)}
|
||||||
|
* placeholder="请选择"
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
const Select = forwardRef<HTMLDivElement, SelectProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = "请选择",
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
error = false,
|
||||||
|
zIndex = 1000,
|
||||||
|
fitContent = false,
|
||||||
|
minWidth,
|
||||||
|
maxWidth,
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||||
|
const triggerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const measureRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const [dropdownPosition, setDropdownPosition] = useState<{
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [contentWidth, setContentWidth] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// 获取当前选中的选项
|
||||||
|
const selectedOption = options.find((opt) => opt.value === value);
|
||||||
|
|
||||||
|
// 计算内容宽度(用于 fitContent)
|
||||||
|
const calculateContentWidth = useCallback(() => {
|
||||||
|
if (!fitContent || !triggerRef.current) return;
|
||||||
|
|
||||||
|
// 获取触发器的计算样式,用于匹配字体
|
||||||
|
const triggerStyles = window.getComputedStyle(triggerRef.current);
|
||||||
|
|
||||||
|
// 创建一个临时元素来测量所有选项的宽度
|
||||||
|
const tempElement = document.createElement("span");
|
||||||
|
tempElement.style.visibility = "hidden";
|
||||||
|
tempElement.style.position = "absolute";
|
||||||
|
tempElement.style.whiteSpace = "nowrap";
|
||||||
|
tempElement.style.fontSize = triggerStyles.fontSize;
|
||||||
|
tempElement.style.fontFamily = triggerStyles.fontFamily;
|
||||||
|
tempElement.style.fontWeight = triggerStyles.fontWeight;
|
||||||
|
tempElement.style.padding = "8px 12px"; // 与选项 padding 一致 (py-2 px-3)
|
||||||
|
document.body.appendChild(tempElement);
|
||||||
|
|
||||||
|
let calculatedWidth = 0;
|
||||||
|
|
||||||
|
// 测量所有选项和占位符的宽度
|
||||||
|
const textsToMeasure = [placeholder, ...options.map((opt) => opt.label)];
|
||||||
|
|
||||||
|
textsToMeasure.forEach((text) => {
|
||||||
|
tempElement.textContent = text;
|
||||||
|
const width = tempElement.offsetWidth;
|
||||||
|
if (width > calculatedWidth) {
|
||||||
|
calculatedWidth = width;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.removeChild(tempElement);
|
||||||
|
|
||||||
|
// 应用最小和最大宽度限制
|
||||||
|
if (minWidth !== undefined && calculatedWidth < minWidth) {
|
||||||
|
calculatedWidth = minWidth;
|
||||||
|
}
|
||||||
|
if (maxWidth !== undefined && calculatedWidth > maxWidth) {
|
||||||
|
calculatedWidth = maxWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
setContentWidth(calculatedWidth);
|
||||||
|
}, [fitContent, options, placeholder, minWidth, maxWidth]);
|
||||||
|
|
||||||
|
// 计算下拉菜单位置
|
||||||
|
const updateDropdownPosition = useCallback(() => {
|
||||||
|
if (!triggerRef.current) return;
|
||||||
|
|
||||||
|
const rect = triggerRef.current.getBoundingClientRect();
|
||||||
|
let width = rect.width;
|
||||||
|
|
||||||
|
// 如果启用 fitContent,使用计算出的内容宽度
|
||||||
|
if (fitContent && contentWidth !== null) {
|
||||||
|
// width = contentWidth;
|
||||||
|
width = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDropdownPosition({
|
||||||
|
top: rect.bottom + window.scrollY,
|
||||||
|
left: rect.left + window.scrollX,
|
||||||
|
width: width,
|
||||||
|
});
|
||||||
|
}, [fitContent, contentWidth]);
|
||||||
|
|
||||||
|
// 打开下拉菜单
|
||||||
|
const handleOpen = useCallback(() => {
|
||||||
|
if (disabled) return;
|
||||||
|
// 如果启用 fitContent,先计算内容宽度
|
||||||
|
if (fitContent) {
|
||||||
|
calculateContentWidth();
|
||||||
|
}
|
||||||
|
// 使用 setTimeout 确保内容宽度计算完成后再更新位置
|
||||||
|
setTimeout(() => {
|
||||||
|
updateDropdownPosition();
|
||||||
|
setIsOpen(true);
|
||||||
|
setFocusedIndex(
|
||||||
|
value ? options.findIndex((opt) => opt.value === value) : -1
|
||||||
|
);
|
||||||
|
}, 0);
|
||||||
|
}, [
|
||||||
|
disabled,
|
||||||
|
fitContent,
|
||||||
|
calculateContentWidth,
|
||||||
|
updateDropdownPosition,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 关闭下拉菜单
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setFocusedIndex(-1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 选择选项
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(optionValue: string) => {
|
||||||
|
if (onChange) {
|
||||||
|
onChange(optionValue);
|
||||||
|
}
|
||||||
|
handleClose();
|
||||||
|
},
|
||||||
|
[onChange, handleClose]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 点击外部关闭
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
triggerRef.current &&
|
||||||
|
!triggerRef.current.contains(event.target as Node) &&
|
||||||
|
dropdownRef.current &&
|
||||||
|
!dropdownRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
updateDropdownPosition();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
updateDropdownPosition();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
window.addEventListener("scroll", handleScroll, true);
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
window.removeEventListener("scroll", handleScroll, true);
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
};
|
||||||
|
}, [isOpen, handleClose, updateDropdownPosition]);
|
||||||
|
|
||||||
|
// 键盘导航
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
const enabledIndices = options
|
||||||
|
.map((opt, idx) => (!opt.disabled ? idx : -1))
|
||||||
|
.filter((idx) => idx !== -1);
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case "ArrowDown":
|
||||||
|
event.preventDefault();
|
||||||
|
setFocusedIndex((prev) => {
|
||||||
|
const currentEnabledIdx = enabledIndices.indexOf(prev);
|
||||||
|
const nextEnabledIdx =
|
||||||
|
currentEnabledIdx < enabledIndices.length - 1
|
||||||
|
? currentEnabledIdx + 1
|
||||||
|
: 0;
|
||||||
|
return enabledIndices[nextEnabledIdx] ?? enabledIndices[0] ?? -1;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "ArrowUp":
|
||||||
|
event.preventDefault();
|
||||||
|
setFocusedIndex((prev) => {
|
||||||
|
const currentEnabledIdx = enabledIndices.indexOf(prev);
|
||||||
|
const nextEnabledIdx =
|
||||||
|
currentEnabledIdx > 0
|
||||||
|
? currentEnabledIdx - 1
|
||||||
|
: enabledIndices.length - 1;
|
||||||
|
return enabledIndices[nextEnabledIdx] ?? enabledIndices[0] ?? -1;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "Enter":
|
||||||
|
case " ":
|
||||||
|
event.preventDefault();
|
||||||
|
if (focusedIndex >= 0 && focusedIndex < options.length) {
|
||||||
|
const option = options[focusedIndex];
|
||||||
|
if (option && !option.disabled) {
|
||||||
|
handleSelect(option.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "Escape":
|
||||||
|
event.preventDefault();
|
||||||
|
handleClose();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [isOpen, focusedIndex, options, handleSelect, handleClose]);
|
||||||
|
|
||||||
|
// 滚动到选中项
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && focusedIndex >= 0 && dropdownRef.current) {
|
||||||
|
const optionElement = dropdownRef.current.querySelector(
|
||||||
|
`[data-option-index="${focusedIndex}"]`
|
||||||
|
);
|
||||||
|
if (optionElement) {
|
||||||
|
optionElement.scrollIntoView({
|
||||||
|
block: "nearest",
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, focusedIndex]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 隐藏的测量元素(用于 fitContent 模式) */}
|
||||||
|
{fitContent && (
|
||||||
|
<span
|
||||||
|
ref={measureRef}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
visibility: "hidden",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 触发器 */}
|
||||||
|
<div
|
||||||
|
ref={(node) => {
|
||||||
|
if (typeof ref === "function") {
|
||||||
|
ref(node);
|
||||||
|
} else if (ref) {
|
||||||
|
ref.current = node;
|
||||||
|
}
|
||||||
|
triggerRef.current = node;
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex justify-start w-full h-[36px] mx-2 bg-transparent border-b-2 border-dashed text-[26px] outline-none cursor-pointer",
|
||||||
|
error ? "border-red-500" : "border-[#4e402e]",
|
||||||
|
disabled && "opacity-50 cursor-not-allowed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={handleOpen}
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-disabled={disabled}
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
|
>
|
||||||
|
{/* <span className={cn("truncate px-2", className)}> */}
|
||||||
|
{selectedOption ? selectedOption.label : placeholder}
|
||||||
|
{/* </span> */}
|
||||||
|
{/* <span
|
||||||
|
className={cn("ml-1 transition-transform", isOpen && "rotate-180")}
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</span> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 下拉菜单 - 使用 Portal 渲染到 body */}
|
||||||
|
{isOpen &&
|
||||||
|
dropdownPosition &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
className="fixed bg-white border border-[#4e402e] rounded shadow-lg max-h-[200px] overflow-auto z-[1000]"
|
||||||
|
style={{
|
||||||
|
top: `${dropdownPosition.top}px`,
|
||||||
|
left: `${dropdownPosition.left}px`,
|
||||||
|
width: `${
|
||||||
|
dropdownPosition.width > 0 ? dropdownPosition.width : "auto"
|
||||||
|
}px`,
|
||||||
|
zIndex: zIndex,
|
||||||
|
}}
|
||||||
|
role="listbox"
|
||||||
|
>
|
||||||
|
{options.map((option, index) => {
|
||||||
|
const isSelected = option.value === value;
|
||||||
|
const isFocused = index === focusedIndex;
|
||||||
|
const isDisabled = option.disabled;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={option.value}
|
||||||
|
data-option-index={index}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-2 cursor-pointer text-[1vw] text-[#4e402e] transition-colors text-center whitespace-nowrap",
|
||||||
|
isSelected && "bg-[#4e402e]/10 font-medium",
|
||||||
|
isFocused && !isSelected && "bg-gray-100",
|
||||||
|
isDisabled &&
|
||||||
|
"opacity-50 cursor-not-allowed pointer-events-none"
|
||||||
|
)}
|
||||||
|
onClick={() => !isDisabled && handleSelect(option.value)}
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Select.displayName = "Select";
|
||||||
|
|
||||||
|
export default Select;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
/** Select 选项 */
|
||||||
|
export interface SelectOption {
|
||||||
|
/** 选项值 */
|
||||||
|
value: string;
|
||||||
|
/** 选项标签 */
|
||||||
|
label: string;
|
||||||
|
/** 是否禁用 */
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Select 组件属性 */
|
||||||
|
export interface SelectProps {
|
||||||
|
/** 选项列表 */
|
||||||
|
options: SelectOption[];
|
||||||
|
/** 当前选中的值 */
|
||||||
|
value?: string;
|
||||||
|
/** 值变化回调 */
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
/** 占位符文本 */
|
||||||
|
placeholder?: string;
|
||||||
|
/** 是否禁用 */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** 自定义类名 */
|
||||||
|
className?: string;
|
||||||
|
/** 是否显示错误状态 */
|
||||||
|
error?: boolean;
|
||||||
|
/** 下拉菜单的 z-index */
|
||||||
|
zIndex?: number;
|
||||||
|
/** 下拉菜单宽度是否自适应内容(根据最长的 label 文字宽度) */
|
||||||
|
fitContent?: boolean;
|
||||||
|
/** 下拉菜单最小宽度(当 fitContent 为 true 时生效) */
|
||||||
|
minWidth?: number;
|
||||||
|
/** 下拉菜单最大宽度(当 fitContent 为 true 时生效) */
|
||||||
|
maxWidth?: number;
|
||||||
|
}
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
# Toast 提示组件
|
|
||||||
|
|
||||||
通用的 Toast 提示组件,支持多种类型、位置和自定义配置。无需 Provider 包裹,可直接使用。
|
|
||||||
|
|
||||||
## 功能特性
|
|
||||||
|
|
||||||
- ✅ 支持 4 种类型:success、error、warning、info
|
|
||||||
- ✅ 支持 7 个位置:top-left、top-center、top-right、center、bottom-left、bottom-center、bottom-right
|
|
||||||
- ✅ 自动消失和手动关闭
|
|
||||||
- ✅ 多个提示同时显示
|
|
||||||
- ✅ 流畅的动画效果(基于 framer-motion)
|
|
||||||
- ✅ 支持自定义图标和样式
|
|
||||||
- ✅ TypeScript 类型支持
|
|
||||||
- ✅ 无需 Provider,直接使用
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
### 基本使用
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { toast } from "@/components";
|
|
||||||
|
|
||||||
// 显示成功提示
|
|
||||||
toast.success("操作成功!");
|
|
||||||
|
|
||||||
// 显示错误提示
|
|
||||||
toast.error("操作失败,请重试");
|
|
||||||
|
|
||||||
// 显示警告提示
|
|
||||||
toast.warning("请注意");
|
|
||||||
|
|
||||||
// 显示信息提示
|
|
||||||
toast.info("这是一条提示信息");
|
|
||||||
```
|
|
||||||
|
|
||||||
### 全局配置(可选)
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { toast } from "@/components";
|
|
||||||
|
|
||||||
// 配置默认位置和最大显示数量
|
|
||||||
toast.config({
|
|
||||||
defaultPosition: "top-right",
|
|
||||||
maxCount: 5,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## 详细用法
|
|
||||||
|
|
||||||
### 1. 显示自定义提示
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { toast } from "@/components";
|
|
||||||
|
|
||||||
toast.show({
|
|
||||||
type: "info",
|
|
||||||
title: "自定义标题",
|
|
||||||
message: "这是一条自定义提示信息",
|
|
||||||
duration: 3000, // 3秒后自动关闭,0 表示不自动关闭
|
|
||||||
closable: true, // 是否显示关闭按钮
|
|
||||||
position: "center", // 位置:center 表示屏幕中间
|
|
||||||
onClose: () => {
|
|
||||||
console.log("提示已关闭");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 使用 ReactNode 作为消息内容
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { toast } from "@/components";
|
|
||||||
|
|
||||||
toast.success(
|
|
||||||
<div>
|
|
||||||
<strong>操作成功!</strong>
|
|
||||||
<p>您的数据已保存</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 自定义图标
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { toast } from "@/components";
|
|
||||||
import { CheckCircle } from "lucide-react";
|
|
||||||
|
|
||||||
toast.show({
|
|
||||||
type: "success",
|
|
||||||
message: "自定义图标",
|
|
||||||
icon: <CheckCircle className="w-6 h-6" />,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 手动关闭提示
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { toast } from "@/components";
|
|
||||||
|
|
||||||
// 显示提示并保存 ID
|
|
||||||
const toastId = toast.success("这条提示可以手动关闭");
|
|
||||||
|
|
||||||
// 3秒后手动关闭
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.close(toastId);
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
// 关闭所有提示
|
|
||||||
toast.closeAll();
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. 在非 React 组件中使用
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// 可以在任何地方使用,包括工具函数、事件处理器等
|
|
||||||
import { toast } from "@/components";
|
|
||||||
|
|
||||||
export function handleApiError(error: Error) {
|
|
||||||
toast.error(`请求失败: ${error.message}`);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## API 参考
|
|
||||||
|
|
||||||
### toast 对象
|
|
||||||
|
|
||||||
| 方法 | 说明 | 参数 |
|
|
||||||
| -------- | -------------- | --------------------------------------------------------------------------------- |
|
|
||||||
| config | 全局配置 | `{ defaultPosition?: ToastPosition, maxCount?: number }` |
|
|
||||||
| show | 显示自定义提示 | `ToastOptions` |
|
|
||||||
| success | 显示成功提示 | `message: string \| ReactNode, options?: Omit<ToastOptions, "type" \| "message">` |
|
|
||||||
| error | 显示错误提示 | `message: string \| ReactNode, options?: Omit<ToastOptions, "type" \| "message">` |
|
|
||||||
| warning | 显示警告提示 | `message: string \| ReactNode, options?: Omit<ToastOptions, "type" \| "message">` |
|
|
||||||
| info | 显示信息提示 | `message: string \| ReactNode, options?: Omit<ToastOptions, "type" \| "message">` |
|
|
||||||
| close | 关闭指定提示 | `id: string` |
|
|
||||||
| closeAll | 关闭所有提示 | - |
|
|
||||||
|
|
||||||
### ToastOptions
|
|
||||||
|
|
||||||
| 属性 | 类型 | 默认值 | 说明 |
|
|
||||||
| --------- | ------------------- | ------ | ---------------------------------------- |
|
|
||||||
| type | ToastType | "info" | 提示类型:success、error、warning、info |
|
|
||||||
| title | string | - | 标题 |
|
|
||||||
| message | string \| ReactNode | - | 消息内容(必填) |
|
|
||||||
| duration | number | 3000 | 持续时间(毫秒),0 表示不自动关闭 |
|
|
||||||
| closable | boolean | true | 是否显示关闭按钮 |
|
|
||||||
| icon | ReactNode | - | 自定义图标 |
|
|
||||||
| position | ToastPosition | - | 位置(继承自全局配置的 defaultPosition) |
|
|
||||||
| onClose | () => void | - | 关闭回调 |
|
|
||||||
| className | string | - | 自定义类名 |
|
|
||||||
|
|
||||||
### toast.config() 配置项
|
|
||||||
|
|
||||||
| 属性 | 类型 | 默认值 | 说明 |
|
|
||||||
| --------------- | ------------- | ----------- | ------------ |
|
|
||||||
| defaultPosition | ToastPosition | "top-right" | 默认位置 |
|
|
||||||
| maxCount | number | 5 | 最大显示数量 |
|
|
||||||
|
|
||||||
## 类型定义
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type ToastType = "success" | "error" | "warning" | "info";
|
|
||||||
type ToastPosition =
|
|
||||||
| "top-left"
|
|
||||||
| "top-center"
|
|
||||||
| "top-right"
|
|
||||||
| "center"
|
|
||||||
| "bottom-left"
|
|
||||||
| "bottom-center"
|
|
||||||
| "bottom-right";
|
|
||||||
```
|
|
||||||
|
|
||||||
## 样式定制
|
|
||||||
|
|
||||||
组件使用 Tailwind CSS,支持通过 `className` 属性自定义样式。默认支持深色模式。
|
|
||||||
|
|
||||||
## 向后兼容
|
|
||||||
|
|
||||||
为了保持向后兼容,仍然支持旧的 `useToast` Hook 和 `ToastProvider`,但不推荐使用:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// 旧方式(不推荐,但仍然支持)
|
|
||||||
import { useToast, ToastProvider } from "@/components";
|
|
||||||
|
|
||||||
// 新方式(推荐)
|
|
||||||
import { toast } from "@/components";
|
|
||||||
```
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
1. **无需 Provider**:直接使用 `toast` 对象,无需在应用根组件包裹 Provider
|
|
||||||
2. **自动初始化**:首次调用时会自动创建 Toast 容器
|
|
||||||
3. **位置分组**:提示会自动根据位置分组显示
|
|
||||||
4. **数量限制**:超过 `maxCount` 数量的提示会被自动移除(保留最新的)
|
|
||||||
5. **动画效果**:提示关闭时会先触发动画,然后从 DOM 中移除
|
|
||||||
6. **SSR 支持**:组件会自动检测运行环境,在服务端不会渲染
|
|
||||||
|
|
||||||
## 迁移指南
|
|
||||||
|
|
||||||
### 从旧版本迁移
|
|
||||||
|
|
||||||
**旧版本(需要 Provider):**
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// App.tsx
|
|
||||||
<ToastProvider>
|
|
||||||
<App />
|
|
||||||
</ToastProvider>;
|
|
||||||
|
|
||||||
// Component.tsx
|
|
||||||
const toast = useToast();
|
|
||||||
toast.success("成功!");
|
|
||||||
```
|
|
||||||
|
|
||||||
**新版本(直接使用):**
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// 任何地方直接使用
|
|
||||||
import { toast } from "@/components";
|
|
||||||
toast.success("成功!");
|
|
||||||
```
|
|
||||||
|
|
||||||
只需删除 `ToastProvider` 包裹,并将 `useToast()` 改为直接使用 `toast` 对象即可。
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import {
|
|
||||||
X,
|
|
||||||
CheckCircle2,
|
|
||||||
AlertCircle,
|
|
||||||
AlertTriangle,
|
|
||||||
Info,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import type { ToastInstance } from "./types";
|
|
||||||
|
|
||||||
export interface ToastProps {
|
|
||||||
toast: ToastInstance;
|
|
||||||
onClose: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Toast 图标映射 */
|
|
||||||
const iconMap = {
|
|
||||||
success: CheckCircle2,
|
|
||||||
error: AlertCircle,
|
|
||||||
warning: AlertTriangle,
|
|
||||||
info: Info,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Toast 颜色映射 */
|
|
||||||
const colorMap = {
|
|
||||||
success: {
|
|
||||||
bg: "bg-green-50 dark:bg-green-900/20",
|
|
||||||
border: "border-green-200 dark:border-green-800",
|
|
||||||
icon: "text-green-600 dark:text-green-400",
|
|
||||||
title: "text-green-900 dark:text-green-100",
|
|
||||||
message: "text-green-800 dark:text-green-200",
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
bg: "bg-red-50 dark:bg-red-900/20",
|
|
||||||
border: "border-red-200 dark:border-red-800",
|
|
||||||
icon: "text-red-600 dark:text-red-400",
|
|
||||||
title: "text-red-900 dark:text-red-100",
|
|
||||||
message: "text-red-800 dark:text-red-200",
|
|
||||||
},
|
|
||||||
warning: {
|
|
||||||
bg: "bg-yellow-50 dark:bg-yellow-900/20",
|
|
||||||
border: "border-yellow-200 dark:border-yellow-800",
|
|
||||||
icon: "text-yellow-600 dark:text-yellow-400",
|
|
||||||
title: "text-yellow-900 dark:text-yellow-100",
|
|
||||||
message: "text-yellow-800 dark:text-yellow-200",
|
|
||||||
},
|
|
||||||
info: {
|
|
||||||
bg: "bg-blue-50 dark:bg-blue-900/20",
|
|
||||||
border: "border-blue-200 dark:border-blue-800",
|
|
||||||
icon: "text-blue-600 dark:text-blue-400",
|
|
||||||
title: "text-blue-900 dark:text-blue-100",
|
|
||||||
message: "text-blue-800 dark:text-blue-200",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toast 组件
|
|
||||||
*/
|
|
||||||
export const Toast = ({ toast, onClose }: ToastProps) => {
|
|
||||||
const {
|
|
||||||
id,
|
|
||||||
type = "info",
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
duration = 3000,
|
|
||||||
closable = true,
|
|
||||||
icon,
|
|
||||||
visible,
|
|
||||||
} = toast;
|
|
||||||
|
|
||||||
const IconComponent = iconMap[type];
|
|
||||||
const colors = colorMap[type];
|
|
||||||
|
|
||||||
// 自动关闭
|
|
||||||
useEffect(() => {
|
|
||||||
if (duration > 0 && visible) {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
onClose(id);
|
|
||||||
}, duration);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [duration, id, onClose, visible]);
|
|
||||||
|
|
||||||
if (!visible) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: -20, scale: 0.95 }}
|
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95, transition: { duration: 0.2 } }}
|
|
||||||
className={cn(
|
|
||||||
"relative flex items-start gap-3 p-4 rounded-lg shadow-lg border backdrop-blur-sm",
|
|
||||||
"min-w-[320px] max-w-[420px]",
|
|
||||||
colors.bg,
|
|
||||||
colors.border,
|
|
||||||
toast.className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* 图标 */}
|
|
||||||
<div className={cn("flex-shrink-0 mt-0.5", colors.icon)}>
|
|
||||||
{icon || <IconComponent className="w-5 h-5" />}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 内容 */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
{title && (
|
|
||||||
<div className={cn("text-sm font-semibold mb-1", colors.title)}>
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={cn("text-sm", colors.message)}>{message}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 关闭按钮 */}
|
|
||||||
{closable && (
|
|
||||||
<button
|
|
||||||
onClick={() => onClose(id)}
|
|
||||||
className={cn(
|
|
||||||
"flex-shrink-0 p-1 rounded-md transition-colors",
|
|
||||||
"hover:bg-black/5 dark:hover:bg-white/10",
|
|
||||||
"text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
|
||||||
)}
|
|
||||||
aria-label="关闭"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { AnimatePresence } from "framer-motion";
|
|
||||||
import { usePageScale } from "@/hooks/usePageScale";
|
|
||||||
import { Toast } from "./Toast";
|
|
||||||
import type { ToastInstance, ToastPosition } from "./types";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface ToastContainerProps {
|
|
||||||
toasts: ToastInstance[];
|
|
||||||
defaultPosition: ToastPosition;
|
|
||||||
onClose: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toast 容器组件
|
|
||||||
* 负责渲染所有 Toast 到指定位置
|
|
||||||
*/
|
|
||||||
export const ToastContainer = ({
|
|
||||||
toasts,
|
|
||||||
defaultPosition,
|
|
||||||
onClose,
|
|
||||||
}: ToastContainerProps) => {
|
|
||||||
// 获取页面缩放信息
|
|
||||||
const {
|
|
||||||
offsetX,
|
|
||||||
offsetY,
|
|
||||||
scaledWidth,
|
|
||||||
scaledHeight,
|
|
||||||
windowWidth,
|
|
||||||
windowHeight,
|
|
||||||
} = usePageScale();
|
|
||||||
|
|
||||||
// 按位置分组
|
|
||||||
const toastsByPosition = toasts.reduce((acc, toast) => {
|
|
||||||
const position = toast.position || defaultPosition;
|
|
||||||
if (!acc[position]) {
|
|
||||||
acc[position] = [];
|
|
||||||
}
|
|
||||||
acc[position].push(toast);
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<ToastPosition, ToastInstance[]>);
|
|
||||||
|
|
||||||
// 计算中心位置(基于缩放后的实际尺寸)
|
|
||||||
const centerX = offsetX + scaledWidth / 2;
|
|
||||||
const centerY = offsetY + scaledHeight / 2;
|
|
||||||
|
|
||||||
// 位置样式映射
|
|
||||||
const getPositionStyle = (position: ToastPosition): React.CSSProperties => {
|
|
||||||
const baseStyle: React.CSSProperties = {
|
|
||||||
position: "fixed",
|
|
||||||
zIndex: 9999,
|
|
||||||
pointerEvents: "none",
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (position) {
|
|
||||||
case "top-left":
|
|
||||||
return {
|
|
||||||
...baseStyle,
|
|
||||||
top: `${offsetY + 16}px`,
|
|
||||||
left: `${offsetX + 16}px`,
|
|
||||||
};
|
|
||||||
case "top-center":
|
|
||||||
return {
|
|
||||||
...baseStyle,
|
|
||||||
top: `${offsetY + 16}px`,
|
|
||||||
left: `${centerX}px`,
|
|
||||||
transform: "translateX(-50%)",
|
|
||||||
};
|
|
||||||
case "top-right":
|
|
||||||
return {
|
|
||||||
...baseStyle,
|
|
||||||
top: `${offsetY + 16}px`,
|
|
||||||
right: `${windowWidth - offsetX - scaledWidth + 16}px`,
|
|
||||||
};
|
|
||||||
case "center":
|
|
||||||
return {
|
|
||||||
...baseStyle,
|
|
||||||
top: `${centerY}px`,
|
|
||||||
left: `${centerX}px`,
|
|
||||||
transform: "translate(-50%, -50%)",
|
|
||||||
};
|
|
||||||
case "bottom-left":
|
|
||||||
return {
|
|
||||||
...baseStyle,
|
|
||||||
bottom: `${windowHeight - offsetY - scaledHeight + 16}px`,
|
|
||||||
left: `${offsetX + 16}px`,
|
|
||||||
};
|
|
||||||
case "bottom-center":
|
|
||||||
return {
|
|
||||||
...baseStyle,
|
|
||||||
bottom: `${windowHeight - offsetY - scaledHeight + 16}px`,
|
|
||||||
left: `${centerX}px`,
|
|
||||||
transform: "translateX(-50%)",
|
|
||||||
};
|
|
||||||
case "bottom-right":
|
|
||||||
return {
|
|
||||||
...baseStyle,
|
|
||||||
bottom: `${windowHeight - offsetY - scaledHeight + 16}px`,
|
|
||||||
right: `${windowWidth - offsetX - scaledWidth + 16}px`,
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return baseStyle;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof window === "undefined") return null;
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<>
|
|
||||||
{Object.entries(toastsByPosition).map(([position, positionToasts]) => (
|
|
||||||
<div key={position} style={getPositionStyle(position as ToastPosition)}>
|
|
||||||
<div className="flex flex-col gap-2 pointer-events-auto">
|
|
||||||
<AnimatePresence mode="popLayout">
|
|
||||||
{positionToasts.map((toast) => (
|
|
||||||
<Toast key={toast.id} toast={toast} onClose={onClose} />
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>,
|
|
||||||
document.body
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
import { createRoot, Root } from "react-dom/client";
|
|
||||||
import { ToastContainer } from "./ToastContainer";
|
|
||||||
import type { ToastInstance, ToastOptions, ToastPosition } from "./types";
|
|
||||||
import { ReactNode } from "react";
|
|
||||||
|
|
||||||
interface ToastConfig {
|
|
||||||
defaultPosition?: ToastPosition;
|
|
||||||
maxCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
class ToastManagerClass {
|
|
||||||
private toasts: ToastInstance[] = [];
|
|
||||||
private containerRoot: Root | null = null;
|
|
||||||
private configOptions: Required<ToastConfig> = {
|
|
||||||
defaultPosition: "top-right",
|
|
||||||
maxCount: 5,
|
|
||||||
};
|
|
||||||
private listeners: Set<() => void> = new Set();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化容器
|
|
||||||
*/
|
|
||||||
private initContainer() {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
if (this.containerRoot) return;
|
|
||||||
|
|
||||||
const container = document.createElement("div");
|
|
||||||
container.id = "toast-container";
|
|
||||||
document.body.appendChild(container);
|
|
||||||
|
|
||||||
this.containerRoot = createRoot(container);
|
|
||||||
this.render();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 渲染 Toast 容器
|
|
||||||
*/
|
|
||||||
private render() {
|
|
||||||
if (!this.containerRoot) return;
|
|
||||||
|
|
||||||
this.containerRoot.render(
|
|
||||||
<ToastContainer
|
|
||||||
toasts={this.toasts}
|
|
||||||
defaultPosition={this.configOptions.defaultPosition}
|
|
||||||
onClose={this.close.bind(this)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通知所有监听器
|
|
||||||
*/
|
|
||||||
private notify() {
|
|
||||||
this.listeners.forEach((listener) => listener());
|
|
||||||
this.render();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成唯一 ID
|
|
||||||
*/
|
|
||||||
private generateId(): string {
|
|
||||||
return `toast-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 全局配置
|
|
||||||
*/
|
|
||||||
config(options: ToastConfig) {
|
|
||||||
this.configOptions = {
|
|
||||||
...this.configOptions,
|
|
||||||
...options,
|
|
||||||
};
|
|
||||||
// 如果容器已初始化,更新渲染
|
|
||||||
if (this.containerRoot) {
|
|
||||||
this.render();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 显示 Toast
|
|
||||||
*/
|
|
||||||
show(options: ToastOptions): string {
|
|
||||||
this.initContainer();
|
|
||||||
|
|
||||||
const id = this.generateId();
|
|
||||||
const position = options.position || this.configOptions.defaultPosition;
|
|
||||||
|
|
||||||
const newToast: ToastInstance = {
|
|
||||||
id,
|
|
||||||
visible: true,
|
|
||||||
type: options.type || "info",
|
|
||||||
title: options.title,
|
|
||||||
message: options.message,
|
|
||||||
duration: options.duration ?? 3000,
|
|
||||||
closable: options.closable ?? true,
|
|
||||||
icon: options.icon,
|
|
||||||
position,
|
|
||||||
onClose: options.onClose,
|
|
||||||
className: options.className,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.toasts = this.toasts.filter((t) => t.visible);
|
|
||||||
this.toasts.push(newToast);
|
|
||||||
// 限制最大数量
|
|
||||||
this.toasts = this.toasts.slice(-this.configOptions.maxCount);
|
|
||||||
|
|
||||||
this.notify();
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 显示成功提示
|
|
||||||
*/
|
|
||||||
success(
|
|
||||||
message: string | ReactNode,
|
|
||||||
options?: Omit<ToastOptions, "type" | "message">
|
|
||||||
): string {
|
|
||||||
return this.show({ ...options, type: "success", message });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 显示错误提示
|
|
||||||
*/
|
|
||||||
error(
|
|
||||||
message: string | ReactNode,
|
|
||||||
options?: Omit<ToastOptions, "type" | "message">
|
|
||||||
): string {
|
|
||||||
return this.show({ ...options, type: "error", message });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 显示警告提示
|
|
||||||
*/
|
|
||||||
warning(
|
|
||||||
message: string | ReactNode,
|
|
||||||
options?: Omit<ToastOptions, "type" | "message">
|
|
||||||
): string {
|
|
||||||
return this.show({ ...options, type: "warning", message });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 显示信息提示
|
|
||||||
*/
|
|
||||||
info(
|
|
||||||
message: string | ReactNode,
|
|
||||||
options?: Omit<ToastOptions, "type" | "message">
|
|
||||||
): string {
|
|
||||||
return this.show({ ...options, type: "info", message });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 关闭指定 Toast
|
|
||||||
*/
|
|
||||||
close(id: string) {
|
|
||||||
this.toasts = this.toasts.map((toast) => {
|
|
||||||
if (toast.id === id) {
|
|
||||||
toast.onClose?.();
|
|
||||||
return { ...toast, visible: false };
|
|
||||||
}
|
|
||||||
return toast;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 延迟移除,等待动画完成
|
|
||||||
setTimeout(() => {
|
|
||||||
this.toasts = this.toasts.filter((toast) => toast.id !== id);
|
|
||||||
this.notify();
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
this.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 关闭所有 Toast
|
|
||||||
*/
|
|
||||||
closeAll() {
|
|
||||||
this.toasts.forEach((toast) => {
|
|
||||||
toast.onClose?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.toasts = this.toasts.map((toast) => ({
|
|
||||||
...toast,
|
|
||||||
visible: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
this.toasts = [];
|
|
||||||
this.notify();
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
this.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 订阅状态变化(用于 Hook)
|
|
||||||
*/
|
|
||||||
subscribe(listener: () => void) {
|
|
||||||
this.listeners.add(listener);
|
|
||||||
return () => {
|
|
||||||
this.listeners.delete(listener);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取当前 toasts(用于 Hook)
|
|
||||||
*/
|
|
||||||
getToasts(): ToastInstance[] {
|
|
||||||
return this.toasts;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建全局单例
|
|
||||||
export const toastManager = new ToastManagerClass();
|
|
||||||
|
|
||||||
// 导出全局 toast 对象
|
|
||||||
export const toast = {
|
|
||||||
config: toastManager.config.bind(toastManager),
|
|
||||||
show: toastManager.show.bind(toastManager),
|
|
||||||
success: toastManager.success.bind(toastManager),
|
|
||||||
error: toastManager.error.bind(toastManager),
|
|
||||||
warning: toastManager.warning.bind(toastManager),
|
|
||||||
info: toastManager.info.bind(toastManager),
|
|
||||||
close: toastManager.close.bind(toastManager),
|
|
||||||
closeAll: toastManager.closeAll.bind(toastManager),
|
|
||||||
};
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
import {
|
|
||||||
createContext,
|
|
||||||
useContext,
|
|
||||||
useState,
|
|
||||||
useCallback,
|
|
||||||
ReactNode,
|
|
||||||
} from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { AnimatePresence } from "framer-motion";
|
|
||||||
import { Toast } from "./Toast";
|
|
||||||
import type { ToastInstance, ToastOptions, ToastPosition } from "./types";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface ToastContextValue {
|
|
||||||
show: (options: ToastOptions) => string;
|
|
||||||
success: (
|
|
||||||
message: string | ReactNode,
|
|
||||||
options?: Omit<ToastOptions, "type" | "message">
|
|
||||||
) => string;
|
|
||||||
error: (
|
|
||||||
message: string | ReactNode,
|
|
||||||
options?: Omit<ToastOptions, "type" | "message">
|
|
||||||
) => string;
|
|
||||||
warning: (
|
|
||||||
message: string | ReactNode,
|
|
||||||
options?: Omit<ToastOptions, "type" | "message">
|
|
||||||
) => string;
|
|
||||||
info: (
|
|
||||||
message: string | ReactNode,
|
|
||||||
options?: Omit<ToastOptions, "type" | "message">
|
|
||||||
) => string;
|
|
||||||
close: (id: string) => void;
|
|
||||||
closeAll: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
|
||||||
|
|
||||||
export const useToastContext = () => {
|
|
||||||
const context = useContext(ToastContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useToast must be used within ToastProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ToastProviderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
/** 默认位置 */
|
|
||||||
defaultPosition?: ToastPosition;
|
|
||||||
/** 最大显示数量 */
|
|
||||||
maxCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toast Provider 组件
|
|
||||||
*
|
|
||||||
* @deprecated 已废弃,请直接使用 `toast` 对象,无需 Provider
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* // 新方式(推荐)
|
|
||||||
* import { toast } from "@/components";
|
|
||||||
* toast.success("操作成功!");
|
|
||||||
*
|
|
||||||
* // 旧方式(已废弃)
|
|
||||||
* <ToastProvider>
|
|
||||||
* <App />
|
|
||||||
* </ToastProvider>
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export const ToastProvider = ({
|
|
||||||
children,
|
|
||||||
defaultPosition = "top-right",
|
|
||||||
maxCount = 5,
|
|
||||||
}: ToastProviderProps) => {
|
|
||||||
const [toasts, setToasts] = useState<ToastInstance[]>([]);
|
|
||||||
|
|
||||||
// 生成唯一 ID
|
|
||||||
const generateId = () =>
|
|
||||||
`toast-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
|
||||||
|
|
||||||
// 显示 Toast
|
|
||||||
const show = useCallback(
|
|
||||||
(options: ToastOptions): string => {
|
|
||||||
const id = generateId();
|
|
||||||
const position = options.position || defaultPosition;
|
|
||||||
|
|
||||||
const newToast: ToastInstance = {
|
|
||||||
id,
|
|
||||||
visible: true,
|
|
||||||
type: options.type || "info",
|
|
||||||
title: options.title,
|
|
||||||
message: options.message,
|
|
||||||
duration: options.duration ?? 3000,
|
|
||||||
closable: options.closable ?? true,
|
|
||||||
icon: options.icon,
|
|
||||||
position,
|
|
||||||
onClose: options.onClose,
|
|
||||||
className: options.className,
|
|
||||||
};
|
|
||||||
|
|
||||||
setToasts((prev) => {
|
|
||||||
const filtered = prev.filter((t) => t.visible);
|
|
||||||
const newToasts = [...filtered, newToast];
|
|
||||||
// 限制最大数量
|
|
||||||
return newToasts.slice(-maxCount);
|
|
||||||
});
|
|
||||||
|
|
||||||
return id;
|
|
||||||
},
|
|
||||||
[defaultPosition, maxCount]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 关闭 Toast
|
|
||||||
const close = useCallback((id: string) => {
|
|
||||||
setToasts((prev) =>
|
|
||||||
prev.map((toast) => {
|
|
||||||
if (toast.id === id) {
|
|
||||||
// 触发关闭回调
|
|
||||||
toast.onClose?.();
|
|
||||||
return { ...toast, visible: false };
|
|
||||||
}
|
|
||||||
return toast;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// 延迟移除,等待动画完成
|
|
||||||
setTimeout(() => {
|
|
||||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
|
||||||
}, 300);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 关闭所有 Toast
|
|
||||||
const closeAll = useCallback(() => {
|
|
||||||
setToasts((prev) =>
|
|
||||||
prev.map((toast) => {
|
|
||||||
toast.onClose?.();
|
|
||||||
return { ...toast, visible: false };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setToasts([]);
|
|
||||||
}, 300);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 便捷方法
|
|
||||||
const success = useCallback(
|
|
||||||
(
|
|
||||||
message: string | ReactNode,
|
|
||||||
options?: Omit<ToastOptions, "type" | "message">
|
|
||||||
) => {
|
|
||||||
return show({ ...options, type: "success", message });
|
|
||||||
},
|
|
||||||
[show]
|
|
||||||
);
|
|
||||||
|
|
||||||
const error = useCallback(
|
|
||||||
(
|
|
||||||
message: string | ReactNode,
|
|
||||||
options?: Omit<ToastOptions, "type" | "message">
|
|
||||||
) => {
|
|
||||||
return show({ ...options, type: "error", message });
|
|
||||||
},
|
|
||||||
[show]
|
|
||||||
);
|
|
||||||
|
|
||||||
const warning = useCallback(
|
|
||||||
(
|
|
||||||
message: string | ReactNode,
|
|
||||||
options?: Omit<ToastOptions, "type" | "message">
|
|
||||||
) => {
|
|
||||||
return show({ ...options, type: "warning", message });
|
|
||||||
},
|
|
||||||
[show]
|
|
||||||
);
|
|
||||||
|
|
||||||
const info = useCallback(
|
|
||||||
(
|
|
||||||
message: string | ReactNode,
|
|
||||||
options?: Omit<ToastOptions, "type" | "message">
|
|
||||||
) => {
|
|
||||||
return show({ ...options, type: "info", message });
|
|
||||||
},
|
|
||||||
[show]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 按位置分组
|
|
||||||
const toastsByPosition = toasts.reduce((acc, toast) => {
|
|
||||||
const position = toast.position || defaultPosition;
|
|
||||||
if (!acc[position]) {
|
|
||||||
acc[position] = [];
|
|
||||||
}
|
|
||||||
acc[position].push(toast);
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<ToastPosition, ToastInstance[]>);
|
|
||||||
|
|
||||||
// 位置样式映射
|
|
||||||
const positionClasses: Record<ToastPosition, string> = {
|
|
||||||
"top-left": "top-4 left-4",
|
|
||||||
"top-center": "top-4 left-1/2 -translate-x-1/2",
|
|
||||||
"top-right": "top-4 right-4",
|
|
||||||
center: "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
|
|
||||||
"bottom-left": "bottom-4 left-4",
|
|
||||||
"bottom-center": "bottom-4 left-1/2 -translate-x-1/2",
|
|
||||||
"bottom-right": "bottom-4 right-4",
|
|
||||||
};
|
|
||||||
|
|
||||||
const value: ToastContextValue = {
|
|
||||||
show,
|
|
||||||
success,
|
|
||||||
error,
|
|
||||||
warning,
|
|
||||||
info,
|
|
||||||
close,
|
|
||||||
closeAll,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToastContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
{typeof window !== "undefined" &&
|
|
||||||
createPortal(
|
|
||||||
<>
|
|
||||||
{Object.entries(toastsByPosition).map(
|
|
||||||
([position, positionToasts]) => (
|
|
||||||
<div
|
|
||||||
key={position}
|
|
||||||
className={cn(
|
|
||||||
"fixed z-[9999] pointer-events-none",
|
|
||||||
positionClasses[position as ToastPosition]
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-2 pointer-events-auto">
|
|
||||||
<AnimatePresence mode="popLayout">
|
|
||||||
{positionToasts.map((toast) => (
|
|
||||||
<Toast key={toast.id} toast={toast} onClose={close} />
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
</ToastContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
// 全局 toast 对象(推荐使用)
|
|
||||||
export { toast } from "./ToastManager";
|
|
||||||
|
|
||||||
// Toast 组件
|
|
||||||
export { Toast } from "./Toast";
|
|
||||||
|
|
||||||
// Hook(向后兼容,推荐直接使用 toast 对象)
|
|
||||||
export { useToast } from "./useToast";
|
|
||||||
|
|
||||||
// 类型导出
|
|
||||||
export type {
|
|
||||||
ToastType,
|
|
||||||
ToastPosition,
|
|
||||||
ToastOptions,
|
|
||||||
ToastInstance,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
// 废弃的 Provider(保留以保持向后兼容,但不推荐使用)
|
|
||||||
export { ToastProvider, useToastContext } from "./ToastProvider";
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { ReactNode } from "react";
|
|
||||||
|
|
||||||
/** Toast 类型 */
|
|
||||||
export type ToastType = "success" | "error" | "warning" | "info";
|
|
||||||
|
|
||||||
/** Toast 位置 */
|
|
||||||
export type ToastPosition =
|
|
||||||
| "top-left"
|
|
||||||
| "top-center"
|
|
||||||
| "top-right"
|
|
||||||
| "center"
|
|
||||||
| "bottom-left"
|
|
||||||
| "bottom-center"
|
|
||||||
| "bottom-right";
|
|
||||||
|
|
||||||
/** Toast 配置项 */
|
|
||||||
export interface ToastOptions {
|
|
||||||
/** 提示类型 */
|
|
||||||
type?: ToastType;
|
|
||||||
/** 标题 */
|
|
||||||
title?: string;
|
|
||||||
/** 内容 */
|
|
||||||
message: string | ReactNode;
|
|
||||||
/** 持续时间(毫秒),0 表示不自动关闭 */
|
|
||||||
duration?: number;
|
|
||||||
/** 是否显示关闭按钮 */
|
|
||||||
closable?: boolean;
|
|
||||||
/** 自定义图标 */
|
|
||||||
icon?: ReactNode;
|
|
||||||
/** 位置 */
|
|
||||||
position?: ToastPosition;
|
|
||||||
/** 关闭回调 */
|
|
||||||
onClose?: () => void;
|
|
||||||
/** 自定义类名 */
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Toast 实例 */
|
|
||||||
export interface ToastInstance extends ToastOptions {
|
|
||||||
/** 唯一 ID */
|
|
||||||
id: string;
|
|
||||||
/** 是否可见 */
|
|
||||||
visible: boolean;
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { toastManager } from "./ToastManager";
|
|
||||||
import type { ToastInstance, ToastOptions } from "./types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* useToast Hook
|
|
||||||
*
|
|
||||||
* @deprecated 推荐直接使用 `toast` 对象,无需 Hook
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* // 推荐方式:直接使用 toast 对象
|
|
||||||
* import { toast } from "@/components";
|
|
||||||
* toast.success("操作成功!");
|
|
||||||
*
|
|
||||||
* // 旧方式(仍然支持,但不推荐)
|
|
||||||
* const toast = useToast();
|
|
||||||
* toast.success("操作成功!");
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export const useToast = () => {
|
|
||||||
const [, forceUpdate] = useState({});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unsubscribe = toastManager.subscribe(() => {
|
|
||||||
forceUpdate({});
|
|
||||||
});
|
|
||||||
|
|
||||||
return unsubscribe;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 返回全局 toast 对象(向后兼容)
|
|
||||||
return {
|
|
||||||
show: toastManager.show.bind(toastManager),
|
|
||||||
success: toastManager.success.bind(toastManager),
|
|
||||||
error: toastManager.error.bind(toastManager),
|
|
||||||
warning: toastManager.warning.bind(toastManager),
|
|
||||||
info: toastManager.info.bind(toastManager),
|
|
||||||
close: toastManager.close.bind(toastManager),
|
|
||||||
closeAll: toastManager.closeAll.bind(toastManager),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type { ToastOptions };
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* 敏感词屏蔽字库
|
||||||
|
* 用于过滤用户输入中的敏感词汇,将其替换为 *
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 敏感词库(可根据实际需求扩展)
|
||||||
|
const SENSITIVE_WORDS = [
|
||||||
|
"敏感词1",
|
||||||
|
"敏感词2",
|
||||||
|
"测试",
|
||||||
|
"垃圾",
|
||||||
|
// 可以根据实际需求添加更多敏感词
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查文本中是否包含敏感词
|
||||||
|
* @param text 待检查的文本
|
||||||
|
* @returns 是否包含敏感词
|
||||||
|
*/
|
||||||
|
export function containsSensitiveWord(text: string): boolean {
|
||||||
|
if (!text) return false;
|
||||||
|
|
||||||
|
const normalizedText = text.toLowerCase().trim();
|
||||||
|
|
||||||
|
return SENSITIVE_WORDS.some((word) =>
|
||||||
|
normalizedText.includes(word.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将文本中的敏感词替换为 *
|
||||||
|
* @param text 待处理的文本
|
||||||
|
* @returns 处理后的文本
|
||||||
|
*/
|
||||||
|
export function filterSensitiveWords(text: string): string {
|
||||||
|
if (!text) return text;
|
||||||
|
|
||||||
|
let result = text;
|
||||||
|
|
||||||
|
SENSITIVE_WORDS.forEach((word) => {
|
||||||
|
const regex = new RegExp(word, "gi");
|
||||||
|
result = result.replace(regex, "*".repeat(word.length));
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实时过滤输入内容(在用户输入时调用)
|
||||||
|
* @param input 用户输入的内容
|
||||||
|
* @returns 过滤后的内容
|
||||||
|
*/
|
||||||
|
export function filterInput(input: string): string {
|
||||||
|
if (!input) return input;
|
||||||
|
|
||||||
|
// 先进行敏感词过滤
|
||||||
|
let filtered = filterSensitiveWords(input);
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type DragFillResult,
|
type DragFillResult,
|
||||||
LetterToChenyun,
|
LetterToChenyun,
|
||||||
StudyGuideModal,
|
StudyGuideModal,
|
||||||
|
EndingOverlay,
|
||||||
} from "@/components";
|
} from "@/components";
|
||||||
import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils";
|
import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils";
|
||||||
import { SuccessAlert, DragQuizPanel } from "@/components/layout";
|
import { SuccessAlert, DragQuizPanel } from "@/components/layout";
|
||||||
@@ -416,8 +417,11 @@ const ChenyunInternalization = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StudyGuideModal
|
<EndingOverlay
|
||||||
visible={showStudyGuide}
|
visible={showStudyGuide}
|
||||||
|
text="恭喜你已经顺利完成了陈云纪念馆的学习,至此,你已经完成了本课程所有故居的学习,
|
||||||
|
希望你能有所收获,学习八位名人的优秀品质和精神,在新时代为中华民族伟大复兴发光发热!"
|
||||||
|
animationSpeed={200}
|
||||||
onContinue={() => {
|
onContinue={() => {
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
audioRef.current.pause();
|
audioRef.current.pause();
|
||||||
@@ -426,15 +430,8 @@ const ChenyunInternalization = () => {
|
|||||||
setShowStudyGuide(false);
|
setShowStudyGuide(false);
|
||||||
navigate(`/${moduleName}/${starName}/home`);
|
navigate(`/${moduleName}/${starName}/home`);
|
||||||
}}
|
}}
|
||||||
>
|
audioSrc={getPublicPath("audio/chenyun/mp3_chenyun_40.mp3")}
|
||||||
恭喜你已经顺利完成了陈云纪念馆的学习,至此,你已经完成了本课程所有故居的学习,
|
/>
|
||||||
希望你能有所收获,学习八位名人的优秀品质和精神,在新时代为中华民族伟大复兴发光发热!
|
|
||||||
{/* 同学你好,欢迎来到走进故居-对话学习模块!{"\n"}
|
|
||||||
进入陈云故居后,我们一起来了解陈云同志经历的7个阶段{"\n"}
|
|
||||||
“水乡少年 艰辛成长”、“商务启航 投身革命”、“苏区淬炼 崭露头角”、“延安担当
|
|
||||||
经济破局”、{"\n"}
|
|
||||||
“恢复时期 经济奠基”、“建设时期 务实兴邦”、“改革浪潮 誉画实业”; */}
|
|
||||||
</StudyGuideModal>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export const YinyuanFile = ({
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* 走势图 - 银元之战解决后显示,点击后隐藏 */}
|
{/* 走势图 - 银元之战解决后显示,点击后隐藏 */}
|
||||||
{isSolved && showTrendChartIcon && (
|
{isSolved && (
|
||||||
<div
|
<div
|
||||||
className="absolute -top-40 left-1/2 -translate-x-1/2 cursor-pointer transition-all duration-300 hover:scale-105 z-2"
|
className="absolute -top-40 left-1/2 -translate-x-1/2 cursor-pointer transition-all duration-300 hover:scale-105 z-2"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
type VideoRef,
|
type VideoRef,
|
||||||
type DragFillResult,
|
type DragFillResult,
|
||||||
LetterToHuangyanpei,
|
LetterToHuangyanpei,
|
||||||
StudyGuideModal,
|
EndingOverlay,
|
||||||
|
Narration,
|
||||||
} from "@/components";
|
} from "@/components";
|
||||||
import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils";
|
import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils";
|
||||||
import { SuccessAlert, DragQuizPanel } from "@/components/layout";
|
import { SuccessAlert, DragQuizPanel } from "@/components/layout";
|
||||||
@@ -87,7 +88,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "quiz-3",
|
id: "quiz-3",
|
||||||
time: 370, // 6:10
|
time: 360, // 6:10
|
||||||
title: "题目3",
|
title: "题目3",
|
||||||
prompt:
|
prompt:
|
||||||
'当年黄炎培先生有推广国货的爱国情怀,如今我们的"国货"已经自强自立,请将其中的国产企业/品牌放置到空格处',
|
'当年黄炎培先生有推广国货的爱国情怀,如今我们的"国货"已经自强自立,请将其中的国产企业/品牌放置到空格处',
|
||||||
@@ -136,6 +137,8 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
|
|||||||
const HuangyanpeiInternalization = () => {
|
const HuangyanpeiInternalization = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { moduleName } = useParams<{ moduleName: string }>();
|
const { moduleName } = useParams<{ moduleName: string }>();
|
||||||
|
|
||||||
|
const [narrationVisible, setNarrationVisible] = useState(false);
|
||||||
const [activeSuccess, setActiveSuccess] = useState<string | null>(null);
|
const [activeSuccess, setActiveSuccess] = useState<string | null>(null);
|
||||||
const [successContent, setSuccessContent] = useState<string>("");
|
const [successContent, setSuccessContent] = useState<string>("");
|
||||||
const [resumeCallback, setResumeCallback] = useState<(() => void) | null>(
|
const [resumeCallback, setResumeCallback] = useState<(() => void) | null>(
|
||||||
@@ -156,7 +159,7 @@ const HuangyanpeiInternalization = () => {
|
|||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVisible(true);
|
setNarrationVisible(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 组件卸载时清理背景音乐
|
// 组件卸载时清理背景音乐
|
||||||
@@ -360,6 +363,17 @@ const HuangyanpeiInternalization = () => {
|
|||||||
onClick={() => navigate(`/${moduleName}/${starName}/home`)}
|
onClick={() => navigate(`/${moduleName}/${starName}/home`)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Narration
|
||||||
|
visible={narrationVisible}
|
||||||
|
onClose={() => {
|
||||||
|
setNarrationVisible(false);
|
||||||
|
setVisible(true);
|
||||||
|
}}
|
||||||
|
audioSrc={getPublicPath("audio/huangyanpei/mp3_huangyanpei_27.mp3")}
|
||||||
|
text={`从川沙内史第的少年求学路,到中华职业教育社的拓荒征程,黄炎培先生用一生践行“教育救国”的初心,以“手脑并用”的理念为中国职业教育埋下火种。百年后的今天,这颗火种已长成燎原之势,照亮无数中职学子的成才之路。站在新时代的门槛上,我们回望先生的足迹,不仅是缅怀一段历史,更是要让“职教兴国”的精神,成为当代青年成长的指引。`}
|
||||||
|
animationSpeed={200}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 内化结束后的背景音乐 */}
|
{/* 内化结束后的背景音乐 */}
|
||||||
<audio
|
<audio
|
||||||
ref={audioRef}
|
ref={audioRef}
|
||||||
@@ -448,8 +462,10 @@ const HuangyanpeiInternalization = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StudyGuideModal
|
<EndingOverlay
|
||||||
visible={showStudyGuide}
|
visible={showStudyGuide}
|
||||||
|
text="恭喜你已经顺利完成了黄炎培故居的学习,请继续前往下一个故居吧!"
|
||||||
|
animationSpeed={200}
|
||||||
onContinue={() => {
|
onContinue={() => {
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
audioRef.current.pause();
|
audioRef.current.pause();
|
||||||
@@ -458,9 +474,8 @@ const HuangyanpeiInternalization = () => {
|
|||||||
setShowStudyGuide(false);
|
setShowStudyGuide(false);
|
||||||
navigate(`/${moduleName}/${starName}/home`);
|
navigate(`/${moduleName}/${starName}/home`);
|
||||||
}}
|
}}
|
||||||
>
|
audioSrc={getPublicPath("audio/huangyanpei/mp3_huangyanpei_31.mp3")}
|
||||||
恭喜你已经顺利完成了黄炎培故居的学习,请继续前往下一个故居吧!
|
/>
|
||||||
</StudyGuideModal>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ const HuangyanpeiPlace4 = () => {
|
|||||||
speaker="guide"
|
speaker="guide"
|
||||||
audioSrc={getPublicPath("audio/huangyanpei/mp3_huangyanpei_15.mp3")}
|
audioSrc={getPublicPath("audio/huangyanpei/mp3_huangyanpei_15.mp3")}
|
||||||
>
|
>
|
||||||
这期间,1935年中华职业教育社还与荣氏三兄弟合作,再申新三厂办理劳动工自治区,对工人进行文化教育。
|
这期间,1935年中华职业教育社还与荣氏三兄弟合作,在申新三厂办理劳动工自治区,对工人进行文化教育。
|
||||||
</GuideStepsItem>
|
</GuideStepsItem>
|
||||||
</GuideSteps>
|
</GuideSteps>
|
||||||
|
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ const BambooPuzzleGame = ({
|
|||||||
>
|
>
|
||||||
当年办学经费超紧张,没课本、没器材,黄先生就带着学生自己动手!
|
当年办学经费超紧张,没课本、没器材,黄先生就带着学生自己动手!
|
||||||
{"\n"}
|
{"\n"}
|
||||||
快来一起做器具吧。
|
快来一起做教具吧。
|
||||||
</StudyGuideModal>
|
</StudyGuideModal>
|
||||||
|
|
||||||
{/* 返回按钮 */}
|
{/* 返回按钮 */}
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ const TowelSalesDialog = ({
|
|||||||
{/* 题目文本 */}
|
{/* 题目文本 */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-4xl leading-relaxed">
|
<div className="text-4xl leading-relaxed">
|
||||||
从以下话术里,挑选3句组成你的推销语 (拖拽至对话框)
|
【拖拽】从以下话术里,挑选三句组成你的推销语,放置在国货毛巾上。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user