feat: implement team registration management feature with backend API and frontend UI.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import { ConfigProvider, RecordsProvider } from './context'
|
||||
import { Layout } from './components/layout'
|
||||
import { Dashboard, Upload, Records, Accounts, Config, S2AConfig, EmailConfig, Monitor, Cleaner } from './pages'
|
||||
import { Dashboard, Upload, Records, Accounts, Config, S2AConfig, EmailConfig, Monitor, Cleaner, TeamReg } from './pages'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -15,6 +15,7 @@ function App() {
|
||||
<Route path="accounts" element={<Accounts />} />
|
||||
<Route path="monitor" element={<Monitor />} />
|
||||
<Route path="cleaner" element={<Cleaner />} />
|
||||
<Route path="team-reg" element={<TeamReg />} />
|
||||
<Route path="config" element={<Config />} />
|
||||
<Route path="config/s2a" element={<S2AConfig />} />
|
||||
<Route path="config/email" element={<EmailConfig />} />
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Mail,
|
||||
Cog,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface SidebarProps {
|
||||
@@ -35,6 +36,7 @@ const navItems: NavItem[] = [
|
||||
{ to: '/accounts', icon: Users, label: '号池账号' },
|
||||
{ to: '/monitor', icon: Activity, label: '号池监控' },
|
||||
{ to: '/cleaner', icon: Trash2, label: '定期清理' },
|
||||
{ to: '/team-reg', icon: UserPlus, label: 'Team 注册' },
|
||||
{
|
||||
to: '/config',
|
||||
icon: Settings,
|
||||
|
||||
476
frontend/src/pages/TeamReg.tsx
Normal file
476
frontend/src/pages/TeamReg.tsx
Normal file
@@ -0,0 +1,476 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import {
|
||||
Play,
|
||||
Square,
|
||||
Download,
|
||||
Terminal,
|
||||
Settings,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
FileJson,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
|
||||
|
||||
interface TeamRegStatus {
|
||||
running: boolean
|
||||
started_at: string
|
||||
config: {
|
||||
count: number
|
||||
concurrency: number
|
||||
proxy: string
|
||||
auto_import: boolean
|
||||
}
|
||||
logs: string[]
|
||||
output_file: string
|
||||
imported: number
|
||||
}
|
||||
|
||||
export default function TeamReg() {
|
||||
const [status, setStatus] = useState<TeamRegStatus | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [starting, setStarting] = useState(false)
|
||||
const [stopping, setStopping] = useState(false)
|
||||
const [importing, setImporting] = useState(false)
|
||||
|
||||
// 配置表单
|
||||
const [count, setCount] = useState(5)
|
||||
const [concurrency, setConcurrency] = useState(2)
|
||||
const [proxy, setProxy] = useState('')
|
||||
const [autoImport, setAutoImport] = useState(true)
|
||||
|
||||
// 日志相关
|
||||
const logsContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [autoScroll, setAutoScroll] = useState(true)
|
||||
|
||||
// 获取状态
|
||||
const fetchStatus = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/team-reg/status')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setStatus(data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取状态失败:', e)
|
||||
}
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
// 初始化和轮询
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
|
||||
// 如果正在运行,每秒刷新状态
|
||||
const interval = setInterval(() => {
|
||||
fetchStatus()
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchStatus])
|
||||
|
||||
// 自动滚动到底部
|
||||
useEffect(() => {
|
||||
if (autoScroll && logsContainerRef.current) {
|
||||
logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight
|
||||
}
|
||||
}, [status?.logs, autoScroll])
|
||||
|
||||
// 启动注册
|
||||
const handleStart = async () => {
|
||||
setStarting(true)
|
||||
try {
|
||||
const res = await fetch('/api/team-reg/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
count,
|
||||
concurrency,
|
||||
proxy,
|
||||
auto_import: autoImport,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
await fetchStatus()
|
||||
} else {
|
||||
alert(data.message || '启动失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('启动失败:', e)
|
||||
alert('启动失败: ' + (e instanceof Error ? e.message : '网络错误'))
|
||||
}
|
||||
setStarting(false)
|
||||
}
|
||||
|
||||
// 停止注册
|
||||
const handleStop = async () => {
|
||||
if (!confirm('确定要停止当前注册任务吗?')) return
|
||||
setStopping(true)
|
||||
try {
|
||||
const res = await fetch('/api/team-reg/stop', { method: 'POST' })
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
await fetchStatus()
|
||||
} else {
|
||||
alert(data.message || '停止失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('停止失败:', e)
|
||||
}
|
||||
setStopping(false)
|
||||
}
|
||||
|
||||
// 手动导入
|
||||
const handleImport = async () => {
|
||||
setImporting(true)
|
||||
try {
|
||||
const res = await fetch('/api/team-reg/import', { method: 'POST' })
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
alert(data.message)
|
||||
await fetchStatus()
|
||||
} else {
|
||||
alert(data.message || '导入失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('导入失败:', e)
|
||||
alert('导入失败: ' + (e instanceof Error ? e.message : '网络错误'))
|
||||
}
|
||||
setImporting(false)
|
||||
}
|
||||
|
||||
const isRunning = status?.running || false
|
||||
const logs = status?.logs || []
|
||||
const outputFile = status?.output_file || ''
|
||||
const importedCount = status?.imported || 0
|
||||
|
||||
// 解析日志中的成功/失败计数
|
||||
const parseLogStats = () => {
|
||||
let success = 0
|
||||
let failed = 0
|
||||
logs.forEach(log => {
|
||||
if (log.includes('[OK]') && (log.includes('注册成功') || log.includes('支付成功'))) {
|
||||
success++
|
||||
}
|
||||
if (log.includes('[!]') || log.includes('[错误]')) {
|
||||
failed++
|
||||
}
|
||||
})
|
||||
return { success: Math.floor(success / 2), failed } // 注册+支付各算一次
|
||||
}
|
||||
|
||||
const logStats = parseLogStats()
|
||||
|
||||
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">
|
||||
批量注册 ChatGPT Team 账号并自动支付
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchStatus}
|
||||
disabled={loading}
|
||||
icon={<RefreshCw className={`h-4 w-4 ${loading ? '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-2xl font-bold ${isRunning ? 'text-green-500' : 'text-slate-400'}`}>
|
||||
{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" />
|
||||
) : (
|
||||
<Terminal 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-2xl font-bold text-green-500">{logStats.success}</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-2xl font-bold text-orange-500">{logStats.failed}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 rounded-xl bg-orange-100 dark:bg-orange-900/30 flex items-center justify-center">
|
||||
<AlertTriangle className="h-6 w-6 text-orange-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-2xl font-bold text-blue-500">{importedCount}</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>
|
||||
</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">
|
||||
<Input
|
||||
label="注册数量"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={count}
|
||||
onChange={(e) => setCount(Number(e.target.value))}
|
||||
hint="要注册的 Team 账号数量 (1-100)"
|
||||
disabled={isRunning}
|
||||
/>
|
||||
<Input
|
||||
label="并发数"
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={concurrency}
|
||||
onChange={(e) => setConcurrency(Number(e.target.value))}
|
||||
hint="同时进行的注册任务数 (1-10)"
|
||||
disabled={isRunning}
|
||||
/>
|
||||
<Input
|
||||
label="代理地址"
|
||||
type="text"
|
||||
value={proxy}
|
||||
onChange={(e) => setProxy(e.target.value)}
|
||||
placeholder="留空使用默认代理"
|
||||
hint="HTTP 代理地址,如 http://127.0.0.1:7890"
|
||||
disabled={isRunning}
|
||||
/>
|
||||
<div className="p-3 rounded-lg bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoImport}
|
||||
onChange={(e) => setAutoImport(e.target.checked)}
|
||||
disabled={isRunning}
|
||||
className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">完成后自动导入</p>
|
||||
<p className="text-xs text-slate-500">注册完成后自动将账号导入母号列表</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
{!isRunning ? (
|
||||
<Button
|
||||
onClick={handleStart}
|
||||
loading={starting}
|
||||
className="flex-1"
|
||||
icon={<Play className="h-4 w-4" />}
|
||||
>
|
||||
开始注册
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleStop}
|
||||
loading={stopping}
|
||||
variant="outline"
|
||||
className="flex-1 border-red-500 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
icon={<Square className="h-4 w-4" />}
|
||||
>
|
||||
停止
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 输出文件和导入按钮 */}
|
||||
{outputFile && (
|
||||
<div className="pt-4 border-t border-slate-200 dark:border-slate-700 space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<FileJson className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-slate-500">输出文件:</span>
|
||||
<span className="font-mono text-slate-900 dark:text-slate-100 truncate">
|
||||
{outputFile.split(/[/\\]/).pop()}
|
||||
</span>
|
||||
</div>
|
||||
{!autoImport && (
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
loading={importing}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
icon={<Download 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">
|
||||
<Terminal className="h-5 w-5 text-green-500" />
|
||||
实时日志
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoScroll}
|
||||
onChange={(e) => setAutoScroll(e.target.checked)}
|
||||
className="h-3 w-3 rounded"
|
||||
/>
|
||||
自动滚动
|
||||
</label>
|
||||
<span className="text-xs text-slate-400">
|
||||
{logs.length} 条日志
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
ref={logsContainerRef}
|
||||
className="h-[500px] overflow-y-auto bg-slate-900 rounded-lg p-4 font-mono text-sm"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-slate-500">
|
||||
<div className="text-center">
|
||||
<Terminal className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||
<p>等待启动注册任务...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{logs.map((log, i) => (
|
||||
<LogLine key={i} log={log} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 使用说明 */}
|
||||
<Card className="glass-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5 text-purple-500" />
|
||||
使用说明
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm text-slate-600 dark:text-slate-400">
|
||||
<div>
|
||||
<h4 className="font-medium text-slate-900 dark:text-slate-100 mb-2">流程</h4>
|
||||
<ol className="list-decimal list-inside space-y-1">
|
||||
<li>设置注册数量和并发数</li>
|
||||
<li>配置代理地址(可选)</li>
|
||||
<li>点击"开始注册"启动任务</li>
|
||||
<li>等待注册完成,观察实时日志</li>
|
||||
<li>完成后自动/手动导入母号列表</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-slate-900 dark:text-slate-100 mb-2">注意事项</h4>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>需要在服务器上放置 <code className="bg-slate-100 dark:bg-slate-800 px-1 rounded">team-reg</code> 可执行文件</li>
|
||||
<li>程序会自动处理 SEPA 支付</li>
|
||||
<li>支持中断恢复,Ctrl+C 会保存已完成的账号</li>
|
||||
<li>导入后的账号会出现在"母号管理"页面</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 日志行组件 - 根据内容着色
|
||||
function LogLine({ log }: { log: string }) {
|
||||
let colorClass = 'text-slate-300'
|
||||
|
||||
if (log.includes('[OK]') || log.includes('成功')) {
|
||||
colorClass = 'text-green-400'
|
||||
} else if (log.includes('[!]') || log.includes('重试') || log.includes('[错误]')) {
|
||||
colorClass = 'text-orange-400'
|
||||
} else if (log.includes('[系统]') || log.includes('[输入]')) {
|
||||
colorClass = 'text-blue-400'
|
||||
} else if (log.includes('[W1]')) {
|
||||
colorClass = 'text-cyan-400'
|
||||
} else if (log.includes('[W2]')) {
|
||||
colorClass = 'text-purple-400'
|
||||
} else if (log.includes('[W3]')) {
|
||||
colorClass = 'text-yellow-400'
|
||||
} else if (log.includes('[W4]')) {
|
||||
colorClass = 'text-pink-400'
|
||||
} else if (log.includes('完成') || log.includes('结果已保存')) {
|
||||
colorClass = 'text-emerald-400 font-medium'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${colorClass} leading-relaxed whitespace-pre-wrap break-all`}>
|
||||
{log}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,4 +7,6 @@ export { default as S2AConfig } from './S2AConfig'
|
||||
export { default as EmailConfig } from './EmailConfig'
|
||||
export { default as Monitor } from './Monitor'
|
||||
export { default as Cleaner } from './Cleaner'
|
||||
export { default as TeamReg } from './TeamReg'
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user