From 6d236419b95a8b8c764dcdf3fa461d5596a881b1 Mon Sep 17 00:00:00 2001 From: kyx236 Date: Fri, 30 Jan 2026 08:57:16 +0800 Subject: [PATCH] feat: implement batch team owner pooling functionality with dedicated upload, processing, logging, and results pages. --- backend/cmd/main.go | 107 +++- backend/internal/logger/logger.go | 35 +- frontend/src/App.tsx | 4 +- frontend/src/api/s2a.ts | 2 +- frontend/src/components/layout/Sidebar.tsx | 4 +- frontend/src/components/upload/LogStream.tsx | 12 +- frontend/src/components/upload/OwnerList.tsx | 12 +- frontend/src/pages/EmailConfig.tsx | 6 +- frontend/src/pages/TeamProcess.tsx | 483 ------------------ frontend/src/pages/Upload.tsx | 503 +++++++++++++------ frontend/src/pages/index.ts | 2 +- 11 files changed, 477 insertions(+), 693 deletions(-) delete mode 100644 frontend/src/pages/TeamProcess.tsx diff --git a/backend/cmd/main.go b/backend/cmd/main.go index f41cc7e..3535bc7 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -3,11 +3,13 @@ package main import ( "encoding/json" "fmt" + "io" "net" "net/http" "os" "path/filepath" "strings" + "time" "codex-pool/internal/api" "codex-pool/internal/config" @@ -19,23 +21,30 @@ import ( ) func main() { - fmt.Println("============================================================") - fmt.Println(" Codex Pool - HTTP API Server") - fmt.Println("============================================================") - fmt.Println() + // ANSI 颜色代码 + colorReset := "\033[0m" + colorCyan := "\033[36m" + colorGreen := "\033[32m" + colorYellow := "\033[33m" + colorGray := "\033[90m" + colorBold := "\033[1m" + + fmt.Printf("%s%s============================================================%s\n", colorBold, colorCyan, colorReset) + fmt.Printf("%s%s Codex Pool - HTTP API Server%s\n", colorBold, colorCyan, colorReset) + fmt.Printf("%s%s============================================================%s\n\n", colorBold, colorCyan, colorReset) // 确定数据目录 dataDir := "data" // 确保数据目录存在 if err := os.MkdirAll(dataDir, 0755); err != nil { - fmt.Printf("[警告] 创建数据目录失败: %v, 使用当前目录\n", err) + fmt.Printf("%s[WARN]%s 创建数据目录失败: %v, 使用当前目录\n", colorYellow, colorReset, err) dataDir = "." } // 初始化数据库 (先于配置) dbPath := filepath.Join(dataDir, "codex-pool.db") if err := database.Init(dbPath); err != nil { - fmt.Printf("[错误] 数据库初始化失败: %v\n", err) + fmt.Printf("%s[ERROR]%s 数据库初始化失败: %v\n", "\033[31m", colorReset, err) os.Exit(1) } @@ -46,25 +55,25 @@ func main() { // 初始化邮箱服务 if len(cfg.MailServices) > 0 { mail.Init(cfg.MailServices) - fmt.Printf("[邮箱] 已加载 %d 个邮箱服务\n", len(cfg.MailServices)) + fmt.Printf("%s[邮箱]%s 已加载 %d 个邮箱服务\n", colorGreen, colorReset, len(cfg.MailServices)) } - fmt.Printf("[配置] 数据库: %s\n", dbPath) - fmt.Printf("[配置] 端口: %d\n", cfg.Port) + fmt.Printf("%s[配置]%s 数据库: %s\n", colorGray, colorReset, dbPath) + fmt.Printf("%s[配置]%s 端口: %d\n", colorGray, colorReset, cfg.Port) if cfg.S2AApiBase != "" { - fmt.Printf("[配置] S2A API: %s\n", cfg.S2AApiBase) + fmt.Printf("%s[配置]%s S2A API: %s\n", colorGray, colorReset, cfg.S2AApiBase) } else { - fmt.Println("[配置] S2A API: 未配置 (请在Web界面配置)") + fmt.Printf("%s[配置]%s S2A API: %s未配置%s (请在Web界面配置)\n", colorGray, colorReset, colorYellow, colorReset) } if cfg.ProxyEnabled { - fmt.Printf("[配置] 代理: %s (已启用)\n", cfg.DefaultProxy) + fmt.Printf("%s[配置]%s 代理: %s (已启用)\n", colorGray, colorReset, cfg.DefaultProxy) } else { - fmt.Println("[配置] 代理: 已禁用") + fmt.Printf("%s[配置]%s 代理: 已禁用\n", colorGray, colorReset) } if web.IsEmbedded() { - fmt.Println("[前端] 嵌入模式") + fmt.Printf("%s[前端]%s 嵌入模式\n", colorGreen, colorReset) } else { - fmt.Println("[前端] 开发模式 (未嵌入)") + fmt.Printf("%s[前端]%s 开发模式 (未嵌入)\n", colorYellow, colorReset) } fmt.Println() @@ -85,6 +94,7 @@ func startServer(cfg *config.Config) { // S2A 代理 API mux.HandleFunc("/api/s2a/test", api.CORS(handleS2ATest)) + mux.HandleFunc("/api/s2a/proxy/", api.CORS(handleS2AProxy)) // 通配代理 // 邮箱服务 API mux.HandleFunc("/api/mail/services", api.CORS(handleMailServices)) @@ -123,16 +133,22 @@ func startServer(cfg *config.Config) { } addr := fmt.Sprintf(":%d", cfg.Port) + + // ANSI 颜色代码 + colorReset := "\033[0m" + colorGreen := "\033[32m" + colorCyan := "\033[36m" + // 显示访问地址 - fmt.Println("[服务] 启动于:") - fmt.Printf(" - 本地: http://localhost:%d\n", cfg.Port) + fmt.Printf("%s[服务]%s 启动于:\n", colorGreen, colorReset) + fmt.Printf(" - 本地: %shttp://localhost:%d%s\n", colorCyan, cfg.Port, colorReset) if ip := getOutboundIP(); ip != "" { - fmt.Printf(" - 外部: http://%s:%d\n", ip, cfg.Port) + fmt.Printf(" - 外部: %shttp://%s:%d%s\n", colorCyan, ip, cfg.Port, colorReset) } fmt.Println() if err := http.ListenAndServe(addr, mux); err != nil { - fmt.Printf("[错误] 服务启动失败: %v\n", err) + fmt.Printf("\033[31m[ERROR]\033[0m 服务启动失败: %v\n", err) os.Exit(1) } } @@ -241,6 +257,59 @@ func handleS2ATest(w http.ResponseWriter, r *http.Request) { }) } +// handleS2AProxy 代理 S2A API 请求 +func handleS2AProxy(w http.ResponseWriter, r *http.Request) { + if config.Global == nil || config.Global.S2AApiBase == "" || config.Global.S2AAdminKey == "" { + api.Error(w, http.StatusBadRequest, "S2A 配置未设置") + return + } + + // 提取路径: /api/s2a/proxy/xxx -> /api/v1/admin/xxx + path := strings.TrimPrefix(r.URL.Path, "/api/s2a/proxy") + targetURL := config.Global.S2AApiBase + "/api/v1/admin" + path + if r.URL.RawQuery != "" { + targetURL += "?" + r.URL.RawQuery + } + + // 创建代理请求 + proxyReq, err := http.NewRequest(r.Method, targetURL, r.Body) + if err != nil { + api.Error(w, http.StatusInternalServerError, "创建请求失败") + return + } + + // 复制请求头 + for key, values := range r.Header { + for _, value := range values { + proxyReq.Header.Add(key, value) + } + } + + // 设置认证头 + proxyReq.Header.Set("Authorization", "Bearer "+config.Global.S2AAdminKey) + proxyReq.Header.Set("Content-Type", "application/json") + + // 发送请求 + client := &http.Client{Timeout: 30 * 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() + + // 复制响应头 + for key, values := range resp.Header { + for _, value := range values { + w.Header().Add(key, value) + } + } + + // 复制响应状态和内容 + w.WriteHeader(resp.StatusCode) + io.Copy(w, resp.Body) +} + func handleMailServices(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": diff --git a/backend/internal/logger/logger.go b/backend/internal/logger/logger.go index c76fb18..ce141da 100644 --- a/backend/internal/logger/logger.go +++ b/backend/internal/logger/logger.go @@ -73,23 +73,44 @@ func log(level, message, email, module string) { broadcast(entry) - // 打印到控制台 + // 打印到控制台 (带时间戳和颜色) + timestamp := entry.Timestamp.Format("15:04:05") + + // ANSI 颜色代码 + colorReset := "\033[0m" + colorGray := "\033[90m" + colorGreen := "\033[32m" + colorRed := "\033[31m" + colorYellow := "\033[33m" + colorCyan := "\033[36m" + prefix := "" + color := "" switch level { case "info": - prefix = "[INFO]" + prefix = "INFO" + color = colorCyan case "success": - prefix = "[SUCCESS]" + prefix = "SUCCESS" + color = colorGreen case "error": - prefix = "[ERROR]" + prefix = "ERROR" + color = colorRed case "warning": - prefix = "[WARN]" + prefix = "WARN" + color = colorYellow } if email != "" { - fmt.Printf("%s [%s] %s - %s\n", prefix, module, email, message) + fmt.Printf("%s%s%s %s[%s]%s [%s] %s - %s\n", + colorGray, timestamp, colorReset, + color, prefix, colorReset, + module, email, message) } else { - fmt.Printf("%s [%s] %s\n", prefix, module, message) + fmt.Printf("%s%s%s %s[%s]%s [%s] %s\n", + colorGray, timestamp, colorReset, + color, prefix, colorReset, + module, message) } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f098efb..4385c9d 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, TeamProcess } from './pages' +import { Dashboard, Upload, Records, Accounts, Config, S2AConfig, EmailConfig, Monitor } from './pages' function App() { return ( @@ -14,7 +14,6 @@ function App() { } /> } /> } /> - } /> } /> } /> } /> @@ -26,3 +25,4 @@ function App() { } export default App + diff --git a/frontend/src/api/s2a.ts b/frontend/src/api/s2a.ts index 7a5555d..11ae1ad 100644 --- a/frontend/src/api/s2a.ts +++ b/frontend/src/api/s2a.ts @@ -12,7 +12,7 @@ import type { import type { AccountListParams } from '../types' // 使用后端代理 API 来避免 CORS 问题 -const PROXY_BASE = 'http://localhost:8088/api/s2a/proxy' +const PROXY_BASE = '/api/s2a/proxy' export class S2AClient { // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 2fa29fd..220ee45 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -12,7 +12,6 @@ import { Server, Mail, Cog, - UsersRound } from 'lucide-react' interface SidebarProps { @@ -29,8 +28,7 @@ interface NavItem { const navItems: NavItem[] = [ { to: '/', icon: LayoutDashboard, label: '仪表盘' }, - { to: '/upload', icon: Upload, label: '上传入库' }, - { to: '/team', icon: UsersRound, label: 'Team 批量处理' }, + { to: '/upload', icon: Upload, label: '批量入库' }, { to: '/records', icon: History, label: '加号记录' }, { to: '/accounts', icon: Users, label: '号池账号' }, { to: '/monitor', icon: Activity, label: '号池监控' }, diff --git a/frontend/src/components/upload/LogStream.tsx b/frontend/src/components/upload/LogStream.tsx index bcdc684..b3320b5 100644 --- a/frontend/src/components/upload/LogStream.tsx +++ b/frontend/src/components/upload/LogStream.tsx @@ -10,10 +10,6 @@ interface LogEntry { step?: string } -interface LogStreamProps { - apiBase?: string -} - const levelColors: Record = { info: 'text-blue-400', success: 'text-green-400', @@ -37,7 +33,7 @@ const stepLabels: Record = { database: '数据库', } -export default function LogStream({ apiBase = 'http://localhost:8088' }: LogStreamProps) { +export default function LogStream() { const [logs, setLogs] = useState([]) const [connected, setConnected] = useState(false) const [paused, setPaused] = useState(false) @@ -47,7 +43,7 @@ export default function LogStream({ apiBase = 'http://localhost:8088' }: LogStre useEffect(() => { if (paused) return - const eventSource = new EventSource(`${apiBase}/api/logs/stream`) + const eventSource = new EventSource('/api/logs/stream') eventSourceRef.current = eventSource eventSource.onopen = () => { @@ -71,7 +67,7 @@ export default function LogStream({ apiBase = 'http://localhost:8088' }: LogStre return () => { eventSource.close() } - }, [apiBase, paused]) + }, [paused]) useEffect(() => { if (logContainerRef.current) { @@ -81,7 +77,7 @@ export default function LogStream({ apiBase = 'http://localhost:8088' }: LogStre const handleClear = async () => { try { - await fetch(`${apiBase}/api/logs/clear`, { method: 'POST' }) + await fetch('/api/logs/clear', { method: 'POST' }) setLogs([]) } catch (e) { console.error('Failed to clear logs:', e) diff --git a/frontend/src/components/upload/OwnerList.tsx b/frontend/src/components/upload/OwnerList.tsx index dc77542..add8fda 100644 --- a/frontend/src/components/upload/OwnerList.tsx +++ b/frontend/src/components/upload/OwnerList.tsx @@ -10,10 +10,6 @@ interface TeamOwner { created_at: string } -interface OwnerListProps { - apiBase?: string -} - const statusColors: Record = { valid: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', registered: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', @@ -26,7 +22,7 @@ const statusLabels: Record = { pooled: '已入库', } -export default function OwnerList({ apiBase = 'http://localhost:8088' }: OwnerListProps) { +export default function OwnerList() { const [owners, setOwners] = useState([]) const [total, setTotal] = useState(0) const [loading, setLoading] = useState(false) @@ -45,7 +41,7 @@ export default function OwnerList({ apiBase = 'http://localhost:8088' }: OwnerLi params.set('status', filter) } - const res = await fetch(`${apiBase}/api/db/owners?${params}`) + const res = await fetch(`/api/db/owners?${params}`) const data = await res.json() if (data.code === 0) { setOwners(data.data.owners || []) @@ -65,7 +61,7 @@ export default function OwnerList({ apiBase = 'http://localhost:8088' }: OwnerLi const handleDelete = async (id: number) => { if (!confirm('确认删除此账号?')) return try { - await fetch(`${apiBase}/api/db/owners/${id}`, { method: 'DELETE' }) + await fetch(`/api/db/owners/${id}`, { method: 'DELETE' }) loadOwners() } catch (e) { console.error('Failed to delete:', e) @@ -75,7 +71,7 @@ export default function OwnerList({ apiBase = 'http://localhost:8088' }: OwnerLi const handleClearAll = async () => { if (!confirm('确认清空所有账号?此操作不可恢复!')) return try { - await fetch(`${apiBase}/api/db/owners/clear`, { method: 'POST' }) + await fetch('/api/db/owners/clear', { method: 'POST' }) loadOwners() } catch (e) { console.error('Failed to clear:', e) diff --git a/frontend/src/pages/EmailConfig.tsx b/frontend/src/pages/EmailConfig.tsx index 13286a7..43d57de 100644 --- a/frontend/src/pages/EmailConfig.tsx +++ b/frontend/src/pages/EmailConfig.tsx @@ -4,8 +4,6 @@ import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../comp import { useConfig } from '../hooks/useConfig' import type { MailServiceConfig } from '../types' -const API_BASE = 'http://localhost:8088' - export default function EmailConfig() { const { config, updateEmailConfig } = useConfig() @@ -27,7 +25,7 @@ export default function EmailConfig() { // 保存到后端 try { - const res = await fetch(`${API_BASE}/api/mail/services`, { + const res = await fetch('/api/mail/services', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ services }), @@ -74,7 +72,7 @@ export default function EmailConfig() { setTestResults(prev => ({ ...prev, [index]: { success: false, message: '测试中...' } })) try { - const res = await fetch(`${API_BASE}/api/mail/services/test`, { + const res = await fetch('/api/mail/services/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/frontend/src/pages/TeamProcess.tsx b/frontend/src/pages/TeamProcess.tsx deleted file mode 100644 index bd191be..0000000 --- a/frontend/src/pages/TeamProcess.tsx +++ /dev/null @@ -1,483 +0,0 @@ -import { useState, useEffect, useCallback } from 'react' -import { - Users, - Play, - Square, - RefreshCw, - Settings, - CheckCircle, - Clock, - Upload, - Loader2, - AlertTriangle, -} from 'lucide-react' -import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common' -import { useConfig } from '../hooks/useConfig' - -interface Owner { - email: string - password: string - token: string -} - -interface TeamResult { - team_index: number - owner_email: string - team_id: string - registered: number - added_to_s2a: number - member_emails: string[] - errors: string[] - duration_ms: number -} - -interface ProcessStatus { - running: boolean - started_at: string - total_teams: number - completed: number - results: TeamResult[] - elapsed_ms: number -} - -export default function TeamProcess() { - const { config } = useConfig() - const [owners, setOwners] = useState([]) - const [status, setStatus] = useState(null) - const [loading, setLoading] = useState(false) - const [polling, setPolling] = useState(false) - - // 配置 - const [membersPerTeam, setMembersPerTeam] = useState(4) - const [concurrentTeams, setConcurrentTeams] = useState(2) - const [browserType, setBrowserType] = useState<'chromedp' | 'rod'>('chromedp') - const [headless, setHeadless] = useState(true) - const [proxy, setProxy] = useState('') - - const backendUrl = config.s2a.apiBase.replace(/\/api.*$/, '').replace(/:8080/, ':8088') - - // 获取状态 - const fetchStatus = useCallback(async () => { - try { - const res = await fetch(`${backendUrl}/api/team/status`) - if (res.ok) { - const data = await res.json() - if (data.code === 0) { - setStatus(data.data) - if (!data.data.running) { - setPolling(false) - } - } - } - } catch (e) { - console.error('获取状态失败:', e) - } - }, [backendUrl]) - - // 轮询状态 - useEffect(() => { - if (polling) { - const interval = setInterval(fetchStatus, 2000) - return () => clearInterval(interval) - } - }, [polling, fetchStatus]) - - // 初始化 - useEffect(() => { - fetchStatus() - }, [fetchStatus]) - - // 上传账号文件 - const handleFileUpload = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return - - try { - const text = await file.text() - const data = JSON.parse(text) - const parsed = Array.isArray(data) ? data : [data] - - const validOwners = parsed.filter((a: Record) => - (a.email || a.account) && a.password && (a.token || a.access_token) - ).map((a: Record) => ({ - email: (a.email || a.account) as string, - password: a.password as string, - token: (a.token || a.access_token) as string, - })) - - setOwners(validOwners) - setConcurrentTeams(Math.min(validOwners.length, 2)) - } catch (err) { - alert('文件解析失败,请确保是有效的 JSON 格式') - } - } - - // 启动处理 - const handleStart = async () => { - if (owners.length === 0) { - alert('请先上传账号文件') - return - } - - setLoading(true) - try { - const res = await fetch(`${backendUrl}/api/team/process`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - owners: owners.slice(0, concurrentTeams), - members_per_team: membersPerTeam, - concurrent_teams: concurrentTeams, - browser_type: browserType, - headless, - proxy, - }), - }) - - if (res.ok) { - setPolling(true) - fetchStatus() - } else { - const data = await res.json() - alert(data.message || '启动失败') - } - } catch (e) { - console.error('启动失败:', e) - alert('启动失败') - } - setLoading(false) - } - - // 停止处理 - const handleStop = async () => { - try { - await fetch(`${backendUrl}/api/team/stop`, { method: 'POST' }) - setPolling(false) - fetchStatus() - } catch (e) { - console.error('停止失败:', e) - } - } - - const isRunning = status?.running - - // 计算统计 - const totalRegistered = status?.results.reduce((sum, r) => sum + r.registered, 0) || 0 - const totalS2A = status?.results.reduce((sum, r) => sum + r.added_to_s2a, 0) || 0 - const expectedTotal = (status?.total_teams || 0) * membersPerTeam - - return ( -
- {/* Header */} -
-
-

- Team 批量处理 -

-

- 多 Team 并发注册成员并入库 S2A -

-
-
- -
-
- - {/* 状态概览 */} -
- - -
-
-

运行状态

-

- {isRunning ? '运行中' : '空闲'} -

-
-
- {isRunning ? ( - - ) : ( - - )} -
-
-
-
- - - -
-
-

进度

-

- {status?.completed || 0} / {status?.total_teams || '-'} -

-
-
- -
-
-
-
- - - -
-
-

已注册

-

- {totalRegistered} / {expectedTotal || '-'} -

-
-
- -
-
-
-
- - - -
-
-

已入库

-

{totalS2A}

-
-
- -
-
-
-
-
- -
- {/* 配置面板 */} - - - - - 处理配置 - - - - {/* 账号文件上传 */} -
- -
- -
-
- - setMembersPerTeam(Number(e.target.value))} - disabled={isRunning} - /> - - setConcurrentTeams(Number(e.target.value))} - disabled={isRunning} - hint={`最多 ${owners.length} 个`} - /> - -
- -
- - -
-
- -
- setHeadless(e.target.checked)} - disabled={isRunning} - className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500" - /> - -
- - setProxy(e.target.value)} - disabled={isRunning} - /> - -
- {isRunning ? ( - - ) : ( - - )} -
-
-
- - {/* 结果列表 */} - - - - - 处理结果 - - {status && status.elapsed_ms > 0 && ( - - 耗时: {(status.elapsed_ms / 1000).toFixed(1)}s - - )} - - - {status?.results && status.results.length > 0 ? ( -
- {status.results.map((result) => ( -
-
-
- - Team {result.team_index} - - - {result.owner_email} - -
-
- - - 注册: {result.registered} - - - - 入库: {result.added_to_s2a} - -
-
- - {result.member_emails.length > 0 && ( -
-

成员邮箱:

-
- {result.member_emails.map((email, idx) => ( - - {email} - - ))} -
-
- )} - - {result.errors.length > 0 && ( -
-

- - 错误: -

-
- {result.errors.map((err, idx) => ( -

- • {err} -

- ))} -
-
- )} - -
- 耗时: {(result.duration_ms / 1000).toFixed(1)}s -
-
- ))} -
- ) : ( -
- -

暂无处理结果

-

上传账号文件并点击开始处理

-
- )} -
-
-
-
- ) -} diff --git a/frontend/src/pages/Upload.tsx b/frontend/src/pages/Upload.tsx index 28981ec..5c013d1 100644 --- a/frontend/src/pages/Upload.tsx +++ b/frontend/src/pages/Upload.tsx @@ -1,20 +1,24 @@ import { useState, useCallback, useEffect } from 'react' import { Link } from 'react-router-dom' -import { Upload as UploadIcon, Settings, Play, Loader2, List, Activity } from 'lucide-react' +import { + Upload as UploadIcon, + Settings, + Play, + Square, + Loader2, + List, + Activity, + Users, + CheckCircle, + AlertTriangle, + RefreshCw, +} from 'lucide-react' import { FileDropzone } from '../components/upload' import LogStream from '../components/upload/LogStream' import OwnerList from '../components/upload/OwnerList' -import { Card, CardContent, Button, Tabs } from '../components/common' +import { Card, CardHeader, CardTitle, CardContent, Button, Tabs, Input } from '../components/common' import { useConfig } from '../hooks/useConfig' -interface PoolingConfig { - owner_concurrency: number // 母号并发数 - include_owner: boolean // 是否入库母号 - serial_authorize: boolean - browser_type: 'rod' | 'cdp' - proxy: string -} - interface OwnerStats { total: number valid: number @@ -22,31 +26,51 @@ interface OwnerStats { pooled: number } -type TabType = 'upload' | 'owners' | 'logs' +interface TeamResult { + team_index: number + owner_email: string + team_id: string + registered: number + added_to_s2a: number + member_emails: string[] + errors: string[] + duration_ms: number +} + +interface ProcessStatus { + running: boolean + started_at: string + total_teams: number + completed: number + results: TeamResult[] + elapsed_ms: number +} + +type TabType = 'upload' | 'owners' | 'logs' | 'results' export default function Upload() { const { config, isConnected } = useConfig() - const apiBase = 'http://localhost:8088' const [activeTab, setActiveTab] = useState('upload') const [fileError, setFileError] = useState(null) const [validating, setValidating] = useState(false) - const [pooling, setPooling] = useState(false) const [stats, setStats] = useState(null) - const [poolingConfig, setPoolingConfig] = useState({ - owner_concurrency: 1, - include_owner: true, - serial_authorize: true, - browser_type: 'rod', - proxy: '', - }) + const [status, setStatus] = useState(null) + const [polling, setPolling] = useState(false) + const [loading, setLoading] = useState(false) + + // 配置 + const [membersPerTeam, setMembersPerTeam] = useState(4) + const [concurrentTeams, setConcurrentTeams] = useState(2) + const [browserType, setBrowserType] = useState<'chromedp' | 'rod'>('chromedp') + const [proxy, setProxy] = useState('') const hasConfig = config.s2a.apiBase && config.s2a.adminKey // Load stats const loadStats = useCallback(async () => { try { - const res = await fetch(`${apiBase}/api/db/owners/stats`) + const res = await fetch('/api/db/owners/stats') const data = await res.json() if (data.code === 0) { setStats(data.data) @@ -54,11 +78,39 @@ export default function Upload() { } catch (e) { console.error('Failed to load stats:', e) } - }, [apiBase]) + }, []) + + // 获取状态 + const fetchStatus = useCallback(async () => { + try { + const res = await fetch('/api/team/status') + if (res.ok) { + const data = await res.json() + if (data.code === 0) { + setStatus(data.data) + if (!data.data.running) { + setPolling(false) + loadStats() // 刷新统计 + } + } + } + } catch (e) { + console.error('获取状态失败:', e) + } + }, [loadStats]) + + // 轮询状态 + useEffect(() => { + if (polling) { + const interval = setInterval(fetchStatus, 2000) + return () => clearInterval(interval) + } + }, [polling, fetchStatus]) useEffect(() => { loadStats() - }, [loadStats]) + fetchStatus() + }, [loadStats, fetchStatus]) // Upload and validate const handleFileSelect = useCallback( @@ -69,11 +121,9 @@ export default function Upload() { try { const text = await file.text() const json = JSON.parse(text) - - // Support both array and single account const accounts = Array.isArray(json) ? json : [json] - const res = await fetch(`${apiBase}/api/upload/validate`, { + const res = await fetch('/api/upload/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accounts }), @@ -91,50 +141,68 @@ export default function Upload() { setValidating(false) } }, - [apiBase, loadStats] + [loadStats] ) - // Start pooling - const handleStartPooling = useCallback(async () => { - setPooling(true) - setActiveTab('logs') // Switch to logs tab + // 开始处理 + const handleStart = useCallback(async () => { + if (!stats?.valid || stats.valid === 0) { + alert('请先上传有效的账号文件') + return + } + + setLoading(true) + setActiveTab('logs') // 切换到日志 + try { - const res = await fetch(`${apiBase}/api/pooling/start`, { + const res = await fetch('/api/team/process', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(poolingConfig), + body: JSON.stringify({ + members_per_team: membersPerTeam, + concurrent_teams: Math.min(concurrentTeams, stats?.valid || 1), + browser_type: browserType, + headless: true, // 始终使用无头模式 + proxy, + }), }) - const data = await res.json() - if (data.code !== 0) { + if (res.ok) { + setPolling(true) + fetchStatus() + } else { + const data = await res.json() alert(data.message || '启动失败') } } catch (e) { - console.error('Failed to start pooling:', e) - } finally { - // Check status periodically - const checkStatus = async () => { - try { - const res = await fetch(`${apiBase}/api/pooling/status`) - const data = await res.json() - if (data.code === 0 && !data.data.running) { - setPooling(false) - loadStats() - } else { - setTimeout(checkStatus, 2000) - } - } catch { - setPooling(false) - } - } - setTimeout(checkStatus, 2000) + console.error('启动失败:', e) + alert('启动失败') } - }, [apiBase, poolingConfig, loadStats]) + setLoading(false) + }, [stats, membersPerTeam, concurrentTeams, browserType, proxy, fetchStatus]) + + // 停止处理 + const handleStop = useCallback(async () => { + try { + await fetch('/api/team/stop', { method: 'POST' }) + setPolling(false) + fetchStatus() + } catch (e) { + console.error('停止失败:', e) + } + }, [fetchStatus]) + + const isRunning = status?.running || polling + + // 计算统计 + const totalRegistered = status?.results?.reduce((sum, r) => sum + r.registered, 0) || 0 + const totalS2A = status?.results?.reduce((sum, r) => sum + r.added_to_s2a, 0) || 0 const tabs = [ { id: 'upload', label: '上传', icon: UploadIcon }, { id: 'owners', label: '母号列表', icon: List, count: stats?.total }, { id: 'logs', label: '日志', icon: Activity }, + { id: 'results', label: '处理结果', icon: CheckCircle, count: status?.results?.length }, ] return ( @@ -144,12 +212,22 @@ export default function Upload() {

- 上传与入库 + 批量入库

- 上传 Team Owner JSON,验证并入库到 S2A + 上传 Team Owner JSON,批量注册并入库到 S2A

+
+ +
{/* Connection warning */} @@ -171,6 +249,37 @@ export default function Upload() { )} + {/* Status Overview - Compact */} +
+
+
+ {isRunning ? ( + + ) : ( +
+ )} +
+
状态
+
+ {isRunning ? '运行中' : '空闲'} +
+
+
+
+
+
待处理
+
{stats?.valid || 0}
+
+
+
已注册
+
{totalRegistered}
+
+
+
已入库
+
{totalS2A}
+
+
+ {/* Tabs */} {validating && ( @@ -202,108 +311,95 @@ export default function Upload() { - {/* Stats - Compact inline */} - {stats && ( -
-
-
{stats.total}
-
总数
-
-
-
{stats.valid}
-
有效
-
-
-
{stats.registered}
-
已注册
-
-
-
{stats.pooled}
-
已入库
-
-
- )} - - {/* Pooling Config - Compact */} + {/* Config */} - -
- - 入库设置 -
- + + + + 处理配置 + + +
-
- - setPoolingConfig({ ...poolingConfig, owner_concurrency: Number(e.target.value) })} - className="w-full h-9 px-3 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800" - /> -
-
- - -
-
- -
- - -
- -
- - setPoolingConfig({ ...poolingConfig, proxy: e.target.value })} - className="w-full h-9 px-3 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800" + setMembersPerTeam(Number(e.target.value))} + disabled={isRunning} + /> + setConcurrentTeams(Number(e.target.value))} + disabled={isRunning} + hint={`最多 ${stats?.valid || 0} 个`} />
- +
+ +
+ + +
+
+ + setProxy(e.target.value)} + disabled={isRunning} + /> + +
+ {isRunning ? ( + + ) : ( + + )} +
@@ -316,7 +412,7 @@ export default function Upload() { 实时日志
- +
@@ -325,13 +421,106 @@ export default function Upload() { {activeTab === 'owners' && (
- +
)} {activeTab === 'logs' && (
- + +
+ )} + + {activeTab === 'results' && ( +
+ + + + + 处理结果 + + {status && status.elapsed_ms > 0 && ( + + 耗时: {(status.elapsed_ms / 1000).toFixed(1)}s + + )} + + + {status?.results && status.results.length > 0 ? ( +
+ {status.results.map((result) => ( +
+
+
+ + Team {result.team_index} + + + {result.owner_email} + +
+
+ + + 注册: {result.registered} + + + + 入库: {result.added_to_s2a} + +
+
+ + {result.member_emails.length > 0 && ( +
+

成员邮箱:

+
+ {result.member_emails.map((email, idx) => ( + + {email} + + ))} +
+
+ )} + + {result.errors.length > 0 && ( +
+

+ + 错误: +

+
+ {result.errors.map((err, idx) => ( +

+ • {err} +

+ ))} +
+
+ )} + +
+ 耗时: {(result.duration_ms / 1000).toFixed(1)}s +
+
+ ))} +
+ ) : ( +
+ +

暂无处理结果

+

上传账号文件并点击开始处理

+
+ )} +
+
)} diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index 3f60a8b..39f4c2c 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -6,4 +6,4 @@ export { default as Config } from './Config' export { default as S2AConfig } from './S2AConfig' export { default as EmailConfig } from './EmailConfig' export { default as Monitor } from './Monitor' -export { default as TeamProcess } from './TeamProcess' +