feat: Implement initial backend API server with configuration, S2A proxy, and data management, and add frontend Toast component and Config page.
This commit is contained in:
173
frontend/src/components/Toast.tsx
Normal file
173
frontend/src/components/Toast.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CheckCircle, XCircle, AlertTriangle, Info, X } from 'lucide-react'
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
export interface ToastMessage {
|
||||
id: string
|
||||
type: ToastType
|
||||
text: string
|
||||
duration?: number // 毫秒,默认 3000
|
||||
}
|
||||
|
||||
interface ToastItemProps {
|
||||
toast: ToastMessage
|
||||
onClose: (id: string) => void
|
||||
}
|
||||
|
||||
function ToastItem({ toast, onClose }: ToastItemProps) {
|
||||
const [isExiting, setIsExiting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const duration = toast.duration || 3000
|
||||
const timer = setTimeout(() => {
|
||||
setIsExiting(true)
|
||||
setTimeout(() => onClose(toast.id), 300) // 等待退出动画完成
|
||||
}, duration)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [toast.id, toast.duration, onClose])
|
||||
|
||||
const handleClose = () => {
|
||||
setIsExiting(true)
|
||||
setTimeout(() => onClose(toast.id), 300)
|
||||
}
|
||||
|
||||
const config = {
|
||||
success: {
|
||||
icon: CheckCircle,
|
||||
bg: 'bg-green-50 dark:bg-green-900/40',
|
||||
border: 'border-green-200 dark:border-green-800',
|
||||
text: 'text-green-800 dark:text-green-200',
|
||||
iconColor: 'text-green-500',
|
||||
},
|
||||
error: {
|
||||
icon: XCircle,
|
||||
bg: 'bg-red-50 dark:bg-red-900/40',
|
||||
border: 'border-red-200 dark:border-red-800',
|
||||
text: 'text-red-800 dark:text-red-200',
|
||||
iconColor: 'text-red-500',
|
||||
},
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
bg: 'bg-yellow-50 dark:bg-yellow-900/40',
|
||||
border: 'border-yellow-200 dark:border-yellow-800',
|
||||
text: 'text-yellow-800 dark:text-yellow-200',
|
||||
iconColor: 'text-yellow-500',
|
||||
},
|
||||
info: {
|
||||
icon: Info,
|
||||
bg: 'bg-blue-50 dark:bg-blue-900/40',
|
||||
border: 'border-blue-200 dark:border-blue-800',
|
||||
text: 'text-blue-800 dark:text-blue-200',
|
||||
iconColor: 'text-blue-500',
|
||||
},
|
||||
}
|
||||
|
||||
const { icon: Icon, bg, border, text, iconColor } = config[toast.type]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex items-center gap-3 px-4 py-3 rounded-lg border shadow-lg backdrop-blur-sm
|
||||
${bg} ${border} ${text}
|
||||
transform transition-all duration-300 ease-out
|
||||
${isExiting ? 'opacity-0 translate-x-full' : 'opacity-100 translate-x-0'}
|
||||
`}
|
||||
role="alert"
|
||||
>
|
||||
<Icon className={`h-5 w-5 flex-shrink-0 ${iconColor}`} />
|
||||
<p className="flex-1 text-sm font-medium">{toast.text}</p>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="flex-shrink-0 p-1 rounded-full hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ToastContainerProps {
|
||||
toasts: ToastMessage[]
|
||||
onClose: (id: string) => void
|
||||
}
|
||||
|
||||
export function ToastContainer({ toasts, onClose }: ToastContainerProps) {
|
||||
if (toasts.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none">
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} className="pointer-events-auto">
|
||||
<ToastItem toast={toast} onClose={onClose} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Toast Hook
|
||||
let toastCounter = 0
|
||||
let globalSetToasts: React.Dispatch<React.SetStateAction<ToastMessage[]>> | null = null
|
||||
|
||||
export function useToast() {
|
||||
const [toasts, setToasts] = useState<ToastMessage[]>([])
|
||||
|
||||
// 注册全局 setToasts
|
||||
useEffect(() => {
|
||||
globalSetToasts = setToasts
|
||||
return () => {
|
||||
globalSetToasts = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const addToast = (type: ToastType, text: string, duration?: number) => {
|
||||
const id = `toast-${++toastCounter}`
|
||||
const newToast: ToastMessage = { id, type, text, duration }
|
||||
setToasts((prev) => [...prev, newToast])
|
||||
return id
|
||||
}
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}
|
||||
|
||||
const toast = {
|
||||
success: (text: string, duration?: number) => addToast('success', text, duration),
|
||||
error: (text: string, duration?: number) => addToast('error', text, duration),
|
||||
warning: (text: string, duration?: number) => addToast('warning', text, duration),
|
||||
info: (text: string, duration?: number) => addToast('info', text, duration),
|
||||
}
|
||||
|
||||
return { toasts, toast, removeToast }
|
||||
}
|
||||
|
||||
// 全局 toast 函数(用于不在组件内调用的场景)
|
||||
export const toast = {
|
||||
success: (text: string, duration?: number) => {
|
||||
if (globalSetToasts) {
|
||||
const id = `toast-${++toastCounter}`
|
||||
globalSetToasts((prev) => [...prev, { id, type: 'success', text, duration }])
|
||||
}
|
||||
},
|
||||
error: (text: string, duration?: number) => {
|
||||
if (globalSetToasts) {
|
||||
const id = `toast-${++toastCounter}`
|
||||
globalSetToasts((prev) => [...prev, { id, type: 'error', text, duration }])
|
||||
}
|
||||
},
|
||||
warning: (text: string, duration?: number) => {
|
||||
if (globalSetToasts) {
|
||||
const id = `toast-${++toastCounter}`
|
||||
globalSetToasts((prev) => [...prev, { id, type: 'warning', text, duration }])
|
||||
}
|
||||
},
|
||||
info: (text: string, duration?: number) => {
|
||||
if (globalSetToasts) {
|
||||
const id = `toast-${++toastCounter}`
|
||||
globalSetToasts((prev) => [...prev, { id, type: 'info', text, duration }])
|
||||
}
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user