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