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;
|
width: 0;
|
||||||
height: 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