feat: implement batch team owner pooling functionality with dedicated upload, processing, logging, and results pages.
This commit is contained in:
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
<Route path="records" element={<Records />} />
|
||||
<Route path="accounts" element={<Accounts />} />
|
||||
<Route path="monitor" element={<Monitor />} />
|
||||
<Route path="team" element={<TeamProcess />} />
|
||||
<Route path="config" element={<Config />} />
|
||||
<Route path="config/s2a" element={<S2AConfig />} />
|
||||
<Route path="config/email" element={<EmailConfig />} />
|
||||
@@ -26,3 +25,4 @@ function App() {
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '号池监控' },
|
||||
|
||||
@@ -10,10 +10,6 @@ interface LogEntry {
|
||||
step?: string
|
||||
}
|
||||
|
||||
interface LogStreamProps {
|
||||
apiBase?: string
|
||||
}
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
info: 'text-blue-400',
|
||||
success: 'text-green-400',
|
||||
@@ -37,7 +33,7 @@ const stepLabels: Record<string, string> = {
|
||||
database: '数据库',
|
||||
}
|
||||
|
||||
export default function LogStream({ apiBase = 'http://localhost:8088' }: LogStreamProps) {
|
||||
export default function LogStream() {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||
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)
|
||||
|
||||
@@ -10,10 +10,6 @@ interface TeamOwner {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface OwnerListProps {
|
||||
apiBase?: string
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
pooled: '已入库',
|
||||
}
|
||||
|
||||
export default function OwnerList({ apiBase = 'http://localhost:8088' }: OwnerListProps) {
|
||||
export default function OwnerList() {
|
||||
const [owners, setOwners] = useState<TeamOwner[]>([])
|
||||
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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<Owner[]>([])
|
||||
const [status, setStatus] = useState<ProcessStatus | null>(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<HTMLInputElement>) => {
|
||||
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<string, unknown>) =>
|
||||
(a.email || a.account) && a.password && (a.token || a.access_token)
|
||||
).map((a: Record<string, unknown>) => ({
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
Team 批量处理
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
多 Team 并发注册成员并入库 S2A
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchStatus}
|
||||
icon={<RefreshCw className={`h-4 w-4 ${polling ? 'animate-spin' : ''}`} />}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 状态概览 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="stat-card card-hover">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">运行状态</p>
|
||||
<p className={`text-lg font-bold ${isRunning ? 'text-green-500' : 'text-slate-500'}`}>
|
||||
{isRunning ? '运行中' : '空闲'}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`h-12 w-12 rounded-xl flex items-center justify-center ${isRunning ? 'bg-green-100 dark:bg-green-900/30' : 'bg-slate-100 dark:bg-slate-800'
|
||||
}`}>
|
||||
{isRunning ? (
|
||||
<Loader2 className="h-6 w-6 text-green-500 animate-spin" />
|
||||
) : (
|
||||
<Clock className="h-6 w-6 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="stat-card card-hover">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">进度</p>
|
||||
<p className="text-lg font-bold text-blue-500">
|
||||
{status?.completed || 0} / {status?.total_teams || '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 rounded-xl bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<Users className="h-6 w-6 text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="stat-card card-hover">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">已注册</p>
|
||||
<p className="text-lg font-bold text-green-500">
|
||||
{totalRegistered} / {expectedTotal || '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 rounded-xl bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||
<CheckCircle className="h-6 w-6 text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="stat-card card-hover">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">已入库</p>
|
||||
<p className="text-lg font-bold text-purple-500">{totalS2A}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 rounded-xl bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
|
||||
<Settings className="h-6 w-6 text-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 配置面板 */}
|
||||
<Card className="glass-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5 text-blue-500" />
|
||||
处理配置
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 账号文件上传 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Owner 账号文件
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex-1 flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-lg cursor-pointer hover:border-blue-500 transition-colors">
|
||||
<Upload className="h-5 w-5 text-slate-400" />
|
||||
<span className="text-sm text-slate-500">
|
||||
{owners.length > 0 ? `已加载 ${owners.length} 个账号` : '选择 JSON 文件'}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
disabled={isRunning}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="每个 Team 成员数"
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={membersPerTeam}
|
||||
onChange={(e) => setMembersPerTeam(Number(e.target.value))}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="并发 Team 数"
|
||||
type="number"
|
||||
min={1}
|
||||
max={Math.max(1, owners.length)}
|
||||
value={concurrentTeams}
|
||||
onChange={(e) => setConcurrentTeams(Number(e.target.value))}
|
||||
disabled={isRunning}
|
||||
hint={`最多 ${owners.length} 个`}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
浏览器自动化
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setBrowserType('chromedp')}
|
||||
disabled={isRunning}
|
||||
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${browserType === 'chromedp'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Chromedp (推荐)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBrowserType('rod')}
|
||||
disabled={isRunning}
|
||||
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${browserType === 'rod'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Rod
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="headless"
|
||||
checked={headless}
|
||||
onChange={(e) => setHeadless(e.target.checked)}
|
||||
disabled={isRunning}
|
||||
className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="headless" className="text-sm text-slate-700 dark:text-slate-300">
|
||||
无头模式 (推荐)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="代理地址"
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
value={proxy}
|
||||
onChange={(e) => setProxy(e.target.value)}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
{isRunning ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleStop}
|
||||
className="flex-1"
|
||||
icon={<Square className="h-4 w-4" />}
|
||||
>
|
||||
停止
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleStart}
|
||||
loading={loading}
|
||||
disabled={owners.length === 0}
|
||||
className="flex-1"
|
||||
icon={<Play className="h-4 w-4" />}
|
||||
>
|
||||
开始处理
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 结果列表 */}
|
||||
<Card className="glass-card lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-green-500" />
|
||||
处理结果
|
||||
</CardTitle>
|
||||
{status && status.elapsed_ms > 0 && (
|
||||
<span className="text-sm text-slate-500">
|
||||
耗时: {(status.elapsed_ms / 1000).toFixed(1)}s
|
||||
</span>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{status?.results && status.results.length > 0 ? (
|
||||
<div className="space-y-4 max-h-[500px] overflow-y-auto">
|
||||
{status.results.map((result) => (
|
||||
<div
|
||||
key={result.team_index}
|
||||
className="p-4 rounded-lg bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-1 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-600">
|
||||
Team {result.team_index}
|
||||
</span>
|
||||
<span className="text-sm text-slate-500 truncate max-w-[200px]">
|
||||
{result.owner_email}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="flex items-center gap-1 text-green-600">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
注册: {result.registered}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-purple-600">
|
||||
<Settings className="h-4 w-4" />
|
||||
入库: {result.added_to_s2a}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.member_emails.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<p className="text-xs text-slate-500 mb-1">成员邮箱:</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{result.member_emails.map((email, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="px-2 py-0.5 rounded text-xs bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400"
|
||||
>
|
||||
{email}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.errors.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-red-500 mb-1 flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
错误:
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{result.errors.map((err, idx) => (
|
||||
<p key={idx} className="text-xs text-red-400 pl-4">
|
||||
• {err}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 text-xs text-slate-400">
|
||||
耗时: {(result.duration_ms / 1000).toFixed(1)}s
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<Users className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||
<p>暂无处理结果</p>
|
||||
<p className="text-sm mt-1">上传账号文件并点击开始处理</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<TabType>('upload')
|
||||
const [fileError, setFileError] = useState<string | null>(null)
|
||||
const [validating, setValidating] = useState(false)
|
||||
const [pooling, setPooling] = useState(false)
|
||||
const [stats, setStats] = useState<OwnerStats | null>(null)
|
||||
const [poolingConfig, setPoolingConfig] = useState<PoolingConfig>({
|
||||
owner_concurrency: 1,
|
||||
include_owner: true,
|
||||
serial_authorize: true,
|
||||
browser_type: 'rod',
|
||||
proxy: '',
|
||||
})
|
||||
const [status, setStatus] = useState<ProcessStatus | null>(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() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
||||
<UploadIcon className="h-7 w-7 text-blue-500" />
|
||||
上传与入库
|
||||
批量入库
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
上传 Team Owner JSON,验证并入库到 S2A
|
||||
上传 Team Owner JSON,批量注册并入库到 S2A
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { loadStats(); fetchStatus(); }}
|
||||
icon={<RefreshCw className={`h-4 w-4 ${polling ? 'animate-spin' : ''}`} />}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection warning */}
|
||||
@@ -171,6 +249,37 @@ export default function Upload() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Overview - Compact */}
|
||||
<div className="shrink-0 grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className={`p-3 rounded-lg border ${isRunning ? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800' : 'bg-slate-50 dark:bg-slate-800/50 border-slate-200 dark:border-slate-700'}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
{isRunning ? (
|
||||
<Loader2 className="h-5 w-5 text-green-500 animate-spin" />
|
||||
) : (
|
||||
<div className="h-5 w-5 rounded-full bg-slate-300 dark:bg-slate-600" />
|
||||
)}
|
||||
<div>
|
||||
<div className="text-xs text-slate-500">状态</div>
|
||||
<div className={`font-bold ${isRunning ? 'text-green-600' : 'text-slate-600 dark:text-slate-300'}`}>
|
||||
{isRunning ? '运行中' : '空闲'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50">
|
||||
<div className="text-xs text-blue-600/70 dark:text-blue-400/70">待处理</div>
|
||||
<div className="font-bold text-blue-600 dark:text-blue-400">{stats?.valid || 0}</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800/50">
|
||||
<div className="text-xs text-orange-600/70 dark:text-orange-400/70">已注册</div>
|
||||
<div className="font-bold text-orange-600 dark:text-orange-400">{totalRegistered}</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800/50">
|
||||
<div className="text-xs text-green-600/70 dark:text-green-400/70">已入库</div>
|
||||
<div className="font-bold text-green-600 dark:text-green-400">{totalS2A}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
@@ -190,7 +299,7 @@ export default function Upload() {
|
||||
<CardContent className="p-4">
|
||||
<FileDropzone
|
||||
onFileSelect={handleFileSelect}
|
||||
disabled={validating}
|
||||
disabled={validating || isRunning}
|
||||
error={fileError}
|
||||
/>
|
||||
{validating && (
|
||||
@@ -202,108 +311,95 @@ export default function Upload() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Stats - Compact inline */}
|
||||
{stats && (
|
||||
<div className="shrink-0 grid grid-cols-4 gap-2">
|
||||
<div className="text-center p-2 bg-slate-50 dark:bg-slate-800/50 rounded-lg border border-slate-100 dark:border-slate-700">
|
||||
<div className="text-lg font-bold text-slate-700 dark:text-slate-200">{stats.total}</div>
|
||||
<div className="text-xs text-slate-500">总数</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-100 dark:border-blue-800/50">
|
||||
<div className="text-lg font-bold text-blue-600 dark:text-blue-400">{stats.valid}</div>
|
||||
<div className="text-xs text-blue-600/70 dark:text-blue-400/70">有效</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-orange-50 dark:bg-orange-900/20 rounded-lg border border-orange-100 dark:border-orange-800/50">
|
||||
<div className="text-lg font-bold text-orange-600 dark:text-orange-400">{stats.registered}</div>
|
||||
<div className="text-xs text-orange-600/70 dark:text-orange-400/70">已注册</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-100 dark:border-green-800/50">
|
||||
<div className="text-lg font-bold text-green-600 dark:text-green-400">{stats.pooled}</div>
|
||||
<div className="text-xs text-green-600/70 dark:text-green-400/70">已入库</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pooling Config - Compact */}
|
||||
{/* Config */}
|
||||
<Card hoverable className="shrink-0">
|
||||
<CardContent className="p-4 space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-slate-900 dark:text-slate-100">
|
||||
<Play className="h-4 w-4 text-green-500" />
|
||||
入库设置
|
||||
</div>
|
||||
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Settings className="h-4 w-4 text-blue-500" />
|
||||
处理配置
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
|
||||
母号并发
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={poolingConfig.owner_concurrency}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
|
||||
浏览器
|
||||
</label>
|
||||
<select
|
||||
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"
|
||||
value={poolingConfig.browser_type}
|
||||
onChange={(e) => setPoolingConfig({ ...poolingConfig, browser_type: e.target.value as 'rod' | 'cdp' })}
|
||||
>
|
||||
<option value="rod">Rod (反检测)</option>
|
||||
<option value="cdp">CDP</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={poolingConfig.include_owner}
|
||||
onChange={(e) => setPoolingConfig({ ...poolingConfig, include_owner: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-blue-600"
|
||||
/>
|
||||
<span className="text-slate-700 dark:text-slate-300">入库母号</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={poolingConfig.serial_authorize}
|
||||
onChange={(e) => setPoolingConfig({ ...poolingConfig, serial_authorize: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-blue-600"
|
||||
/>
|
||||
<span className="text-slate-700 dark:text-slate-300">串行授权</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
|
||||
代理(可选)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
value={poolingConfig.proxy}
|
||||
onChange={(e) => 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"
|
||||
<Input
|
||||
label="每个 Team 成员数"
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={membersPerTeam}
|
||||
onChange={(e) => setMembersPerTeam(Number(e.target.value))}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
<Input
|
||||
label="并发 Team 数"
|
||||
type="number"
|
||||
min={1}
|
||||
max={Math.max(1, stats?.valid || 1)}
|
||||
value={concurrentTeams}
|
||||
onChange={(e) => setConcurrentTeams(Number(e.target.value))}
|
||||
disabled={isRunning}
|
||||
hint={`最多 ${stats?.valid || 0} 个`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleStartPooling}
|
||||
disabled={!isConnected || pooling || !stats?.valid}
|
||||
loading={pooling}
|
||||
icon={pooling ? undefined : <Play className="h-4 w-4" />}
|
||||
className="w-full"
|
||||
>
|
||||
{pooling ? '正在入库...' : '开始入库'}
|
||||
</Button>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
浏览器引擎
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setBrowserType('chromedp')}
|
||||
disabled={isRunning}
|
||||
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${browserType === 'chromedp'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Chromedp (推荐)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBrowserType('rod')}
|
||||
disabled={isRunning}
|
||||
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${browserType === 'rod'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Rod
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="代理地址(可选)"
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
value={proxy}
|
||||
onChange={(e) => setProxy(e.target.value)}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
{isRunning ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleStop}
|
||||
className="flex-1"
|
||||
icon={<Square className="h-4 w-4" />}
|
||||
>
|
||||
停止处理
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleStart}
|
||||
loading={loading}
|
||||
disabled={!isConnected || !stats?.valid}
|
||||
className="flex-1"
|
||||
icon={<Play className="h-4 w-4" />}
|
||||
>
|
||||
开始处理
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -316,7 +412,7 @@ export default function Upload() {
|
||||
<span className="text-sm font-medium text-slate-300">实时日志</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<LogStream apiBase={apiBase} />
|
||||
<LogStream />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -325,13 +421,106 @@ export default function Upload() {
|
||||
|
||||
{activeTab === 'owners' && (
|
||||
<div className="h-full overflow-hidden rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shadow-sm">
|
||||
<OwnerList apiBase={apiBase} />
|
||||
<OwnerList />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'logs' && (
|
||||
<div className="h-full overflow-hidden rounded-xl border border-slate-200 dark:border-slate-700 bg-slate-900 shadow-sm">
|
||||
<LogStream apiBase={apiBase} />
|
||||
<LogStream />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'results' && (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-green-500" />
|
||||
处理结果
|
||||
</CardTitle>
|
||||
{status && status.elapsed_ms > 0 && (
|
||||
<span className="text-sm text-slate-500">
|
||||
耗时: {(status.elapsed_ms / 1000).toFixed(1)}s
|
||||
</span>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{status?.results && status.results.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{status.results.map((result) => (
|
||||
<div
|
||||
key={result.team_index}
|
||||
className="p-4 rounded-lg bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-1 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-600">
|
||||
Team {result.team_index}
|
||||
</span>
|
||||
<span className="text-sm text-slate-500 truncate max-w-[200px]">
|
||||
{result.owner_email}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="flex items-center gap-1 text-green-600">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
注册: {result.registered}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-purple-600">
|
||||
<Settings className="h-4 w-4" />
|
||||
入库: {result.added_to_s2a}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.member_emails.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<p className="text-xs text-slate-500 mb-1">成员邮箱:</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{result.member_emails.map((email, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="px-2 py-0.5 rounded text-xs bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400"
|
||||
>
|
||||
{email}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.errors.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-red-500 mb-1 flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
错误:
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{result.errors.map((err, idx) => (
|
||||
<p key={idx} className="text-xs text-red-400 pl-4">
|
||||
• {err}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 text-xs text-slate-400">
|
||||
耗时: {(result.duration_ms / 1000).toFixed(1)}s
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<Users className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||
<p>暂无处理结果</p>
|
||||
<p className="text-sm mt-1">上传账号文件并点击开始处理</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user