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

View File

@@ -17,6 +17,7 @@ import {
} from 'lucide-react'
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
import { useConfig } from '../hooks/useConfig'
import { useToast, ToastContainer } from '../components/Toast'
export default function Config() {
const { config, isConnected, refreshConfig } = useConfig()
@@ -27,7 +28,7 @@ export default function Config() {
const [testingProxy, setTestingProxy] = useState(false)
const [proxyStatus, setProxyStatus] = useState<'unknown' | 'success' | 'error'>('unknown')
const [proxyOriginIP, setProxyOriginIP] = useState('')
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null)
const { toasts, toast, removeToast } = useToast()
// 加载站点名称和代理配置
useEffect(() => {
@@ -38,6 +39,14 @@ export default function Config() {
if (data.code === 0 && data.data) {
setSiteName(data.data.site_name || 'Codex Pool')
setDefaultProxy(data.data.default_proxy || '')
// 恢复代理测试状态
const testStatus = data.data.proxy_test_status
if (testStatus === 'success' || testStatus === 'error') {
setProxyStatus(testStatus)
}
if (data.data.proxy_test_ip) {
setProxyOriginIP(data.data.proxy_test_ip)
}
}
} catch (error) {
console.error('Failed to fetch config:', error)
@@ -49,7 +58,6 @@ export default function Config() {
// 保存站点名称
const handleSaveSiteName = async () => {
setSaving(true)
setMessage(null)
try {
const res = await fetch('/api/config', {
method: 'PUT',
@@ -58,13 +66,13 @@ export default function Config() {
})
const data = await res.json()
if (data.code === 0) {
setMessage({ type: 'success', text: '站点名称已保存' })
toast.success('站点名称已保存')
refreshConfig()
} else {
setMessage({ type: 'error', text: data.message || '保存失败' })
toast.error(data.message || '保存失败')
}
} catch {
setMessage({ type: 'error', text: '网络错误' })
toast.error('网络错误')
} finally {
setSaving(false)
}
@@ -73,7 +81,6 @@ export default function Config() {
// 保存代理地址
const handleSaveProxy = async () => {
setSavingProxy(true)
setMessage(null)
try {
const res = await fetch('/api/config', {
method: 'PUT',
@@ -82,15 +89,15 @@ export default function Config() {
})
const data = await res.json()
if (data.code === 0) {
setMessage({ type: 'success', text: '代理地址已保存' })
toast.success('代理地址已保存')
setProxyStatus('unknown') // 保存后重置状态
setProxyOriginIP('')
refreshConfig()
} else {
setMessage({ type: 'error', text: data.message || '保存失败' })
toast.error(data.message || '保存失败')
}
} catch {
setMessage({ type: 'error', text: '网络错误' })
toast.error('网络错误')
} finally {
setSavingProxy(false)
}
@@ -99,11 +106,10 @@ export default function Config() {
// 测试代理连接
const handleTestProxy = async () => {
if (!defaultProxy.trim()) {
setMessage({ type: 'error', text: '请先输入代理地址' })
toast.error('请先输入代理地址')
return
}
setTestingProxy(true)
setMessage(null)
setProxyStatus('unknown')
setProxyOriginIP('')
try {
@@ -116,14 +122,14 @@ export default function Config() {
if (data.code === 0 && data.data?.connected) {
setProxyStatus('success')
setProxyOriginIP(data.data.origin_ip || '')
setMessage({ type: 'success', text: `代理连接成功${data.data.origin_ip ? `, 出口IP: ${data.data.origin_ip}` : ''}` })
toast.success(`代理连接成功${data.data.origin_ip ? `, 出口IP: ${data.data.origin_ip}` : ''}`)
} else {
setProxyStatus('error')
setMessage({ type: 'error', text: data.message || '代理连接失败' })
toast.error(data.message || '代理连接失败')
}
} catch (e) {
setProxyStatus('error')
setMessage({ type: 'error', text: e instanceof Error ? e.message : '网络错误' })
toast.error(e instanceof Error ? e.message : '网络错误')
} finally {
setTestingProxy(false)
}
@@ -151,178 +157,168 @@ export default function Config() {
]
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
<Settings className="h-7 w-7 text-slate-500" />
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => refreshConfig()}
icon={<RefreshCw className="h-4 w-4" />}
>
</Button>
</div>
{/* Config Cards */}
<div className="grid gap-4">
{configItems.map((item) => (
<Link key={item.to} to={item.to} className="block group">
<Card className="transition-all duration-200 hover:shadow-lg hover:border-blue-300 dark:hover:border-blue-600">
<CardContent className="flex items-center gap-4 py-5">
<div className="p-3 rounded-xl bg-blue-50 dark:bg-blue-900/30 group-hover:bg-blue-100 dark:group-hover:bg-blue-900/50 transition-colors">
<item.icon className="h-6 w-6 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-slate-900 dark:text-slate-100">{item.title}</h3>
<p className="text-sm text-slate-500 dark:text-slate-400">{item.description}</p>
</div>
<div className="flex items-center gap-2">
<item.statusIcon className={`h-4 w-4 ${item.statusColor}`} />
<span className={`text-sm font-medium ${item.statusColor}`}>
{item.status}
</span>
</div>
<ChevronRight className="h-5 w-5 text-slate-400 group-hover:text-blue-500 transition-colors" />
</CardContent>
</Card>
</Link>
))}
</div>
{/* Site Settings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5 text-purple-500" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Message */}
{message && (
<div className={`p-3 rounded-lg text-sm flex items-center gap-2 ${message.type === 'success'
? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}`}>
{message.type === 'success' ? (
<CheckCircle className="h-4 w-4" />
) : (
<XCircle className="h-4 w-4" />
)}
{message.text}
</div>
)}
<>
{/* Toast 通知 */}
<ToastContainer toasts={toasts} onClose={removeToast} />
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
</label>
<div className="flex gap-3">
<Input
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
placeholder="输入站点名称,如:我的号池"
className="flex-1"
/>
<Button
size="sm"
onClick={handleSaveSiteName}
disabled={saving}
icon={saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
className="shrink-0"
>
{saving ? '保存中...' : '保存名称'}
</Button>
</div>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
</p>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
<Settings className="h-7 w-7 text-slate-500" />
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => refreshConfig()}
icon={<RefreshCw className="h-4 w-4" />}
>
</Button>
</div>
{/* 代理地址配置 */}
<div className="pt-4 border-t border-slate-200 dark:border-slate-700">
<div className="flex items-center gap-2 mb-2">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">
{/* Config Cards */}
<div className="grid gap-4">
{configItems.map((item) => (
<Link key={item.to} to={item.to} className="block group">
<Card className="transition-all duration-200 hover:shadow-lg hover:border-blue-300 dark:hover:border-blue-600">
<CardContent className="flex items-center gap-4 py-5">
<div className="p-3 rounded-xl bg-blue-50 dark:bg-blue-900/30 group-hover:bg-blue-100 dark:group-hover:bg-blue-900/50 transition-colors">
<item.icon className="h-6 w-6 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-slate-900 dark:text-slate-100">{item.title}</h3>
<p className="text-sm text-slate-500 dark:text-slate-400">{item.description}</p>
</div>
<div className="flex items-center gap-2">
<item.statusIcon className={`h-4 w-4 ${item.statusColor}`} />
<span className={`text-sm font-medium ${item.statusColor}`}>
{item.status}
</span>
</div>
<ChevronRight className="h-5 w-5 text-slate-400 group-hover:text-blue-500 transition-colors" />
</CardContent>
</Card>
</Link>
))}
</div>
{/* Site Settings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5 text-purple-500" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
</label>
{/* 代理状态徽章 */}
{proxyStatus === 'success' && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
<Wifi className="h-3 w-3" />
</span>
)}
{proxyStatus === 'error' && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400">
<WifiOff className="h-3 w-3" />
</span>
)}
{proxyStatus === 'unknown' && defaultProxy && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-400">
<HelpCircle className="h-3 w-3" />
</span>
)}
<div className="flex gap-3">
<Input
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
placeholder="输入站点名称,如:我的号池"
className="flex-1"
/>
<Button
size="sm"
onClick={handleSaveSiteName}
disabled={saving}
icon={saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
className="shrink-0"
>
{saving ? '保存中...' : '保存名称'}
</Button>
</div>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
</p>
</div>
<div className="flex gap-3">
<Input
value={defaultProxy}
onChange={(e) => {
setDefaultProxy(e.target.value)
setProxyStatus('unknown')
}}
placeholder="http://127.0.0.1:7890"
className="flex-1"
/>
<Button
size="sm"
onClick={handleSaveProxy}
disabled={savingProxy}
icon={savingProxy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
className="shrink-0"
>
{savingProxy ? '保存中...' : '保存代理'}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleTestProxy}
disabled={testingProxy || !defaultProxy.trim()}
icon={testingProxy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wifi className="h-4 w-4" />}
className="shrink-0"
>
{testingProxy ? '测试中...' : '测试连接'}
</Button>
</div>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
{proxyOriginIP && (
<span className="ml-2 text-green-600 dark:text-green-400">
IP: {proxyOriginIP}
</span>
)}
</p>
</div>
</CardContent>
</Card>
{/* Info */}
<Card>
<CardContent>
<div className="text-sm text-slate-500 dark:text-slate-400">
<p></p>
</div>
</CardContent>
</Card>
</div>
{/* 代理地址配置 */}
<div className="pt-4 border-t border-slate-200 dark:border-slate-700">
<div className="flex items-center gap-2 mb-2">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">
</label>
{/* 代理状态徽章 */}
{proxyStatus === 'success' && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
<Wifi className="h-3 w-3" />
</span>
)}
{proxyStatus === 'error' && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400">
<WifiOff className="h-3 w-3" />
</span>
)}
{proxyStatus === 'unknown' && defaultProxy && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-400">
<HelpCircle className="h-3 w-3" />
</span>
)}
</div>
<div className="flex gap-3">
<Input
value={defaultProxy}
onChange={(e) => {
setDefaultProxy(e.target.value)
setProxyStatus('unknown')
}}
placeholder="http://127.0.0.1:7890"
className="flex-1"
/>
<Button
size="sm"
onClick={handleSaveProxy}
disabled={savingProxy}
icon={savingProxy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
className="shrink-0"
>
{savingProxy ? '保存中...' : '保存代理'}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleTestProxy}
disabled={testingProxy || !defaultProxy.trim()}
icon={testingProxy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wifi className="h-4 w-4" />}
className="shrink-0"
>
{testingProxy ? '测试中...' : '测试连接'}
</Button>
</div>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
{proxyOriginIP && (
<span className="ml-2 text-green-600 dark:text-green-400">
IP: {proxyOriginIP}
</span>
)}
</p>
</div>
</CardContent>
</Card>
{/* Info */}
<Card>
<CardContent>
<div className="text-sm text-slate-500 dark:text-slate-400">
<p></p>
</div>
</CardContent>
</Card>
</div>
</>
)
}