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

@@ -217,6 +217,8 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
"group_ids": config.Global.GroupIDs, "group_ids": config.Global.GroupIDs,
"proxy_enabled": config.Global.ProxyEnabled, "proxy_enabled": config.Global.ProxyEnabled,
"default_proxy": config.Global.DefaultProxy, "default_proxy": config.Global.DefaultProxy,
"proxy_test_status": getProxyTestStatus(),
"proxy_test_ip": getProxyTestIP(),
"site_name": config.Global.SiteName, "site_name": config.Global.SiteName,
"mail_services_count": len(config.Global.MailServices), "mail_services_count": len(config.Global.MailServices),
"mail_services": config.Global.MailServices, "mail_services": config.Global.MailServices,
@@ -260,6 +262,11 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
} }
if req.DefaultProxy != nil { if req.DefaultProxy != nil {
config.Global.DefaultProxy = *req.DefaultProxy 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 { if req.SiteName != nil {
config.Global.SiteName = *req.SiteName config.Global.SiteName = *req.SiteName
@@ -1010,6 +1017,11 @@ func handleProxyTest(w http.ResponseWriter, r *http.Request) {
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
logger.Error(fmt.Sprintf("代理测试失败: HTTP %d", resp.StatusCode), "", "proxy") 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)) api.Error(w, http.StatusBadGateway, fmt.Sprintf("代理测试失败: HTTP %d", resp.StatusCode))
return return
} }
@@ -1024,6 +1036,12 @@ func handleProxyTest(w http.ResponseWriter, r *http.Request) {
logger.Success(fmt.Sprintf("代理连接成功, 出口IP: %s", ipResp.Origin), "", "proxy") 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{}{ api.Success(w, map[string]interface{}{
"connected": true, "connected": true,
"message": "代理连接成功", "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 // parseProxyURL 解析代理 URL
func parseProxyURL(proxyURL string) (*url.URL, error) { func parseProxyURL(proxyURL string) (*url.URL, error) {
// 如果没有协议前缀,默认添加 http:// // 如果没有协议前缀,默认添加 http://

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' } from 'lucide-react'
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common' import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
import { useConfig } from '../hooks/useConfig' import { useConfig } from '../hooks/useConfig'
import { useToast, ToastContainer } from '../components/Toast'
export default function Config() { export default function Config() {
const { config, isConnected, refreshConfig } = useConfig() const { config, isConnected, refreshConfig } = useConfig()
@@ -27,7 +28,7 @@ export default function Config() {
const [testingProxy, setTestingProxy] = useState(false) const [testingProxy, setTestingProxy] = useState(false)
const [proxyStatus, setProxyStatus] = useState<'unknown' | 'success' | 'error'>('unknown') const [proxyStatus, setProxyStatus] = useState<'unknown' | 'success' | 'error'>('unknown')
const [proxyOriginIP, setProxyOriginIP] = useState('') const [proxyOriginIP, setProxyOriginIP] = useState('')
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null) const { toasts, toast, removeToast } = useToast()
// 加载站点名称和代理配置 // 加载站点名称和代理配置
useEffect(() => { useEffect(() => {
@@ -38,6 +39,14 @@ export default function Config() {
if (data.code === 0 && data.data) { if (data.code === 0 && data.data) {
setSiteName(data.data.site_name || 'Codex Pool') setSiteName(data.data.site_name || 'Codex Pool')
setDefaultProxy(data.data.default_proxy || '') 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) { } catch (error) {
console.error('Failed to fetch config:', error) console.error('Failed to fetch config:', error)
@@ -49,7 +58,6 @@ export default function Config() {
// 保存站点名称 // 保存站点名称
const handleSaveSiteName = async () => { const handleSaveSiteName = async () => {
setSaving(true) setSaving(true)
setMessage(null)
try { try {
const res = await fetch('/api/config', { const res = await fetch('/api/config', {
method: 'PUT', method: 'PUT',
@@ -58,13 +66,13 @@ export default function Config() {
}) })
const data = await res.json() const data = await res.json()
if (data.code === 0) { if (data.code === 0) {
setMessage({ type: 'success', text: '站点名称已保存' }) toast.success('站点名称已保存')
refreshConfig() refreshConfig()
} else { } else {
setMessage({ type: 'error', text: data.message || '保存失败' }) toast.error(data.message || '保存失败')
} }
} catch { } catch {
setMessage({ type: 'error', text: '网络错误' }) toast.error('网络错误')
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -73,7 +81,6 @@ export default function Config() {
// 保存代理地址 // 保存代理地址
const handleSaveProxy = async () => { const handleSaveProxy = async () => {
setSavingProxy(true) setSavingProxy(true)
setMessage(null)
try { try {
const res = await fetch('/api/config', { const res = await fetch('/api/config', {
method: 'PUT', method: 'PUT',
@@ -82,15 +89,15 @@ export default function Config() {
}) })
const data = await res.json() const data = await res.json()
if (data.code === 0) { if (data.code === 0) {
setMessage({ type: 'success', text: '代理地址已保存' }) toast.success('代理地址已保存')
setProxyStatus('unknown') // 保存后重置状态 setProxyStatus('unknown') // 保存后重置状态
setProxyOriginIP('') setProxyOriginIP('')
refreshConfig() refreshConfig()
} else { } else {
setMessage({ type: 'error', text: data.message || '保存失败' }) toast.error(data.message || '保存失败')
} }
} catch { } catch {
setMessage({ type: 'error', text: '网络错误' }) toast.error('网络错误')
} finally { } finally {
setSavingProxy(false) setSavingProxy(false)
} }
@@ -99,11 +106,10 @@ export default function Config() {
// 测试代理连接 // 测试代理连接
const handleTestProxy = async () => { const handleTestProxy = async () => {
if (!defaultProxy.trim()) { if (!defaultProxy.trim()) {
setMessage({ type: 'error', text: '请先输入代理地址' }) toast.error('请先输入代理地址')
return return
} }
setTestingProxy(true) setTestingProxy(true)
setMessage(null)
setProxyStatus('unknown') setProxyStatus('unknown')
setProxyOriginIP('') setProxyOriginIP('')
try { try {
@@ -116,14 +122,14 @@ export default function Config() {
if (data.code === 0 && data.data?.connected) { if (data.code === 0 && data.data?.connected) {
setProxyStatus('success') setProxyStatus('success')
setProxyOriginIP(data.data.origin_ip || '') 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 { } else {
setProxyStatus('error') setProxyStatus('error')
setMessage({ type: 'error', text: data.message || '代理连接失败' }) toast.error(data.message || '代理连接失败')
} }
} catch (e) { } catch (e) {
setProxyStatus('error') setProxyStatus('error')
setMessage({ type: 'error', text: e instanceof Error ? e.message : '网络错误' }) toast.error(e instanceof Error ? e.message : '网络错误')
} finally { } finally {
setTestingProxy(false) setTestingProxy(false)
} }
@@ -151,6 +157,10 @@ export default function Config() {
] ]
return ( return (
<>
{/* Toast 通知 */}
<ToastContainer toasts={toasts} onClose={removeToast} />
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -206,21 +216,6 @@ export default function Config() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <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>
)}
<div> <div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"> <label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
@@ -324,5 +319,6 @@ export default function Config() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</>
) )
} }