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,178 +157,168 @@ export default function Config() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<>
|
||||||
{/* Header */}
|
{/* Toast 通知 */}
|
||||||
<div className="flex items-center justify-between">
|
<ToastContainer toasts={toasts} onClose={removeToast} />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
<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" />
|
||||||
</label>
|
系统配置
|
||||||
<div className="flex gap-3">
|
</h1>
|
||||||
<Input
|
<p className="text-sm text-slate-500 dark:text-slate-400">配置会自动保存到服务器</p>
|
||||||
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>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => refreshConfig()}
|
||||||
|
icon={<RefreshCw className="h-4 w-4" />}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 代理地址配置 */}
|
{/* Config Cards */}
|
||||||
<div className="pt-4 border-t border-slate-200 dark:border-slate-700">
|
<div className="grid gap-4">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
{configItems.map((item) => (
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">
|
<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>
|
</label>
|
||||||
{/* 代理状态徽章 */}
|
<div className="flex gap-3">
|
||||||
{proxyStatus === 'success' && (
|
<Input
|
||||||
<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">
|
value={siteName}
|
||||||
<Wifi className="h-3 w-3" />
|
onChange={(e) => setSiteName(e.target.value)}
|
||||||
代理可用
|
placeholder="输入站点名称,如:我的号池"
|
||||||
</span>
|
className="flex-1"
|
||||||
)}
|
/>
|
||||||
{proxyStatus === 'error' && (
|
<Button
|
||||||
<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">
|
size="sm"
|
||||||
<WifiOff className="h-3 w-3" />
|
onClick={handleSaveSiteName}
|
||||||
连接失败
|
disabled={saving}
|
||||||
</span>
|
icon={saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||||
)}
|
className="shrink-0"
|
||||||
{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">
|
{saving ? '保存中...' : '保存名称'}
|
||||||
<HelpCircle className="h-3 w-3" />
|
</Button>
|
||||||
未测试
|
</div>
|
||||||
</span>
|
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||||
)}
|
该名称将显示在侧边栏标题和浏览器标签页
|
||||||
|
</p>
|
||||||
</div>
|
</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>
|
<div className="pt-4 border-t border-slate-200 dark:border-slate-700">
|
||||||
<CardContent>
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<div className="text-sm text-slate-500 dark:text-slate-400">
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
<p>配置会保存在服务器端,重启后自动加载。首次启动时会自动创建默认配置。</p>
|
全局代理地址
|
||||||
</div>
|
</label>
|
||||||
</CardContent>
|
{/* 代理状态徽章 */}
|
||||||
</Card>
|
{proxyStatus === 'success' && (
|
||||||
</div>
|
<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