557 lines
21 KiB
TypeScript
557 lines
21 KiB
TypeScript
import { useState, useCallback, useEffect } from 'react'
|
||
import { Link } from 'react-router-dom'
|
||
import {
|
||
Upload as UploadIcon,
|
||
Settings,
|
||
Play,
|
||
Square,
|
||
Loader2,
|
||
List,
|
||
Activity,
|
||
CheckCircle,
|
||
RefreshCw,
|
||
} from 'lucide-react'
|
||
import { FileDropzone } from '../components/upload'
|
||
import LogStream from '../components/upload/LogStream'
|
||
import OwnerList from '../components/upload/OwnerList'
|
||
import BatchResultHistory from '../components/upload/BatchResultHistory'
|
||
import { Card, CardHeader, CardTitle, CardContent, Button, Tabs, Input, Switch } from '../components/common'
|
||
import { useConfig } from '../hooks/useConfig'
|
||
|
||
interface OwnerStats {
|
||
total: number
|
||
valid: number
|
||
registered: number
|
||
pooled: number
|
||
}
|
||
|
||
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 [activeTab, setActiveTab] = useState<TabType>('upload')
|
||
const [fileError, setFileError] = useState<string | null>(null)
|
||
const [validating, setValidating] = useState(false)
|
||
const [stats, setStats] = useState<OwnerStats | null>(null)
|
||
const [status, setStatus] = useState<ProcessStatus | null>(null)
|
||
const [polling, setPolling] = useState(false)
|
||
const [loading, setLoading] = useState(false)
|
||
const [refreshing, setRefreshing] = useState(false)
|
||
const [importResult, setImportResult] = useState<{
|
||
imported: number
|
||
total: number
|
||
account_id_ok: number
|
||
account_id_fail: number
|
||
} | null>(null)
|
||
const [batchCount, setBatchCount] = useState(0)
|
||
|
||
// 配置
|
||
const [membersPerTeam, setMembersPerTeam] = useState(4)
|
||
const [concurrentTeams, setConcurrentTeams] = useState(2)
|
||
const [browserType, setBrowserType] = useState<'chromedp' | 'rod'>('chromedp')
|
||
const [useProxy, setUseProxy] = useState(false) // 是否使用全局代理
|
||
const [includeOwner, setIncludeOwner] = useState(false) // 母号也入库
|
||
const [processCount, setProcessCount] = useState(0) // 处理数量,0表示全部
|
||
|
||
// 获取全局代理地址
|
||
const globalProxy = config.proxy?.default || ''
|
||
|
||
const hasConfig = config.s2a.apiBase && config.s2a.adminKey
|
||
|
||
// Load stats
|
||
const loadStats = useCallback(async () => {
|
||
try {
|
||
const res = await fetch('/api/db/owners/stats')
|
||
const data = await res.json()
|
||
if (data.code === 0) {
|
||
setStats(data.data)
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to load stats:', e)
|
||
}
|
||
}, [])
|
||
|
||
// Load batch count
|
||
const loadBatchCount = useCallback(async () => {
|
||
try {
|
||
const res = await fetch('/api/batch/history?page=1&page_size=1')
|
||
const data = await res.json()
|
||
if (data.code === 0) {
|
||
setBatchCount(data.data.total || 0)
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to load batch count:', e)
|
||
}
|
||
}, [])
|
||
|
||
// 获取状态
|
||
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() // 刷新统计
|
||
loadBatchCount() // 刷新批次数
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('获取状态失败:', e)
|
||
}
|
||
}, [loadStats, loadBatchCount])
|
||
|
||
// 轮询状态
|
||
useEffect(() => {
|
||
if (polling) {
|
||
const interval = setInterval(fetchStatus, 2000)
|
||
return () => clearInterval(interval)
|
||
}
|
||
}, [polling, fetchStatus])
|
||
|
||
useEffect(() => {
|
||
loadStats()
|
||
fetchStatus()
|
||
loadBatchCount()
|
||
}, [loadStats, fetchStatus, loadBatchCount])
|
||
|
||
// Upload and validate
|
||
const handleFileSelect = useCallback(
|
||
async (file: File) => {
|
||
setFileError(null)
|
||
setValidating(true)
|
||
setImportResult(null)
|
||
|
||
try {
|
||
const text = await file.text()
|
||
const res = await fetch('/api/upload/validate', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ content: text, filename: file.name }),
|
||
})
|
||
|
||
const data = await res.json()
|
||
if (data.code === 0) {
|
||
setImportResult({
|
||
imported: data.data.imported,
|
||
total: data.data.total,
|
||
account_id_ok: data.data.account_id_ok || 0,
|
||
account_id_fail: data.data.account_id_fail || 0,
|
||
})
|
||
loadStats()
|
||
} else {
|
||
setFileError(data.message || '验证失败')
|
||
}
|
||
} catch (e) {
|
||
setFileError(e instanceof Error ? e.message : 'JSON 解析失败')
|
||
} finally {
|
||
setValidating(false)
|
||
}
|
||
},
|
||
[loadStats]
|
||
)
|
||
|
||
// 开始处理
|
||
const handleStart = useCallback(async () => {
|
||
if (!stats?.valid || stats.valid === 0) {
|
||
alert('请先上传有效的账号文件')
|
||
return
|
||
}
|
||
|
||
setLoading(true)
|
||
setActiveTab('logs') // 切换到日志
|
||
|
||
try {
|
||
const res = await fetch('/api/team/process', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
members_per_team: membersPerTeam,
|
||
concurrent_teams: Math.min(concurrentTeams, stats?.valid || 1),
|
||
browser_type: browserType,
|
||
headless: true, // 始终使用无头模式
|
||
proxy: useProxy ? globalProxy : '',
|
||
include_owner: includeOwner, // 母号也入库
|
||
process_count: processCount, // 处理数量,0表示全部
|
||
}),
|
||
})
|
||
|
||
if (res.ok) {
|
||
setPolling(true)
|
||
fetchStatus()
|
||
} else {
|
||
const data = await res.json()
|
||
alert(data.message || '启动失败')
|
||
}
|
||
} catch (e) {
|
||
console.error('启动失败:', e)
|
||
alert('启动失败')
|
||
}
|
||
setLoading(false)
|
||
}, [stats, membersPerTeam, concurrentTeams, browserType, useProxy, globalProxy, includeOwner, processCount, 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: batchCount || undefined },
|
||
]
|
||
|
||
return (
|
||
<div className="h-[calc(100vh-6rem)] flex flex-col gap-6">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between shrink-0">
|
||
<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
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={refreshing}
|
||
onClick={async () => {
|
||
setRefreshing(true)
|
||
await Promise.all([loadStats(), fetchStatus(), loadBatchCount()])
|
||
setTimeout(() => setRefreshing(false), 500)
|
||
}}
|
||
icon={<RefreshCw className={`h-4 w-4 ${refreshing || polling ? 'animate-spin' : ''}`} />}
|
||
>
|
||
刷新
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Connection warning */}
|
||
{!hasConfig && (
|
||
<div className="shrink-0 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-xl p-4">
|
||
<div className="flex items-start gap-3">
|
||
<Settings className="h-5 w-5 text-yellow-600 dark:text-yellow-400 mt-0.5" />
|
||
<div>
|
||
<p className="font-medium text-yellow-800 dark:text-yellow-200">
|
||
请先配置 S2A 连接
|
||
</p>
|
||
<Link to="/config/s2a" className="mt-3 inline-block">
|
||
<Button size="sm" variant="outline">
|
||
前往设置
|
||
</Button>
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
</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}
|
||
activeTab={activeTab}
|
||
onChange={(id) => setActiveTab(id as TabType)}
|
||
className="shrink-0"
|
||
/>
|
||
|
||
{/* Tab Content */}
|
||
<div className="flex-1 min-h-0 overflow-hidden">
|
||
{activeTab === 'upload' && (
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 h-full overflow-hidden">
|
||
{/* Left: Upload & Config */}
|
||
<div className="flex flex-col gap-4 overflow-y-auto">
|
||
{/* Upload */}
|
||
<Card hoverable className="shrink-0">
|
||
<CardContent className="p-4">
|
||
<FileDropzone
|
||
onFileSelect={handleFileSelect}
|
||
disabled={validating || isRunning}
|
||
error={fileError}
|
||
/>
|
||
{validating && (
|
||
<div className="mt-3 flex items-center gap-2 text-blue-500 bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg text-sm">
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
<span>正在验证并获取 account_id (20并发)...</span>
|
||
</div>
|
||
)}
|
||
{importResult && !validating && (
|
||
<div className="mt-3 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||
<div className="flex items-center gap-2 text-green-600 dark:text-green-400 font-medium mb-2">
|
||
<CheckCircle className="h-4 w-4" />
|
||
<span>导入完成</span>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||
<div className="p-2 bg-white dark:bg-slate-800 rounded">
|
||
<div className="text-slate-500 text-xs">成功导入</div>
|
||
<div className="font-bold text-green-600">{importResult.imported} / {importResult.total}</div>
|
||
</div>
|
||
<div className="p-2 bg-white dark:bg-slate-800 rounded">
|
||
<div className="text-slate-500 text-xs">Account ID</div>
|
||
<div className="font-bold">
|
||
<span className="text-green-600">{importResult.account_id_ok}</span>
|
||
{importResult.account_id_fail > 0 && (
|
||
<span className="text-red-500 ml-1">/ {importResult.account_id_fail} 失败</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{importResult.account_id_ok > 0 && (
|
||
<div className="mt-2">
|
||
<div className="h-2 bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
|
||
<div
|
||
className="h-full bg-green-500 rounded-full transition-all duration-500"
|
||
style={{ width: `${(importResult.account_id_ok / importResult.total) * 100}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Config */}
|
||
<Card hoverable className="shrink-0">
|
||
<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>
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||
处理母号数量
|
||
</label>
|
||
<div className="flex gap-2">
|
||
<Input
|
||
type="number"
|
||
min={1}
|
||
max={stats?.valid || 100}
|
||
value={processCount || ''}
|
||
onChange={(e) => setProcessCount(Number(e.target.value) || 0)}
|
||
disabled={isRunning}
|
||
placeholder="输入数量"
|
||
className="flex-1"
|
||
/>
|
||
<Button
|
||
variant={processCount === 0 ? 'primary' : 'outline'}
|
||
size="sm"
|
||
onClick={() => setProcessCount(0)}
|
||
disabled={isRunning}
|
||
className="whitespace-nowrap"
|
||
>
|
||
全部 ({stats?.valid || 0})
|
||
</Button>
|
||
</div>
|
||
<p className="text-xs text-slate-500 mt-1">
|
||
{processCount > 0 ? `将处理 ${Math.min(processCount, stats?.valid || 0)} 个母号` : `将处理全部 ${stats?.valid || 0} 个母号`}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<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>
|
||
|
||
<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="p-3 rounded-lg bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700">
|
||
<Switch
|
||
checked={useProxy}
|
||
onChange={setUseProxy}
|
||
disabled={isRunning || !globalProxy}
|
||
label="使用全局代理"
|
||
description={globalProxy ? `当前代理: ${globalProxy}` : '请先在系统配置中设置代理地址'}
|
||
/>
|
||
</div>
|
||
|
||
<div className="p-3 rounded-lg bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700">
|
||
<Switch
|
||
checked={includeOwner}
|
||
onChange={setIncludeOwner}
|
||
disabled={isRunning}
|
||
label="母号也入库"
|
||
description="开启后,母号(Owner)账号也会被注册到 S2A"
|
||
/>
|
||
</div>
|
||
|
||
<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>
|
||
|
||
{/* Right: Quick Log View */}
|
||
<div className="hidden lg:block h-full overflow-hidden rounded-xl border border-slate-200 dark:border-slate-800 bg-slate-900 shadow-inner">
|
||
<div className="h-full flex flex-col">
|
||
<div className="flex items-center gap-2 px-4 py-3 border-b border-slate-800 bg-slate-900/50 backdrop-blur">
|
||
<Activity className="h-4 w-4 text-blue-400" />
|
||
<span className="text-sm font-medium text-slate-300">实时日志</span>
|
||
</div>
|
||
<div className="flex-1 overflow-hidden">
|
||
<LogStream hideHeader={true} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{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 onStatsChange={loadStats} />
|
||
</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 hideHeader={true} />
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'results' && (
|
||
<div className="h-full overflow-hidden rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shadow-sm">
|
||
<BatchResultHistory />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|