527 lines
20 KiB
TypeScript
527 lines
20 KiB
TypeScript
import { useState, useCallback, useEffect } from 'react'
|
||
import { Link } from 'react-router-dom'
|
||
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, CardHeader, CardTitle, CardContent, Button, Tabs, Input } 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 [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('/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)
|
||
}
|
||
}, [])
|
||
|
||
// 获取状态
|
||
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()
|
||
fetchStatus()
|
||
}, [loadStats, fetchStatus])
|
||
|
||
// Upload and validate
|
||
const handleFileSelect = useCallback(
|
||
async (file: File) => {
|
||
setFileError(null)
|
||
setValidating(true)
|
||
|
||
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) {
|
||
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,
|
||
}),
|
||
})
|
||
|
||
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, 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 (
|
||
<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"
|
||
onClick={() => { loadStats(); fetchStatus(); }}
|
||
icon={<RefreshCw className={`h-4 w-4 ${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-2 rounded-lg text-sm">
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
<span>正在验证账号...</span>
|
||
</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 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>
|
||
|
||
<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>
|
||
|
||
{/* 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 />
|
||
</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-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>
|
||
</div>
|
||
)
|
||
}
|