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:
263
frontend/src/pages/Accounts.tsx
Normal file
263
frontend/src/pages/Accounts.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { RefreshCw, Search, Settings, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
StatusBadge,
|
||||
} from '../components/common'
|
||||
import { useS2AApi } from '../hooks/useS2AApi'
|
||||
import { useConfig } from '../hooks/useConfig'
|
||||
import type { S2AAccount, AccountListParams } from '../types'
|
||||
import { formatDateTime } from '../utils/format'
|
||||
|
||||
export default function Accounts() {
|
||||
const { config } = useConfig()
|
||||
const { getAccounts, loading, error } = useS2AApi()
|
||||
|
||||
const [accounts, setAccounts] = useState<S2AAccount[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize] = useState(20)
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const hasConfig = config.s2a.apiBase && config.s2a.adminKey
|
||||
|
||||
const fetchAccounts = useCallback(async () => {
|
||||
if (!hasConfig) return
|
||||
|
||||
setRefreshing(true)
|
||||
const params: AccountListParams = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
platform: 'openai',
|
||||
}
|
||||
|
||||
if (search) params.search = search
|
||||
if (statusFilter) params.status = statusFilter as 'active' | 'inactive' | 'error'
|
||||
|
||||
const result = await getAccounts(params)
|
||||
if (result) {
|
||||
setAccounts(result.data)
|
||||
setTotal(result.total)
|
||||
}
|
||||
setRefreshing(false)
|
||||
}, [hasConfig, page, pageSize, search, statusFilter, getAccounts])
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts()
|
||||
}, [fetchAccounts])
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setPage(1)
|
||||
fetchAccounts()
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
|
||||
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">号池账号</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">查看 S2A 号池中的账号列表</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchAccounts}
|
||||
disabled={!hasConfig || refreshing}
|
||||
icon={<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Connection warning */}
|
||||
{!hasConfig && (
|
||||
<div className="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>
|
||||
<p className="mt-1 text-sm text-yellow-700 dark:text-yellow-300">
|
||||
查看账号列表需要配置 S2A API 连接信息
|
||||
</p>
|
||||
<Link to="/config" className="mt-3 inline-block">
|
||||
<Button size="sm" variant="outline">
|
||||
前往配置
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
{hasConfig && (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSearch} className="flex flex-wrap items-end gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<Input
|
||||
placeholder="搜索账号名称..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
options={[
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 'active', label: '正常' },
|
||||
{ value: 'inactive', label: '停用' },
|
||||
{ value: 'error', label: '错误' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" icon={<Search className="h-4 w-4" />}>
|
||||
搜索
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4">
|
||||
<p className="text-red-700 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account List */}
|
||||
{hasConfig && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>账号列表</CardTitle>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">共 {total} 个账号</span>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading && accounts.length === 0 ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-16 bg-slate-100 dark:bg-slate-700 rounded-lg animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : accounts.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-slate-500 dark:text-slate-400">暂无账号</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow hoverable={false}>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead className="text-right">并发</TableHead>
|
||||
<TableHead className="text-right">优先级</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{accounts.map((account) => (
|
||||
<TableRow key={account.id}>
|
||||
<TableCell>
|
||||
<span className="font-mono text-sm">{account.id}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-medium">{account.name}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{account.type}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={account.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{account.current_concurrency !== undefined ? (
|
||||
<span>
|
||||
{account.current_concurrency}/{account.concurrency}
|
||||
</span>
|
||||
) : (
|
||||
account.concurrency
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{account.priority}</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{formatDateTime(account.created_at)}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
第 {page} 页,共 {totalPages} 页
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
icon={<ChevronLeft className="h-4 w-4" />}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
icon={<ChevronRight className="h-4 w-4" />}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
301
frontend/src/pages/Config.tsx
Normal file
301
frontend/src/pages/Config.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
Server,
|
||||
Mail,
|
||||
ChevronRight,
|
||||
Settings,
|
||||
Save,
|
||||
RefreshCw,
|
||||
Globe,
|
||||
ToggleLeft,
|
||||
ToggleRight
|
||||
} from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
|
||||
import { useConfig } from '../hooks/useConfig'
|
||||
|
||||
export default function Config() {
|
||||
const { config, isConnected } = useConfig()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null)
|
||||
|
||||
// 编辑状态
|
||||
const [editS2ABase, setEditS2ABase] = useState('')
|
||||
const [editS2AKey, setEditS2AKey] = useState('')
|
||||
const [editConcurrency, setEditConcurrency] = useState(2)
|
||||
const [editPriority, setEditPriority] = useState(0)
|
||||
const [editGroupIds, setEditGroupIds] = useState('')
|
||||
const [proxyEnabled, setProxyEnabled] = useState(false)
|
||||
const [proxyAddress, setProxyAddress] = useState('')
|
||||
|
||||
// 获取服务器配置
|
||||
const fetchServerConfig = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/config')
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
setEditS2ABase(data.data.s2a_api_base || '')
|
||||
setEditS2AKey(data.data.s2a_admin_key || '')
|
||||
setEditConcurrency(data.data.concurrency || 2)
|
||||
setEditPriority(data.data.priority || 0)
|
||||
setEditGroupIds(data.data.group_ids?.join(', ') || '')
|
||||
setProxyEnabled(data.data.proxy_enabled || false)
|
||||
setProxyAddress(data.data.default_proxy || '')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch config:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchServerConfig()
|
||||
}, [])
|
||||
|
||||
// 保存配置
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setMessage(null)
|
||||
try {
|
||||
// 解析 group_ids
|
||||
const groupIds = editGroupIds
|
||||
.split(',')
|
||||
.map(s => parseInt(s.trim()))
|
||||
.filter(n => !isNaN(n))
|
||||
|
||||
const res = await fetch('/api/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
s2a_api_base: editS2ABase,
|
||||
s2a_admin_key: editS2AKey,
|
||||
concurrency: editConcurrency,
|
||||
priority: editPriority,
|
||||
group_ids: groupIds,
|
||||
proxy_enabled: proxyEnabled,
|
||||
default_proxy: proxyAddress,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
setMessage({ type: 'success', text: '配置已保存' })
|
||||
fetchServerConfig()
|
||||
} else {
|
||||
setMessage({ type: 'error', text: data.error || '保存失败' })
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: '网络错误' })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const configItems = [
|
||||
{
|
||||
to: '/config/s2a',
|
||||
icon: Server,
|
||||
title: 'S2A 高级配置',
|
||||
description: 'S2A 号池详细设置和测试',
|
||||
status: isConnected ? '已连接' : '未连接',
|
||||
statusColor: isConnected ? 'text-green-600 dark:text-green-400' : 'text-red-500',
|
||||
},
|
||||
{
|
||||
to: '/config/email',
|
||||
icon: Mail,
|
||||
title: '邮箱服务配置',
|
||||
description: '配置邮箱服务用于自动注册',
|
||||
status: (config.email?.services?.length ?? 0) > 0 ? '已配置' : '未配置',
|
||||
statusColor: (config.email?.services?.length ?? 0) > 0 ? 'text-green-600 dark:text-green-400' : 'text-orange-500',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
||||
<Settings className="h-7 w-7 text-slate-500" />
|
||||
系统配置
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">配置会自动保存到服务器</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchServerConfig}
|
||||
disabled={loading}
|
||||
icon={<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
{message && (
|
||||
<div className={`p-3 rounded-lg text-sm ${message.type === 'success'
|
||||
? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Config Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Server className="h-5 w-5 text-blue-500" />
|
||||
核心配置
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* S2A Config */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
S2A API 地址
|
||||
</label>
|
||||
<Input
|
||||
value={editS2ABase}
|
||||
onChange={(e) => setEditS2ABase(e.target.value)}
|
||||
placeholder="https://your-s2a-server.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
S2A Admin Key
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={editS2AKey}
|
||||
onChange={(e) => setEditS2AKey(e.target.value)}
|
||||
placeholder="admin-xxxxxx"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pooling Config */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
入库并发
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={editConcurrency}
|
||||
onChange={(e) => setEditConcurrency(parseInt(e.target.value) || 1)}
|
||||
min={1}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
优先级
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={editPriority}
|
||||
onChange={(e) => setEditPriority(parseInt(e.target.value) || 0)}
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
分组 ID (逗号分隔)
|
||||
</label>
|
||||
<Input
|
||||
value={editGroupIds}
|
||||
onChange={(e) => setEditGroupIds(e.target.value)}
|
||||
placeholder="1, 2, 3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Proxy Config */}
|
||||
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-5 w-5 text-orange-500" />
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">代理设置</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setProxyEnabled(!proxyEnabled)}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
{proxyEnabled ? (
|
||||
<>
|
||||
<ToggleRight className="h-6 w-6 text-green-500" />
|
||||
<span className="text-green-600 dark:text-green-400">已启用</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ToggleLeft className="h-6 w-6 text-slate-400" />
|
||||
<span className="text-slate-500">已禁用</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
value={proxyAddress}
|
||||
onChange={(e) => setProxyAddress(e.target.value)}
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
disabled={!proxyEnabled}
|
||||
className={!proxyEnabled ? 'opacity-50' : ''}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
服务器部署时通常不需要代理,在本地开发或特殊网络环境下可启用
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
icon={<Save className="h-4 w-4" />}
|
||||
>
|
||||
{saving ? '保存中...' : '保存配置'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sub Config Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{configItems.map((item) => (
|
||||
<Link key={item.to} to={item.to} className="block group">
|
||||
<Card className="h-full transition-all duration-200 hover:shadow-lg hover:border-blue-300 dark:hover:border-blue-600">
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<div className="p-3 rounded-xl bg-blue-50 dark:bg-blue-900/30 group-hover:bg-blue-100 dark:group-hover:bg-blue-900/50 transition-colors">
|
||||
<item.icon className="h-6 w-6 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100">{item.title}</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{item.description}</p>
|
||||
</div>
|
||||
<span className={`text-sm font-medium ${item.statusColor}`}>
|
||||
{item.status}
|
||||
</span>
|
||||
<ChevronRight className="h-5 w-5 text-slate-400 group-hover:text-blue-500 transition-colors" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400">
|
||||
<p>配置会保存在服务器端,重启后自动加载。首次启动时会自动创建默认配置。</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
162
frontend/src/pages/Dashboard.tsx
Normal file
162
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Upload, RefreshCw, Settings } from 'lucide-react'
|
||||
import { PoolStatus, RecentRecords } from '../components/dashboard'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button } from '../components/common'
|
||||
import { useS2AApi } from '../hooks/useS2AApi'
|
||||
import { useRecords } from '../hooks/useRecords'
|
||||
import { useConfig } from '../hooks/useConfig'
|
||||
import type { DashboardStats } from '../types'
|
||||
import { formatNumber, formatCurrency } from '../utils/format'
|
||||
|
||||
export default function Dashboard() {
|
||||
const { getDashboardStats, loading, error, isConnected } = useS2AApi()
|
||||
const { records } = useRecords()
|
||||
const { config } = useConfig()
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const fetchStats = async () => {
|
||||
if (!isConnected) return
|
||||
setRefreshing(true)
|
||||
const data = await getDashboardStats()
|
||||
if (data) {
|
||||
setStats(data)
|
||||
}
|
||||
setRefreshing(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isConnected])
|
||||
|
||||
const hasConfig = config.s2a.apiBase && config.s2a.adminKey
|
||||
|
||||
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">仪表盘</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">号池状态概览</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchStats}
|
||||
disabled={!isConnected || refreshing}
|
||||
icon={<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
<Link to="/upload">
|
||||
<Button size="sm" icon={<Upload className="h-4 w-4" />}>
|
||||
上传入库
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection warning */}
|
||||
{!hasConfig && (
|
||||
<div className="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>
|
||||
<p className="mt-1 text-sm text-yellow-700 dark:text-yellow-300">
|
||||
前往系统配置页面设置 S2A API 地址和管理密钥
|
||||
</p>
|
||||
<Link to="/config" className="mt-3 inline-block">
|
||||
<Button size="sm" variant="outline">
|
||||
前往配置
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pool Status */}
|
||||
<PoolStatus stats={stats} loading={loading || refreshing} error={error} />
|
||||
|
||||
{/* Stats Summary */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card hoverable>
|
||||
<CardHeader>
|
||||
<CardTitle>今日统计</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">请求数</p>
|
||||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{formatNumber(stats.today_requests)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Token 消耗</p>
|
||||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{formatNumber(stats.today_tokens)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">费用</p>
|
||||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{formatCurrency(stats.today_cost)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">TPM</p>
|
||||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{formatNumber(stats.tpm)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card hoverable>
|
||||
<CardHeader>
|
||||
<CardTitle>累计统计</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">总请求数</p>
|
||||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{formatNumber(stats.total_requests)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">总 Token</p>
|
||||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{formatNumber(stats.total_tokens)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">总费用</p>
|
||||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{formatCurrency(stats.total_cost)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">过载账号</p>
|
||||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{stats.overload_accounts}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Records */}
|
||||
<RecentRecords records={records} loading={loading} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
251
frontend/src/pages/EmailConfig.tsx
Normal file
251
frontend/src/pages/EmailConfig.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { CheckCircle, Save, Mail, Plus, Trash2, TestTube, Loader2, Settings, Server } from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
|
||||
import { useConfig } from '../hooks/useConfig'
|
||||
import type { MailServiceConfig } from '../types'
|
||||
|
||||
const API_BASE = 'http://localhost:8088'
|
||||
|
||||
export default function EmailConfig() {
|
||||
const { config, updateEmailConfig } = useConfig()
|
||||
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [services, setServices] = useState<MailServiceConfig[]>(config.email?.services || [])
|
||||
const [testingIndex, setTestingIndex] = useState<number | null>(null)
|
||||
const [testResults, setTestResults] = useState<Record<number, { success: boolean; message: string }>>({})
|
||||
|
||||
// 同步配置变化
|
||||
useEffect(() => {
|
||||
if (config.email?.services) {
|
||||
setServices(config.email.services)
|
||||
}
|
||||
}, [config.email?.services])
|
||||
|
||||
const handleSave = async () => {
|
||||
// 保存到前端 context
|
||||
updateEmailConfig({ services })
|
||||
|
||||
// 保存到后端
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/mail/services`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ services }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddService = () => {
|
||||
setServices([
|
||||
...services,
|
||||
{
|
||||
name: `邮箱服务 ${services.length + 1}`,
|
||||
apiBase: '',
|
||||
apiToken: '',
|
||||
domain: '',
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const handleRemoveService = (index: number) => {
|
||||
if (services.length <= 1) {
|
||||
return // 至少保留一个服务
|
||||
}
|
||||
const newServices = services.filter((_, i) => i !== index)
|
||||
setServices(newServices)
|
||||
}
|
||||
|
||||
const handleUpdateService = (index: number, updates: Partial<MailServiceConfig>) => {
|
||||
const newServices = [...services]
|
||||
newServices[index] = { ...newServices[index], ...updates }
|
||||
setServices(newServices)
|
||||
}
|
||||
|
||||
const handleTestService = async (index: number) => {
|
||||
const service = services[index]
|
||||
setTestingIndex(index)
|
||||
setTestResults(prev => ({ ...prev, [index]: { success: false, message: '测试中...' } }))
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/mail/services/test`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
api_base: service.apiBase,
|
||||
api_token: service.apiToken,
|
||||
domain: service.domain,
|
||||
email_path: service.emailPath,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (res.ok && data.code === 0) {
|
||||
setTestResults(prev => ({ ...prev, [index]: { success: true, message: '连接成功' } }))
|
||||
} else {
|
||||
setTestResults(prev => ({ ...prev, [index]: { success: false, message: data.message || '连接失败' } }))
|
||||
}
|
||||
} catch (error) {
|
||||
setTestResults(prev => ({ ...prev, [index]: { success: false, message: '网络错误' } }))
|
||||
} finally {
|
||||
setTestingIndex(null)
|
||||
}
|
||||
}
|
||||
|
||||
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 flex items-center gap-2">
|
||||
<Mail className="h-7 w-7 text-purple-500" />
|
||||
邮箱服务配置
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
配置多个邮箱服务用于自动注册和验证码接收
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleAddService}
|
||||
icon={<Plus className="h-4 w-4" />}
|
||||
>
|
||||
添加服务
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
icon={saved ? <CheckCircle className="h-4 w-4" /> : <Save className="h-4 w-4" />}
|
||||
>
|
||||
{saved ? '已保存' : '保存配置'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Cards */}
|
||||
<div className="space-y-4">
|
||||
{services.map((service, index) => (
|
||||
<Card key={index}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Server className="h-5 w-5 text-purple-500" />
|
||||
<span>{service.name || `服务 ${index + 1}`}</span>
|
||||
<span className="text-sm font-normal text-slate-500">
|
||||
(@{service.domain || '未设置域名'})
|
||||
</span>
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{testResults[index] && (
|
||||
<span className={`text-sm ${testResults[index].success ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{testResults[index].message}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleTestService(index)}
|
||||
disabled={testingIndex === index || !service.apiBase}
|
||||
icon={testingIndex === index ? <Loader2 className="h-4 w-4 animate-spin" /> : <TestTube className="h-4 w-4" />}
|
||||
>
|
||||
测试
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveService(index)}
|
||||
disabled={services.length <= 1}
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
className="text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="服务名称"
|
||||
placeholder="如:主邮箱服务"
|
||||
value={service.name}
|
||||
onChange={(e) => handleUpdateService(index, { name: e.target.value })}
|
||||
hint="用于识别不同的邮箱服务"
|
||||
/>
|
||||
<Input
|
||||
label="邮箱域名"
|
||||
placeholder="如:example.com"
|
||||
value={service.domain}
|
||||
onChange={(e) => handleUpdateService(index, { domain: e.target.value })}
|
||||
hint="生成邮箱地址的域名后缀"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="API 地址"
|
||||
placeholder="https://mail.example.com"
|
||||
value={service.apiBase}
|
||||
onChange={(e) => handleUpdateService(index, { apiBase: e.target.value })}
|
||||
hint="邮箱服务 API 地址"
|
||||
/>
|
||||
<Input
|
||||
label="API Token"
|
||||
type="password"
|
||||
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
value={service.apiToken}
|
||||
onChange={(e) => handleUpdateService(index, { apiToken: e.target.value })}
|
||||
hint="邮箱服务的 API 认证令牌"
|
||||
/>
|
||||
|
||||
{/* Advanced Settings (Collapsed by default) */}
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-sm text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 flex items-center gap-1">
|
||||
<Settings className="h-4 w-4" />
|
||||
高级设置
|
||||
</summary>
|
||||
<div className="mt-4 space-y-4 pl-5 border-l-2 border-slate-200 dark:border-slate-700">
|
||||
<Input
|
||||
label="邮件列表 API 路径"
|
||||
placeholder="/api/public/emailList (默认)"
|
||||
value={service.emailPath || ''}
|
||||
onChange={(e) => handleUpdateService(index, { emailPath: e.target.value })}
|
||||
hint="获取邮件列表的 API 路径"
|
||||
/>
|
||||
<Input
|
||||
label="创建用户 API 路径"
|
||||
placeholder="/api/public/addUser (默认)"
|
||||
value={service.addUserApi || ''}
|
||||
onChange={(e) => handleUpdateService(index, { addUserApi: e.target.value })}
|
||||
hint="创建邮箱用户的 API 路径"
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Help Info */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400">
|
||||
<p className="font-medium mb-2">配置说明:</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>可以添加多个邮箱服务,系统会轮询使用各个服务</li>
|
||||
<li>每个服务需要配置独立的 API 地址、Token 和域名</li>
|
||||
<li>邮箱域名决定生成的邮箱地址后缀(如 xxx@esyteam.edu.kg)</li>
|
||||
<li>验证码会自动从配置的邮箱服务获取</li>
|
||||
<li>高级设置通常不需要修改,使用默认值即可</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
588
frontend/src/pages/Monitor.tsx
Normal file
588
frontend/src/pages/Monitor.tsx
Normal file
@@ -0,0 +1,588 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Target,
|
||||
Activity,
|
||||
RefreshCw,
|
||||
Play,
|
||||
Pause,
|
||||
Shield,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Zap,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
} from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
|
||||
import { useConfig } from '../hooks/useConfig'
|
||||
import type { DashboardStats } from '../types'
|
||||
|
||||
interface PoolStatus {
|
||||
target: number
|
||||
current: number
|
||||
deficit: number
|
||||
last_check: string
|
||||
auto_add: boolean
|
||||
min_interval: number
|
||||
last_auto_add: string
|
||||
polling_enabled: boolean
|
||||
polling_interval: number
|
||||
}
|
||||
|
||||
interface HealthCheckResult {
|
||||
account_id: number
|
||||
email: string
|
||||
status: string
|
||||
checked_at: string
|
||||
error?: string
|
||||
auto_paused?: boolean
|
||||
}
|
||||
|
||||
interface AutoAddLog {
|
||||
timestamp: string
|
||||
target: number
|
||||
current: number
|
||||
deficit: number
|
||||
action: string
|
||||
success: number
|
||||
failed: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export default function Monitor() {
|
||||
const { config } = useConfig()
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const [poolStatus, setPoolStatus] = useState<PoolStatus | null>(null)
|
||||
const [healthResults, setHealthResults] = useState<HealthCheckResult[]>([])
|
||||
const [autoAddLogs, setAutoAddLogs] = useState<AutoAddLog[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [checkingHealth, setCheckingHealth] = useState(false)
|
||||
const [autoPauseEnabled, setAutoPauseEnabled] = useState(false)
|
||||
|
||||
// 配置表单状态
|
||||
const [targetInput, setTargetInput] = useState(50)
|
||||
const [autoAdd, setAutoAdd] = useState(false)
|
||||
const [minInterval, setMinInterval] = useState(300)
|
||||
const [pollingEnabled, setPollingEnabled] = useState(false)
|
||||
const [pollingInterval, setPollingInterval] = useState(60)
|
||||
|
||||
const backendUrl = config.s2a.apiBase.replace(/\/api.*$/, '').replace(/:8080/, ':8088')
|
||||
|
||||
// 获取号池状态
|
||||
const fetchPoolStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${backendUrl}/api/pool/status`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.code === 0) {
|
||||
setPoolStatus(data.data)
|
||||
setTargetInput(data.data.target)
|
||||
setAutoAdd(data.data.auto_add)
|
||||
setMinInterval(data.data.min_interval)
|
||||
setPollingEnabled(data.data.polling_enabled)
|
||||
setPollingInterval(data.data.polling_interval)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取号池状态失败:', e)
|
||||
}
|
||||
}, [backendUrl])
|
||||
|
||||
// 刷新 S2A 统计
|
||||
const refreshStats = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
const res = await fetch(`${backendUrl}/api/pool/refresh`, { method: 'POST' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.code === 0) {
|
||||
setStats(data.data)
|
||||
}
|
||||
}
|
||||
await fetchPoolStatus()
|
||||
} catch (e) {
|
||||
console.error('刷新统计失败:', e)
|
||||
}
|
||||
setRefreshing(false)
|
||||
}, [backendUrl, fetchPoolStatus])
|
||||
|
||||
// 设置目标
|
||||
const handleSetTarget = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await fetch(`${backendUrl}/api/pool/target`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
target: targetInput,
|
||||
auto_add: autoAdd,
|
||||
min_interval: minInterval,
|
||||
}),
|
||||
})
|
||||
await fetchPoolStatus()
|
||||
} catch (e) {
|
||||
console.error('设置目标失败:', e)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
// 控制轮询
|
||||
const handleTogglePolling = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await fetch(`${backendUrl}/api/pool/polling`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enabled: !pollingEnabled,
|
||||
interval: pollingInterval,
|
||||
}),
|
||||
})
|
||||
setPollingEnabled(!pollingEnabled)
|
||||
await fetchPoolStatus()
|
||||
} catch (e) {
|
||||
console.error('控制轮询失败:', e)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
// 健康检查
|
||||
const handleHealthCheck = async (autoPause: boolean = false) => {
|
||||
setCheckingHealth(true)
|
||||
try {
|
||||
await fetch(`${backendUrl}/api/health-check/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ auto_pause: autoPause }),
|
||||
})
|
||||
// 等待一会儿再获取结果
|
||||
setTimeout(async () => {
|
||||
const res = await fetch(`${backendUrl}/api/health-check/results`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.code === 0) {
|
||||
setHealthResults(data.data || [])
|
||||
}
|
||||
}
|
||||
setCheckingHealth(false)
|
||||
}, 5000)
|
||||
} catch (e) {
|
||||
console.error('健康检查失败:', e)
|
||||
setCheckingHealth(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取自动补号日志
|
||||
const fetchAutoAddLogs = async () => {
|
||||
try {
|
||||
const res = await fetch(`${backendUrl}/api/auto-add/logs`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.code === 0) {
|
||||
setAutoAddLogs(data.data || [])
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取日志失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
useEffect(() => {
|
||||
fetchPoolStatus()
|
||||
refreshStats()
|
||||
fetchAutoAddLogs()
|
||||
}, [fetchPoolStatus, refreshStats])
|
||||
|
||||
// 计算健康状态
|
||||
const healthySummary = healthResults.reduce(
|
||||
(acc, r) => {
|
||||
if (r.status === 'active' && !r.error) acc.healthy++
|
||||
else acc.unhealthy++
|
||||
return acc
|
||||
},
|
||||
{ healthy: 0, unhealthy: 0 }
|
||||
)
|
||||
|
||||
const deficit = poolStatus ? Math.max(0, poolStatus.target - poolStatus.current) : 0
|
||||
const healthPercent = poolStatus && poolStatus.target > 0
|
||||
? Math.min(100, (poolStatus.current / poolStatus.target) * 100)
|
||||
: 0
|
||||
|
||||
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">号池监控</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
实时监控号池状态,自动补号管理
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refreshStats}
|
||||
disabled={refreshing}
|
||||
icon={<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 状态概览卡片 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg: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 text-slate-900 dark:text-slate-100 animate-countUp">
|
||||
{poolStatus?.current ?? '-'} / {poolStatus?.target ?? '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 rounded-xl bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<Target className="h-6 w-6 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="h-2 bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full progress-gradient rounded-full transition-all duration-500"
|
||||
style={{ width: `${healthPercent}%` }}
|
||||
/>
|
||||
</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 animate-countUp ${deficit > 0 ? 'text-orange-500' : 'text-green-500'
|
||||
}`}>
|
||||
{deficit}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`h-12 w-12 rounded-xl flex items-center justify-center ${deficit > 0 ? 'bg-orange-100 dark:bg-orange-900/30' : 'bg-green-100 dark:bg-green-900/30'
|
||||
}`}>
|
||||
{deficit > 0 ? (
|
||||
<TrendingDown className="h-6 w-6 text-orange-500" />
|
||||
) : (
|
||||
<TrendingUp className="h-6 w-6 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{deficit > 0 && (
|
||||
<p className="mt-2 text-xs text-orange-500 flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
低于目标
|
||||
</p>
|
||||
)}
|
||||
</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-slate-900 dark:text-slate-100">
|
||||
{pollingEnabled ? '运行中' : '已停止'}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`h-12 w-12 rounded-xl flex items-center justify-center ${pollingEnabled ? 'bg-green-100 dark:bg-green-900/30' : 'bg-slate-100 dark:bg-slate-800'
|
||||
}`}>
|
||||
{pollingEnabled ? (
|
||||
<Activity className="h-6 w-6 text-green-500 animate-pulse" />
|
||||
) : (
|
||||
<Pause className="h-6 w-6 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{pollingEnabled && (
|
||||
<p className="mt-2 text-xs text-slate-500 flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
每 {pollingInterval} 秒刷新
|
||||
</p>
|
||||
)}
|
||||
</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-slate-900 dark:text-slate-100">
|
||||
{autoAdd ? '已启用' : '已禁用'}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`h-12 w-12 rounded-xl flex items-center justify-center ${autoAdd ? 'bg-purple-100 dark:bg-purple-900/30' : 'bg-slate-100 dark:bg-slate-800'
|
||||
}`}>
|
||||
<Zap className={`h-6 w-6 ${autoAdd ? 'text-purple-500' : 'text-slate-400'}`} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 配置面板 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 目标设置 */}
|
||||
<Card className="glass-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Target className="h-5 w-5 text-blue-500" />
|
||||
号池目标设置
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Input
|
||||
label="目标账号数"
|
||||
type="number"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={targetInput}
|
||||
onChange={(e) => setTargetInput(Number(e.target.value))}
|
||||
hint="期望保持的活跃账号数量"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="autoAdd"
|
||||
checked={autoAdd}
|
||||
onChange={(e) => setAutoAdd(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="autoAdd" className="text-sm text-slate-700 dark:text-slate-300">
|
||||
启用自动补号
|
||||
</label>
|
||||
</div>
|
||||
<Input
|
||||
label="最小间隔 (秒)"
|
||||
type="number"
|
||||
min={60}
|
||||
max={3600}
|
||||
value={minInterval}
|
||||
onChange={(e) => setMinInterval(Number(e.target.value))}
|
||||
hint="两次自动补号的最小间隔"
|
||||
disabled={!autoAdd}
|
||||
/>
|
||||
<Button onClick={handleSetTarget} loading={loading} className="w-full">
|
||||
保存设置
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 轮询控制 */}
|
||||
<Card className="glass-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5 text-green-500" />
|
||||
实时监控设置
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Input
|
||||
label="轮询间隔 (秒)"
|
||||
type="number"
|
||||
min={10}
|
||||
max={300}
|
||||
value={pollingInterval}
|
||||
onChange={(e) => setPollingInterval(Number(e.target.value))}
|
||||
hint="自动刷新号池状态的间隔时间"
|
||||
/>
|
||||
<div className="p-4 rounded-lg bg-slate-50 dark:bg-slate-800/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">监控状态</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
{pollingEnabled ? '正在实时监控号池状态' : '监控已暂停'}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`status-dot ${pollingEnabled ? 'online' : 'offline'}`} />
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleTogglePolling}
|
||||
loading={loading}
|
||||
variant={pollingEnabled ? 'outline' : 'primary'}
|
||||
className="w-full"
|
||||
icon={pollingEnabled ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
>
|
||||
{pollingEnabled ? '停止监控' : '启动监控'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 健康检查 */}
|
||||
<Card className="glass-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-purple-500" />
|
||||
账号健康检查
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoPauseEnabled}
|
||||
onChange={(e) => setAutoPauseEnabled(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-slate-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
自动暂停问题账号
|
||||
</label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleHealthCheck(autoPauseEnabled)}
|
||||
disabled={checkingHealth}
|
||||
loading={checkingHealth}
|
||||
icon={<Shield className="h-4 w-4" />}
|
||||
>
|
||||
{checkingHealth ? '检查中...' : '开始检查'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{healthResults.length > 0 ? (
|
||||
<>
|
||||
{/* 统计 */}
|
||||
<div className="flex gap-4 mb-4">
|
||||
<div className="flex items-center gap-2 text-green-600">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<span>健康: {healthySummary.healthy}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-red-500">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span>异常: {healthySummary.unhealthy}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 结果列表 */}
|
||||
<div className="max-h-64 overflow-y-auto space-y-2">
|
||||
{healthResults.map((result) => (
|
||||
<div
|
||||
key={result.account_id}
|
||||
className={`flex items-center justify-between p-3 rounded-lg ${result.error
|
||||
? 'bg-red-50 dark:bg-red-900/20'
|
||||
: 'bg-green-50 dark:bg-green-900/20'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">
|
||||
{result.email}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">ID: {result.account_id}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={`text-sm font-medium ${result.error ? 'text-red-500' : 'text-green-600'
|
||||
}`}>
|
||||
{result.status}
|
||||
</p>
|
||||
{result.error && (
|
||||
<p className="text-xs text-red-400">{result.error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
<Shield className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||
<p>点击"开始检查"验证所有账号状态</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* S2A 实时统计 */}
|
||||
{stats && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>S2A 实时统计</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20">
|
||||
<p className="text-2xl font-bold text-blue-600">{stats.total_accounts}</p>
|
||||
<p className="text-sm text-slate-500">总账号</p>
|
||||
</div>
|
||||
<div className="text-center p-4 rounded-lg bg-green-50 dark:bg-green-900/20">
|
||||
<p className="text-2xl font-bold text-green-600">{stats.normal_accounts}</p>
|
||||
<p className="text-sm text-slate-500">正常</p>
|
||||
</div>
|
||||
<div className="text-center p-4 rounded-lg bg-red-50 dark:bg-red-900/20">
|
||||
<p className="text-2xl font-bold text-red-500">{stats.error_accounts}</p>
|
||||
<p className="text-sm text-slate-500">错误</p>
|
||||
</div>
|
||||
<div className="text-center p-4 rounded-lg bg-orange-50 dark:bg-orange-900/20">
|
||||
<p className="text-2xl font-bold text-orange-500">{stats.ratelimit_accounts}</p>
|
||||
<p className="text-sm text-slate-500">限流</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 自动补号日志 */}
|
||||
{autoAddLogs.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5 text-slate-500" />
|
||||
操作日志
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={fetchAutoAddLogs}
|
||||
icon={<RefreshCw className="h-4 w-4" />}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-h-64 overflow-y-auto space-y-2">
|
||||
{[...autoAddLogs].reverse().slice(0, 20).map((log, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`flex items-center justify-between p-3 rounded-lg text-sm ${log.action.includes('trigger') || log.action.includes('decrease')
|
||||
? 'bg-orange-50 dark:bg-orange-900/20'
|
||||
: log.action.includes('increase')
|
||||
? 'bg-green-50 dark:bg-green-900/20'
|
||||
: 'bg-slate-50 dark:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-slate-400">
|
||||
{new Date(log.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
<span className="font-medium text-slate-900 dark:text-slate-100">
|
||||
{log.message}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{log.current} / {log.target}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
112
frontend/src/pages/Records.tsx
Normal file
112
frontend/src/pages/Records.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Trash2, Calendar } from 'lucide-react'
|
||||
import { RecordList, RecordStats } from '../components/records'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
|
||||
import { useRecords } from '../hooks/useRecords'
|
||||
|
||||
export default function Records() {
|
||||
const { records, deleteRecord, clearRecords, getStats } = useRecords()
|
||||
const [startDate, setStartDate] = useState('')
|
||||
const [endDate, setEndDate] = useState('')
|
||||
|
||||
const stats = useMemo(() => getStats(), [getStats])
|
||||
|
||||
const filteredRecords = useMemo(() => {
|
||||
if (!startDate && !endDate) return records
|
||||
|
||||
return records.filter((record) => {
|
||||
const recordDate = new Date(record.timestamp)
|
||||
const start = startDate ? new Date(startDate) : null
|
||||
const end = endDate ? new Date(endDate + 'T23:59:59') : null
|
||||
|
||||
if (start && recordDate < start) return false
|
||||
if (end && recordDate > end) return false
|
||||
return true
|
||||
})
|
||||
}, [records, startDate, endDate])
|
||||
|
||||
const handleClearFilter = () => {
|
||||
setStartDate('')
|
||||
setEndDate('')
|
||||
}
|
||||
|
||||
const handleClearAll = () => {
|
||||
if (window.confirm('确定要清空所有记录吗?此操作不可恢复。')) {
|
||||
clearRecords()
|
||||
}
|
||||
}
|
||||
|
||||
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">加号记录</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">查看历史入库记录</p>
|
||||
</div>
|
||||
{records.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearAll}
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
>
|
||||
清空记录
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<RecordStats stats={stats} />
|
||||
|
||||
{/* Filter */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
日期筛选
|
||||
</CardTitle>
|
||||
{(startDate || endDate) && (
|
||||
<Button variant="ghost" size="sm" onClick={handleClearFilter}>
|
||||
清除筛选
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="w-40">
|
||||
<Input
|
||||
label="开始日期"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<Input
|
||||
label="结束日期"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400">
|
||||
共 {filteredRecords.length} 条记录
|
||||
{filteredRecords.length !== records.length && <span className="ml-1">(已筛选)</span>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Record List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>记录列表</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RecordList records={filteredRecords} onDelete={deleteRecord} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
241
frontend/src/pages/S2AConfig.tsx
Normal file
241
frontend/src/pages/S2AConfig.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useState } from 'react'
|
||||
import { TestTube, CheckCircle, XCircle, Loader2, Save, Server, Plus, X } from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
|
||||
import { useConfig } from '../hooks/useConfig'
|
||||
|
||||
export default function S2AConfig() {
|
||||
const {
|
||||
config,
|
||||
updateS2AConfig,
|
||||
updatePoolingConfig,
|
||||
testConnection,
|
||||
isConnected,
|
||||
} = useConfig()
|
||||
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<boolean | null>(null)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
// Local form state - S2A 连接
|
||||
const [s2aApiBase, setS2aApiBase] = useState(config.s2a.apiBase)
|
||||
const [s2aAdminKey, setS2aAdminKey] = useState(config.s2a.adminKey)
|
||||
|
||||
// Local form state - 入库设置
|
||||
const [poolingConcurrency, setPoolingConcurrency] = useState(config.pooling.concurrency)
|
||||
const [poolingPriority, setPoolingPriority] = useState(config.pooling.priority)
|
||||
const [groupIds, setGroupIds] = useState<number[]>(config.pooling.groupIds || [])
|
||||
const [newGroupId, setNewGroupId] = useState('')
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
// Save first
|
||||
updateS2AConfig({ apiBase: s2aApiBase, adminKey: s2aAdminKey })
|
||||
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
|
||||
// Wait a bit for the client to be recreated
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
const result = await testConnection()
|
||||
setTestResult(result)
|
||||
setTesting(false)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
updateS2AConfig({ apiBase: s2aApiBase, adminKey: s2aAdminKey })
|
||||
updatePoolingConfig({
|
||||
concurrency: poolingConcurrency,
|
||||
priority: poolingPriority,
|
||||
groupIds: groupIds,
|
||||
})
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
}
|
||||
|
||||
const handleAddGroupId = () => {
|
||||
const id = parseInt(newGroupId, 10)
|
||||
if (!isNaN(id) && !groupIds.includes(id)) {
|
||||
setGroupIds([...groupIds, id])
|
||||
setNewGroupId('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveGroupId = (id: number) => {
|
||||
setGroupIds(groupIds.filter(g => g !== id))
|
||||
}
|
||||
|
||||
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 flex items-center gap-2">
|
||||
<Server className="h-7 w-7 text-blue-500" />
|
||||
S2A 配置
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">配置 S2A 号池连接和入库参数</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
icon={saved ? <CheckCircle className="h-4 w-4" /> : <Save className="h-4 w-4" />}
|
||||
>
|
||||
{saved ? '已保存' : '保存配置'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* S2A Connection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>S2A 连接配置</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<span className="flex items-center gap-1 text-sm text-green-600 dark:text-green-400">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
已连接
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
<XCircle className="h-4 w-4" />
|
||||
未连接
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Input
|
||||
label="S2A API 地址"
|
||||
placeholder="http://localhost:8080"
|
||||
value={s2aApiBase}
|
||||
onChange={(e) => setS2aApiBase(e.target.value)}
|
||||
hint="S2A 服务的 API 地址,例如 http://localhost:8080"
|
||||
/>
|
||||
<Input
|
||||
label="Admin API Key"
|
||||
type="password"
|
||||
placeholder="admin-xxxxxxxxxxxxxxxx"
|
||||
value={s2aAdminKey}
|
||||
onChange={(e) => setS2aAdminKey(e.target.value)}
|
||||
hint="S2A 管理密钥,可在 S2A 后台 Settings 页面获取"
|
||||
/>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testing || !s2aApiBase || !s2aAdminKey}
|
||||
icon={
|
||||
testing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<TestTube className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
>
|
||||
{testing ? '测试中...' : '测试连接'}
|
||||
</Button>
|
||||
{testResult !== null && (
|
||||
<span
|
||||
className={`text-sm ${testResult
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}`}
|
||||
>
|
||||
{testResult ? '连接成功' : '连接失败'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pooling Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>入库默认设置</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="默认并发数"
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={poolingConcurrency}
|
||||
onChange={(e) => setPoolingConcurrency(Number(e.target.value))}
|
||||
hint="账号的默认并发请求数"
|
||||
/>
|
||||
<Input
|
||||
label="默认优先级"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={poolingPriority}
|
||||
onChange={(e) => setPoolingPriority(Number(e.target.value))}
|
||||
hint="账号的默认优先级,数值越大优先级越高"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Group IDs */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
分组 ID
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{groupIds.map(id => (
|
||||
<span
|
||||
key={id}
|
||||
className="inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
>
|
||||
{id}
|
||||
<button
|
||||
onClick={() => handleRemoveGroupId(id)}
|
||||
className="hover:text-red-500 transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{groupIds.length === 0 && (
|
||||
<span className="text-sm text-slate-400">未设置分组</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Input
|
||||
placeholder="输入分组 ID"
|
||||
type="number"
|
||||
min={1}
|
||||
value={newGroupId}
|
||||
onChange={(e) => setNewGroupId(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAddGroupId()}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleAddGroupId}
|
||||
disabled={!newGroupId}
|
||||
icon={<Plus className="h-4 w-4" />}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
入库时账号将被分配到这些分组
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Info */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400">
|
||||
<p className="font-medium mb-2">配置说明:</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>S2A API 地址是您部署的 S2A 服务的完整 URL</li>
|
||||
<li>Admin API Key 用于管理账号池,具有完全权限</li>
|
||||
<li>入库默认设置会应用到新入库的账号</li>
|
||||
<li>分组 ID 用于将账号归类到指定分组</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
483
frontend/src/pages/TeamProcess.tsx
Normal file
483
frontend/src/pages/TeamProcess.tsx
Normal file
@@ -0,0 +1,483 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Users,
|
||||
Play,
|
||||
Square,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Upload,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
|
||||
import { useConfig } from '../hooks/useConfig'
|
||||
|
||||
interface Owner {
|
||||
email: string
|
||||
password: string
|
||||
token: string
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export default function TeamProcess() {
|
||||
const { config } = useConfig()
|
||||
const [owners, setOwners] = useState<Owner[]>([])
|
||||
const [status, setStatus] = useState<ProcessStatus | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [polling, setPolling] = useState(false)
|
||||
|
||||
// 配置
|
||||
const [membersPerTeam, setMembersPerTeam] = useState(4)
|
||||
const [concurrentTeams, setConcurrentTeams] = useState(2)
|
||||
const [browserType, setBrowserType] = useState<'chromedp' | 'rod'>('chromedp')
|
||||
const [headless, setHeadless] = useState(true)
|
||||
const [proxy, setProxy] = useState('')
|
||||
|
||||
const backendUrl = config.s2a.apiBase.replace(/\/api.*$/, '').replace(/:8080/, ':8088')
|
||||
|
||||
// 获取状态
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${backendUrl}/api/team/status`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.code === 0) {
|
||||
setStatus(data.data)
|
||||
if (!data.data.running) {
|
||||
setPolling(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取状态失败:', e)
|
||||
}
|
||||
}, [backendUrl])
|
||||
|
||||
// 轮询状态
|
||||
useEffect(() => {
|
||||
if (polling) {
|
||||
const interval = setInterval(fetchStatus, 2000)
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [polling, fetchStatus])
|
||||
|
||||
// 初始化
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
}, [fetchStatus])
|
||||
|
||||
// 上传账号文件
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
const text = await file.text()
|
||||
const data = JSON.parse(text)
|
||||
const parsed = Array.isArray(data) ? data : [data]
|
||||
|
||||
const validOwners = parsed.filter((a: Record<string, unknown>) =>
|
||||
(a.email || a.account) && a.password && (a.token || a.access_token)
|
||||
).map((a: Record<string, unknown>) => ({
|
||||
email: (a.email || a.account) as string,
|
||||
password: a.password as string,
|
||||
token: (a.token || a.access_token) as string,
|
||||
}))
|
||||
|
||||
setOwners(validOwners)
|
||||
setConcurrentTeams(Math.min(validOwners.length, 2))
|
||||
} catch (err) {
|
||||
alert('文件解析失败,请确保是有效的 JSON 格式')
|
||||
}
|
||||
}
|
||||
|
||||
// 启动处理
|
||||
const handleStart = async () => {
|
||||
if (owners.length === 0) {
|
||||
alert('请先上传账号文件')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${backendUrl}/api/team/process`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
owners: owners.slice(0, concurrentTeams),
|
||||
members_per_team: membersPerTeam,
|
||||
concurrent_teams: concurrentTeams,
|
||||
browser_type: browserType,
|
||||
headless,
|
||||
proxy,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setPolling(true)
|
||||
fetchStatus()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
alert(data.message || '启动失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('启动失败:', e)
|
||||
alert('启动失败')
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
// 停止处理
|
||||
const handleStop = async () => {
|
||||
try {
|
||||
await fetch(`${backendUrl}/api/team/stop`, { method: 'POST' })
|
||||
setPolling(false)
|
||||
fetchStatus()
|
||||
} catch (e) {
|
||||
console.error('停止失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const isRunning = status?.running
|
||||
|
||||
// 计算统计
|
||||
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 expectedTotal = (status?.total_teams || 0) * membersPerTeam
|
||||
|
||||
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">
|
||||
多 Team 并发注册成员并入库 S2A
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchStatus}
|
||||
icon={<RefreshCw className={`h-4 w-4 ${polling ? '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-lg font-bold ${isRunning ? 'text-green-500' : 'text-slate-500'}`}>
|
||||
{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" />
|
||||
) : (
|
||||
<Clock 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-lg font-bold text-blue-500">
|
||||
{status?.completed || 0} / {status?.total_teams || '-'}
|
||||
</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>
|
||||
|
||||
<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-lg font-bold text-green-500">
|
||||
{totalRegistered} / {expectedTotal || '-'}
|
||||
</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-lg font-bold text-purple-500">{totalS2A}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 rounded-xl bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
|
||||
<Settings className="h-6 w-6 text-purple-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">
|
||||
{/* 账号文件上传 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Owner 账号文件
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex-1 flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-lg cursor-pointer hover:border-blue-500 transition-colors">
|
||||
<Upload className="h-5 w-5 text-slate-400" />
|
||||
<span className="text-sm text-slate-500">
|
||||
{owners.length > 0 ? `已加载 ${owners.length} 个账号` : '选择 JSON 文件'}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
disabled={isRunning}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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, owners.length)}
|
||||
value={concurrentTeams}
|
||||
onChange={(e) => setConcurrentTeams(Number(e.target.value))}
|
||||
disabled={isRunning}
|
||||
hint={`最多 ${owners.length} 个`}
|
||||
/>
|
||||
|
||||
<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="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="headless"
|
||||
checked={headless}
|
||||
onChange={(e) => setHeadless(e.target.checked)}
|
||||
disabled={isRunning}
|
||||
className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="headless" className="text-sm text-slate-700 dark:text-slate-300">
|
||||
无头模式 (推荐)
|
||||
</label>
|
||||
</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-4">
|
||||
{isRunning ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleStop}
|
||||
className="flex-1"
|
||||
icon={<Square className="h-4 w-4" />}
|
||||
>
|
||||
停止
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleStart}
|
||||
loading={loading}
|
||||
disabled={owners.length === 0}
|
||||
className="flex-1"
|
||||
icon={<Play 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">
|
||||
<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 max-h-[500px] overflow-y-auto">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
9
frontend/src/pages/index.ts
Normal file
9
frontend/src/pages/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { default as Dashboard } from './Dashboard'
|
||||
export { default as Upload } from './Upload'
|
||||
export { default as Records } from './Records'
|
||||
export { default as Accounts } from './Accounts'
|
||||
export { default as Config } from './Config'
|
||||
export { default as S2AConfig } from './S2AConfig'
|
||||
export { default as EmailConfig } from './EmailConfig'
|
||||
export { default as Monitor } from './Monitor'
|
||||
export { default as TeamProcess } from './TeamProcess'
|
||||
Reference in New Issue
Block a user