diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 3991581..c478582 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -123,6 +123,7 @@ func startServer(cfg *config.Config) { mux.HandleFunc("/api/s2a/proxy/", api.CORS(handleS2AProxy)) // 通配代理 mux.HandleFunc("/api/s2a/clean-errors", api.CORS(api.HandleCleanErrorAccounts)) // 清理错误账号 mux.HandleFunc("/api/s2a/cleaner/settings", api.CORS(handleCleanerSettings)) // 清理服务设置 + mux.HandleFunc("/api/s2a/profiles", api.CORS(handleS2AProfiles)) // S2A 配置预设管理 // 统计 API mux.HandleFunc("/api/stats/max-rpm", api.CORS(api.HandleGetMaxRPM)) // 今日最高 RPM @@ -1138,6 +1139,62 @@ func handleCleanerSettings(w http.ResponseWriter, r *http.Request) { } } +// handleS2AProfiles GET/POST/DELETE /api/s2a/profiles +func handleS2AProfiles(w http.ResponseWriter, r *http.Request) { + if database.Instance == nil { + api.Error(w, http.StatusInternalServerError, "数据库未初始化") + return + } + + switch r.Method { + case http.MethodGet: + profiles, err := database.Instance.GetS2AProfiles() + if err != nil { + api.Error(w, http.StatusInternalServerError, fmt.Sprintf("获取配置预设失败: %v", err)) + return + } + // 确保 group_ids 解析为 JSON + // (数据库层返回的是 JSON string, 这里前端可以直接用,或者我们在 Go 里转一下) + // 由于 database.S2AProfile 定义 GroupIDs 为 string,我们直接返回即可,前端解析 + api.Success(w, profiles) + + case http.MethodPost: + var p database.S2AProfile + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + api.Error(w, http.StatusBadRequest, "请求格式错误") + return + } + if p.Name == "" { + api.Error(w, http.StatusBadRequest, "配置名称不能为空") + return + } + + id, err := database.Instance.AddS2AProfile(p) + if err != nil { + api.Error(w, http.StatusInternalServerError, fmt.Sprintf("保存配置预设失败: %v", err)) + return + } + p.ID = id + api.Success(w, p) + + case http.MethodDelete: + idStr := r.URL.Query().Get("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + api.Error(w, http.StatusBadRequest, "无效的 ID") + return + } + if err := database.Instance.DeleteS2AProfile(id); err != nil { + api.Error(w, http.StatusInternalServerError, fmt.Sprintf("删除配置预设失败: %v", err)) + return + } + api.Success(w, map[string]string{"message": "已删除"}) + + default: + api.Error(w, http.StatusMethodNotAllowed, "不支持的方法") + } +} + // handleProxyTest POST /api/proxy/test - 测试代理连接 func handleProxyTest(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { diff --git a/backend/internal/database/sqlite.go b/backend/internal/database/sqlite.go index 25832f1..7e73a18 100644 --- a/backend/internal/database/sqlite.go +++ b/backend/internal/database/sqlite.go @@ -23,6 +23,20 @@ type TeamOwner struct { LastCheckedAt *time.Time `json:"last_checked_at,omitempty"` } +// S2AProfile S2A 配置预设 +type S2AProfile struct { + ID int64 `json:"id"` + Name string `json:"name"` + APIBase string `json:"api_base"` + AdminKey string `json:"admin_key"` + Concurrency int `json:"concurrency"` + Priority int `json:"priority"` + GroupIDs string `json:"group_ids"` // JSON array string + ProxyEnabled bool `json:"proxy_enabled"` + ProxyAddress string `json:"proxy_address"` + CreatedAt time.Time `json:"created_at"` +} + // DB 数据库管理器 type DB struct { db *sql.DB @@ -161,6 +175,20 @@ func (d *DB) createTables() error { CREATE INDEX IF NOT EXISTS idx_app_logs_timestamp ON app_logs(timestamp); CREATE INDEX IF NOT EXISTS idx_app_logs_module ON app_logs(module); CREATE INDEX IF NOT EXISTS idx_app_logs_level ON app_logs(level); + + -- S2A 配置预设表 + CREATE TABLE IF NOT EXISTS s2a_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + api_base TEXT NOT NULL, + admin_key TEXT NOT NULL, + concurrency INTEGER DEFAULT 2, + priority INTEGER DEFAULT 0, + group_ids TEXT, + proxy_enabled INTEGER DEFAULT 0, + proxy_address TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); `) return err } @@ -1260,3 +1288,51 @@ func (d *DB) Close() error { } return nil } + +// GetS2AProfiles 获取所有 S2A 配置预设 +func (d *DB) GetS2AProfiles() ([]S2AProfile, error) { + rows, err := d.db.Query(` + SELECT id, name, api_base, admin_key, concurrency, priority, group_ids, proxy_enabled, proxy_address, created_at + FROM s2a_profiles + ORDER BY created_at DESC + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var profiles []S2AProfile + for rows.Next() { + var p S2AProfile + var proxyEnabled int + err := rows.Scan(&p.ID, &p.Name, &p.APIBase, &p.AdminKey, &p.Concurrency, &p.Priority, &p.GroupIDs, &proxyEnabled, &p.ProxyAddress, &p.CreatedAt) + if err != nil { + continue + } + p.ProxyEnabled = proxyEnabled == 1 + profiles = append(profiles, p) + } + return profiles, nil +} + +// AddS2AProfile 添加 S2A 配置预设 +func (d *DB) AddS2AProfile(p S2AProfile) (int64, error) { + proxyEnabled := 0 + if p.ProxyEnabled { + proxyEnabled = 1 + } + result, err := d.db.Exec(` + INSERT INTO s2a_profiles (name, api_base, admin_key, concurrency, priority, group_ids, proxy_enabled, proxy_address, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + `, p.Name, p.APIBase, p.AdminKey, p.Concurrency, p.Priority, p.GroupIDs, proxyEnabled, p.ProxyAddress) + if err != nil { + return 0, err + } + return result.LastInsertId() +} + +// DeleteS2AProfile 删除 S2A 配置预设 +func (d *DB) DeleteS2AProfile(id int64) error { + _, err := d.db.Exec("DELETE FROM s2a_profiles WHERE id = ?", id) + return err +} diff --git a/frontend/src/pages/S2AConfig.tsx b/frontend/src/pages/S2AConfig.tsx index 38efe5f..c53150b 100644 --- a/frontend/src/pages/S2AConfig.tsx +++ b/frontend/src/pages/S2AConfig.tsx @@ -1,7 +1,8 @@ -import { useState, useEffect } from 'react' -import { TestTube, CheckCircle, XCircle, Loader2, Save, Server, Plus, X, Globe, ToggleLeft, ToggleRight } from 'lucide-react' +import { useState, useEffect, useCallback } from 'react' +import { TestTube, CheckCircle, XCircle, Loader2, Save, Server, Plus, X, Globe, ToggleLeft, ToggleRight, Bookmark, Trash2, Download } from 'lucide-react' import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common' import { useConfig } from '../hooks/useConfig' +import type { S2AProfile } from '../types' export default function S2AConfig() { const { @@ -30,6 +31,25 @@ export default function S2AConfig() { const [proxyEnabled, setProxyEnabled] = useState(false) const [proxyAddress, setProxyAddress] = useState('') + // 预设管理 + const [profiles, setProfiles] = useState([]) + const [profileName, setProfileName] = useState('') + const [showSavePreset, setShowSavePreset] = useState(false) + const [savingPreset, setSavingPreset] = useState(false) + + // 加载预设列表 + const fetchProfiles = useCallback(async () => { + try { + const res = await fetch('/api/s2a/profiles') + const data = await res.json() + if (data.code === 0 && data.data) { + setProfiles(data.data) + } + } catch (error) { + console.error('Failed to fetch profiles:', error) + } + }, []) + // 从服务器加载配置 const fetchConfig = async () => { setLoading(true) @@ -54,15 +74,13 @@ export default function S2AConfig() { useEffect(() => { fetchConfig() - }, []) + fetchProfiles() + }, [fetchProfiles]) const handleTestConnection = async () => { setTesting(true) setTestResult(null) - - // 先保存配置 await handleSave() - const result = await testConnection() setTestResult(result) setTesting(false) @@ -78,8 +96,8 @@ export default function S2AConfig() { body: JSON.stringify({ s2a_api_base: s2aApiBase, s2a_admin_key: s2aAdminKey, - concurrency: concurrency, - priority: priority, + concurrency, + priority, group_ids: groupIds, proxy_enabled: proxyEnabled, default_proxy: proxyAddress, @@ -111,6 +129,72 @@ export default function S2AConfig() { setGroupIds(groupIds.filter(g => g !== id)) } + // 保存为预设 + const handleSavePreset = async () => { + if (!profileName.trim()) return + setSavingPreset(true) + try { + const res = await fetch('/api/s2a/profiles', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: profileName.trim(), + api_base: s2aApiBase, + admin_key: s2aAdminKey, + concurrency, + priority, + group_ids: JSON.stringify(groupIds), + proxy_enabled: proxyEnabled, + proxy_address: proxyAddress, + }), + }) + const data = await res.json() + if (data.code === 0) { + setMessage({ type: 'success', text: `预设「${profileName}」已保存` }) + setProfileName('') + setShowSavePreset(false) + fetchProfiles() + } else { + setMessage({ type: 'error', text: data.message || '保存预设失败' }) + } + } catch { + setMessage({ type: 'error', text: '网络错误' }) + } finally { + setSavingPreset(false) + } + } + + // 加载预设到表单 + const handleLoadProfile = (profile: S2AProfile) => { + setS2aApiBase(profile.api_base) + setS2aAdminKey(profile.admin_key) + setConcurrency(profile.concurrency) + setPriority(profile.priority) + try { + const ids = JSON.parse(profile.group_ids || '[]') + setGroupIds(Array.isArray(ids) ? ids : []) + } catch { + setGroupIds([]) + } + setProxyEnabled(profile.proxy_enabled) + setProxyAddress(profile.proxy_address || '') + setMessage({ type: 'success', text: `已加载预设「${profile.name}」,请点击保存配置以应用` }) + } + + // 删除预设 + const handleDeleteProfile = async (id: number, name: string) => { + try { + const res = await fetch(`/api/s2a/profiles?id=${id}`, { method: 'DELETE' }) + const data = await res.json() + if (data.code === 0) { + setMessage({ type: 'success', text: `预设「${name}」已删除` }) + fetchProfiles() + } + } catch { + setMessage({ type: 'error', text: '删除失败' }) + } + } + if (loading) { return (
@@ -130,13 +214,22 @@ export default function S2AConfig() {

配置 S2A 号池连接、入库参数和代理设置

- +
+ + +
{/* Message */} @@ -149,207 +242,300 @@ export default function S2AConfig() { )} - {/* S2A Connection */} - - - - - S2A 连接配置 - -
- {isConnected ? ( - - - 已连接 - - ) : ( - - - 未连接 - - )} -
-
- -
- setS2aApiBase(e.target.value)} - hint="S2A 服务的 API 地址" - /> - setS2aAdminKey(e.target.value)} - hint="S2A 管理密钥,可在 S2A 后台 Settings 页面获取" - /> -
-
- - {testResult !== null && ( - - {testResult ? '连接成功' : '连接失败'} - - )} -
-
-
- - {/* Pooling Settings */} - - - 入库默认设置 - - -
- setConcurrency(Number(e.target.value))} - hint="账号的默认并发请求数" - /> - setPriority(Number(e.target.value))} - hint="账号的默认优先级,数值越大优先级越高" - /> -
- - {/* Group IDs */} -
- -
- {groupIds.map(id => ( - - {id} - - - ))} - {groupIds.length === 0 && ( - 未设置分组 - )} -
-
-
+ {/* Save Preset Inline */} + {showSavePreset && ( + + +
+
setNewGroupId(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleAddGroupId()} - className="h-10" + label="预设名称" + placeholder="例如:生产环境、测试环境" + value={profileName} + onChange={(e) => setProfileName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSavePreset()} />
+
-

- 入库时账号将被分配到这些分组 +

+ 将当前所有配置(连接、入库参数、代理)保存为预设,方便快速切换

-
- - + + + )} - {/* Proxy Settings */} - - - - - 代理设置 - - - - - setProxyAddress(e.target.value)} - placeholder="http://127.0.0.1:7890" - disabled={!proxyEnabled} - className={!proxyEnabled ? 'opacity-50' : ''} - /> -

- 服务器部署时通常不需要代理,在本地开发或特殊网络环境下可启用 -

-
-
+ {/* Main Grid Layout */} +
+ {/* Left: Main Config (col-span-2) */} +
+ {/* S2A Connection */} + + + + + S2A 连接配置 + +
+ {isConnected ? ( + + + 已连接 + + ) : ( + + + 未连接 + + )} +
+
+ +
+ setS2aApiBase(e.target.value)} + hint="S2A 服务的 API 地址" + /> + setS2aAdminKey(e.target.value)} + hint="S2A 管理密钥" + /> +
+
+ + {testResult !== null && ( + + {testResult ? '连接成功' : '连接失败'} + + )} +
+
+
- {/* Info */} - - -
-

配置说明:

-
    -
  • S2A API 地址是您部署的 S2A 服务的完整 URL
  • -
  • Admin API Key 用于管理账号池,具有完全权限
  • -
  • 入库默认设置会应用到新入库的账号
  • -
  • 分组 ID 用于将账号归类到指定分组
  • -
  • 配置会自动保存到服务器数据库
  • -
-
-
-
+ {/* Pooling Settings */} + + + 入库默认设置 + + +
+ setConcurrency(Number(e.target.value))} + hint="账号的默认并发请求数" + /> + setPriority(Number(e.target.value))} + hint="数值越大优先级越高" + /> +
+ {/* Group IDs */} +
+ +
+ {groupIds.map(id => ( + + {id} + + + ))} + {groupIds.length === 0 && ( + 未设置分组 + )} +
+
+
+ setNewGroupId(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAddGroupId()} + className="h-10" + /> +
+ +
+
+
+
+ + {/* Proxy Settings */} + + + + + 代理设置 + + + + + setProxyAddress(e.target.value)} + placeholder="http://127.0.0.1:7890" + disabled={!proxyEnabled} + className={!proxyEnabled ? 'opacity-50' : ''} + /> +

+ 服务器部署时通常不需要代理,在本地开发或特殊网络环境下可启用 +

+
+
+
+ + {/* Right: Saved Profiles Sidebar (col-span-1) */} +
+ + + + + 已保存配置 + + {profiles.length} 个预设 + + + {profiles.length === 0 ? ( +
+ +

暂无保存的配置

+

点击上方「保存为预设」按钮保存当前配置

+
+ ) : ( +
+ {profiles.map(profile => ( + + ))} +
+ )} +
+
+
+
) } + +// 预设列表项组件 +function ProfileItem({ profile, onLoad, onDelete }: { + profile: S2AProfile + onLoad: (p: S2AProfile) => void + onDelete: (id: number, name: string) => void +}) { + const [confirming, setConfirming] = useState(false) + + let parsedGroups: number[] = [] + try { + parsedGroups = JSON.parse(profile.group_ids || '[]') + } catch { /* ignore */ } + + return ( +
+
+ {profile.name} +
+ + {confirming ? ( + + ) : ( + + )} +
+
+
+

{profile.api_base || '未设置 API'}

+
+ 并发: {profile.concurrency} + 优先级: {profile.priority} + {parsedGroups.length > 0 && 分组: {parsedGroups.join(',')}} + {profile.proxy_enabled && 代理} +
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 220ccd1..ed25d47 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -72,6 +72,20 @@ export interface S2AAccount { active_sessions?: number } +// S2A 配置预设 +export interface S2AProfile { + id: number + name: string + api_base: string + admin_key: string + concurrency: number + priority: number + group_ids: string // JSON string from backend + proxy_enabled: boolean + proxy_address: string + created_at: string +} + // 分页响应 export interface PaginatedResponse { data: T[]