feat: Implement Codex proxy configuration page with CRUD and testing capabilities, supported by new backend API and services for error handling, team processing, mail, and ChatGPT registration.

This commit is contained in:
2026-02-03 04:33:37 +08:00
parent f867e20c0e
commit 637753ddaa
5 changed files with 248 additions and 216 deletions

View File

@@ -79,9 +79,7 @@ func checkAndCleanErrors() {
return return
} }
// 执行清理 // 获取错误账号列表
logger.Status("定期清理错误账号中...", "", "cleaner")
errorAccounts, err := fetchAllErrorAccounts() errorAccounts, err := fetchAllErrorAccounts()
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("获取错误账号列表失败: %v", err), "", "cleaner") logger.Error(fmt.Sprintf("获取错误账号列表失败: %v", err), "", "cleaner")
@@ -89,10 +87,14 @@ func checkAndCleanErrors() {
} }
if len(errorAccounts) == 0 { if len(errorAccounts) == 0 {
logger.Info("无错误账号需要清理", "", "cleaner")
lastCleanTime = time.Now() lastCleanTime = time.Now()
return return
} }
// 执行清理
logger.Status(fmt.Sprintf("定期清理错误账号中: 共 %d 个", len(errorAccounts)), "", "cleaner")
success := 0 success := 0
failed := 0 failed := 0

View File

@@ -539,21 +539,29 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul
logger.Warning(fmt.Sprintf("%s 首次邀请失败,继续尝试: %v", logPrefix, err), owner.Email, "team") logger.Warning(fmt.Sprintf("%s 首次邀请失败,继续尝试: %v", logPrefix, err), owner.Email, "team")
} }
// Step 3: 并发注册成员 // Step 3: 流水线模式 - 注册成功的成员立即开始入库
// 每个成员:邀请 → 注册失败重试1次 // 每个成员:邀请 → 注册 → 入库失败重试1次
// Team 有4次额外补救机会 // Team 有4次额外补救机会
type MemberAccount struct { type MemberAccount struct {
Email string Email string
Password string Password string
Success bool Success bool
S2ADone bool // 入库是否完成
S2AOK bool // 入库是否成功
} }
children := make([]MemberAccount, req.MembersPerTeam) children := make([]MemberAccount, req.MembersPerTeam)
var memberMu sync.Mutex var memberMu sync.Mutex
var memberWg sync.WaitGroup
// 共享标志Team 邀请已满,所有 goroutine 应停止 // 共享标志Team 邀请已满,所有 goroutine 应停止
var teamExhausted int32 var teamExhausted int32
// 入库计数器
var s2aSuccessCount int32
var s2aFailCount int32
// 入库并发控制信号量
s2aSem := make(chan struct{}, req.ConcurrentS2A)
// 检查 Team 是否已满的辅助函数 // 检查 Team 是否已满的辅助函数
isTeamExhausted := func() bool { isTeamExhausted := func() bool {
return atomic.LoadInt32(&teamExhausted) == 1 return atomic.LoadInt32(&teamExhausted) == 1
@@ -571,8 +579,106 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul
} }
} }
// 注册单个成员的函数带1次重试 // 入库单个成员的函数
registerMember := func(memberIdx int, email, password string) bool { doS2A := func(memberIdx int, memberEmail, memberPassword string) bool {
memberLogPrefix := fmt.Sprintf("%s [成员 %d]", logPrefix, memberIdx+1)
memberStartTime := time.Now()
// 获取入库信号量
s2aSem <- struct{}{}
defer func() { <-s2aSem }()
logger.Status(fmt.Sprintf("%s 入库中... | 邮箱: %s", memberLogPrefix, memberEmail), memberEmail, "team")
var s2aSuccess bool
var lastError string
for attempt := 0; attempt < 2; attempt++ { // 最多重试1次
if attempt > 0 {
logger.Warning(fmt.Sprintf("%s 入库重试 (第%d次)", memberLogPrefix, attempt+1), memberEmail, "team")
}
// 创建日志回调
authLogger := auth.NewAuthLogger(memberEmail, logPrefix, memberIdx+1, func(entry auth.AuthLogEntry) {
if entry.IsError {
logger.Error(fmt.Sprintf("%s %s", memberLogPrefix, entry.Message), memberEmail, "team")
} else {
switch entry.Step {
case auth.StepNavigate, auth.StepInputEmail, auth.StepInputPassword,
auth.StepComplete, auth.StepConsent, auth.StepSelectWorkspace:
logger.Info(fmt.Sprintf("%s %s", memberLogPrefix, entry.Message), memberEmail, "team")
}
}
})
// 获取授权 URL
s2aResp, err := auth.GenerateS2AAuthURL(config.Global.S2AApiBase, config.Global.S2AAdminKey, config.Global.ProxyID)
if err != nil {
lastError = fmt.Sprintf("获取授权URL失败: %v", err)
logger.Error(fmt.Sprintf("%s %s", memberLogPrefix, lastError), memberEmail, "team")
continue
}
// 根据配置选择授权方式
var code string
if config.Global.AuthMethod == "api" {
proxyToUse := req.Proxy
if poolProxy, poolErr := database.Instance.GetRandomCodexProxy(); poolErr == nil && poolProxy != "" {
proxyToUse = poolProxy
logger.Info(fmt.Sprintf("%s 使用代理池: %s", memberLogPrefix, getProxyDisplay(poolProxy)), memberEmail, "team")
}
code, err = auth.CompleteWithCodexAPI(memberEmail, memberPassword, teamID, s2aResp.Data.AuthURL, s2aResp.Data.SessionID, proxyToUse, authLogger)
if proxyToUse != req.Proxy && proxyToUse != "" {
database.Instance.UpdateCodexProxyStats(proxyToUse, err == nil)
}
} else {
code, err = auth.CompleteWithChromedpLogged(s2aResp.Data.AuthURL, memberEmail, memberPassword, teamID, req.Headless, req.Proxy, authLogger)
}
if err != nil {
lastError = fmt.Sprintf("浏览器授权失败: %v", err)
logger.Error(fmt.Sprintf("%s %s", memberLogPrefix, lastError), memberEmail, "team")
continue
}
// 提交到 S2A
_, err = auth.SubmitS2AOAuth(
config.Global.S2AApiBase,
config.Global.S2AAdminKey,
s2aResp.Data.SessionID,
code,
memberEmail,
config.Global.Concurrency,
config.Global.Priority,
config.Global.GroupIDs,
config.Global.ProxyID,
)
if err != nil {
lastError = fmt.Sprintf("S2A提交失败: %v", err)
logger.Error(fmt.Sprintf("%s %s", memberLogPrefix, lastError), memberEmail, "team")
continue
}
s2aSuccess = true
memberDuration := time.Since(memberStartTime)
logger.Success(fmt.Sprintf("%s ✓ 入库成功 (总耗时: %.1fs)", memberLogPrefix, memberDuration.Seconds()), memberEmail, "team")
break
}
if s2aSuccess {
atomic.AddInt32(&s2aSuccessCount, 1)
} else {
atomic.AddInt32(&s2aFailCount, 1)
memberMu.Lock()
result.Errors = append(result.Errors, fmt.Sprintf("成员 %d 入库失败: %s", memberIdx+1, lastError))
memberMu.Unlock()
}
return s2aSuccess
}
// 注册并入库单个成员的函数带1次重试- 流水线模式
var s2aWg sync.WaitGroup
registerAndS2AMember := func(memberIdx int, email, password string) bool {
name := register.GenerateName() name := register.GenerateName()
birthdate := register.GenerateBirthdate() birthdate := register.GenerateBirthdate()
memberLogPrefix := fmt.Sprintf("%s [成员 %d]", logPrefix, memberIdx+1) memberLogPrefix := fmt.Sprintf("%s [成员 %d]", logPrefix, memberIdx+1)
@@ -618,24 +724,40 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul
continue continue
} }
// 成功 // 注册成功
regDuration := time.Since(regStartTime) regDuration := time.Since(regStartTime)
memberMu.Lock() memberMu.Lock()
children[memberIdx] = MemberAccount{Email: currentEmail, Password: currentPassword, Success: true} children[memberIdx] = MemberAccount{Email: currentEmail, Password: currentPassword, Success: true}
result.MemberEmails = append(result.MemberEmails, currentEmail)
result.Registered++
memberMu.Unlock() memberMu.Unlock()
logger.Success(fmt.Sprintf("%s ✓ 注册成功 (耗时: %.1fs)", memberLogPrefix, regDuration.Seconds()), currentEmail, "team") logger.Success(fmt.Sprintf("%s ✓ 注册成功 (耗时: %.1fs)", memberLogPrefix, regDuration.Seconds()), currentEmail, "team")
// 流水线:注册成功后立即启动入库(异步)
s2aWg.Add(1)
go func(idx int, e, p string) {
defer s2aWg.Done()
success := doS2A(idx, e, p)
memberMu.Lock()
children[idx].S2ADone = true
children[idx].S2AOK = success
memberMu.Unlock()
}(memberIdx, currentEmail, currentPassword)
return true return true
} }
return false return false
} }
// 第一轮:并发注册4个成员 // 第一轮:并发注册成员(注册成功后立即入库)
logger.Info(fmt.Sprintf("%s ════════ 开始注册阶段 ════════ 目标: %d 个成员", logPrefix, req.MembersPerTeam), owner.Email, "team") logger.Info(fmt.Sprintf("%s ════════ 开始流水线处理 ════════ 目标: %d 个成员", logPrefix, req.MembersPerTeam), owner.Email, "team")
regPhaseStartTime := time.Now() pipelineStartTime := time.Now()
var regWg sync.WaitGroup
for i := 0; i < req.MembersPerTeam; i++ { for i := 0; i < req.MembersPerTeam; i++ {
memberWg.Add(1) regWg.Add(1)
go func(idx int) { go func(idx int) {
defer memberWg.Done() defer regWg.Done()
// 检查是否应该停止 // 检查是否应该停止
if isTeamExhausted() { if isTeamExhausted() {
return return
@@ -643,13 +765,15 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul
email := mail.GenerateEmail() email := mail.GenerateEmail()
password := register.GeneratePassword() password := register.GeneratePassword()
logger.Info(fmt.Sprintf("%s [成员 %d] 邮箱: %s | 密码: %s", logPrefix, idx+1, email, password), email, "team") logger.Info(fmt.Sprintf("%s [成员 %d] 邮箱: %s | 密码: %s", logPrefix, idx+1, email, password), email, "team")
registerMember(idx, email, password) registerAndS2AMember(idx, email, password)
}(i) }(i)
} }
memberWg.Wait() regWg.Wait()
// 如果 Team 已满,直接跳过补救和后续处理 // 如果 Team 已满,等待已启动的入库完成
if isTeamExhausted() { if isTeamExhausted() {
s2aWg.Wait()
result.AddedToS2A = int(atomic.LoadInt32(&s2aSuccessCount))
result.Errors = append(result.Errors, "Team 邀请已满") result.Errors = append(result.Errors, "Team 邀请已满")
result.DurationMs = time.Since(startTime).Milliseconds() result.DurationMs = time.Since(startTime).Milliseconds()
return result return result
@@ -671,184 +795,41 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul
email := mail.GenerateEmail() email := mail.GenerateEmail()
password := register.GeneratePassword() password := register.GeneratePassword()
if registerMember(slotIdx, email, password) { if registerAndS2AMember(slotIdx, email, password) {
failedSlots = failedSlots[1:] // 成功,移除这个槽位 failedSlots = failedSlots[1:] // 成功,移除这个槽位
} }
} }
// 等待所有入库完成
s2aWg.Wait()
// 补救后再次检查 Team 是否已满 // 补救后再次检查 Team 是否已满
if isTeamExhausted() { if isTeamExhausted() {
result.AddedToS2A = int(atomic.LoadInt32(&s2aSuccessCount))
result.Errors = append(result.Errors, "Team 邀请已满") result.Errors = append(result.Errors, "Team 邀请已满")
result.DurationMs = time.Since(startTime).Milliseconds() result.DurationMs = time.Since(startTime).Milliseconds()
return result return result
} }
// 统计注册成功数 // 统计最终结果
registeredChildren := make([]MemberAccount, 0)
for _, c := range children {
if c.Success {
registeredChildren = append(registeredChildren, c)
result.MemberEmails = append(result.MemberEmails, c.Email)
result.Registered++
}
}
if len(failedSlots) > 0 { if len(failedSlots) > 0 {
result.Errors = append(result.Errors, fmt.Sprintf("%d 个成员注册失败", len(failedSlots))) result.Errors = append(result.Errors, fmt.Sprintf("%d 个成员注册失败", len(failedSlots)))
} }
regPhaseDuration := time.Since(regPhaseStartTime)
logger.Info(fmt.Sprintf("%s ════════ 注册阶段完成 ════════ 成功: %d/%d, 耗时: %.1fs", logPrefix, result.Registered, req.MembersPerTeam, regPhaseDuration.Seconds()), owner.Email, "team")
// 如果没有任何成员注册成功,跳过入库步骤 result.AddedToS2A = int(atomic.LoadInt32(&s2aSuccessCount))
if len(registeredChildren) == 0 { pipelineDuration := time.Since(pipelineStartTime)
logger.Warning(fmt.Sprintf("%s 没有成员注册成功,跳过入库步骤", logPrefix), owner.Email, "team") logger.Info(fmt.Sprintf("%s ════════ 流水线完成 ════════ 注册: %d/%d, 入库: %d, 耗时: %.1fs",
logPrefix, result.Registered, req.MembersPerTeam, result.AddedToS2A, pipelineDuration.Seconds()), owner.Email, "team")
// 如果没有任何成员注册成功,跳过母号入库
if result.Registered == 0 {
logger.Warning(fmt.Sprintf("%s 没有成员注册成功,跳过母号入库", logPrefix), owner.Email, "team")
result.DurationMs = time.Since(startTime).Milliseconds() result.DurationMs = time.Since(startTime).Milliseconds()
markOwnerResult(false) markOwnerResult(false)
return result return result
} }
// Step 4: S2A 授权入库(成员)- 并发入库 // Step 4: 母号也入库(如果开启)- 带重试
logger.Info(fmt.Sprintf("%s ════════ 开始入库阶段 ════════ 共 %d 个成员, 并发数: %d", logPrefix, len(registeredChildren), req.ConcurrentS2A), owner.Email, "team")
s2aStartTime := time.Now()
// 入库结果
type S2AResult struct {
Index int
Email string
Success bool
Error string
}
s2aResults := make(chan S2AResult, len(registeredChildren))
s2aSem := make(chan struct{}, req.ConcurrentS2A) // 并发控制信号量
var s2aWg sync.WaitGroup
for i, child := range registeredChildren {
if !teamProcessState.Running {
break
}
s2aWg.Add(1)
go func(memberIdx int, memberChild MemberAccount) {
defer s2aWg.Done()
// 获取信号量
s2aSem <- struct{}{}
defer func() { <-s2aSem }()
memberStartTime := time.Now()
memberLogPrefix := fmt.Sprintf("%s [成员 %d]", logPrefix, memberIdx+1)
logger.Status(fmt.Sprintf("%s 入库中... | 邮箱: %s", memberLogPrefix, memberChild.Email), memberChild.Email, "team")
var s2aSuccess bool
var lastError string
for attempt := 0; attempt < 2; attempt++ { // 最多重试1次
if attempt > 0 {
logger.Warning(fmt.Sprintf("%s 入库重试 (第%d次)", memberLogPrefix, attempt+1), memberChild.Email, "team")
}
// 创建日志回调(输出关键日志和调试信息)
authLogger := auth.NewAuthLogger(memberChild.Email, logPrefix, memberIdx+1, func(entry auth.AuthLogEntry) {
if entry.IsError {
logger.Error(fmt.Sprintf("%s %s", memberLogPrefix, entry.Message), memberChild.Email, "team")
} else {
// 输出关键步骤:导航、输入、完成等
switch entry.Step {
case auth.StepNavigate, auth.StepInputEmail, auth.StepInputPassword,
auth.StepComplete, auth.StepConsent, auth.StepSelectWorkspace:
logger.Info(fmt.Sprintf("%s %s", memberLogPrefix, entry.Message), memberChild.Email, "team")
}
}
})
// 获取授权 URL
s2aResp, err := auth.GenerateS2AAuthURL(config.Global.S2AApiBase, config.Global.S2AAdminKey, config.Global.ProxyID)
if err != nil {
lastError = fmt.Sprintf("获取授权URL失败: %v", err)
logger.Error(fmt.Sprintf("%s %s", memberLogPrefix, lastError), memberChild.Email, "team")
continue
}
// 根据配置选择授权方式
var code string
if config.Global.AuthMethod == "api" {
// 使用纯 API 模式CodexAuth- 使用 S2A 生成的授权 URL
// 从代理池随机选择代理
proxyToUse := req.Proxy
if poolProxy, poolErr := database.Instance.GetRandomCodexProxy(); poolErr == nil && poolProxy != "" {
proxyToUse = poolProxy
logger.Info(fmt.Sprintf("%s 使用代理池: %s", memberLogPrefix, getProxyDisplay(poolProxy)), memberChild.Email, "team")
}
code, err = auth.CompleteWithCodexAPI(memberChild.Email, memberChild.Password, teamID, s2aResp.Data.AuthURL, s2aResp.Data.SessionID, proxyToUse, authLogger)
// 更新代理统计
if proxyToUse != req.Proxy && proxyToUse != "" {
database.Instance.UpdateCodexProxyStats(proxyToUse, err == nil)
}
} else {
// 使用 Chromedp 浏览器自动化
code, err = auth.CompleteWithChromedpLogged(s2aResp.Data.AuthURL, memberChild.Email, memberChild.Password, teamID, req.Headless, req.Proxy, authLogger)
}
if err != nil {
lastError = fmt.Sprintf("浏览器授权失败: %v", err)
logger.Error(fmt.Sprintf("%s %s", memberLogPrefix, lastError), memberChild.Email, "team")
continue
}
// 提交到 S2A
_, err = auth.SubmitS2AOAuth(
config.Global.S2AApiBase,
config.Global.S2AAdminKey,
s2aResp.Data.SessionID,
code,
memberChild.Email,
config.Global.Concurrency,
config.Global.Priority,
config.Global.GroupIDs,
config.Global.ProxyID,
)
if err != nil {
lastError = fmt.Sprintf("S2A提交失败: %v", err)
logger.Error(fmt.Sprintf("%s %s", memberLogPrefix, lastError), memberChild.Email, "team")
continue
}
s2aSuccess = true
memberDuration := time.Since(memberStartTime)
logger.Success(fmt.Sprintf("%s ✓ 入库成功 (总耗时: %.1fs)", memberLogPrefix, memberDuration.Seconds()), memberChild.Email, "team")
break
}
s2aResults <- S2AResult{
Index: memberIdx,
Email: memberChild.Email,
Success: s2aSuccess,
Error: lastError,
}
}(i, child)
}
// 等待所有入库完成
go func() {
s2aWg.Wait()
close(s2aResults)
}()
// 收集入库结果
for s2aRes := range s2aResults {
if s2aRes.Success {
result.AddedToS2A++
} else {
result.Errors = append(result.Errors, fmt.Sprintf("成员 %d 入库失败: %s", s2aRes.Index+1, s2aRes.Error))
}
}
s2aDuration := time.Since(s2aStartTime)
logger.Info(fmt.Sprintf("%s ════════ 入库阶段完成 ════════ 成功: %d/%d, 耗时: %.1fs", logPrefix, result.AddedToS2A, len(registeredChildren), s2aDuration.Seconds()), owner.Email, "team")
// Step 5: 母号也入库(如果开启)- 带重试
if req.IncludeOwner && teamProcessState.Running { if req.IncludeOwner && teamProcessState.Running {
ownerLogPrefix := fmt.Sprintf("%s [母号 ]", logPrefix) ownerLogPrefix := fmt.Sprintf("%s [母号 ]", logPrefix)
ownerStartTime := time.Now() ownerStartTime := time.Now()

View File

@@ -383,7 +383,7 @@ func (m *Client) WaitForCode(email string, timeout time.Duration) (string, error
} }
} }
} }
time.Sleep(2 * time.Second) time.Sleep(1 * time.Second)
} }
return "", fmt.Errorf("验证码获取超时") return "", fmt.Errorf("验证码获取超时")
@@ -414,7 +414,7 @@ func (m *Client) WaitForInviteLink(email string, timeout time.Duration) (string,
} }
} }
} }
time.Sleep(2 * time.Second) time.Sleep(1 * time.Second)
} }
return "", fmt.Errorf("等待邀请邮件超时") return "", fmt.Errorf("等待邀请邮件超时")

View File

@@ -344,9 +344,9 @@ func APIRegister(email, password, realName, birthdate, proxy string, logPrefix s
return nil, fmt.Errorf("发送验证邮件失败: %v", err) return nil, fmt.Errorf("发送验证邮件失败: %v", err)
} }
// 获取验证码 (带超时 90s) - 合并日志,使用 Status 显示等待状态 // 获取验证码 (带超时 60s) - 合并日志,使用 Status 显示等待状态
logger.Status(fmt.Sprintf("%s 验证邮箱中...", logPrefix), email, "register") logger.Status(fmt.Sprintf("%s 验证邮箱中...", logPrefix), email, "register")
otpCode, err := mail.GetVerificationCode(email, 90*time.Second) otpCode, err := mail.GetVerificationCode(email, 60*time.Second)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -4,7 +4,7 @@ import {
Loader2, Save, RefreshCcw, CheckCircle, XCircle, Loader2, Save, RefreshCcw, CheckCircle, XCircle,
AlertTriangle, Clock, MapPin, Play, PlayCircle AlertTriangle, Clock, MapPin, Play, PlayCircle
} from 'lucide-react' } from 'lucide-react'
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common' import { Card, CardHeader, CardTitle, CardContent, Button, Input, useToast } from '../components/common'
interface CodexProxy { interface CodexProxy {
id: number id: number
@@ -32,7 +32,7 @@ export default function CodexProxyConfig() {
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [testingIds, setTestingIds] = useState<Set<number>>(new Set()) const [testingIds, setTestingIds] = useState<Set<number>>(new Set())
const [testingAll, setTestingAll] = useState(false) const [testingAll, setTestingAll] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null) const toast = useToast()
// 单个添加 // 单个添加
const [newProxyUrl, setNewProxyUrl] = useState('') const [newProxyUrl, setNewProxyUrl] = useState('')
@@ -67,7 +67,6 @@ export default function CodexProxyConfig() {
const handleAddProxy = async () => { const handleAddProxy = async () => {
if (!newProxyUrl.trim()) return if (!newProxyUrl.trim()) return
setSaving(true) setSaving(true)
setMessage(null)
try { try {
const res = await fetch('/api/codex-proxy', { const res = await fetch('/api/codex-proxy', {
method: 'POST', method: 'POST',
@@ -79,15 +78,29 @@ export default function CodexProxyConfig() {
}) })
const data = await res.json() const data = await res.json()
if (data.code === 0) { if (data.code === 0) {
setMessage({ type: 'success', text: '代理添加成功' }) // 局部添加新代理到列表
const newProxy: CodexProxy = {
id: data.data.id,
proxy_url: newProxyUrl.trim(),
description: newDescription.trim(),
is_enabled: true,
last_used_at: null,
success_count: 0,
fail_count: 0,
location: '',
last_test_at: null,
created_at: new Date().toISOString(),
}
setProxies(prev => [...prev, newProxy])
setStats(prev => ({ ...prev, total: prev.total + 1, enabled: prev.enabled + 1 }))
setNewProxyUrl('') setNewProxyUrl('')
setNewDescription('') setNewDescription('')
fetchProxies() toast.success('代理添加成功')
} else { } else {
setMessage({ type: 'error', text: data.message || '添加失败' }) toast.error(data.message || '添加失败')
} }
} catch { } catch {
setMessage({ type: 'error', text: '网络错误' }) toast.error('网络错误')
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -98,7 +111,6 @@ export default function CodexProxyConfig() {
const lines = batchInput.split('\n').filter(line => line.trim()) const lines = batchInput.split('\n').filter(line => line.trim())
if (lines.length === 0) return if (lines.length === 0) return
setSaving(true) setSaving(true)
setMessage(null)
try { try {
const res = await fetch('/api/codex-proxy', { const res = await fetch('/api/codex-proxy', {
method: 'POST', method: 'POST',
@@ -107,14 +119,15 @@ export default function CodexProxyConfig() {
}) })
const data = await res.json() const data = await res.json()
if (data.code === 0) { if (data.code === 0) {
setMessage({ type: 'success', text: `成功添加 ${data.data.added}/${data.data.total} 个代理` }) toast.success(`成功添加 ${data.data.added}/${data.data.total} 个代理`)
setBatchInput('') setBatchInput('')
// 批量添加后需要刷新列表获取新的代理ID
fetchProxies() fetchProxies()
} else { } else {
setMessage({ type: 'error', text: data.message || '添加失败' }) toast.error(data.message || '添加失败')
} }
} catch { } catch {
setMessage({ type: 'error', text: '网络错误' }) toast.error('网络错误')
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -126,10 +139,23 @@ export default function CodexProxyConfig() {
const res = await fetch(`/api/codex-proxy?id=${id}`, { method: 'PUT' }) const res = await fetch(`/api/codex-proxy?id=${id}`, { method: 'PUT' })
const data = await res.json() const data = await res.json()
if (data.code === 0) { if (data.code === 0) {
fetchProxies() // 局部更新状态
setProxies(prev => prev.map(p =>
p.id === id ? { ...p, is_enabled: !p.is_enabled } : p
))
// 更新统计
const proxy = proxies.find(p => p.id === id)
if (proxy) {
if (proxy.is_enabled) {
setStats(prev => ({ ...prev, enabled: prev.enabled - 1, disabled: prev.disabled + 1 }))
} else {
setStats(prev => ({ ...prev, enabled: prev.enabled + 1, disabled: prev.disabled - 1 }))
}
}
} }
} catch (error) { } catch (error) {
console.error('切换状态失败:', error) console.error('切换状态失败:', error)
toast.error('切换状态失败')
} }
} }
@@ -140,10 +166,23 @@ export default function CodexProxyConfig() {
const res = await fetch(`/api/codex-proxy?id=${id}`, { method: 'DELETE' }) const res = await fetch(`/api/codex-proxy?id=${id}`, { method: 'DELETE' })
const data = await res.json() const data = await res.json()
if (data.code === 0) { if (data.code === 0) {
fetchProxies() // 局部删除
const proxy = proxies.find(p => p.id === id)
setProxies(prev => prev.filter(p => p.id !== id))
// 更新统计
if (proxy) {
setStats(prev => ({
...prev,
total: prev.total - 1,
enabled: proxy.is_enabled ? prev.enabled - 1 : prev.enabled,
disabled: proxy.is_enabled ? prev.disabled : prev.disabled - 1,
}))
}
toast.success('代理已删除')
} }
} catch (error) { } catch (error) {
console.error('删除失败:', error) console.error('删除失败:', error)
toast.error('删除失败')
} }
} }
@@ -151,6 +190,11 @@ export default function CodexProxyConfig() {
const handleTestProxy = async (id: number) => { const handleTestProxy = async (id: number) => {
if (testingIds.has(id)) return if (testingIds.has(id)) return
setTestingIds(prev => new Set(prev).add(id)) setTestingIds(prev => new Set(prev).add(id))
// 找到当前代理用于显示
const proxy = proxies.find(p => p.id === id)
const proxyDisplay = proxy ? formatProxyDisplay(proxy.proxy_url) : `ID:${id}`
try { try {
const res = await fetch(`/api/codex-proxy/test?id=${id}`, { method: 'POST' }) const res = await fetch(`/api/codex-proxy/test?id=${id}`, { method: 'POST' })
const data = await res.json() const data = await res.json()
@@ -161,14 +205,22 @@ export default function CodexProxyConfig() {
? { ...p, location: data.data.location, last_test_at: new Date().toISOString(), success_count: p.success_count + 1 } ? { ...p, location: data.data.location, last_test_at: new Date().toISOString(), success_count: p.success_count + 1 }
: p : p
)) ))
// 使用 toast 提示成功
const location = data.data.location || '未知'
const ip = data.data.ip || ''
toast.success(`${proxyDisplay} 测试成功 → ${ip} (${location})`)
} else { } else {
alert(`测试失败: ${data.message}`) // 测试失败,局部更新失败计数
// 虽然失败也需要刷新列表以获取最新的统计数据 setProxies(prev => prev.map(p =>
fetchProxies() p.id === id
? { ...p, fail_count: p.fail_count + 1 }
: p
))
toast.error(`${proxyDisplay} 测试失败: ${data.message || '未知错误'}`)
} }
} catch (error) { } catch (error) {
console.error('测试代理出错:', error) console.error('测试代理出错:', error)
alert('网络错误,测试失败') toast.error(`${proxyDisplay} 网络错误,测试失败`)
} finally { } finally {
setTestingIds(prev => { setTestingIds(prev => {
const next = new Set(prev) const next = new Set(prev)
@@ -182,7 +234,6 @@ export default function CodexProxyConfig() {
const handleTestAll = async () => { const handleTestAll = async () => {
if (testingAll || proxies.length === 0) return if (testingAll || proxies.length === 0) return
setTestingAll(true) setTestingAll(true)
setMessage(null)
let successCount = 0 let successCount = 0
let failCount = 0 let failCount = 0
@@ -236,10 +287,15 @@ export default function CodexProxyConfig() {
// 刷新列表获取最新数据 // 刷新列表获取最新数据
await fetchProxies() await fetchProxies()
setTestingAll(false) setTestingAll(false)
setMessage({
type: successCount > 0 ? 'success' : 'error', // 使用 toast 提示结果
text: `测试完成: ${successCount} 成功, ${failCount} 失败` if (successCount > 0 && failCount === 0) {
}) toast.success(`一键测试完成: 全部 ${successCount} 个代理测试成功`)
} else if (successCount === 0 && failCount > 0) {
toast.error(`一键测试完成: 全部 ${failCount} 个代理测试失败`)
} else {
toast.info(`一键测试完成: ${successCount} 成功, ${failCount} 失败`)
}
} }
// 清空所有 // 清空所有
@@ -249,11 +305,14 @@ export default function CodexProxyConfig() {
const res = await fetch('/api/codex-proxy?all=true', { method: 'DELETE' }) const res = await fetch('/api/codex-proxy?all=true', { method: 'DELETE' })
const data = await res.json() const data = await res.json()
if (data.code === 0) { if (data.code === 0) {
setMessage({ type: 'success', text: '已清空所有代理' }) // 局部清空
fetchProxies() setProxies([])
setStats({ total: 0, enabled: 0, disabled: 0 })
toast.success('已清空所有代理')
} }
} catch (error) { } catch (error) {
console.error('清空失败:', error) console.error('清空失败:', error)
toast.error('清空失败')
} }
} }
@@ -326,16 +385,6 @@ export default function CodexProxyConfig() {
</div> </div>
</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>
)}
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
<Card> <Card>