diff --git a/backend/internal/api/error_cleaner.go b/backend/internal/api/error_cleaner.go index 6765167..d02fadb 100644 --- a/backend/internal/api/error_cleaner.go +++ b/backend/internal/api/error_cleaner.go @@ -79,9 +79,7 @@ func checkAndCleanErrors() { return } - // 执行清理 - logger.Status("定期清理错误账号中...", "", "cleaner") - + // 获取错误账号列表 errorAccounts, err := fetchAllErrorAccounts() if err != nil { logger.Error(fmt.Sprintf("获取错误账号列表失败: %v", err), "", "cleaner") @@ -89,10 +87,14 @@ func checkAndCleanErrors() { } if len(errorAccounts) == 0 { + logger.Info("无错误账号需要清理", "", "cleaner") lastCleanTime = time.Now() return } + // 执行清理 + logger.Status(fmt.Sprintf("定期清理错误账号中: 共 %d 个", len(errorAccounts)), "", "cleaner") + success := 0 failed := 0 diff --git a/backend/internal/api/team_process.go b/backend/internal/api/team_process.go index cb1f917..ed06674 100644 --- a/backend/internal/api/team_process.go +++ b/backend/internal/api/team_process.go @@ -539,21 +539,29 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul logger.Warning(fmt.Sprintf("%s 首次邀请失败,继续尝试: %v", logPrefix, err), owner.Email, "team") } - // Step 3: 并发注册成员 - // 每个成员:邀请 → 注册,失败重试1次 + // Step 3: 流水线模式 - 注册成功的成员立即开始入库 + // 每个成员:邀请 → 注册 → 入库,失败重试1次 // Team 有4次额外补救机会 type MemberAccount struct { Email string Password string Success bool + S2ADone bool // 入库是否完成 + S2AOK bool // 入库是否成功 } children := make([]MemberAccount, req.MembersPerTeam) var memberMu sync.Mutex - var memberWg sync.WaitGroup // 共享标志:Team 邀请已满,所有 goroutine 应停止 var teamExhausted int32 + // 入库计数器 + var s2aSuccessCount int32 + var s2aFailCount int32 + + // 入库并发控制信号量 + s2aSem := make(chan struct{}, req.ConcurrentS2A) + // 检查 Team 是否已满的辅助函数 isTeamExhausted := func() bool { 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() birthdate := register.GenerateBirthdate() memberLogPrefix := fmt.Sprintf("%s [成员 %d]", logPrefix, memberIdx+1) @@ -618,24 +724,40 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul continue } - // 成功 + // 注册成功 regDuration := time.Since(regStartTime) memberMu.Lock() children[memberIdx] = MemberAccount{Email: currentEmail, Password: currentPassword, Success: true} + result.MemberEmails = append(result.MemberEmails, currentEmail) + result.Registered++ memberMu.Unlock() 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 false } - // 第一轮:并发注册4个成员 - logger.Info(fmt.Sprintf("%s ════════ 开始注册阶段 ════════ 目标: %d 个成员", logPrefix, req.MembersPerTeam), owner.Email, "team") - regPhaseStartTime := time.Now() + // 第一轮:并发注册成员(注册成功后立即入库) + logger.Info(fmt.Sprintf("%s ════════ 开始流水线处理 ════════ 目标: %d 个成员", logPrefix, req.MembersPerTeam), owner.Email, "team") + pipelineStartTime := time.Now() + + var regWg sync.WaitGroup for i := 0; i < req.MembersPerTeam; i++ { - memberWg.Add(1) + regWg.Add(1) go func(idx int) { - defer memberWg.Done() + defer regWg.Done() // 检查是否应该停止 if isTeamExhausted() { return @@ -643,13 +765,15 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul email := mail.GenerateEmail() password := register.GeneratePassword() logger.Info(fmt.Sprintf("%s [成员 %d] 邮箱: %s | 密码: %s", logPrefix, idx+1, email, password), email, "team") - registerMember(idx, email, password) + registerAndS2AMember(idx, email, password) }(i) } - memberWg.Wait() + regWg.Wait() - // 如果 Team 已满,直接跳过补救和后续处理 + // 如果 Team 已满,等待已启动的入库完成 if isTeamExhausted() { + s2aWg.Wait() + result.AddedToS2A = int(atomic.LoadInt32(&s2aSuccessCount)) result.Errors = append(result.Errors, "Team 邀请已满") result.DurationMs = time.Since(startTime).Milliseconds() return result @@ -671,184 +795,41 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul email := mail.GenerateEmail() password := register.GeneratePassword() - if registerMember(slotIdx, email, password) { + if registerAndS2AMember(slotIdx, email, password) { failedSlots = failedSlots[1:] // 成功,移除这个槽位 } } + // 等待所有入库完成 + s2aWg.Wait() + // 补救后再次检查 Team 是否已满 if isTeamExhausted() { + result.AddedToS2A = int(atomic.LoadInt32(&s2aSuccessCount)) result.Errors = append(result.Errors, "Team 邀请已满") result.DurationMs = time.Since(startTime).Milliseconds() 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 { 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") - // 如果没有任何成员注册成功,跳过入库步骤 - if len(registeredChildren) == 0 { - logger.Warning(fmt.Sprintf("%s 没有成员注册成功,跳过入库步骤", logPrefix), owner.Email, "team") + result.AddedToS2A = int(atomic.LoadInt32(&s2aSuccessCount)) + pipelineDuration := time.Since(pipelineStartTime) + 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() markOwnerResult(false) return result } - // Step 4: S2A 授权入库(成员)- 并发入库 - 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: 母号也入库(如果开启)- 带重试 + // Step 4: 母号也入库(如果开启)- 带重试 if req.IncludeOwner && teamProcessState.Running { ownerLogPrefix := fmt.Sprintf("%s [母号 ]", logPrefix) ownerStartTime := time.Now() diff --git a/backend/internal/mail/service.go b/backend/internal/mail/service.go index cb59932..bd229da 100644 --- a/backend/internal/mail/service.go +++ b/backend/internal/mail/service.go @@ -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("验证码获取超时") @@ -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("等待邀请邮件超时") diff --git a/backend/internal/register/chatgpt.go b/backend/internal/register/chatgpt.go index 8f51eec..6beaf55 100644 --- a/backend/internal/register/chatgpt.go +++ b/backend/internal/register/chatgpt.go @@ -344,9 +344,9 @@ func APIRegister(email, password, realName, birthdate, proxy string, logPrefix s return nil, fmt.Errorf("发送验证邮件失败: %v", err) } - // 获取验证码 (带超时 90s) - 合并日志,使用 Status 显示等待状态 + // 获取验证码 (带超时 60s) - 合并日志,使用 Status 显示等待状态 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 { return nil, err } diff --git a/frontend/src/pages/CodexProxyConfig.tsx b/frontend/src/pages/CodexProxyConfig.tsx index 1379584..566364a 100644 --- a/frontend/src/pages/CodexProxyConfig.tsx +++ b/frontend/src/pages/CodexProxyConfig.tsx @@ -4,7 +4,7 @@ import { Loader2, Save, RefreshCcw, CheckCircle, XCircle, AlertTriangle, Clock, MapPin, Play, PlayCircle } 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 { id: number @@ -32,7 +32,7 @@ export default function CodexProxyConfig() { const [saving, setSaving] = useState(false) const [testingIds, setTestingIds] = useState>(new Set()) const [testingAll, setTestingAll] = useState(false) - const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null) + const toast = useToast() // 单个添加 const [newProxyUrl, setNewProxyUrl] = useState('') @@ -67,7 +67,6 @@ export default function CodexProxyConfig() { const handleAddProxy = async () => { if (!newProxyUrl.trim()) return setSaving(true) - setMessage(null) try { const res = await fetch('/api/codex-proxy', { method: 'POST', @@ -79,15 +78,29 @@ export default function CodexProxyConfig() { }) const data = await res.json() 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('') setNewDescription('') - fetchProxies() + toast.success('代理添加成功') } else { - setMessage({ type: 'error', text: data.message || '添加失败' }) + toast.error(data.message || '添加失败') } } catch { - setMessage({ type: 'error', text: '网络错误' }) + toast.error('网络错误') } finally { setSaving(false) } @@ -98,7 +111,6 @@ export default function CodexProxyConfig() { const lines = batchInput.split('\n').filter(line => line.trim()) if (lines.length === 0) return setSaving(true) - setMessage(null) try { const res = await fetch('/api/codex-proxy', { method: 'POST', @@ -107,14 +119,15 @@ export default function CodexProxyConfig() { }) const data = await res.json() if (data.code === 0) { - setMessage({ type: 'success', text: `成功添加 ${data.data.added}/${data.data.total} 个代理` }) + toast.success(`成功添加 ${data.data.added}/${data.data.total} 个代理`) setBatchInput('') + // 批量添加后需要刷新列表获取新的代理ID fetchProxies() } else { - setMessage({ type: 'error', text: data.message || '添加失败' }) + toast.error(data.message || '添加失败') } } catch { - setMessage({ type: 'error', text: '网络错误' }) + toast.error('网络错误') } finally { setSaving(false) } @@ -126,10 +139,23 @@ export default function CodexProxyConfig() { const res = await fetch(`/api/codex-proxy?id=${id}`, { method: 'PUT' }) const data = await res.json() 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) { 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 data = await res.json() 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) { console.error('删除失败:', error) + toast.error('删除失败') } } @@ -151,24 +190,37 @@ export default function CodexProxyConfig() { const handleTestProxy = async (id: number) => { if (testingIds.has(id)) return 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 { const res = await fetch(`/api/codex-proxy/test?id=${id}`, { method: 'POST' }) const data = await res.json() if (data.code === 0) { // 局部更新代理信息 - setProxies(prev => prev.map(p => - p.id === id - ? { ...p, location: data.data.location, last_test_at: new Date().toISOString(), success_count: p.success_count + 1 } + setProxies(prev => prev.map(p => + p.id === id + ? { ...p, location: data.data.location, last_test_at: new Date().toISOString(), success_count: p.success_count + 1 } : p )) + // 使用 toast 提示成功 + const location = data.data.location || '未知' + const ip = data.data.ip || '' + toast.success(`${proxyDisplay} 测试成功 → ${ip} (${location})`) } else { - alert(`测试失败: ${data.message}`) - // 虽然失败也需要刷新列表以获取最新的统计数据 - fetchProxies() + // 测试失败,局部更新失败计数 + setProxies(prev => prev.map(p => + p.id === id + ? { ...p, fail_count: p.fail_count + 1 } + : p + )) + toast.error(`${proxyDisplay} 测试失败: ${data.message || '未知错误'}`) } } catch (error) { console.error('测试代理出错:', error) - alert('网络错误,测试失败') + toast.error(`${proxyDisplay} 网络错误,测试失败`) } finally { setTestingIds(prev => { const next = new Set(prev) @@ -182,7 +234,6 @@ export default function CodexProxyConfig() { const handleTestAll = async () => { if (testingAll || proxies.length === 0) return setTestingAll(true) - setMessage(null) let successCount = 0 let failCount = 0 @@ -236,10 +287,15 @@ export default function CodexProxyConfig() { // 刷新列表获取最新数据 await fetchProxies() setTestingAll(false) - setMessage({ - type: successCount > 0 ? 'success' : 'error', - text: `测试完成: ${successCount} 成功, ${failCount} 失败` - }) + + // 使用 toast 提示结果 + 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 data = await res.json() if (data.code === 0) { - setMessage({ type: 'success', text: '已清空所有代理' }) - fetchProxies() + // 局部清空 + setProxies([]) + setStats({ total: 0, enabled: 0, disabled: 0 }) + toast.success('已清空所有代理') } } catch (error) { console.error('清空失败:', error) + toast.error('清空失败') } } @@ -326,16 +385,6 @@ export default function CodexProxyConfig() { - {/* Message */} - {message && ( -
- {message.text} -
- )} - {/* Stats */}