chore(frontend): 新增全局 Toast 通知组件与入场动画

components/ui/Toast.tsx:
- Context + Provider 模式,全局可用 useToast() hook
- 支持 success / error / info 三种类型,颜色自动适配
- 3.5s 自动消失,支持手动关闭
- 固定右下角渲染,z-[9999]

index.css:
- 新增 @keyframes slide-in 右侧滑入动画
This commit is contained in:
2026-08-04 17:30:17 +08:00
parent 2b5e3ae4c3
commit 8fcb33ab5e
2 changed files with 118 additions and 0 deletions

View File

@ -0,0 +1,99 @@
import {
createContext,
useContext,
useState,
useCallback,
type ReactNode,
} from "react";
import { X, CheckCircle, AlertCircle, Info } from "lucide-react";
// ============================================================
// 类型
// ============================================================
type ToastType = "success" | "error" | "info";
interface ToastItem {
id: number;
message: string;
type: ToastType;
}
interface ToastContextValue {
toast: (message: string, type?: ToastType) => void;
}
// ============================================================
// Context
// ============================================================
const ToastContext = createContext<ToastContextValue | null>(null);
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast 必须在 ToastProvider 内使用");
return ctx;
}
// ============================================================
// Provider
// ============================================================
let _nextId = 1;
export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<ToastItem[]>([]);
const toast = useCallback((message: string, type: ToastType = "info") => {
const id = _nextId++;
setItems((prev) => [...prev, { id, message, type }]);
setTimeout(() => {
setItems((prev) => prev.filter((t) => t.id !== id));
}, 3500);
}, []);
return (
<ToastContext.Provider value={{ toast }}>
{children}
{/* Toast 渲染区 */}
<div className="fixed bottom-6 right-6 z-[9999] flex flex-col-reverse gap-2">
{items.map((item) => {
const icon =
item.type === "success" ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : item.type === "error" ? (
<AlertCircle className="h-4 w-4 text-red-500" />
) : (
<Info className="h-4 w-4 text-blue-500" />
);
const borderColor =
item.type === "success"
? "border-green-200 bg-green-50"
: item.type === "error"
? "border-red-200 bg-red-50"
: "border-blue-200 bg-blue-50";
return (
<div
key={item.id}
className={`flex items-center gap-2 rounded-lg border px-4 py-3 shadow-lg animate-slide-in ${borderColor} min-w-[280px] max-w-sm`}
>
{icon}
<span className="flex-1 text-sm text-gray-700">{item.message}</span>
<button
onClick={() =>
setItems((prev) => prev.filter((t) => t.id !== item.id))
}
className="text-gray-400 hover:text-gray-600"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
})}
</div>
</ToastContext.Provider>
);
}

View File

@ -34,3 +34,22 @@ body {
width: 0;
height: 0;
}
/* ============================================================
Toast 动画
============================================================ */
@keyframes slide-in {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-slide-in {
animation: slide-in 0.3s ease-out;
}