From b20399a00a5bd8b4dcbab9b963a7b4df128f66d8 Mon Sep 17 00:00:00 2001 From: kyx236 Date: Tue, 3 Feb 2026 06:45:54 +0800 Subject: [PATCH] feat: Introduce core application structure, configuration, monitoring, and team management features. --- backend/internal/api/auto_add.go | 47 ++- backend/internal/api/monitor.go | 116 +++++- backend/internal/api/owner_ban_check.go | 26 +- backend/internal/api/team_process.go | 44 ++- backend/internal/api/team_reg_exec.go | 12 +- backend/internal/api/upload.go | 3 +- backend/internal/auth/codex_api.go | 7 +- backend/internal/database/sqlite.go | 44 ++- frontend/src/App.tsx | 3 +- frontend/src/components/LiveLogViewer.tsx | 4 +- frontend/src/components/common/Select.tsx | 131 +++++-- frontend/src/components/layout/Sidebar.tsx | 6 +- frontend/src/pages/CodexProxyConfig.tsx | 229 +++++++++++- frontend/src/pages/Config.tsx | 402 ++++----------------- frontend/src/pages/Monitor.tsx | 378 +++++++++---------- frontend/src/pages/S2AStats.tsx | 105 ++++++ frontend/src/pages/TeamReg.tsx | 34 +- frontend/src/pages/index.ts | 1 + 18 files changed, 961 insertions(+), 631 deletions(-) create mode 100644 frontend/src/pages/S2AStats.tsx diff --git a/backend/internal/api/auto_add.go b/backend/internal/api/auto_add.go index 727202c..3f76cbf 100644 --- a/backend/internal/api/auto_add.go +++ b/backend/internal/api/auto_add.go @@ -128,8 +128,16 @@ func checkAndAutoAdd() { return } - // 计算需要多少个 Team(每个 Team 产生 4 个账号) - teamsNeeded := (deficit + 3) / 4 // 向上取整 + // 读取每 Team 成员数配置 + membersPerTeam := 4 + if val, _ := database.Instance.GetConfig("monitor_members_per_team"); val != "" { + if v, err := strconv.Atoi(val); err == nil && v >= 1 && v <= 10 { + membersPerTeam = v + } + } + + // 计算需要多少个 Team + teamsNeeded := (deficit + membersPerTeam - 1) / membersPerTeam // 向上取整 // 获取可用的 Owner owners, err := database.Instance.GetPendingOwners() @@ -164,13 +172,14 @@ func checkAndAutoAdd() { } } - // 读取代理配置 + // 读取代理配置 - 支持代理池模式 proxyURL := "" replenishUseProxy := false if val, _ := database.Instance.GetConfig("monitor_replenish_use_proxy"); val == "true" { replenishUseProxy = true } if replenishUseProxy { + // 使用全局代理配置(支持 pool:random, pool:id:N 等格式) proxyURL = config.Global.DefaultProxy } @@ -180,10 +189,27 @@ func checkAndAutoAdd() { browserType = val } + // 读取并发 Team 数配置 + concurrentTeams := 2 + if val, _ := database.Instance.GetConfig("monitor_concurrent_teams"); val != "" { + if v, err := strconv.Atoi(val); err == nil && v >= 1 && v <= 10 { + concurrentTeams = v + } + } + + // 读取入库并发数配置 + s2aConcurrency := 2 + if val, _ := database.Instance.GetConfig("monitor_s2a_concurrency"); val != "" { + if v, err := strconv.Atoi(val); err == nil && v >= 1 && v <= 4 { + s2aConcurrency = v + } + } + req := TeamProcessRequest{ Owners: reqOwners, - MembersPerTeam: 4, - ConcurrentTeams: 2, + MembersPerTeam: membersPerTeam, + ConcurrentTeams: concurrentTeams, + S2AConcurrency: s2aConcurrency, IncludeOwner: false, Headless: true, BrowserType: browserType, @@ -192,7 +218,13 @@ func checkAndAutoAdd() { // 输出代理使用状态日志 if proxyURL != "" { - logger.Info(fmt.Sprintf("自动补号: 使用代理 %s", proxyURL), "", "auto-add") + displayProxy := proxyURL + if proxyURL == "pool:random" { + displayProxy = "代理池轮询模式" + } else if len(proxyURL) > 8 && proxyURL[:8] == "pool:id:" { + displayProxy = fmt.Sprintf("代理池固定项 (ID: %s)", proxyURL[8:]) + } + logger.Info(fmt.Sprintf("自动补号: 使用代理 %s", displayProxy), "", "auto-add") } else { logger.Info("自动补号: 未使用代理", "", "auto-add") } @@ -209,7 +241,8 @@ func checkAndAutoAdd() { // 异步执行 go runTeamProcess(req) - logger.Success(fmt.Sprintf("自动补号任务已启动: %d 个 Team (浏览器: %s)", actualTeams, browserType), "", "auto-add") + logger.Success(fmt.Sprintf("自动补号任务已启动: %d 个 Team, 每 Team %d 成员, 并发 %d (浏览器: %s)", + actualTeams, membersPerTeam, concurrentTeams, browserType), "", "auto-add") } // getS2AAccountCount 获取 S2A 当前账号数量 diff --git a/backend/internal/api/monitor.go b/backend/internal/api/monitor.go index 3616e9b..da204be 100644 --- a/backend/internal/api/monitor.go +++ b/backend/internal/api/monitor.go @@ -11,14 +11,20 @@ import ( // MonitorSettings 监控设置 type MonitorSettings struct { - Target int `json:"target"` - AutoAdd bool `json:"auto_add"` - MinInterval int `json:"min_interval"` - CheckInterval int `json:"check_interval"` // 自动补号检查间隔(秒) - PollingEnabled bool `json:"polling_enabled"` - PollingInterval int `json:"polling_interval"` - ReplenishUseProxy bool `json:"replenish_use_proxy"` // 补号时使用代理 - BrowserType string `json:"browser_type"` // 授权浏览器引擎: chromedp 或 rod + Target int `json:"target"` + AutoAdd bool `json:"auto_add"` + AutoRegister bool `json:"auto_register"` // 母号不足时自动注册 + AutoRegConcurrency int `json:"auto_reg_concurrency"` // 自动注册并发数 + AutoRegUseProxy bool `json:"auto_reg_use_proxy"` // 自动注册时使用代理 + MinInterval int `json:"min_interval"` + CheckInterval int `json:"check_interval"` // 自动补号检查间隔(秒) + PollingEnabled bool `json:"polling_enabled"` + PollingInterval int `json:"polling_interval"` + ReplenishUseProxy bool `json:"replenish_use_proxy"` // 补号时使用代理 + BrowserType string `json:"browser_type"` // 授权浏览器引擎: chromedp 或 rod + MembersPerTeam int `json:"members_per_team"` // 每 Team 成员数 + ConcurrentTeams int `json:"concurrent_teams"` // 并发 Team 数 + S2AConcurrency int `json:"s2a_concurrency"` // 入库并发数 } // HandleGetMonitorSettings 获取监控设置 @@ -34,14 +40,20 @@ func HandleGetMonitorSettings(w http.ResponseWriter, r *http.Request) { } settings := MonitorSettings{ - Target: 50, - AutoAdd: false, - MinInterval: 300, - CheckInterval: 60, - PollingEnabled: false, - PollingInterval: 60, - ReplenishUseProxy: false, - BrowserType: "chromedp", // 默认使用 chromedp + Target: 50, + AutoAdd: false, + AutoRegister: false, + AutoRegConcurrency: 2, + AutoRegUseProxy: false, + MinInterval: 300, + CheckInterval: 60, + PollingEnabled: false, + PollingInterval: 60, + ReplenishUseProxy: false, + BrowserType: "chromedp", // 默认使用 chromedp + MembersPerTeam: 4, // 默认每 Team 4 个成员 + ConcurrentTeams: 2, // 默认并发 2 个 Team + S2AConcurrency: 2, // 默认入库并发 2 } if val, _ := database.Instance.GetConfig("monitor_target"); val != "" { @@ -52,6 +64,17 @@ func HandleGetMonitorSettings(w http.ResponseWriter, r *http.Request) { if val, _ := database.Instance.GetConfig("monitor_auto_add"); val == "true" { settings.AutoAdd = true } + if val, _ := database.Instance.GetConfig("monitor_auto_register"); val == "true" { + settings.AutoRegister = true + } + if val, _ := database.Instance.GetConfig("monitor_auto_reg_concurrency"); val != "" { + if v, err := strconv.Atoi(val); err == nil { + settings.AutoRegConcurrency = v + } + } + if val, _ := database.Instance.GetConfig("monitor_auto_reg_use_proxy"); val == "true" { + settings.AutoRegUseProxy = true + } if val, _ := database.Instance.GetConfig("monitor_min_interval"); val != "" { if v, err := strconv.Atoi(val); err == nil { settings.MinInterval = v @@ -76,6 +99,21 @@ func HandleGetMonitorSettings(w http.ResponseWriter, r *http.Request) { if val, _ := database.Instance.GetConfig("monitor_browser_type"); val != "" { settings.BrowserType = val } + if val, _ := database.Instance.GetConfig("monitor_members_per_team"); val != "" { + if v, err := strconv.Atoi(val); err == nil { + settings.MembersPerTeam = v + } + } + if val, _ := database.Instance.GetConfig("monitor_concurrent_teams"); val != "" { + if v, err := strconv.Atoi(val); err == nil { + settings.ConcurrentTeams = v + } + } + if val, _ := database.Instance.GetConfig("monitor_s2a_concurrency"); val != "" { + if v, err := strconv.Atoi(val); err == nil { + settings.S2AConcurrency = v + } + } Success(w, settings) } @@ -113,6 +151,22 @@ func HandleSaveMonitorSettings(w http.ResponseWriter, r *http.Request) { if err := database.Instance.SetConfig("monitor_auto_add", strconv.FormatBool(settings.AutoAdd)); err != nil { saveErrors = append(saveErrors, "auto_add: "+err.Error()) } + if err := database.Instance.SetConfig("monitor_auto_register", strconv.FormatBool(settings.AutoRegister)); err != nil { + saveErrors = append(saveErrors, "auto_register: "+err.Error()) + } + // 自动注册并发数 (1-10) + autoRegConcurrency := settings.AutoRegConcurrency + if autoRegConcurrency < 1 { + autoRegConcurrency = 1 + } else if autoRegConcurrency > 10 { + autoRegConcurrency = 10 + } + if err := database.Instance.SetConfig("monitor_auto_reg_concurrency", strconv.Itoa(autoRegConcurrency)); err != nil { + saveErrors = append(saveErrors, "auto_reg_concurrency: "+err.Error()) + } + if err := database.Instance.SetConfig("monitor_auto_reg_use_proxy", strconv.FormatBool(settings.AutoRegUseProxy)); err != nil { + saveErrors = append(saveErrors, "auto_reg_use_proxy: "+err.Error()) + } if err := database.Instance.SetConfig("monitor_min_interval", strconv.Itoa(settings.MinInterval)); err != nil { saveErrors = append(saveErrors, "min_interval: "+err.Error()) } @@ -141,6 +195,36 @@ func HandleSaveMonitorSettings(w http.ResponseWriter, r *http.Request) { if err := database.Instance.SetConfig("monitor_browser_type", browserType); err != nil { saveErrors = append(saveErrors, "browser_type: "+err.Error()) } + // 每 Team 成员数 (1-10) + membersPerTeam := settings.MembersPerTeam + if membersPerTeam < 1 { + membersPerTeam = 1 + } else if membersPerTeam > 10 { + membersPerTeam = 10 + } + if err := database.Instance.SetConfig("monitor_members_per_team", strconv.Itoa(membersPerTeam)); err != nil { + saveErrors = append(saveErrors, "members_per_team: "+err.Error()) + } + // 并发 Team 数 (1-10) + concurrentTeams := settings.ConcurrentTeams + if concurrentTeams < 1 { + concurrentTeams = 1 + } else if concurrentTeams > 10 { + concurrentTeams = 10 + } + if err := database.Instance.SetConfig("monitor_concurrent_teams", strconv.Itoa(concurrentTeams)); err != nil { + saveErrors = append(saveErrors, "concurrent_teams: "+err.Error()) + } + // 入库并发数 (1-4) + s2aConcurrency := settings.S2AConcurrency + if s2aConcurrency < 1 { + s2aConcurrency = 1 + } else if s2aConcurrency > 4 { + s2aConcurrency = 4 + } + if err := database.Instance.SetConfig("monitor_s2a_concurrency", strconv.Itoa(s2aConcurrency)); err != nil { + saveErrors = append(saveErrors, "s2a_concurrency: "+err.Error()) + } if len(saveErrors) > 0 { errMsg := "保存监控设置部分失败: " + saveErrors[0] diff --git a/backend/internal/api/owner_ban_check.go b/backend/internal/api/owner_ban_check.go index 488012a..994f197 100644 --- a/backend/internal/api/owner_ban_check.go +++ b/backend/internal/api/owner_ban_check.go @@ -148,11 +148,11 @@ func HandleBanCheckSettings(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: settings := map[string]interface{}{ - "enabled": false, - "interval": 1800, // 检查服务间隔(秒) - "check_hours": 24, // 多少小时后重新检查 - "last_check": lastBanCheckTime, - "task_state": banCheckTaskState, + "enabled": false, + "interval": 1800, // 检查服务间隔(秒) + "check_hours": 24, // 多少小时后重新检查 + "last_check": lastBanCheckTime, + "task_state": banCheckTaskState, "service_running": banCheckRunning, } @@ -225,9 +225,9 @@ func HandleManualBanCheck(w http.ResponseWriter, r *http.Request) { } var req struct { - IDs []int64 `json:"ids"` // 指定检查的母号 ID,为空则检查所有有效母号 - Concurrency int `json:"concurrency"` // 并发数 - ForceCheck bool `json:"force_check"` // 是否强制检查(忽略上次检查时间) + IDs []int64 `json:"ids"` // 指定检查的母号 ID,为空则检查所有有效母号 + Concurrency int `json:"concurrency"` // 并发数 + ForceCheck bool `json:"force_check"` // 是否强制检查(忽略上次检查时间) } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { // 允许空 body @@ -315,10 +315,10 @@ func runBanCheckTask(owners []database.TeamOwner, concurrency int) { taskChan := make(chan database.TeamOwner, len(owners)) var wg sync.WaitGroup - // 获取代理配置 - proxy := "" + // 获取代理配置模式 + proxyStr := "" if config.Global != nil { - proxy = config.Global.GetProxy() + proxyStr = config.Global.GetProxy() } // 启动 worker @@ -327,7 +327,9 @@ func runBanCheckTask(owners []database.TeamOwner, concurrency int) { go func() { defer wg.Done() for owner := range taskChan { - result := checkSingleOwnerBan(owner, proxy) + // 每次循环重新解析代理,支持轮询 + resolvedProxy := database.Instance.ResolveProxy(proxyStr) + result := checkSingleOwnerBan(owner, resolvedProxy) // 更新计数 atomic.AddInt32(&banCheckTaskState.Checked, 1) diff --git a/backend/internal/api/team_process.go b/backend/internal/api/team_process.go index ed06674..ae1e331 100644 --- a/backend/internal/api/team_process.go +++ b/backend/internal/api/team_process.go @@ -155,12 +155,15 @@ func HandleTeamProcess(w http.ResponseWriter, r *http.Request) { req.Proxy = config.Global.GetProxy() // 使用新的代理获取方法 } if req.Proxy != "" { - normalized, err := proxyutil.Normalize(req.Proxy) - if err != nil { - Error(w, http.StatusBadRequest, fmt.Sprintf("代理格式错误: %v", err)) - return + // 如果是模式字符串(pool:random, pool:id:N),跳过 Normalize + if !strings.HasPrefix(req.Proxy, "pool:") && req.Proxy != "[RANDOM]" { + normalized, err := proxyutil.Normalize(req.Proxy) + if err != nil { + Error(w, http.StatusBadRequest, fmt.Sprintf("代理格式错误: %v", err)) + return + } + req.Proxy = normalized } - req.Proxy = normalized } // 初始化状态 @@ -466,7 +469,9 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul // Step 1: 获取 Team ID(优先使用已存储的 account_id) var teamID string - inviter := invite.NewWithProxy(owner.Token, req.Proxy) + // Resolve proxy for inviter + resolvedProxy := database.Instance.ResolveProxy(req.Proxy) + inviter := invite.NewWithProxy(owner.Token, resolvedProxy) if owner.AccountID != "" { // 直接使用数据库中存储的 account_id @@ -581,7 +586,7 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul // 入库单个成员的函数 doS2A := func(memberIdx int, memberEmail, memberPassword string) bool { - memberLogPrefix := fmt.Sprintf("%s [成员 %d]", logPrefix, memberIdx+1) + memberLogPrefix := fmt.Sprintf("%s [Member %d]", logPrefix, memberIdx+1) memberStartTime := time.Now() // 获取入库信号量 @@ -598,15 +603,21 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul 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") + case auth.StepInputEmail: + logger.Info(fmt.Sprintf("%s 提交邮箱: %s", memberLogPrefix, memberEmail), memberEmail, "team") + case auth.StepInputPassword: + logger.Info(fmt.Sprintf("%s 验证密码...", memberLogPrefix), memberEmail, "team") + case auth.StepSelectWorkspace: + logger.Info(fmt.Sprintf("%s 选择工作区: %s", memberLogPrefix, teamID), memberEmail, "team") + case auth.StepComplete: + logger.Info(fmt.Sprintf("%s 授权成功,获取到授权码", memberLogPrefix), memberEmail, "team") } } }) @@ -622,10 +633,9 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul // 根据配置选择授权方式 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") + proxyToUse := database.Instance.ResolveProxy(req.Proxy) + if proxyToUse != req.Proxy && proxyToUse != "" { + logger.Info(fmt.Sprintf("%s 使用解析代理: %s", memberLogPrefix, getProxyDisplay(proxyToUse)), memberEmail, "team") } code, err = auth.CompleteWithCodexAPI(memberEmail, memberPassword, teamID, s2aResp.Data.AuthURL, s2aResp.Data.SessionID, proxyToUse, authLogger) if proxyToUse != req.Proxy && proxyToUse != "" { @@ -681,7 +691,7 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul registerAndS2AMember := func(memberIdx int, email, password string) bool { name := register.GenerateName() birthdate := register.GenerateBirthdate() - memberLogPrefix := fmt.Sprintf("%s [成员 %d]", logPrefix, memberIdx+1) + memberLogPrefix := fmt.Sprintf("%s [Member %d]", logPrefix, memberIdx+1) regStartTime := time.Now() for attempt := 0; attempt < 2; attempt++ { // 最多尝试2次(首次+1次重试) @@ -764,7 +774,7 @@ 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") + logger.Info(fmt.Sprintf("%s [Member %d] Email: %s | Password: %s", logPrefix, idx+1, email, password), email, "team") registerAndS2AMember(idx, email, password) }(i) } diff --git a/backend/internal/api/team_reg_exec.go b/backend/internal/api/team_reg_exec.go index 7acd6ef..194c04f 100644 --- a/backend/internal/api/team_reg_exec.go +++ b/backend/internal/api/team_reg_exec.go @@ -297,8 +297,14 @@ func runTeamRegProcess(config TeamRegConfig) { } } + // 解析代理 + resolvedProxy := database.Instance.ResolveProxy(config.Proxy) + if config.Proxy != resolvedProxy && config.Proxy != "" && resolvedProxy != "" { + addTeamRegLog(fmt.Sprintf("[系统] 代理模式: %s -> %s", config.Proxy, resolvedProxy)) + } + addTeamRegLog(fmt.Sprintf("[系统] 配置: 数量=%d, 并发=%d, 代理=%s", - config.Count, config.Concurrency, config.Proxy)) + config.Count, config.Concurrency, resolvedProxy)) // 创建上下文用于取消 ctx, cancel := context.WithCancel(context.Background()) @@ -360,8 +366,8 @@ func runTeamRegProcess(config TeamRegConfig) { fmt.Fprintf(stdin, "%d\n", config.Concurrency) time.Sleep(200 * time.Millisecond) - addTeamRegLog(fmt.Sprintf("[输入] 代理地址: %s", config.Proxy)) - fmt.Fprintf(stdin, "%s\n", config.Proxy) + addTeamRegLog(fmt.Sprintf("[输入] 代理地址: %s", resolvedProxy)) + fmt.Fprintf(stdin, "%s\n", resolvedProxy) // 等待进程完成 err = cmd.Wait() diff --git a/backend/internal/api/upload.go b/backend/internal/api/upload.go index b855cee..8680346 100644 --- a/backend/internal/api/upload.go +++ b/backend/internal/api/upload.go @@ -379,7 +379,8 @@ func fetchAccountID(token string) (string, error) { proxy = cfg.GetProxy() } - tlsClient, err := client.New(proxy) + resolvedProxy := database.Instance.ResolveProxy(proxy) + tlsClient, err := client.New(resolvedProxy) if err != nil { return "", fmt.Errorf("创建 TLS 客户端失败: %v", err) } diff --git a/backend/internal/auth/codex_api.go b/backend/internal/auth/codex_api.go index d735e25..1e29823 100644 --- a/backend/internal/auth/codex_api.go +++ b/backend/internal/auth/codex_api.go @@ -477,11 +477,10 @@ func (c *CodexAPIAuth) ObtainAuthorizationCode() (string, error) { } } - c.logStep(StepInputPassword, "邮箱提交响应 pageType=%s, 包含password=%v", pageType, strings.Contains(string(body), "password")) + c.logStep(StepInputPassword, "验证密码...") if pageType == "password" || strings.Contains(string(body), "password") { // 5. 验证密码 - c.logStep(StepInputPassword, "验证密码...") if !c.callSentinelReq("authorize_continue__auto") { return "", fmt.Errorf("Sentinel 请求失败") } @@ -493,7 +492,6 @@ func (c *CodexAPIAuth) ObtainAuthorizationCode() (string, error) { verifyHeaders["OpenAI-Sentinel-Token"] = c.getSentinelHeader("password_verify") passwordPayload := map[string]string{ - "username": c.email, "password": c.password, } @@ -502,7 +500,6 @@ func (c *CodexAPIAuth) ObtainAuthorizationCode() (string, error) { c.logError(StepInputPassword, "密码验证失败: %d - %s", resp.StatusCode, string(body[:min(200, len(body))])) return "", fmt.Errorf("密码验证失败: %d", resp.StatusCode) } - c.logStep(StepInputPassword, "密码验证成功") // 解析密码验证响应 json.Unmarshal(body, &data) @@ -520,8 +517,6 @@ func (c *CodexAPIAuth) ObtainAuthorizationCode() (string, error) { c.logError(StepInputPassword, "账号需要邮箱验证,无法继续 Codex 授权流程") return "", fmt.Errorf("账号需要邮箱验证,请使用浏览器模式或等待账号状态更新") } - } else { - c.logStep(StepInputPassword, "跳过密码验证步骤 (服务器未要求)") } // 6. 选择工作区 diff --git a/backend/internal/database/sqlite.go b/backend/internal/database/sqlite.go index 0c9f1f9..0e25762 100644 --- a/backend/internal/database/sqlite.go +++ b/backend/internal/database/sqlite.go @@ -4,6 +4,8 @@ import ( "database/sql" "encoding/json" "fmt" + "strconv" + "strings" "time" _ "github.com/mattn/go-sqlite3" @@ -941,10 +943,44 @@ func (d *DB) DeleteCodexProxy(id int64) error { return err } -// ClearCodexProxies 清空所有代理 -func (d *DB) ClearCodexProxies() error { - _, err := d.db.Exec("DELETE FROM codex_auth_proxies") - return err +// GetCodexProxyByID 获取指定 ID 的代理地址 +func (d *DB) GetCodexProxyByID(id int64) (string, error) { + var proxyURL string + err := d.db.QueryRow("SELECT proxy_url FROM codex_auth_proxies WHERE id = ?", id).Scan(&proxyURL) + if err == sql.ErrNoRows { + return "", nil + } + return proxyURL, err +} + +// ResolveProxy 解析代理字符串(支持 pool:random, pool:id:N, 或直接 URL) +func (d *DB) ResolveProxy(proxyStr string) string { + if proxyStr == "" { + return "" + } + + // 兼容旧的 [RANDOM] 格式 + if proxyStr == "pool:random" || proxyStr == "[RANDOM]" { + p, _ := d.GetRandomCodexProxy() + return p + } + + // 处理 pool:id:N 格式 + if strings.HasPrefix(proxyStr, "pool:id:") { + idStr := strings.TrimPrefix(proxyStr, "pool:id:") + id, err := strconv.ParseInt(idStr, 10, 64) + if err == nil { + p, _ := d.GetCodexProxyByID(id) + return p + } + } + + // 否则视为字面 URL,如果没协议头加一个(保持现有行为) + if !strings.HasPrefix(proxyStr, "http://") && !strings.HasPrefix(proxyStr, "https://") && !strings.HasPrefix(proxyStr, "socks5://") { + return "http://" + proxyStr + } + + return proxyStr } // GetCodexProxyStats 获取代理统计 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3c363cb..e59f8e2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import { Routes, Route } from 'react-router-dom' import { ConfigProvider, RecordsProvider } from './context' import { Layout } from './components/layout' -import { Dashboard, Upload, Records, Accounts, Config, S2AConfig, EmailConfig, Monitor, Cleaner, TeamReg, CodexProxyConfig } from './pages' +import { Dashboard, Upload, Records, Accounts, Config, S2AConfig, EmailConfig, Monitor, Cleaner, TeamReg, CodexProxyConfig, S2AStats } from './pages' function App() { return ( @@ -20,6 +20,7 @@ function App() { } /> } /> } /> + } /> diff --git a/frontend/src/components/LiveLogViewer.tsx b/frontend/src/components/LiveLogViewer.tsx index 2004e15..df7841d 100644 --- a/frontend/src/components/LiveLogViewer.tsx +++ b/frontend/src/components/LiveLogViewer.tsx @@ -211,13 +211,13 @@ export default function LiveLogViewer({ > {log.timestamp} - {log.level} + {log.level === 'success' ? '✓' : log.level} [{log.module}] {log.email && ( {log.email} )} - {log.message} + {log.message} )) )} diff --git a/frontend/src/components/common/Select.tsx b/frontend/src/components/common/Select.tsx index 557b3c3..a978d22 100644 --- a/frontend/src/components/common/Select.tsx +++ b/frontend/src/components/common/Select.tsx @@ -1,5 +1,5 @@ -import { type SelectHTMLAttributes, forwardRef } from 'react' -import { ChevronDown } from 'lucide-react' +import { useState, useRef, useEffect, forwardRef, useImperativeHandle } from 'react' +import { ChevronDown, Check } from 'lucide-react' export interface SelectOption { value: string | number @@ -7,62 +7,125 @@ export interface SelectOption { disabled?: boolean } -export interface SelectProps extends Omit, 'children'> { +export interface SelectProps { label?: string error?: string hint?: string options: SelectOption[] placeholder?: string + value?: string | number + onChange?: (e: { target: { value: string | number } }) => void + className?: string + disabled?: boolean + id?: string } -const Select = forwardRef( - ({ className = '', label, error, hint, options, placeholder, id, ...props }, ref) => { +const Select = forwardRef( + ({ className = '', label, error, hint, options, placeholder, value, onChange, disabled, id }, ref) => { + const [isOpen, setIsOpen] = useState(false) + const containerRef = useRef(null) const selectId = id || label?.toLowerCase().replace(/\s+/g, '-') + // Expose some functionality if needed + useImperativeHandle(ref, () => ({ + focus: () => containerRef.current?.focus(), + })) + + // Handle click outside to close + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + const selectedOption = options.find((opt) => opt.value === value) + + const handleSelect = (option: SelectOption) => { + if (option.disabled || disabled) return + if (onChange) { + onChange({ target: { value: option.value } }) + } + setIsOpen(false) + } + return ( -
+
{label && ( )}
- - + + {selectedOption ? selectedOption.label : placeholder || '请选择...'} + + + + + + + {isOpen && ( +
+ {options.length === 0 ? ( +
+ 无可用选项 +
+ ) : ( + options.map((option) => ( +
handleSelect(option)} + className={` + relative cursor-pointer select-none py-2.5 pl-10 pr-4 transition-colors + ${option.value === value + ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 font-medium' + : 'text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700/50' + } + ${option.disabled ? 'opacity-50 cursor-not-allowed grayscale' : ''} + `} + > + {option.label} + {option.value === value && ( + + + + )} +
+ )) + )} +
+ )}
- {error &&

{error}

} + {error &&

{error}

} {hint && !error && ( -

{hint}

+

{hint}

)}
) diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index e11c3ae..aba2fbd 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -16,6 +16,7 @@ import { Trash2, UserPlus, Globe, + BarChart3, } from 'lucide-react' interface SidebarProps { @@ -36,6 +37,7 @@ const navItems: NavItem[] = [ { to: '/records', icon: History, label: '加号记录' }, { to: '/accounts', icon: Users, label: '号池账号' }, { to: '/monitor', icon: Activity, label: '号池监控' }, + { to: '/s2a-stats', icon: BarChart3, label: 'S2A 统计' }, { to: '/cleaner', icon: Trash2, label: '定期清理' }, { to: '/team-reg', icon: UserPlus, label: 'Team 注册' }, { @@ -46,7 +48,7 @@ const navItems: NavItem[] = [ { to: '/config', icon: Cog, label: '配置概览' }, { to: '/config/s2a', icon: Server, label: 'S2A 配置' }, { to: '/config/email', icon: Mail, label: '邮箱配置' }, - { to: '/config/codex-proxy', icon: Globe, label: 'CodexAuth代理池' }, + { to: '/config/codex-proxy', icon: Globe, label: '代理配置' }, ] }, ] @@ -133,7 +135,7 @@ export default function Sidebar({ isOpen, onClose }: SidebarProps) { {/* Sidebar */}