feat(s2a): Add dynamic group fetching with credentials validation
- Add backend endpoint `/api/s2a/fetch-groups` to fetch S2A groups using arbitrary credentials without depending on global config - Implement `handleFetchGroups` handler with proper error handling and multiple auth header support (Bearer token, X-API-Key, X-Admin-Key) - Add frontend API function `fetchGroupsWithCredentials` to call the new endpoint and parse S2A standard response format - Add group fetching UI in S2AConfig edit mode with refresh button and loading state - Implement batch group name caching in list view to display human-readable group names instead of IDs - Deduplicate API requests by grouping profiles with identical credentials to avoid redundant calls - Add fallback to display group ID when name is unavailable
This commit is contained in:
@@ -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":
|
||||
|
||||
@@ -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<GroupResponse[]> {
|
||||
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 || '获取分组失败')
|
||||
}
|
||||
|
||||
@@ -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<GroupResponse[]>([])
|
||||
const [fetchingGroups, setFetchingGroups] = useState(false)
|
||||
const [groupsFetched, setGroupsFetched] = useState(false)
|
||||
|
||||
// 列表视图分组名称缓存: { [groupId]: groupName }
|
||||
const [groupNameCache, setGroupNameCache] = useState<Record<number, string>>({})
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<boolean | null>(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<number, string> = {}
|
||||
// 按 api_base+admin_key 去重,避免重复请求
|
||||
const seen = new Map<string, S2AProfile>()
|
||||
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() {
|
||||
<Input label="默认并发数" type="number" min={1} max={100} value={formConcurrency} onChange={(e) => setFormConcurrency(Number(e.target.value))} hint="账号的默认并发请求数" />
|
||||
<Input label="默认优先级" type="number" min={0} max={100} value={formPriority} onChange={(e) => setFormPriority(Number(e.target.value))} hint="数值越大优先级越高" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">分组 ID</label>
|
||||
<div className="space-y-3">
|
||||
{/* 标题 + 获取按钮 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">分组</label>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleFetchGroups}
|
||||
disabled={fetchingGroups || !formApiBase || !formAdminKey}
|
||||
icon={fetchingGroups ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||
className="h-7 text-xs px-3"
|
||||
>
|
||||
{fetchingGroups ? '获取中...' : '获取分组列表'}
|
||||
</Button>
|
||||
</div>
|
||||
{!formApiBase || !formAdminKey ? (
|
||||
<p className="text-xs text-slate-400">请先填写 S2A API 地址和 Admin Key,再获取分组列表</p>
|
||||
) : null}
|
||||
|
||||
{/* 已选分组 badges */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formGroupIds.map(id => (
|
||||
<span key={id} className="inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
{id}
|
||||
<span key={id} className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
{groupsFetched ? getGroupName(id) : `#${id}`}
|
||||
<button onClick={() => handleRemoveGroupId(id)} className="hover:text-red-500 transition-colors"><X className="h-3 w-3" /></button>
|
||||
</span>
|
||||
))}
|
||||
{formGroupIds.length === 0 && <span className="text-sm text-slate-400">未设置分组</span>}
|
||||
{formGroupIds.length === 0 && <span className="text-sm text-slate-400">未选择分组</span>}
|
||||
</div>
|
||||
|
||||
{/* 可选分组列表 */}
|
||||
{groupsFetched && availableGroups.length > 0 && (
|
||||
<div className="border rounded-lg p-3 bg-slate-50 dark:bg-slate-800/50 dark:border-slate-700 space-y-1.5">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mb-2">点击添加分组:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableGroups
|
||||
.filter(g => !formGroupIds.includes(g.id))
|
||||
.map(g => (
|
||||
<button
|
||||
key={g.id}
|
||||
onClick={() => setFormGroupIds([...formGroupIds, g.id])}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 hover:border-blue-400 hover:text-blue-600 dark:hover:border-blue-500 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{g.name}
|
||||
<span className="text-xs text-slate-400">#{g.id}</span>
|
||||
</button>
|
||||
))}
|
||||
{availableGroups.filter(g => !formGroupIds.includes(g.id)).length === 0 && (
|
||||
<span className="text-xs text-slate-400">所有分组已添加</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{groupsFetched && availableGroups.length === 0 && (
|
||||
<p className="text-xs text-slate-400">该 S2A 实例暂无分组</p>
|
||||
)}
|
||||
|
||||
{/* 手动输入 fallback */}
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300 select-none">
|
||||
手动输入分组 ID
|
||||
</summary>
|
||||
<div className="flex gap-3 mt-2 items-stretch">
|
||||
<div className="flex-1 max-w-xs">
|
||||
<Input placeholder="输入分组 ID" type="number" min={1} value={newGroupId} onChange={(e) => setNewGroupId(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleAddGroupId()} className="h-10" />
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleAddGroupId} disabled={!newGroupId} icon={<Plus className="h-4 w-4" />} className="h-10 min-w-[80px] px-4">添加</Button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -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<number, string>
|
||||
onActivate: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
@@ -492,7 +609,7 @@ function ProfileCard({ profile, isActive, isActivating, onActivate, onEdit, onDe
|
||||
</span>
|
||||
{parsedGroups.length > 0 && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
分组 {parsedGroups.join(', ')}
|
||||
分组 {parsedGroups.map(id => groupNameCache[id] || `#${id}`).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
{profile.proxy_enabled && (
|
||||
|
||||
Reference in New Issue
Block a user