From 842a4ab4b267bb8437388f5fba4c5540303ea7bd Mon Sep 17 00:00:00 2001 From: kyx236 Date: Sun, 1 Feb 2026 03:57:51 +0800 Subject: [PATCH] feat: Implement initial backend API server with configuration, S2A proxy, and data management, and add frontend Toast component and Config page. --- backend/cmd/main.go | 38 ++++ frontend/src/components/Toast.tsx | 173 +++++++++++++++ frontend/src/pages/Config.tsx | 356 +++++++++++++++--------------- 3 files changed, 387 insertions(+), 180 deletions(-) create mode 100644 frontend/src/components/Toast.tsx 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 ( +
+ +

{toast.text}

+ +
+ ) +} + +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" - /> - -
-

- 该名称将显示在侧边栏标题和浏览器标签页 -

+

+ + 系统配置 +

+

配置会自动保存到服务器

+ +
- {/* 代理地址配置 */} -
-
-
- - - {/* Info */} - - -
-

配置会保存在服务器端,重启后自动加载。首次启动时会自动创建默认配置。

-
-
-
-
+ {/* 代理地址配置 */} +
+
+ + {/* 代理状态徽章 */} + {proxyStatus === 'success' && ( + + + 代理可用 + + )} + {proxyStatus === 'error' && ( + + + 连接失败 + + )} + {proxyStatus === 'unknown' && defaultProxy && ( + + + 未测试 + + )} +
+
+ { + setDefaultProxy(e.target.value) + setProxyStatus('unknown') + }} + placeholder="http://127.0.0.1:7890" + className="flex-1" + /> + + +
+

+ 设置全局默认代理地址,用于批量入库和自动补号 + {proxyOriginIP && ( + + 出口IP: {proxyOriginIP} + + )} +

+
+ + + + {/* Info */} + + +
+

配置会保存在服务器端,重启后自动加载。首次启动时会自动创建默认配置。

+
+
+
+
+ ) }