feat: implement batch team owner pooling functionality with dedicated upload, processing, logging, and results pages.

This commit is contained in:
2026-01-30 08:57:16 +08:00
parent 9dfa61ac05
commit 6d236419b9
11 changed files with 477 additions and 693 deletions

View File

@@ -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>