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/clean-errors", api.CORS(api.HandleCleanErrorAccounts)) // 清理错误账号
|
||||||
mux.HandleFunc("/api/s2a/cleaner/settings", api.CORS(handleCleanerSettings)) // 清理服务设置
|
mux.HandleFunc("/api/s2a/cleaner/settings", api.CORS(handleCleanerSettings)) // 清理服务设置
|
||||||
mux.HandleFunc("/api/s2a/profiles", api.CORS(handleS2AProfiles)) // S2A 配置预设管理
|
mux.HandleFunc("/api/s2a/profiles", api.CORS(handleS2AProfiles)) // S2A 配置预设管理
|
||||||
|
mux.HandleFunc("/api/s2a/fetch-groups", api.CORS(handleFetchGroups)) // 用任意凭据获取分组列表
|
||||||
|
|
||||||
// 统计 API
|
// 统计 API
|
||||||
mux.HandleFunc("/api/stats/max-rpm", api.CORS(api.HandleGetMaxRPM)) // 今日最高 RPM
|
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)
|
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) {
|
func handleMailServices(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case "GET":
|
case "GET":
|
||||||
|
|||||||
@@ -165,3 +165,26 @@ export class S2AClient {
|
|||||||
export function createS2AClient(baseUrl: string, apiKey: string): S2AClient {
|
export function createS2AClient(baseUrl: string, apiKey: string): S2AClient {
|
||||||
return new S2AClient({ baseUrl, apiKey })
|
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 { 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 { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
|
||||||
import { useConfig } from '../hooks/useConfig'
|
import { useConfig } from '../hooks/useConfig'
|
||||||
|
import { fetchGroupsWithCredentials } from '../api/s2a'
|
||||||
|
import type { GroupResponse } from '../api/types'
|
||||||
import type { S2AProfile } from '../types'
|
import type { S2AProfile } from '../types'
|
||||||
|
|
||||||
type ViewMode = 'list' | 'edit'
|
type ViewMode = 'list' | 'edit'
|
||||||
@@ -26,6 +28,14 @@ export default function S2AConfig() {
|
|||||||
const [formProxyEnabled, setFormProxyEnabled] = useState(false)
|
const [formProxyEnabled, setFormProxyEnabled] = useState(false)
|
||||||
const [formProxyAddress, setFormProxyAddress] = useState('')
|
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 [saving, setSaving] = useState(false)
|
||||||
const [testing, setTesting] = useState(false)
|
const [testing, setTesting] = useState(false)
|
||||||
const [testResult, setTestResult] = useState<boolean | null>(null)
|
const [testResult, setTestResult] = useState<boolean | null>(null)
|
||||||
@@ -58,6 +68,32 @@ export default function S2AConfig() {
|
|||||||
Promise.all([fetchProfiles(), fetchActiveConfig()]).finally(() => setLoading(false))
|
Promise.all([fetchProfiles(), fetchActiveConfig()]).finally(() => setLoading(false))
|
||||||
}, [fetchProfiles, fetchActiveConfig])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!message) return
|
if (!message) return
|
||||||
@@ -77,6 +113,9 @@ export default function S2AConfig() {
|
|||||||
setFormProxyEnabled(false)
|
setFormProxyEnabled(false)
|
||||||
setFormProxyAddress('')
|
setFormProxyAddress('')
|
||||||
setTestResult(null)
|
setTestResult(null)
|
||||||
|
setAvailableGroups([])
|
||||||
|
setFetchingGroups(false)
|
||||||
|
setGroupsFetched(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 进入新建模式
|
// 进入新建模式
|
||||||
@@ -101,6 +140,9 @@ export default function S2AConfig() {
|
|||||||
setFormProxyEnabled(profile.proxy_enabled)
|
setFormProxyEnabled(profile.proxy_enabled)
|
||||||
setFormProxyAddress(profile.proxy_address || '')
|
setFormProxyAddress(profile.proxy_address || '')
|
||||||
setTestResult(null)
|
setTestResult(null)
|
||||||
|
setAvailableGroups([])
|
||||||
|
setFetchingGroups(false)
|
||||||
|
setGroupsFetched(false)
|
||||||
setViewMode('edit')
|
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 handleAddGroupId = () => {
|
||||||
const id = parseInt(newGroupId, 10)
|
const id = parseInt(newGroupId, 10)
|
||||||
if (!isNaN(id) && !formGroupIds.includes(id)) {
|
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={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="数值越大优先级越高" />
|
<Input label="默认优先级" type="number" min={0} max={100} value={formPriority} onChange={(e) => setFormPriority(Number(e.target.value))} hint="数值越大优先级越高" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">分组 ID</label>
|
{/* 标题 + 获取按钮 */}
|
||||||
|
<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">
|
<div className="flex flex-wrap gap-2">
|
||||||
{formGroupIds.map(id => (
|
{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">
|
<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">
|
||||||
{id}
|
{groupsFetched ? getGroupName(id) : `#${id}`}
|
||||||
<button onClick={() => handleRemoveGroupId(id)} className="hover:text-red-500 transition-colors"><X className="h-3 w-3" /></button>
|
<button onClick={() => handleRemoveGroupId(id)} className="hover:text-red-500 transition-colors"><X className="h-3 w-3" /></button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{formGroupIds.length === 0 && <span className="text-sm text-slate-400">未设置分组</span>}
|
{formGroupIds.length === 0 && <span className="text-sm text-slate-400">未选择分组</span>}
|
||||||
</div>
|
</div>
|
||||||
<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" />
|
{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>
|
</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>
|
{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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -440,6 +555,7 @@ export default function S2AConfig() {
|
|||||||
profile={profile}
|
profile={profile}
|
||||||
isActive={activeApiBase === profile.api_base}
|
isActive={activeApiBase === profile.api_base}
|
||||||
isActivating={activating === profile.id}
|
isActivating={activating === profile.id}
|
||||||
|
groupNameCache={groupNameCache}
|
||||||
onActivate={() => handleActivate(profile)}
|
onActivate={() => handleActivate(profile)}
|
||||||
onEdit={() => handleEdit(profile)}
|
onEdit={() => handleEdit(profile)}
|
||||||
onDelete={() => handleDelete(profile.id, profile.name)}
|
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
|
profile: S2AProfile
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
isActivating: boolean
|
isActivating: boolean
|
||||||
|
groupNameCache: Record<number, string>
|
||||||
onActivate: () => void
|
onActivate: () => void
|
||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
@@ -492,7 +609,7 @@ function ProfileCard({ profile, isActive, isActivating, onActivate, onEdit, onDe
|
|||||||
</span>
|
</span>
|
||||||
{parsedGroups.length > 0 && (
|
{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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
{profile.proxy_enabled && (
|
{profile.proxy_enabled && (
|
||||||
|
|||||||
Reference in New Issue
Block a user