484 lines
22 KiB
TypeScript
484 lines
22 KiB
TypeScript
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>
|
|
)
|
|
}
|