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:
99
frontend/src/components/ui/Toast.tsx
Normal file
99
frontend/src/components/ui/Toast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user