diff --git a/backend/cmd/main.go b/backend/cmd/main.go
index 8c69f82..9393f1e 100644
--- a/backend/cmd/main.go
+++ b/backend/cmd/main.go
@@ -217,6 +217,8 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
"group_ids": config.Global.GroupIDs,
"proxy_enabled": config.Global.ProxyEnabled,
"default_proxy": config.Global.DefaultProxy,
+ "proxy_test_status": getProxyTestStatus(),
+ "proxy_test_ip": getProxyTestIP(),
"site_name": config.Global.SiteName,
"mail_services_count": len(config.Global.MailServices),
"mail_services": config.Global.MailServices,
@@ -260,6 +262,11 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
}
if req.DefaultProxy != nil {
config.Global.DefaultProxy = *req.DefaultProxy
+ // 代理地址变更时,重置测试状态
+ if database.Instance != nil {
+ database.Instance.SetConfig("proxy_test_status", "unknown")
+ database.Instance.SetConfig("proxy_test_ip", "")
+ }
}
if req.SiteName != nil {
config.Global.SiteName = *req.SiteName
@@ -1010,6 +1017,11 @@ func handleProxyTest(w http.ResponseWriter, r *http.Request) {
if resp.StatusCode != http.StatusOK {
logger.Error(fmt.Sprintf("代理测试失败: HTTP %d", resp.StatusCode), "", "proxy")
+ // 保存失败状态
+ if database.Instance != nil {
+ database.Instance.SetConfig("proxy_test_status", "error")
+ database.Instance.SetConfig("proxy_test_ip", "")
+ }
api.Error(w, http.StatusBadGateway, fmt.Sprintf("代理测试失败: HTTP %d", resp.StatusCode))
return
}
@@ -1024,6 +1036,12 @@ func handleProxyTest(w http.ResponseWriter, r *http.Request) {
logger.Success(fmt.Sprintf("代理连接成功, 出口IP: %s", ipResp.Origin), "", "proxy")
+ // 保存成功状态到数据库
+ if database.Instance != nil {
+ database.Instance.SetConfig("proxy_test_status", "success")
+ database.Instance.SetConfig("proxy_test_ip", ipResp.Origin)
+ }
+
api.Success(w, map[string]interface{}{
"connected": true,
"message": "代理连接成功",
@@ -1031,6 +1049,26 @@ func handleProxyTest(w http.ResponseWriter, r *http.Request) {
})
}
+// getProxyTestStatus 获取代理测试状态
+func getProxyTestStatus() string {
+ if database.Instance == nil {
+ return "unknown"
+ }
+ if val, _ := database.Instance.GetConfig("proxy_test_status"); val != "" {
+ return val
+ }
+ return "unknown"
+}
+
+// getProxyTestIP 获取代理测试出口IP
+func getProxyTestIP() string {
+ if database.Instance == nil {
+ return ""
+ }
+ val, _ := database.Instance.GetConfig("proxy_test_ip")
+ return val
+}
+
// parseProxyURL 解析代理 URL
func parseProxyURL(proxyURL string) (*url.URL, error) {
// 如果没有协议前缀,默认添加 http://
diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx
new file mode 100644
index 0000000..9ee33ec
--- /dev/null
+++ b/frontend/src/components/Toast.tsx
@@ -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 (
+
+ )
+}
+
+interface ToastContainerProps {
+ toasts: ToastMessage[]
+ onClose: (id: string) => void
+}
+
+export function ToastContainer({ toasts, onClose }: ToastContainerProps) {
+ if (toasts.length === 0) return null
+
+ return (
+
+ {toasts.map((toast) => (
+
+
+
+ ))}
+
+ )
+}
+
+// Toast Hook
+let toastCounter = 0
+let globalSetToasts: React.Dispatch> | null = null
+
+export function useToast() {
+ const [toasts, setToasts] = useState([])
+
+ // 注册全局 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 }])
+ }
+ },
+}
diff --git a/frontend/src/pages/Config.tsx b/frontend/src/pages/Config.tsx
index bce59cb..4f58a94 100644
--- a/frontend/src/pages/Config.tsx
+++ b/frontend/src/pages/Config.tsx
@@ -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 (
-
- {/* Header */}
-
-
-
-
- 系统配置
-
-
配置会自动保存到服务器
-
-
-
-
- {/* Config Cards */}
-
- {configItems.map((item) => (
-
-
-
-
-
-
-
-
{item.title}
-
{item.description}
-
-
-
-
- {item.status}
-
-
-
-
-
-
- ))}
-
-
- {/* Site Settings */}
-
-
-
-
- 基础配置
-
-
-
- {/* Message */}
- {message && (
-
- {message.type === 'success' ? (
-
- ) : (
-
- )}
- {message.text}
-
- )}
+ <>
+ {/* Toast 通知 */}
+
+
+ {/* Header */}
+
-
-
- setSiteName(e.target.value)}
- placeholder="输入站点名称,如:我的号池"
- className="flex-1"
- />
- : }
- className="shrink-0"
- >
- {saving ? '保存中...' : '保存名称'}
-
-
-
- 该名称将显示在侧边栏标题和浏览器标签页
-
+
+
+ 系统配置
+
+
配置会自动保存到服务器
+
+
- {/* 代理地址配置 */}
-
-
-
-
-
- {/* Info */}
-
-
-
-
配置会保存在服务器端,重启后自动加载。首次启动时会自动创建默认配置。
-
-
-
-
+ {/* 代理地址配置 */}
+
+
+
+ 全局代理地址
+
+ {/* 代理状态徽章 */}
+ {proxyStatus === 'success' && (
+
+
+ 代理可用
+
+ )}
+ {proxyStatus === 'error' && (
+
+
+ 连接失败
+
+ )}
+ {proxyStatus === 'unknown' && defaultProxy && (
+
+
+ 未测试
+
+ )}
+
+
+ {
+ setDefaultProxy(e.target.value)
+ setProxyStatus('unknown')
+ }}
+ placeholder="http://127.0.0.1:7890"
+ className="flex-1"
+ />
+ : }
+ className="shrink-0"
+ >
+ {savingProxy ? '保存中...' : '保存代理'}
+
+ : }
+ className="shrink-0"
+ >
+ {testingProxy ? '测试中...' : '测试连接'}
+
+
+
+ 设置全局默认代理地址,用于批量入库和自动补号
+ {proxyOriginIP && (
+
+ 出口IP: {proxyOriginIP}
+
+ )}
+
+
+
+
+
+ {/* Info */}
+
+
+
+
配置会保存在服务器端,重启后自动加载。首次启动时会自动创建默认配置。
+
+
+
+
+ >
)
}