feat: Implement initial full-stack application structure including frontend pages, components, hooks, API integration, and backend services for account pooling and management.
This commit is contained in:
340
frontend/src/pages/Upload.tsx
Normal file
340
frontend/src/pages/Upload.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
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 { 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 { 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
|
||||
registered: number
|
||||
pooled: number
|
||||
}
|
||||
|
||||
type TabType = 'upload' | 'owners' | 'logs'
|
||||
|
||||
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 hasConfig = config.s2a.apiBase && config.s2a.adminKey
|
||||
|
||||
// Load stats
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/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)
|
||||
}
|
||||
}, [apiBase])
|
||||
|
||||
useEffect(() => {
|
||||
loadStats()
|
||||
}, [loadStats])
|
||||
|
||||
// Upload and validate
|
||||
const handleFileSelect = useCallback(
|
||||
async (file: File) => {
|
||||
setFileError(null)
|
||||
setValidating(true)
|
||||
|
||||
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`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ accounts }),
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
},
|
||||
[apiBase, loadStats]
|
||||
)
|
||||
|
||||
// Start pooling
|
||||
const handleStartPooling = useCallback(async () => {
|
||||
setPooling(true)
|
||||
setActiveTab('logs') // Switch to logs tab
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/pooling/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(poolingConfig),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (data.code !== 0) {
|
||||
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)
|
||||
}
|
||||
}, [apiBase, poolingConfig, loadStats])
|
||||
|
||||
const tabs = [
|
||||
{ id: 'upload', label: '上传', icon: UploadIcon },
|
||||
{ id: 'owners', label: '母号列表', icon: List, count: stats?.total },
|
||||
{ id: 'logs', label: '日志', icon: Activity },
|
||||
]
|
||||
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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}
|
||||
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>
|
||||
|
||||
{/* 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 */}
|
||||
<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>
|
||||
|
||||
<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"
|
||||
/>
|
||||
</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>
|
||||
</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 apiBase={apiBase} />
|
||||
</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 apiBase={apiBase} />
|
||||
</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} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user