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:
2026-01-30 07:40:35 +08:00
commit f4448bbef2
106 changed files with 19282 additions and 0 deletions

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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'