feat: introduce a new configuration system with dedicated pages for S2A and email settings.

This commit is contained in:
2026-01-30 08:21:12 +08:00
parent 38a58c138d
commit 9dfa61ac05
3 changed files with 217 additions and 297 deletions

View File

@@ -1,7 +1,7 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react' import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
import type { AppConfig } from '../types' import type { AppConfig } from '../types'
import { defaultConfig } from '../types' import { defaultConfig } from '../types'
import { loadConfig, saveConfig } from '../utils/storage' import { saveConfig } from '../utils/storage'
import { S2AClient } from '../api/s2a' import { S2AClient } from '../api/s2a'
interface ConfigContextValue { interface ConfigContextValue {
@@ -14,6 +14,7 @@ interface ConfigContextValue {
isConnected: boolean isConnected: boolean
testConnection: () => Promise<boolean> testConnection: () => Promise<boolean>
s2aClient: S2AClient | null s2aClient: S2AClient | null
refreshConfig: () => Promise<void>
} }
const ConfigContext = createContext<ConfigContextValue | null>(null) const ConfigContext = createContext<ConfigContextValue | null>(null)
@@ -23,24 +24,37 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
const [isConnected, setIsConnected] = useState(false) const [isConnected, setIsConnected] = useState(false)
const [s2aClient, setS2aClient] = useState<S2AClient | null>(null) const [s2aClient, setS2aClient] = useState<S2AClient | null>(null)
// Load config from localStorage on mount // Load config from server on mount
useEffect(() => { const refreshConfig = useCallback(async () => {
const savedConfig = loadConfig() try {
setConfig(savedConfig) const res = await fetch('/api/config')
const data = await res.json()
// Create S2A client if config is available if (data.code === 0 && data.data) {
if (savedConfig.s2a.apiBase && savedConfig.s2a.adminKey) { const serverConfig = data.data
const client = new S2AClient({ setConfig(prev => ({
baseUrl: savedConfig.s2a.apiBase, ...prev,
apiKey: savedConfig.s2a.adminKey, s2a: {
}) ...prev.s2a,
setS2aClient(client) apiBase: serverConfig.s2a_api_base || '',
adminKey: serverConfig.s2a_admin_key || '',
// Test connection on load },
client.testConnection().then(setIsConnected) pooling: {
...prev.pooling,
concurrency: serverConfig.concurrency || 2,
priority: serverConfig.priority || 0,
groupIds: serverConfig.group_ids || [],
},
}))
}
} catch (error) {
console.error('Failed to load config from server:', error)
} }
}, []) }, [])
useEffect(() => {
refreshConfig()
}, [refreshConfig])
// Update S2A client when config changes // Update S2A client when config changes
useEffect(() => { useEffect(() => {
if (config.s2a.apiBase && config.s2a.adminKey) { if (config.s2a.apiBase && config.s2a.adminKey) {
@@ -110,8 +124,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
const testConnection = useCallback(async (): Promise<boolean> => { const testConnection = useCallback(async (): Promise<boolean> => {
try { try {
// 使用后端代理 API 来测试 S2A 连接(避免 CORS 问题) // 使用后端代理 API 来测试 S2A 连接(避免 CORS 问题)
const res = await fetch('http://localhost:8088/api/s2a/test') const res = await fetch('/api/s2a/test')
const connected = res.ok const data = await res.json()
const connected = data.code === 0
setIsConnected(connected) setIsConnected(connected)
return connected return connected
} catch { } catch {
@@ -132,6 +147,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
isConnected, isConnected,
testConnection, testConnection,
s2aClient, s2aClient,
refreshConfig,
}} }}
> >
{children} {children}

View File

@@ -1,105 +1,27 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { import {
Server, Server,
Mail, Mail,
ChevronRight, ChevronRight,
Settings, Settings,
Save,
RefreshCw, RefreshCw,
Globe, CheckCircle,
ToggleLeft, XCircle
ToggleRight
} from 'lucide-react' } from 'lucide-react'
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common' import { Card, CardContent, Button } from '../components/common'
import { useConfig } from '../hooks/useConfig' import { useConfig } from '../hooks/useConfig'
export default function Config() { export default function Config() {
const { config, isConnected } = useConfig() const { config, isConnected, refreshConfig } = useConfig()
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null)
// 编辑状态
const [editS2ABase, setEditS2ABase] = useState('')
const [editS2AKey, setEditS2AKey] = useState('')
const [editConcurrency, setEditConcurrency] = useState(2)
const [editPriority, setEditPriority] = useState(0)
const [editGroupIds, setEditGroupIds] = useState('')
const [proxyEnabled, setProxyEnabled] = useState(false)
const [proxyAddress, setProxyAddress] = useState('')
// 获取服务器配置
const fetchServerConfig = async () => {
setLoading(true)
try {
const res = await fetch('/api/config')
const data = await res.json()
if (data.code === 0 && data.data) {
setEditS2ABase(data.data.s2a_api_base || '')
setEditS2AKey(data.data.s2a_admin_key || '')
setEditConcurrency(data.data.concurrency || 2)
setEditPriority(data.data.priority || 0)
setEditGroupIds(data.data.group_ids?.join(', ') || '')
setProxyEnabled(data.data.proxy_enabled || false)
setProxyAddress(data.data.default_proxy || '')
}
} catch (error) {
console.error('Failed to fetch config:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchServerConfig()
}, [])
// 保存配置
const handleSave = async () => {
setSaving(true)
setMessage(null)
try {
// 解析 group_ids
const groupIds = editGroupIds
.split(',')
.map(s => parseInt(s.trim()))
.filter(n => !isNaN(n))
const res = await fetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
s2a_api_base: editS2ABase,
s2a_admin_key: editS2AKey,
concurrency: editConcurrency,
priority: editPriority,
group_ids: groupIds,
proxy_enabled: proxyEnabled,
default_proxy: proxyAddress,
}),
})
const data = await res.json()
if (data.code === 0) {
setMessage({ type: 'success', text: '配置已保存' })
fetchServerConfig()
} else {
setMessage({ type: 'error', text: data.message || '保存失败' })
}
} catch (error) {
setMessage({ type: 'error', text: '网络错误' })
} finally {
setSaving(false)
}
}
const configItems = [ const configItems = [
{ {
to: '/config/s2a', to: '/config/s2a',
icon: Server, icon: Server,
title: 'S2A 高级配置', title: 'S2A 号池配置',
description: 'S2A 号池详细设置和测试', description: 'S2A 连接、入库参数和代理设置',
status: isConnected ? '已连接' : '未连接', status: isConnected ? '已连接' : '未连接',
statusIcon: isConnected ? CheckCircle : XCircle,
statusColor: isConnected ? 'text-green-600 dark:text-green-400' : 'text-red-500', statusColor: isConnected ? 'text-green-600 dark:text-green-400' : 'text-red-500',
}, },
{ {
@@ -108,6 +30,7 @@ export default function Config() {
title: '邮箱服务配置', title: '邮箱服务配置',
description: '配置邮箱服务用于自动注册', description: '配置邮箱服务用于自动注册',
status: (config.email?.services?.length ?? 0) > 0 ? '已配置' : '未配置', status: (config.email?.services?.length ?? 0) > 0 ? '已配置' : '未配置',
statusIcon: (config.email?.services?.length ?? 0) > 0 ? CheckCircle : XCircle,
statusColor: (config.email?.services?.length ?? 0) > 0 ? 'text-green-600 dark:text-green-400' : 'text-orange-500', statusColor: (config.email?.services?.length ?? 0) > 0 ? 'text-green-600 dark:text-green-400' : 'text-orange-500',
}, },
] ]
@@ -126,151 +49,19 @@ export default function Config() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={fetchServerConfig} onClick={() => refreshConfig()}
disabled={loading} icon={<RefreshCw className="h-4 w-4" />}
icon={<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />}
> >
</Button> </Button>
</div> </div>
{/* Message */} {/* Config Cards */}
{message && ( <div className="grid gap-4">
<div className={`p-3 rounded-lg text-sm ${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.text}
</div>
)}
{/* Quick Config Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="h-5 w-5 text-blue-500" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* S2A Config */}
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
S2A API
</label>
<Input
value={editS2ABase}
onChange={(e) => setEditS2ABase(e.target.value)}
placeholder="https://your-s2a-server.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
S2A Admin Key
</label>
<Input
type="password"
value={editS2AKey}
onChange={(e) => setEditS2AKey(e.target.value)}
placeholder="admin-xxxxxx"
/>
</div>
</div>
{/* Pooling Config */}
<div className="grid gap-4 md:grid-cols-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
</label>
<Input
type="number"
value={editConcurrency}
onChange={(e) => setEditConcurrency(parseInt(e.target.value) || 1)}
min={1}
max={100}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
</label>
<Input
type="number"
value={editPriority}
onChange={(e) => setEditPriority(parseInt(e.target.value) || 0)}
min={0}
max={100}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
ID ()
</label>
<Input
value={editGroupIds}
onChange={(e) => setEditGroupIds(e.target.value)}
placeholder="1, 2, 3"
/>
</div>
</div>
{/* Proxy Config */}
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Globe className="h-5 w-5 text-orange-500" />
<span className="font-medium text-slate-700 dark:text-slate-300"></span>
</div>
<button
onClick={() => setProxyEnabled(!proxyEnabled)}
className="flex items-center gap-2 text-sm"
>
{proxyEnabled ? (
<>
<ToggleRight className="h-6 w-6 text-green-500" />
<span className="text-green-600 dark:text-green-400"></span>
</>
) : (
<>
<ToggleLeft className="h-6 w-6 text-slate-400" />
<span className="text-slate-500"></span>
</>
)}
</button>
</div>
<Input
value={proxyAddress}
onChange={(e) => setProxyAddress(e.target.value)}
placeholder="http://127.0.0.1:7890"
disabled={!proxyEnabled}
className={!proxyEnabled ? 'opacity-50' : ''}
/>
<p className="text-xs text-slate-500 mt-1">
</p>
</div>
{/* Save Button */}
<div className="flex justify-end pt-2">
<Button
onClick={handleSave}
disabled={saving}
icon={<Save className="h-4 w-4" />}
>
{saving ? '保存中...' : '保存配置'}
</Button>
</div>
</CardContent>
</Card>
{/* Sub Config Cards */}
<div className="grid gap-4 md:grid-cols-2">
{configItems.map((item) => ( {configItems.map((item) => (
<Link key={item.to} to={item.to} className="block group"> <Link key={item.to} to={item.to} className="block group">
<Card className="h-full transition-all duration-200 hover:shadow-lg hover:border-blue-300 dark:hover:border-blue-600"> <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-4"> <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"> <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" /> <item.icon className="h-6 w-6 text-blue-600 dark:text-blue-400" />
</div> </div>
@@ -278,9 +69,12 @@ export default function Config() {
<h3 className="font-semibold text-slate-900 dark:text-slate-100">{item.title}</h3> <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> <p className="text-sm text-slate-500 dark:text-slate-400">{item.description}</p>
</div> </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}`}> <span className={`text-sm font-medium ${item.statusColor}`}>
{item.status} {item.status}
</span> </span>
</div>
<ChevronRight className="h-5 w-5 text-slate-400 group-hover:text-blue-500 transition-colors" /> <ChevronRight className="h-5 w-5 text-slate-400 group-hover:text-blue-500 transition-colors" />
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,55 +1,102 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { TestTube, CheckCircle, XCircle, Loader2, Save, Server, Plus, X } from 'lucide-react' import { TestTube, CheckCircle, XCircle, Loader2, Save, Server, Plus, X, Globe, ToggleLeft, ToggleRight } 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'
export default function S2AConfig() { export default function S2AConfig() {
const { const {
config,
updateS2AConfig,
updatePoolingConfig,
testConnection, testConnection,
isConnected, isConnected,
refreshConfig,
} = useConfig() } = useConfig()
const [testing, setTesting] = useState(false) const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<boolean | null>(null) const [testResult, setTestResult] = useState<boolean | null>(null)
const [saved, setSaved] = useState(false) const [saving, setSaving] = useState(false)
const [loading, setLoading] = useState(true)
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null)
// Local form state - S2A 连接 // S2A 连接配置
const [s2aApiBase, setS2aApiBase] = useState(config.s2a.apiBase) const [s2aApiBase, setS2aApiBase] = useState('')
const [s2aAdminKey, setS2aAdminKey] = useState(config.s2a.adminKey) const [s2aAdminKey, setS2aAdminKey] = useState('')
// Local form state - 入库设置 // 入库设置
const [poolingConcurrency, setPoolingConcurrency] = useState(config.pooling.concurrency) const [concurrency, setConcurrency] = useState(2)
const [poolingPriority, setPoolingPriority] = useState(config.pooling.priority) const [priority, setPriority] = useState(0)
const [groupIds, setGroupIds] = useState<number[]>(config.pooling.groupIds || []) const [groupIds, setGroupIds] = useState<number[]>([])
const [newGroupId, setNewGroupId] = useState('') const [newGroupId, setNewGroupId] = useState('')
const handleTestConnection = async () => { // 代理设置
// Save first const [proxyEnabled, setProxyEnabled] = useState(false)
updateS2AConfig({ apiBase: s2aApiBase, adminKey: s2aAdminKey }) const [proxyAddress, setProxyAddress] = useState('')
// 从服务器加载配置
const fetchConfig = async () => {
setLoading(true)
try {
const res = await fetch('/api/config')
const data = await res.json()
if (data.code === 0 && data.data) {
setS2aApiBase(data.data.s2a_api_base || '')
setS2aAdminKey(data.data.s2a_admin_key || '')
setConcurrency(data.data.concurrency || 2)
setPriority(data.data.priority || 0)
setGroupIds(data.data.group_ids || [])
setProxyEnabled(data.data.proxy_enabled || false)
setProxyAddress(data.data.default_proxy || '')
}
} catch (error) {
console.error('Failed to fetch config:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchConfig()
}, [])
const handleTestConnection = async () => {
setTesting(true) setTesting(true)
setTestResult(null) setTestResult(null)
// Wait a bit for the client to be recreated // 先保存配置
await new Promise((resolve) => setTimeout(resolve, 100)) await handleSave()
const result = await testConnection() const result = await testConnection()
setTestResult(result) setTestResult(result)
setTesting(false) setTesting(false)
} }
const handleSave = () => { const handleSave = async () => {
updateS2AConfig({ apiBase: s2aApiBase, adminKey: s2aAdminKey }) setSaving(true)
updatePoolingConfig({ setMessage(null)
concurrency: poolingConcurrency, try {
priority: poolingPriority, const res = await fetch('/api/config', {
groupIds: groupIds, method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
s2a_api_base: s2aApiBase,
s2a_admin_key: s2aAdminKey,
concurrency: concurrency,
priority: priority,
group_ids: groupIds,
proxy_enabled: proxyEnabled,
default_proxy: proxyAddress,
}),
}) })
setSaved(true) const data = await res.json()
setTimeout(() => setSaved(false), 2000) if (data.code === 0) {
setMessage({ type: 'success', text: '配置已保存' })
refreshConfig()
} else {
setMessage({ type: 'error', text: data.message || '保存失败' })
}
} catch {
setMessage({ type: 'error', text: '网络错误' })
} finally {
setSaving(false)
}
} }
const handleAddGroupId = () => { const handleAddGroupId = () => {
@@ -64,6 +111,14 @@ export default function S2AConfig() {
setGroupIds(groupIds.filter(g => g !== id)) setGroupIds(groupIds.filter(g => g !== id))
} }
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
</div>
)
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
@@ -73,20 +128,34 @@ export default function S2AConfig() {
<Server className="h-7 w-7 text-blue-500" /> <Server className="h-7 w-7 text-blue-500" />
S2A S2A
</h1> </h1>
<p className="text-sm text-slate-500 dark:text-slate-400"> S2A </p> <p className="text-sm text-slate-500 dark:text-slate-400"> S2A </p>
</div> </div>
<Button <Button
onClick={handleSave} onClick={handleSave}
icon={saved ? <CheckCircle className="h-4 w-4" /> : <Save className="h-4 w-4" />} disabled={saving}
icon={saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
> >
{saved ? '保存' : '保存配置'} {saving ? '保存中...' : '保存配置'}
</Button> </Button>
</div> </div>
{/* Message */}
{message && (
<div className={`p-3 rounded-lg text-sm ${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.text}
</div>
)}
{/* S2A Connection */} {/* S2A Connection */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>S2A </CardTitle> <CardTitle className="flex items-center gap-2">
<Server className="h-5 w-5 text-blue-500" />
S2A
</CardTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isConnected ? ( {isConnected ? (
<span className="flex items-center gap-1 text-sm text-green-600 dark:text-green-400"> <span className="flex items-center gap-1 text-sm text-green-600 dark:text-green-400">
@@ -102,12 +171,13 @@ export default function S2AConfig() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<Input <Input
label="S2A API 地址" label="S2A API 地址"
placeholder="http://localhost:8080" placeholder="https://your-s2a-server.com"
value={s2aApiBase} value={s2aApiBase}
onChange={(e) => setS2aApiBase(e.target.value)} onChange={(e) => setS2aApiBase(e.target.value)}
hint="S2A 服务的 API 地址,例如 http://localhost:8080" hint="S2A 服务的 API 地址"
/> />
<Input <Input
label="Admin API Key" label="Admin API Key"
@@ -117,6 +187,7 @@ export default function S2AConfig() {
onChange={(e) => setS2aAdminKey(e.target.value)} onChange={(e) => setS2aAdminKey(e.target.value)}
hint="S2A 管理密钥,可在 S2A 后台 Settings 页面获取" hint="S2A 管理密钥,可在 S2A 后台 Settings 页面获取"
/> />
</div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button <Button
variant="outline" variant="outline"
@@ -157,9 +228,9 @@ export default function S2AConfig() {
label="默认并发数" label="默认并发数"
type="number" type="number"
min={1} min={1}
max={10} max={100}
value={poolingConcurrency} value={concurrency}
onChange={(e) => setPoolingConcurrency(Number(e.target.value))} onChange={(e) => setConcurrency(Number(e.target.value))}
hint="账号的默认并发请求数" hint="账号的默认并发请求数"
/> />
<Input <Input
@@ -167,8 +238,8 @@ export default function S2AConfig() {
type="number" type="number"
min={0} min={0}
max={100} max={100}
value={poolingPriority} value={priority}
onChange={(e) => setPoolingPriority(Number(e.target.value))} onChange={(e) => setPriority(Number(e.target.value))}
hint="账号的默认优先级,数值越大优先级越高" hint="账号的默认优先级,数值越大优先级越高"
/> />
</div> </div>
@@ -222,6 +293,44 @@ export default function S2AConfig() {
</CardContent> </CardContent>
</Card> </Card>
{/* Proxy Settings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5 text-orange-500" />
</CardTitle>
<button
onClick={() => setProxyEnabled(!proxyEnabled)}
className="flex items-center gap-2 text-sm"
>
{proxyEnabled ? (
<>
<ToggleRight className="h-6 w-6 text-green-500" />
<span className="text-green-600 dark:text-green-400"></span>
</>
) : (
<>
<ToggleLeft className="h-6 w-6 text-slate-400" />
<span className="text-slate-500"></span>
</>
)}
</button>
</CardHeader>
<CardContent>
<Input
value={proxyAddress}
onChange={(e) => setProxyAddress(e.target.value)}
placeholder="http://127.0.0.1:7890"
disabled={!proxyEnabled}
className={!proxyEnabled ? 'opacity-50' : ''}
/>
<p className="text-xs text-slate-500 mt-2">
</p>
</CardContent>
</Card>
{/* Info */} {/* Info */}
<Card> <Card>
<CardContent> <CardContent>
@@ -232,6 +341,7 @@ export default function S2AConfig() {
<li>Admin API Key </li> <li>Admin API Key </li>
<li></li> <li></li>
<li> ID </li> <li> ID </li>
<li></li>
</ul> </ul>
</div> </div>
</CardContent> </CardContent>