diff --git a/backend/cmd/main.go b/backend/cmd/main.go index c478582..acbe436 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -124,6 +124,7 @@ func startServer(cfg *config.Config) { 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 配置预设管理 + mux.HandleFunc("/api/s2a/fetch-groups", api.CORS(handleFetchGroups)) // 用任意凭据获取分组列表 // 统计 API mux.HandleFunc("/api/stats/max-rpm", api.CORS(api.HandleGetMaxRPM)) // 今日最高 RPM @@ -667,6 +668,59 @@ func handleS2AProxy(w http.ResponseWriter, r *http.Request) { w.Write(bodyBytes) } +// handleFetchGroups 使用任意 api_base + admin_key 获取 S2A 分组列表(不依赖 config.Global) +func handleFetchGroups(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + api.Error(w, http.StatusMethodNotAllowed, "仅支持 POST") + return + } + + var req struct { + ApiBase string `json:"api_base"` + AdminKey string `json:"admin_key"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + api.Error(w, http.StatusBadRequest, "请求格式错误") + return + } + if req.ApiBase == "" || req.AdminKey == "" { + api.Error(w, http.StatusBadRequest, "api_base 和 admin_key 不能为空") + return + } + + targetURL := strings.TrimRight(req.ApiBase, "/") + "/api/v1/admin/groups/all" + + proxyReq, err := http.NewRequest("GET", targetURL, nil) + if err != nil { + api.Error(w, http.StatusInternalServerError, "创建请求失败") + return + } + + proxyReq.Header.Set("Authorization", "Bearer "+req.AdminKey) + proxyReq.Header.Set("X-API-Key", req.AdminKey) + proxyReq.Header.Set("X-Admin-Key", req.AdminKey) + proxyReq.Header.Set("Content-Type", "application/json") + proxyReq.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(proxyReq) + if err != nil { + api.Error(w, http.StatusBadGateway, fmt.Sprintf("请求 S2A 失败: %v", err)) + return + } + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + api.Error(w, http.StatusBadGateway, "读取响应失败") + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + w.Write(bodyBytes) +} + func handleMailServices(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": diff --git a/frontend/src/api/s2a.ts b/frontend/src/api/s2a.ts index c230b87..4010af9 100644 --- a/frontend/src/api/s2a.ts +++ b/frontend/src/api/s2a.ts @@ -165,3 +165,26 @@ export class S2AClient { export function createS2AClient(baseUrl: string, apiKey: string): S2AClient { return new S2AClient({ baseUrl, apiKey }) } + +// 使用任意凭据获取 S2A 分组列表(不依赖当前活动配置) +export async function fetchGroupsWithCredentials(apiBase: string, adminKey: string): Promise { + const response = await fetch('/api/s2a/fetch-groups', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ api_base: apiBase, admin_key: adminKey }), + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`) + } + + const json = await response.json() + + // S2A 标准响应: { code: 0, data: { data: [...] } } + if (json && json.code === 0 && json.data) { + return json.data.data || json.data || [] + } + + throw new Error(json.message || '获取分组失败') +} diff --git a/frontend/src/pages/S2AConfig.tsx b/frontend/src/pages/S2AConfig.tsx index 3176387..d2429d9 100644 --- a/frontend/src/pages/S2AConfig.tsx +++ b/frontend/src/pages/S2AConfig.tsx @@ -1,7 +1,9 @@ import { useState, useEffect, useCallback } from 'react' -import { TestTube, CheckCircle, XCircle, Loader2, Save, Server, Plus, X, Globe, ToggleLeft, ToggleRight, Trash2, ChevronLeft, Zap, Edit2 } from 'lucide-react' +import { TestTube, CheckCircle, XCircle, Loader2, Save, Server, Plus, X, Globe, ToggleLeft, ToggleRight, Trash2, ChevronLeft, Zap, Edit2, RefreshCw } from 'lucide-react' import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common' import { useConfig } from '../hooks/useConfig' +import { fetchGroupsWithCredentials } from '../api/s2a' +import type { GroupResponse } from '../api/types' import type { S2AProfile } from '../types' type ViewMode = 'list' | 'edit' @@ -26,6 +28,14 @@ export default function S2AConfig() { const [formProxyEnabled, setFormProxyEnabled] = useState(false) const [formProxyAddress, setFormProxyAddress] = useState('') + // 分组列表相关 + const [availableGroups, setAvailableGroups] = useState([]) + const [fetchingGroups, setFetchingGroups] = useState(false) + const [groupsFetched, setGroupsFetched] = useState(false) + + // 列表视图分组名称缓存: { [groupId]: groupName } + const [groupNameCache, setGroupNameCache] = useState>({}) + const [saving, setSaving] = useState(false) const [testing, setTesting] = useState(false) const [testResult, setTestResult] = useState(null) @@ -58,6 +68,32 @@ export default function S2AConfig() { Promise.all([fetchProfiles(), fetchActiveConfig()]).finally(() => setLoading(false)) }, [fetchProfiles, fetchActiveConfig]) + // 列表视图: 批量获取各 profile 的分组名称 + useEffect(() => { + if (viewMode !== 'list' || profiles.length === 0) return + const fetchGroupNames = async () => { + const cache: Record = {} + // 按 api_base+admin_key 去重,避免重复请求 + const seen = new Map() + for (const p of profiles) { + if (p.api_base && p.admin_key) { + const key = `${p.api_base}|${p.admin_key}` + if (!seen.has(key)) seen.set(key, p) + } + } + for (const p of seen.values()) { + try { + const groups = await fetchGroupsWithCredentials(p.api_base, p.admin_key) + for (const g of groups) { + cache[g.id] = g.name + } + } catch { /* ignore - will show #id fallback */ } + } + setGroupNameCache(cache) + } + fetchGroupNames() + }, [viewMode, profiles]) + // 清除消息定时器 useEffect(() => { if (!message) return @@ -77,6 +113,9 @@ export default function S2AConfig() { setFormProxyEnabled(false) setFormProxyAddress('') setTestResult(null) + setAvailableGroups([]) + setFetchingGroups(false) + setGroupsFetched(false) } // 进入新建模式 @@ -101,6 +140,9 @@ export default function S2AConfig() { setFormProxyEnabled(profile.proxy_enabled) setFormProxyAddress(profile.proxy_address || '') setTestResult(null) + setAvailableGroups([]) + setFetchingGroups(false) + setGroupsFetched(false) setViewMode('edit') } @@ -222,6 +264,27 @@ export default function S2AConfig() { } } + // 获取分组列表 + const handleFetchGroups = async () => { + if (!formApiBase || !formAdminKey) return + setFetchingGroups(true) + try { + const groups = await fetchGroupsWithCredentials(formApiBase, formAdminKey) + setAvailableGroups(groups) + setGroupsFetched(true) + } catch (err) { + setMessage({ type: 'error', text: `获取分组失败: ${err instanceof Error ? err.message : '未知错误'}` }) + } finally { + setFetchingGroups(false) + } + } + + // 获取分组名称 + const getGroupName = (id: number): string => { + const group = availableGroups.find(g => g.id === id) + return group ? group.name : `#${id}` + } + const handleAddGroupId = () => { const id = parseInt(newGroupId, 10) if (!isNaN(id) && !formGroupIds.includes(id)) { @@ -339,23 +402,75 @@ export default function S2AConfig() { setFormConcurrency(Number(e.target.value))} hint="账号的默认并发请求数" /> setFormPriority(Number(e.target.value))} hint="数值越大优先级越高" /> -
- +
+ {/* 标题 + 获取按钮 */} +
+ + +
+ {!formApiBase || !formAdminKey ? ( +

请先填写 S2A API 地址和 Admin Key,再获取分组列表

+ ) : null} + + {/* 已选分组 badges */}
{formGroupIds.map(id => ( - - {id} + + {groupsFetched ? getGroupName(id) : `#${id}`} ))} - {formGroupIds.length === 0 && 未设置分组} + {formGroupIds.length === 0 && 未选择分组}
-
-
- setNewGroupId(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleAddGroupId()} className="h-10" /> + + {/* 可选分组列表 */} + {groupsFetched && availableGroups.length > 0 && ( +
+

点击添加分组:

+
+ {availableGroups + .filter(g => !formGroupIds.includes(g.id)) + .map(g => ( + + ))} + {availableGroups.filter(g => !formGroupIds.includes(g.id)).length === 0 && ( + 所有分组已添加 + )} +
- -
+ )} + {groupsFetched && availableGroups.length === 0 && ( +

该 S2A 实例暂无分组

+ )} + + {/* 手动输入 fallback */} +
+ + 手动输入分组 ID + +
+
+ setNewGroupId(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleAddGroupId()} className="h-10" /> +
+ +
+
@@ -440,6 +555,7 @@ export default function S2AConfig() { profile={profile} isActive={activeApiBase === profile.api_base} isActivating={activating === profile.id} + groupNameCache={groupNameCache} onActivate={() => handleActivate(profile)} onEdit={() => handleEdit(profile)} onDelete={() => handleDelete(profile.id, profile.name)} @@ -452,10 +568,11 @@ export default function S2AConfig() { } // ==================== 配置卡片组件 ==================== -function ProfileCard({ profile, isActive, isActivating, onActivate, onEdit, onDelete }: { +function ProfileCard({ profile, isActive, isActivating, groupNameCache, onActivate, onEdit, onDelete }: { profile: S2AProfile isActive: boolean isActivating: boolean + groupNameCache: Record onActivate: () => void onEdit: () => void onDelete: () => void @@ -492,7 +609,7 @@ function ProfileCard({ profile, isActive, isActivating, onActivate, onEdit, onDe {parsedGroups.length > 0 && ( - 分组 {parsedGroups.join(', ')} + 分组 {parsedGroups.map(id => groupNameCache[id] || `#${id}`).join(', ')} )} {profile.proxy_enabled && (