feat:init
This commit is contained in:
1 parent
53d0eeac6b
commit
ae0c050010
494 files changed
+17701
-2318
No files matched your search
@@ -0,0 +1,222 @@
|
||||
# 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` 对象即可。
|
||||
@@ -0,0 +1,133 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
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
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,224 @@
|
||||
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),
|
||||
};
|
||||
@@ -0,0 +1,248 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
// 全局 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";
|
||||
@@ -0,0 +1,44 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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 };
|
||||
Reference in new issue
Block a user