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 }])
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user