Files
codexautopool/frontend/src/pages/Upload.tsx

557 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}