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:
2026-02-01 03:57:51 +08:00
parent e27e36b0e0
commit 842a4ab4b2
3 changed files with 387 additions and 180 deletions

View 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 }])
}
},
}