feat: Introduce core application structure, configuration, monitoring, and team management features.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import { ConfigProvider, RecordsProvider } from './context'
|
||||
import { Layout } from './components/layout'
|
||||
import { Dashboard, Upload, Records, Accounts, Config, S2AConfig, EmailConfig, Monitor, Cleaner, TeamReg, CodexProxyConfig } from './pages'
|
||||
import { Dashboard, Upload, Records, Accounts, Config, S2AConfig, EmailConfig, Monitor, Cleaner, TeamReg, CodexProxyConfig, S2AStats } from './pages'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -20,6 +20,7 @@ function App() {
|
||||
<Route path="config/s2a" element={<S2AConfig />} />
|
||||
<Route path="config/email" element={<EmailConfig />} />
|
||||
<Route path="config/codex-proxy" element={<CodexProxyConfig />} />
|
||||
<Route path="s2a-stats" element={<S2AStats />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</RecordsProvider>
|
||||
|
||||
@@ -211,13 +211,13 @@ export default function LiveLogViewer({
|
||||
>
|
||||
<span className="text-slate-500 flex-shrink-0 font-medium">{log.timestamp}</span>
|
||||
<span className={`flex-shrink-0 uppercase font-bold w-20 text-center rounded-[4px] px-1 ${levelColors[log.level] || 'text-slate-400'}`}>
|
||||
{log.level}
|
||||
{log.level === 'success' ? '✓' : log.level}
|
||||
</span>
|
||||
<span className="text-slate-400 flex-shrink-0 opacity-80">[{log.module}]</span>
|
||||
{log.email && (
|
||||
<span className="text-purple-400 flex-shrink-0 truncate max-w-[180px] font-medium">{log.email}</span>
|
||||
)}
|
||||
<span className="text-slate-200 flex-1 break-all">{log.message}</span>
|
||||
<span className={`flex-1 break-all ${log.level === 'success' ? 'text-green-400 font-bold' : 'text-slate-200'}`}>{log.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type SelectHTMLAttributes, forwardRef } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { useState, useRef, useEffect, forwardRef, useImperativeHandle } from 'react'
|
||||
import { ChevronDown, Check } from 'lucide-react'
|
||||
|
||||
export interface SelectOption {
|
||||
value: string | number
|
||||
@@ -7,62 +7,125 @@ export interface SelectOption {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'children'> {
|
||||
export interface SelectProps {
|
||||
label?: string
|
||||
error?: string
|
||||
hint?: string
|
||||
options: SelectOption[]
|
||||
placeholder?: string
|
||||
value?: string | number
|
||||
onChange?: (e: { target: { value: string | number } }) => void
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
id?: string
|
||||
}
|
||||
|
||||
const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className = '', label, error, hint, options, placeholder, id, ...props }, ref) => {
|
||||
const Select = forwardRef<any, SelectProps>(
|
||||
({ className = '', label, error, hint, options, placeholder, value, onChange, disabled, id }, ref) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const selectId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
||||
|
||||
// Expose some functionality if needed
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => containerRef.current?.focus(),
|
||||
}))
|
||||
|
||||
// Handle click outside to close
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const selectedOption = options.find((opt) => opt.value === value)
|
||||
|
||||
const handleSelect = (option: SelectOption) => {
|
||||
if (option.disabled || disabled) return
|
||||
if (onChange) {
|
||||
onChange({ target: { value: option.value } })
|
||||
}
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className={`w-full ${className}`} ref={containerRef}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={selectId}
|
||||
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
|
||||
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
<select
|
||||
ref={ref}
|
||||
<button
|
||||
type="button"
|
||||
id={selectId}
|
||||
className={`w-full px-3 py-2 text-sm rounded-lg border transition-colors appearance-none
|
||||
bg-white dark:bg-slate-800
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
className={`
|
||||
relative w-full flex items-center justify-between
|
||||
px-3 py-2.5 text-sm rounded-xl border transition-all duration-200
|
||||
bg-white dark:bg-slate-800/50
|
||||
text-slate-900 dark:text-slate-100
|
||||
${
|
||||
error
|
||||
? 'border-red-500 focus:border-red-500 focus:ring-red-500'
|
||||
: 'border-slate-300 dark:border-slate-600 focus:border-blue-500 focus:ring-blue-500'
|
||||
${isOpen
|
||||
? 'border-blue-500 ring-2 ring-blue-500/20 shadow-sm'
|
||||
: error
|
||||
? 'border-red-500'
|
||||
: 'border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600'
|
||||
}
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-0
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
pr-10
|
||||
${className}`}
|
||||
{...props}
|
||||
${disabled ? 'opacity-50 cursor-not-allowed bg-slate-50 dark:bg-slate-900/50' : 'cursor-pointer'}
|
||||
focus:outline-none
|
||||
`}
|
||||
>
|
||||
{placeholder && (
|
||||
<option value="" disabled>
|
||||
{placeholder}
|
||||
</option>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value} disabled={option.disabled}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 pointer-events-none" />
|
||||
<span className={`block truncate ${!selectedOption ? 'text-slate-400' : ''}`}>
|
||||
{selectedOption ? selectedOption.label : placeholder || '请选择...'}
|
||||
</span>
|
||||
<span className="flex items-center pointer-events-none transition-transform duration-200" style={{ transform: isOpen ? 'rotate(180deg)' : 'rotate(0)' }}>
|
||||
<ChevronDown className={`h-4 w-4 ${isOpen ? 'text-blue-500' : 'text-slate-400'}`} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 w-full mt-2 py-1 overflow-auto text-base bg-white dark:bg-slate-800 rounded-xl shadow-xl max-h-60 ring-1 ring-black/5 dark:ring-white/10 focus:outline-none sm:text-sm animate-in fade-in zoom-in-95 duration-100">
|
||||
{options.length === 0 ? (
|
||||
<div className="px-4 py-2 text-slate-500 dark:text-slate-400 italic">
|
||||
无可用选项
|
||||
</div>
|
||||
) : (
|
||||
options.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
onClick={() => handleSelect(option)}
|
||||
className={`
|
||||
relative cursor-pointer select-none py-2.5 pl-10 pr-4 transition-colors
|
||||
${option.value === value
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 font-medium'
|
||||
: 'text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700/50'
|
||||
}
|
||||
${option.disabled ? 'opacity-50 cursor-not-allowed grayscale' : ''}
|
||||
`}
|
||||
>
|
||||
<span className="block truncate">{option.label}</span>
|
||||
{option.value === value && (
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-blue-600 dark:text-blue-400">
|
||||
<Check className="h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && <p className="mt-1 text-sm text-red-500">{error}</p>}
|
||||
{error && <p className="mt-1.5 text-xs font-medium text-red-500 ml-1">{error}</p>}
|
||||
{hint && !error && (
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{hint}</p>
|
||||
<p className="mt-1.5 text-xs text-slate-500 dark:text-slate-400 ml-1">{hint}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Trash2,
|
||||
UserPlus,
|
||||
Globe,
|
||||
BarChart3,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface SidebarProps {
|
||||
@@ -36,6 +37,7 @@ const navItems: NavItem[] = [
|
||||
{ to: '/records', icon: History, label: '加号记录' },
|
||||
{ to: '/accounts', icon: Users, label: '号池账号' },
|
||||
{ to: '/monitor', icon: Activity, label: '号池监控' },
|
||||
{ to: '/s2a-stats', icon: BarChart3, label: 'S2A 统计' },
|
||||
{ to: '/cleaner', icon: Trash2, label: '定期清理' },
|
||||
{ to: '/team-reg', icon: UserPlus, label: 'Team 注册' },
|
||||
{
|
||||
@@ -46,7 +48,7 @@ const navItems: NavItem[] = [
|
||||
{ to: '/config', icon: Cog, label: '配置概览' },
|
||||
{ to: '/config/s2a', icon: Server, label: 'S2A 配置' },
|
||||
{ to: '/config/email', icon: Mail, label: '邮箱配置' },
|
||||
{ to: '/config/codex-proxy', icon: Globe, label: 'CodexAuth代理池' },
|
||||
{ to: '/config/codex-proxy', icon: Globe, label: '代理配置' },
|
||||
]
|
||||
},
|
||||
]
|
||||
@@ -133,7 +135,7 @@ export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-50 w-64 bg-white/80 dark:bg-slate-900/90 backdrop-blur-xl border-r border-slate-200 dark:border-slate-800 transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:z-auto ${isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
className={`fixed inset-y-0 left-0 z-50 w-64 bg-white/80 dark:bg-slate-900/90 backdrop-blur-xl border-r border-slate-200 dark:border-slate-800 transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:sticky lg:top-0 lg:h-screen lg:z-auto ${isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
{/* Mobile close button */}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Globe, Plus, Trash2, ToggleLeft, ToggleRight,
|
||||
Loader2, Save, RefreshCcw, CheckCircle, XCircle,
|
||||
AlertTriangle, Clock, MapPin, Play, PlayCircle
|
||||
Loader2, Save, RefreshCw, CheckCircle, XCircle,
|
||||
AlertTriangle, Clock, MapPin, Play, PlayCircle,
|
||||
Settings, Zap
|
||||
} from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button, Input, useToast } from '../components/common'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button, Input, Select, useToast } from '../components/common'
|
||||
|
||||
interface CodexProxy {
|
||||
id: number
|
||||
@@ -34,6 +35,13 @@ export default function CodexProxyConfig() {
|
||||
const [testingAll, setTestingAll] = useState(false)
|
||||
const toast = useToast()
|
||||
|
||||
// 全局与注册代理配置
|
||||
const [globalProxyMode, setGlobalProxyMode] = useState<'manual' | 'pool:random' | 'pool:id'>('manual')
|
||||
const [globalProxyValue, setGlobalProxyValue] = useState('')
|
||||
const [regProxyMode, setRegProxyMode] = useState<'manual' | 'pool:random' | 'pool:id'>('manual')
|
||||
const [regProxyValue, setRegProxyValue] = useState('')
|
||||
const [savingConfig, setSavingConfig] = useState(false)
|
||||
|
||||
// 单个添加
|
||||
const [newProxyUrl, setNewProxyUrl] = useState('')
|
||||
const [newDescription, setNewDescription] = useState('')
|
||||
@@ -59,9 +67,63 @@ export default function CodexProxyConfig() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/config')
|
||||
const data = await res.json()
|
||||
if (data.code === 0 && data.data) {
|
||||
const parse = (val: string) => {
|
||||
if (!val) return { mode: 'manual' as const, value: '' }
|
||||
if (val === 'pool:random' || val === '[RANDOM]') return { mode: 'pool:random' as const, value: '' }
|
||||
if (val.startsWith('pool:id:')) return { mode: 'pool:id' as const, value: val.replace('pool:id:', '') }
|
||||
return { mode: 'manual' as const, value: val }
|
||||
}
|
||||
const g = parse(data.data.default_proxy)
|
||||
setGlobalProxyMode(g.mode)
|
||||
setGlobalProxyValue(g.value)
|
||||
const r = parse(data.data.team_reg_proxy)
|
||||
setRegProxyMode(r.mode)
|
||||
setRegProxyValue(r.value)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取配置失败:', e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchProxies()
|
||||
}, [fetchProxies])
|
||||
fetchConfig()
|
||||
}, [fetchProxies, fetchConfig])
|
||||
|
||||
// 保存代理用途配置
|
||||
const handleSaveUsageConfig = async () => {
|
||||
setSavingConfig(true)
|
||||
try {
|
||||
const stringify = (mode: string, val: string) => {
|
||||
if (mode === 'pool:random') return 'pool:random'
|
||||
if (mode === 'pool:id') return `pool:id:${val}`
|
||||
return val
|
||||
}
|
||||
const res = await fetch('/api/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
default_proxy: stringify(globalProxyMode, globalProxyValue),
|
||||
team_reg_proxy: stringify(regProxyMode, regProxyValue),
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.code === 0) {
|
||||
toast.success('用途配置已保存')
|
||||
} else {
|
||||
toast.error(data.message || '保存失败')
|
||||
}
|
||||
} catch {
|
||||
toast.error('网络错误')
|
||||
} finally {
|
||||
setSavingConfig(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加代理
|
||||
const handleAddProxy = async () => {
|
||||
@@ -354,6 +416,7 @@ export default function CodexProxyConfig() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
@@ -369,7 +432,7 @@ export default function CodexProxyConfig() {
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={fetchProxies}
|
||||
icon={<RefreshCcw className="h-4 w-4" />}
|
||||
icon={<RefreshCw className="h-4 w-4" />}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
@@ -519,7 +582,7 @@ export default function CodexProxyConfig() {
|
||||
{proxies.map((proxy) => {
|
||||
const successRate = getSuccessRate(proxy)
|
||||
const istesting = testingIds.has(proxy.id)
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
key={proxy.id}
|
||||
@@ -609,6 +672,160 @@ export default function CodexProxyConfig() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Usage Configuration */}
|
||||
<Card className="border-blue-100 dark:border-blue-900 shadow-sm">
|
||||
<CardHeader className="bg-slate-50/80 dark:bg-slate-800/80 border-b border-slate-100 dark:border-slate-700 rounded-t-xl">
|
||||
<CardTitle className="flex items-center gap-2 text-slate-800 dark:text-slate-100 font-bold">
|
||||
<Settings className="h-5 w-5 text-blue-500" />
|
||||
代理用途配置
|
||||
</CardTitle>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveUsageConfig}
|
||||
disabled={savingConfig}
|
||||
className="rounded-full px-5"
|
||||
icon={savingConfig ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
>
|
||||
保存配置
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-col divide-y divide-slate-100 dark:divide-slate-700">
|
||||
{/* 全局默认代理 */}
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="space-y-1.5 border-l-4 border-blue-500 pl-4">
|
||||
<h3 className="text-base font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
||||
<Globe className="h-5 w-5 text-blue-500" />
|
||||
全局默认代理配置
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
影响验证、补号等通用任务。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Select
|
||||
label="使用模式"
|
||||
value={globalProxyMode}
|
||||
onChange={(e: any) => setGlobalProxyMode(e.target.value as any)}
|
||||
options={[
|
||||
{ label: '手动输入模式', value: 'manual' },
|
||||
{ label: '池随机轮询 (Random Rotation)', value: 'pool:random' },
|
||||
{ label: '池固定项使用 (Specified ID)', value: 'pool:id' },
|
||||
]}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{globalProxyMode === 'manual' && (
|
||||
<Input
|
||||
label="手动输入代理地址"
|
||||
value={globalProxyValue}
|
||||
onChange={(e) => setGlobalProxyValue(e.target.value)}
|
||||
placeholder="http://host:port 或 user:pass@host:port"
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
|
||||
{globalProxyMode === 'pool:random' && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">模式状态</label>
|
||||
<div className="h-[46px] px-4 rounded-xl bg-blue-50/30 dark:bg-blue-900/10 border border-blue-100/50 dark:border-blue-800/30 flex items-center justify-between text-blue-600 dark:text-blue-400 shadow-sm border-dashed">
|
||||
<div className="flex items-center gap-2 text-sm font-bold">
|
||||
<Zap className="h-4 w-4 fill-current" />
|
||||
代理池随机轮询模式已启用
|
||||
</div>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 font-bold uppercase tracking-wider">Active</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{globalProxyMode === 'pool:id' && (
|
||||
<Select
|
||||
label="从池中选择特定项"
|
||||
value={globalProxyValue}
|
||||
onChange={(e: any) => setGlobalProxyValue(e.target.value)}
|
||||
options={proxies.filter(p => p.is_enabled).map(p => ({
|
||||
label: `${p.description || '未命名'} - ${formatProxyDisplay(p.proxy_url)}`,
|
||||
value: p.id.toString()
|
||||
}))}
|
||||
placeholder="请在下方下拉菜单中选择一个代理"
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 注册专用代理 */}
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="space-y-1.5 border-l-4 border-purple-500 pl-4">
|
||||
<h3 className="text-base font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
||||
<PlayCircle className="h-5 w-5 text-purple-500" />
|
||||
注册环境代理配置
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
影响“团队自动注册 (Team-Reg)”任务。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Select
|
||||
label="使用模式"
|
||||
value={regProxyMode}
|
||||
onChange={(e: any) => setRegProxyMode(e.target.value as any)}
|
||||
options={[
|
||||
{ label: '手动输入模式', value: 'manual' },
|
||||
{ label: '池随机轮询 (Random Rotation)', value: 'pool:random' },
|
||||
{ label: '池固定项使用 (Specified ID)', value: 'pool:id' },
|
||||
]}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{regProxyMode === 'manual' && (
|
||||
<Input
|
||||
label="手动输入代理地址"
|
||||
value={regProxyValue}
|
||||
onChange={(e) => setRegProxyValue(e.target.value)}
|
||||
placeholder="http://host:port 或 user:pass@host:port"
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
|
||||
{regProxyMode === 'pool:random' && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">模式状态</label>
|
||||
<div className="h-[46px] px-4 rounded-xl bg-purple-50/30 dark:bg-purple-900/10 border border-purple-100/50 dark:border-purple-800/30 flex items-center justify-between text-purple-600 dark:text-purple-400 shadow-sm border-dashed">
|
||||
<div className="flex items-center gap-2 text-sm font-bold">
|
||||
<Zap className="h-4 w-4 fill-current" />
|
||||
注册任务随机轮询模式已启用
|
||||
</div>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300 font-bold uppercase tracking-wider">Active</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{regProxyMode === 'pool:id' && (
|
||||
<Select
|
||||
label="从池中选择特定项"
|
||||
value={regProxyValue}
|
||||
onChange={(e: any) => setRegProxyValue(e.target.value)}
|
||||
options={proxies.filter(p => p.is_enabled).map(p => ({
|
||||
label: `${p.description || '未命名'} - ${formatProxyDisplay(p.proxy_url)}`,
|
||||
value: p.id.toString()
|
||||
}))}
|
||||
placeholder="请在下方下拉菜单中选择一个代理"
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Info */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
|
||||
@@ -11,11 +11,9 @@ import {
|
||||
Save,
|
||||
Loader2,
|
||||
Globe,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
HelpCircle,
|
||||
Zap,
|
||||
Monitor
|
||||
Monitor,
|
||||
Network
|
||||
} from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
|
||||
import { useConfig } from '../hooks/useConfig'
|
||||
@@ -24,19 +22,10 @@ import { useToast, ToastContainer } from '../components/Toast'
|
||||
export default function Config() {
|
||||
const { config, isConnected, refreshConfig } = useConfig()
|
||||
const [siteName, setSiteName] = useState('')
|
||||
const [defaultProxy, setDefaultProxy] = useState('')
|
||||
const [teamRegProxy, setTeamRegProxy] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [savingProxy, setSavingProxy] = useState(false)
|
||||
const [savingTeamRegProxy, setSavingTeamRegProxy] = useState(false)
|
||||
const [testingProxy, setTestingProxy] = useState(false)
|
||||
const [testingTeamRegProxy, setTestingTeamRegProxy] = useState(false)
|
||||
const [proxyStatus, setProxyStatus] = useState<'unknown' | 'success' | 'error'>('unknown')
|
||||
const [teamRegProxyStatus, setTeamRegProxyStatus] = useState<'unknown' | 'success' | 'error'>('unknown')
|
||||
const [proxyOriginIP, setProxyOriginIP] = useState('')
|
||||
const [teamRegProxyIP, setTeamRegProxyIP] = useState('')
|
||||
const [authMethod, setAuthMethod] = useState<'api' | 'browser'>('browser')
|
||||
const [savingAuthMethod, setSavingAuthMethod] = useState(false)
|
||||
const [proxyPoolCount, setProxyPoolCount] = useState<number>(0)
|
||||
const { toasts, toast, removeToast } = useToast()
|
||||
|
||||
// 加载站点名称和代理配置
|
||||
@@ -47,24 +36,6 @@ export default function Config() {
|
||||
const data = await res.json()
|
||||
if (data.code === 0 && data.data) {
|
||||
setSiteName(data.data.site_name || 'Codex Pool')
|
||||
setDefaultProxy(data.data.default_proxy || '')
|
||||
setTeamRegProxy(data.data.team_reg_proxy || '')
|
||||
// 恢复全局代理测试状态
|
||||
const testStatus = data.data.proxy_test_status
|
||||
if (testStatus === 'success' || testStatus === 'error') {
|
||||
setProxyStatus(testStatus)
|
||||
}
|
||||
if (data.data.proxy_test_ip) {
|
||||
setProxyOriginIP(data.data.proxy_test_ip)
|
||||
}
|
||||
// 恢复注册代理测试状态
|
||||
const teamRegTestStatus = data.data.team_reg_proxy_test_status
|
||||
if (teamRegTestStatus === 'success' || teamRegTestStatus === 'error') {
|
||||
setTeamRegProxyStatus(teamRegTestStatus)
|
||||
}
|
||||
if (data.data.team_reg_proxy_test_ip) {
|
||||
setTeamRegProxyIP(data.data.team_reg_proxy_test_ip)
|
||||
}
|
||||
// 加载授权方式
|
||||
if (data.data.auth_method) {
|
||||
setAuthMethod(data.data.auth_method === 'api' ? 'api' : 'browser')
|
||||
@@ -74,7 +45,21 @@ export default function Config() {
|
||||
console.error('Failed to fetch config:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchProxyPoolCount = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/codex-proxy')
|
||||
const data = await res.json()
|
||||
if (data.code === 0 && data.data) {
|
||||
setProxyPoolCount(data.data.stats?.total || 0)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch proxy pool count:', error)
|
||||
}
|
||||
}
|
||||
|
||||
fetchConfig()
|
||||
fetchProxyPoolCount()
|
||||
}, [])
|
||||
|
||||
// 保存站点名称
|
||||
@@ -100,117 +85,6 @@ export default function Config() {
|
||||
}
|
||||
}
|
||||
|
||||
// 保存代理地址
|
||||
const handleSaveProxy = async () => {
|
||||
setSavingProxy(true)
|
||||
try {
|
||||
const res = await fetch('/api/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ default_proxy: defaultProxy }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.code === 0) {
|
||||
toast.success('代理地址已保存')
|
||||
setProxyStatus('unknown') // 保存后重置状态
|
||||
setProxyOriginIP('')
|
||||
refreshConfig()
|
||||
} else {
|
||||
toast.error(data.message || '保存失败')
|
||||
}
|
||||
} catch {
|
||||
toast.error('网络错误')
|
||||
} finally {
|
||||
setSavingProxy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 测试代理连接
|
||||
const handleTestProxy = async () => {
|
||||
if (!defaultProxy.trim()) {
|
||||
toast.error('请先输入代理地址')
|
||||
return
|
||||
}
|
||||
setTestingProxy(true)
|
||||
setProxyStatus('unknown')
|
||||
setProxyOriginIP('')
|
||||
try {
|
||||
const res = await fetch('/api/proxy/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ proxy_url: defaultProxy, proxy_type: 'default' }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.code === 0 && data.data?.connected) {
|
||||
setProxyStatus('success')
|
||||
setProxyOriginIP(data.data.origin_ip || '')
|
||||
toast.success(`代理连接成功${data.data.origin_ip ? `, 出口IP: ${data.data.origin_ip}` : ''}`)
|
||||
} else {
|
||||
setProxyStatus('error')
|
||||
toast.error(data.message || '代理连接失败')
|
||||
}
|
||||
} catch (e) {
|
||||
setProxyStatus('error')
|
||||
toast.error(e instanceof Error ? e.message : '网络错误')
|
||||
} finally {
|
||||
setTestingProxy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存注册代理地址
|
||||
const handleSaveTeamRegProxy = async () => {
|
||||
setSavingTeamRegProxy(true)
|
||||
try {
|
||||
const res = await fetch('/api/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ team_reg_proxy: teamRegProxy }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.code === 0) {
|
||||
toast.success('注册代理地址已保存')
|
||||
refreshConfig()
|
||||
} else {
|
||||
toast.error(data.message || '保存失败')
|
||||
}
|
||||
} catch {
|
||||
toast.error('网络错误')
|
||||
} finally {
|
||||
setSavingTeamRegProxy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 测试注册代理连接
|
||||
const handleTestTeamRegProxy = async () => {
|
||||
if (!teamRegProxy.trim()) {
|
||||
toast.error('请先输入注册代理地址')
|
||||
return
|
||||
}
|
||||
setTestingTeamRegProxy(true)
|
||||
setTeamRegProxyStatus('unknown')
|
||||
setTeamRegProxyIP('')
|
||||
try {
|
||||
const res = await fetch('/api/proxy/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ proxy_url: teamRegProxy, proxy_type: 'team_reg' }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.code === 0 && data.data?.connected) {
|
||||
setTeamRegProxyStatus('success')
|
||||
setTeamRegProxyIP(data.data.origin_ip || '')
|
||||
toast.success(`注册代理连接成功${data.data.origin_ip ? `, 出口IP: ${data.data.origin_ip}` : ''}`)
|
||||
} else {
|
||||
setTeamRegProxyStatus('error')
|
||||
toast.error(data.message || '注册代理连接失败')
|
||||
}
|
||||
} catch (e) {
|
||||
setTeamRegProxyStatus('error')
|
||||
toast.error(e instanceof Error ? e.message : '网络错误')
|
||||
} finally {
|
||||
setTestingTeamRegProxy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存授权方式
|
||||
const handleSaveAuthMethod = async (method: 'api' | 'browser') => {
|
||||
@@ -258,6 +132,15 @@ export default function Config() {
|
||||
statusIcon: (config.email?.services?.length ?? 0) > 0 ? CheckCircle : XCircle,
|
||||
statusColor: (config.email?.services?.length ?? 0) > 0 ? 'text-green-600 dark:text-green-400' : 'text-orange-500',
|
||||
},
|
||||
{
|
||||
to: '/config/codex-proxy',
|
||||
icon: Network,
|
||||
title: '代理池配置',
|
||||
description: '管理代理池,用于注册和验证',
|
||||
status: proxyPoolCount > 0 ? `${proxyPoolCount} 个代理` : '未配置',
|
||||
statusIcon: proxyPoolCount > 0 ? CheckCircle : XCircle,
|
||||
statusColor: proxyPoolCount > 0 ? 'text-green-600 dark:text-green-400' : 'text-orange-500',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -285,33 +168,7 @@ export default function Config() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Config Cards */}
|
||||
<div className="grid gap-4">
|
||||
{configItems.map((item) => (
|
||||
<Link key={item.to} to={item.to} className="block group">
|
||||
<Card className="transition-all duration-200 hover:shadow-lg hover:border-blue-300 dark:hover:border-blue-600">
|
||||
<CardContent className="flex items-center gap-4 py-5">
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
<item.statusIcon className={`h-4 w-4 ${item.statusColor}`} />
|
||||
<span className={`text-sm font-medium ${item.statusColor}`}>
|
||||
{item.status}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-slate-400 group-hover:text-blue-500 transition-colors" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Site Settings */}
|
||||
{/* 基础配置 - 移到顶部 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -338,7 +195,7 @@ export default function Config() {
|
||||
icon={saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
className="shrink-0"
|
||||
>
|
||||
{saving ? '保存中...' : '保存名称'}
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
@@ -351,71 +208,71 @@ export default function Config() {
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">
|
||||
授权方式
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* API 模式 - CodexAuth */}
|
||||
<button
|
||||
onClick={() => handleSaveAuthMethod('api')}
|
||||
disabled={savingAuthMethod}
|
||||
className={`relative flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all duration-200 ${authMethod === 'api'
|
||||
className={`relative flex items-center gap-3 p-3 rounded-xl border-2 transition-all duration-200 text-left ${authMethod === 'api'
|
||||
? 'border-blue-500 bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/30 dark:to-indigo-900/30 shadow-sm'
|
||||
: 'border-slate-200 dark:border-slate-700 hover:border-blue-300 dark:hover:border-blue-800 hover:bg-slate-50 dark:hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
{authMethod === 'api' && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<CheckCircle className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
)}
|
||||
<div className={`p-3 rounded-lg ${authMethod === 'api'
|
||||
<div className={`shrink-0 p-2 rounded-lg ${authMethod === 'api'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400'
|
||||
}`}>
|
||||
<Zap className="h-6 w-6" />
|
||||
<Zap className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className={`font-semibold ${authMethod === 'api'
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`font-semibold text-sm ${authMethod === 'api'
|
||||
? 'text-blue-700 dark:text-blue-300'
|
||||
: 'text-slate-700 dark:text-slate-300'
|
||||
}`}>
|
||||
CodexAuth API
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||
<div className="text-[11px] text-slate-500 dark:text-slate-400 mt-0.5 truncate">
|
||||
纯 API 模式,快速稳定
|
||||
</div>
|
||||
</div>
|
||||
{authMethod === 'api' && (
|
||||
<div className="absolute top-1.5 right-1.5">
|
||||
<CheckCircle className="h-3.5 w-3.5 text-blue-500" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 浏览器模式 */}
|
||||
<button
|
||||
onClick={() => handleSaveAuthMethod('browser')}
|
||||
disabled={savingAuthMethod}
|
||||
className={`relative flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all duration-200 ${authMethod === 'browser'
|
||||
className={`relative flex items-center gap-3 p-3 rounded-xl border-2 transition-all duration-200 text-left ${authMethod === 'browser'
|
||||
? 'border-emerald-500 bg-gradient-to-br from-emerald-50 to-green-50 dark:from-emerald-900/30 dark:to-green-900/30 shadow-sm'
|
||||
: 'border-slate-200 dark:border-slate-700 hover:border-emerald-300 dark:hover:border-emerald-800 hover:bg-slate-50 dark:hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
{authMethod === 'browser' && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<CheckCircle className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
)}
|
||||
<div className={`p-3 rounded-lg ${authMethod === 'browser'
|
||||
<div className={`shrink-0 p-2 rounded-lg ${authMethod === 'browser'
|
||||
? 'bg-emerald-500 text-white'
|
||||
: 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400'
|
||||
}`}>
|
||||
<Monitor className="h-6 w-6" />
|
||||
<Monitor className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className={`font-semibold ${authMethod === 'browser'
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`font-semibold text-sm ${authMethod === 'browser'
|
||||
? 'text-emerald-700 dark:text-emerald-300'
|
||||
: 'text-slate-700 dark:text-slate-300'
|
||||
}`}>
|
||||
浏览器模拟
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||
<div className="text-[11px] text-slate-500 dark:text-slate-400 mt-0.5 truncate">
|
||||
Chromedp 自动化,兼容性好
|
||||
</div>
|
||||
</div>
|
||||
{authMethod === 'browser' && (
|
||||
<div className="absolute top-1.5 right-1.5">
|
||||
<CheckCircle className="h-3.5 w-3.5 text-emerald-500" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{authMethod === 'api' && (
|
||||
@@ -430,140 +287,35 @@ export default function Config() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-slate-200 dark:border-slate-700">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
全局代理地址
|
||||
</label>
|
||||
{/* 代理状态徽章 */}
|
||||
{proxyStatus === 'success' && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||||
<Wifi className="h-3 w-3" />
|
||||
代理可用
|
||||
</span>
|
||||
)}
|
||||
{proxyStatus === 'error' && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
||||
<WifiOff className="h-3 w-3" />
|
||||
连接失败
|
||||
</span>
|
||||
)}
|
||||
{proxyStatus === 'unknown' && defaultProxy && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-400">
|
||||
<HelpCircle className="h-3 w-3" />
|
||||
未测试
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Input
|
||||
value={defaultProxy}
|
||||
onChange={(e) => {
|
||||
setDefaultProxy(e.target.value)
|
||||
setProxyStatus('unknown')
|
||||
}}
|
||||
placeholder="http://127.0.0.1:7890 或 1.2.3.4:5678:user:pass"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveProxy}
|
||||
disabled={savingProxy}
|
||||
icon={savingProxy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
className="shrink-0"
|
||||
>
|
||||
{savingProxy ? '保存中...' : '保存代理'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleTestProxy}
|
||||
disabled={testingProxy || !defaultProxy.trim()}
|
||||
icon={testingProxy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wifi className="h-4 w-4" />}
|
||||
className="shrink-0"
|
||||
>
|
||||
{testingProxy ? '测试中...' : '测试连接'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
设置全局默认代理地址,用于批量入库和自动补号。支持格式:http://host:port、http://user:pass@host:port、host:port:user:pass(默认按 http 解析)
|
||||
{proxyOriginIP && (
|
||||
<span className="ml-2 text-green-600 dark:text-green-400">
|
||||
出口IP: {proxyOriginIP}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 注册代理地址配置 */}
|
||||
<div className="pt-4 border-t border-slate-200 dark:border-slate-700">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
注册代理地址
|
||||
</label>
|
||||
{/* 代理状态徽章 */}
|
||||
{teamRegProxyStatus === 'success' && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||||
<Wifi className="h-3 w-3" />
|
||||
代理可用
|
||||
</span>
|
||||
)}
|
||||
{teamRegProxyStatus === 'error' && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
||||
<WifiOff className="h-3 w-3" />
|
||||
连接失败
|
||||
</span>
|
||||
)}
|
||||
{teamRegProxyStatus === 'unknown' && teamRegProxy && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-400">
|
||||
<HelpCircle className="h-3 w-3" />
|
||||
未测试
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Input
|
||||
value={teamRegProxy}
|
||||
onChange={(e) => {
|
||||
setTeamRegProxy(e.target.value)
|
||||
setTeamRegProxyStatus('unknown')
|
||||
}}
|
||||
placeholder="http://user:pass@host:port"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveTeamRegProxy}
|
||||
disabled={savingTeamRegProxy}
|
||||
icon={savingTeamRegProxy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
className="shrink-0"
|
||||
>
|
||||
{savingTeamRegProxy ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleTestTeamRegProxy}
|
||||
disabled={testingTeamRegProxy || !teamRegProxy.trim()}
|
||||
icon={testingTeamRegProxy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wifi className="h-4 w-4" />}
|
||||
className="shrink-0"
|
||||
>
|
||||
{testingTeamRegProxy ? '测试中...' : '测试连接'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
Team 自动注册功能使用的代理地址。建议使用高质量住宅代理以避免被限制。
|
||||
{teamRegProxyIP && (
|
||||
<span className="ml-2 text-green-600 dark:text-green-400">
|
||||
出口IP: {teamRegProxyIP}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Config Cards - S2A、邮箱、代理池配置 */}
|
||||
<div className="grid gap-4">
|
||||
{configItems.map((item) => (
|
||||
<Link key={item.to} to={item.to} className="block group">
|
||||
<Card className="transition-all duration-200 hover:shadow-lg hover:border-blue-300 dark:hover:border-blue-600">
|
||||
<CardContent className="flex items-center gap-4 py-5">
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
<item.statusIcon className={`h-4 w-4 ${item.statusColor}`} />
|
||||
<span className={`text-sm font-medium ${item.statusColor}`}>
|
||||
{item.status}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-slate-400 group-hover:text-blue-500 transition-colors" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
|
||||
@@ -5,19 +5,15 @@ import {
|
||||
RefreshCw,
|
||||
Play,
|
||||
Pause,
|
||||
Shield,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Zap,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Save,
|
||||
Monitor as MonitorIcon,
|
||||
} from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button, Input, Switch } from '../components/common'
|
||||
import LiveLogViewer from '../components/LiveLogViewer'
|
||||
import type { DashboardStats } from '../types'
|
||||
|
||||
interface PoolStatus {
|
||||
target: number
|
||||
@@ -31,15 +27,6 @@ interface PoolStatus {
|
||||
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
|
||||
@@ -52,18 +39,17 @@ interface AutoAddLog {
|
||||
}
|
||||
|
||||
export default function Monitor() {
|
||||
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)
|
||||
|
||||
// 配置表单状态 - 从后端 SQLite 加载
|
||||
const [targetInput, setTargetInput] = useState(50)
|
||||
const [autoAdd, setAutoAdd] = useState(false)
|
||||
const [autoRegister, setAutoRegister] = useState(false) // 母号不足时自动注册
|
||||
const [autoRegConcurrency, setAutoRegConcurrency] = useState(2) // 自动注册并发数
|
||||
const [autoRegUseProxy, setAutoRegUseProxy] = useState(false) // 自动注册时使用代理
|
||||
const [minInterval, setMinInterval] = useState(300)
|
||||
const [checkInterval, setCheckInterval] = useState(60)
|
||||
const [pollingEnabled, setPollingEnabled] = useState(false)
|
||||
@@ -72,6 +58,11 @@ export default function Monitor() {
|
||||
const [globalProxy, setGlobalProxy] = useState('') // 全局代理地址(只读显示)
|
||||
const [authMethod, setAuthMethod] = useState<'api' | 'browser'>('browser') // 授权方式
|
||||
|
||||
// 自动补号配置
|
||||
const [membersPerTeam, setMembersPerTeam] = useState(4)
|
||||
const [concurrentTeams, setConcurrentTeams] = useState(2)
|
||||
const [s2aConcurrency, setS2aConcurrency] = useState(2)
|
||||
|
||||
// 倒计时状态
|
||||
const [countdown, setCountdown] = useState(60)
|
||||
|
||||
@@ -100,7 +91,6 @@ export default function Monitor() {
|
||||
try {
|
||||
const data = await requestS2A('/dashboard/stats')
|
||||
if (data) {
|
||||
setStats(data)
|
||||
// 更新 poolStatus
|
||||
setPoolStatus({
|
||||
current: data.normal_accounts || 0,
|
||||
@@ -129,11 +119,17 @@ export default function Monitor() {
|
||||
body: JSON.stringify({
|
||||
target: targetInput,
|
||||
auto_add: autoAdd,
|
||||
auto_register: autoRegister,
|
||||
auto_reg_concurrency: autoRegConcurrency,
|
||||
auto_reg_use_proxy: autoRegUseProxy,
|
||||
min_interval: minInterval,
|
||||
check_interval: checkInterval,
|
||||
polling_enabled: pollingEnabled,
|
||||
polling_interval: pollingInterval,
|
||||
replenish_use_proxy: replenishUseProxy,
|
||||
members_per_team: membersPerTeam,
|
||||
concurrent_teams: concurrentTeams,
|
||||
s2a_concurrency: s2aConcurrency,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
@@ -175,10 +171,14 @@ export default function Monitor() {
|
||||
body: JSON.stringify({
|
||||
target: targetInput,
|
||||
auto_add: autoAdd,
|
||||
auto_register: autoRegister,
|
||||
min_interval: minInterval,
|
||||
check_interval: checkInterval,
|
||||
polling_enabled: newPollingEnabled,
|
||||
polling_interval: pollingInterval,
|
||||
members_per_team: membersPerTeam,
|
||||
concurrent_teams: concurrentTeams,
|
||||
s2a_concurrency: s2aConcurrency,
|
||||
}),
|
||||
})
|
||||
} catch (e) {
|
||||
@@ -202,10 +202,14 @@ export default function Monitor() {
|
||||
body: JSON.stringify({
|
||||
target: targetInput,
|
||||
auto_add: autoAdd,
|
||||
auto_register: autoRegister,
|
||||
min_interval: minInterval,
|
||||
check_interval: checkInterval,
|
||||
polling_enabled: pollingEnabled,
|
||||
polling_interval: pollingInterval,
|
||||
members_per_team: membersPerTeam,
|
||||
concurrent_teams: concurrentTeams,
|
||||
s2a_concurrency: s2aConcurrency,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
@@ -225,30 +229,6 @@ export default function Monitor() {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
// 健康检查 - S2A 没有此接口,显示提示
|
||||
const handleHealthCheck = async (_autoPause: boolean = false) => {
|
||||
setCheckingHealth(true)
|
||||
// S2A 没有健康检查 API,使用 dashboard/stats 模拟
|
||||
try {
|
||||
const data = await requestS2A('/dashboard/stats')
|
||||
if (data) {
|
||||
// 模拟健康检查结果
|
||||
setHealthResults([
|
||||
{
|
||||
account_id: 0,
|
||||
email: '统计摘要',
|
||||
status: 'info',
|
||||
checked_at: new Date().toISOString(),
|
||||
error: `正常: ${data.normal_accounts || 0}, 错误: ${data.error_accounts || 0}, 限流: ${data.ratelimit_accounts || 0}`,
|
||||
}
|
||||
])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('健康检查失败:', e)
|
||||
}
|
||||
setCheckingHealth(false)
|
||||
}
|
||||
|
||||
// 获取自动补号日志 - S2A 没有此接口
|
||||
const fetchAutoAddLogs = async () => {
|
||||
// S2A 没有自动补号日志 API,留空
|
||||
@@ -279,6 +259,9 @@ export default function Monitor() {
|
||||
const s = json.data
|
||||
const target = s.target || 50
|
||||
const autoAddVal = s.auto_add || false
|
||||
const autoRegisterVal = s.auto_register || false
|
||||
const autoRegConcurrencyVal = s.auto_reg_concurrency || 2
|
||||
const autoRegUseProxyVal = s.auto_reg_use_proxy || false
|
||||
const minIntervalVal = s.min_interval || 300
|
||||
const checkIntervalVal = s.check_interval || 60
|
||||
const pollingEnabledVal = s.polling_enabled || false
|
||||
@@ -287,6 +270,9 @@ export default function Monitor() {
|
||||
|
||||
setTargetInput(target)
|
||||
setAutoAdd(autoAddVal)
|
||||
setAutoRegister(autoRegisterVal)
|
||||
setAutoRegConcurrency(autoRegConcurrencyVal)
|
||||
setAutoRegUseProxy(autoRegUseProxyVal)
|
||||
setMinInterval(minIntervalVal)
|
||||
setCheckInterval(checkIntervalVal)
|
||||
setPollingEnabled(pollingEnabledVal)
|
||||
@@ -295,6 +281,11 @@ export default function Monitor() {
|
||||
savedPollingIntervalRef.current = interval
|
||||
setCountdown(interval)
|
||||
|
||||
// 加载自动补号配置
|
||||
setMembersPerTeam(s.members_per_team || 4)
|
||||
setConcurrentTeams(s.concurrent_teams || 2)
|
||||
setS2aConcurrency(s.s2a_concurrency || 2)
|
||||
|
||||
// 返回加载的配置用于后续刷新
|
||||
return { target, autoAdd: autoAddVal, minInterval: minIntervalVal, checkInterval: checkIntervalVal, pollingEnabled: pollingEnabledVal, pollingInterval: interval, replenishUseProxy: replenishUseProxyVal }
|
||||
}
|
||||
@@ -316,7 +307,6 @@ export default function Monitor() {
|
||||
const data = await requestS2A('/dashboard/stats')
|
||||
if (data) {
|
||||
const target = settings?.target || 50
|
||||
setStats(data)
|
||||
setPoolStatus({
|
||||
current: data.normal_accounts || 0,
|
||||
target: target,
|
||||
@@ -360,16 +350,6 @@ export default function Monitor() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pollingEnabled])
|
||||
|
||||
// 计算健康状态
|
||||
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)
|
||||
@@ -532,6 +512,15 @@ export default function Monitor() {
|
||||
description="开启后,当号池不足时自动补充账号"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700">
|
||||
<Switch
|
||||
checked={autoRegister}
|
||||
onChange={setAutoRegister}
|
||||
disabled={!autoAdd}
|
||||
label="母号不足时自动注册"
|
||||
description="开启后,当可用母号不足时自动注册新的 ChatGPT 账号(使用 Team 注册功能)"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700">
|
||||
<Switch
|
||||
checked={replenishUseProxy}
|
||||
@@ -577,26 +566,6 @@ export default function Monitor() {
|
||||
要切换授权方式,请在“基础配置”页面中修改
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
label="最小间隔 (秒)"
|
||||
type="number"
|
||||
min={60}
|
||||
max={3600}
|
||||
value={minInterval}
|
||||
onChange={(e) => setMinInterval(Number(e.target.value))}
|
||||
hint="两次自动补号的最小间隔"
|
||||
disabled={!autoAdd}
|
||||
/>
|
||||
<Input
|
||||
label="检查间隔 (秒)"
|
||||
type="number"
|
||||
min={10}
|
||||
max={300}
|
||||
value={checkInterval}
|
||||
onChange={(e) => setCheckInterval(Number(e.target.value))}
|
||||
hint="自动补号检查频率 (10-300秒)"
|
||||
disabled={!autoAdd}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Button onClick={handleSetTarget} loading={loading} className="w-full">
|
||||
@@ -648,6 +617,156 @@ export default function Monitor() {
|
||||
<div className={`status-dot ${pollingEnabled ? 'online' : 'offline'}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 自动补号配置 */}
|
||||
<div className="pt-4 border-t border-slate-200 dark:border-slate-700">
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3 flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-blue-500" />
|
||||
自动补号策略 (频率与并发)
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
|
||||
每 Team 成员数
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={membersPerTeam}
|
||||
onChange={(e) => setMembersPerTeam(Number(e.target.value) || 4)}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border transition-colors
|
||||
bg-white dark:bg-slate-800
|
||||
text-slate-900 dark:text-slate-100
|
||||
border-slate-300 dark:border-slate-600
|
||||
focus:border-blue-500 focus:ring-blue-500
|
||||
focus:outline-none focus:ring-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
|
||||
并发 Team 数
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={concurrentTeams}
|
||||
onChange={(e) => setConcurrentTeams(Number(e.target.value) || 2)}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border transition-colors
|
||||
bg-white dark:bg-slate-800
|
||||
text-slate-900 dark:text-slate-100
|
||||
border-slate-300 dark:border-slate-600
|
||||
focus:border-blue-500 focus:ring-blue-500
|
||||
focus:outline-none focus:ring-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
|
||||
入库并发数
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={4}
|
||||
value={s2aConcurrency}
|
||||
onChange={(e) => setS2aConcurrency(Number(e.target.value) || 2)}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border transition-colors
|
||||
bg-white dark:bg-slate-800
|
||||
text-slate-900 dark:text-slate-100
|
||||
border-slate-300 dark:border-slate-600
|
||||
focus:border-blue-500 focus:ring-blue-500
|
||||
focus:outline-none focus:ring-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
自动补号时使用的配置,入库并发数推荐 1-4
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 pt-3 mt-3 border-t border-dashed border-slate-200 dark:border-slate-700/50">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
|
||||
最小补号间隔 (秒)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={60}
|
||||
max={3600}
|
||||
value={minInterval}
|
||||
onChange={(e) => setMinInterval(Number(e.target.value))}
|
||||
disabled={!autoAdd}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border transition-colors
|
||||
bg-white dark:bg-slate-800
|
||||
text-slate-900 dark:text-slate-100
|
||||
border-slate-300 dark:border-slate-600
|
||||
focus:border-blue-500 focus:ring-blue-500
|
||||
focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
|
||||
补号检查间隔 (秒)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={10}
|
||||
max={300}
|
||||
value={checkInterval}
|
||||
onChange={(e) => setCheckInterval(Number(e.target.value))}
|
||||
disabled={!autoAdd}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border transition-colors
|
||||
bg-white dark:bg-slate-800
|
||||
text-slate-900 dark:text-slate-100
|
||||
border-slate-300 dark:border-slate-600
|
||||
focus:border-blue-500 focus:ring-blue-500
|
||||
focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 自动注册配置 - 仅在启用自动注册时显示 */}
|
||||
{autoRegister && autoAdd && (
|
||||
<div className="mt-3 pt-3 border-t border-dashed border-purple-300 dark:border-purple-700/50">
|
||||
<p className="text-xs font-medium text-purple-600 dark:text-purple-400 mb-2 flex items-center gap-1">
|
||||
<Zap className="h-3 w-3" />
|
||||
自动注册配置
|
||||
</p>
|
||||
<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={autoRegConcurrency}
|
||||
onChange={(e) => setAutoRegConcurrency(Number(e.target.value) || 2)}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border transition-colors
|
||||
bg-white dark:bg-slate-800
|
||||
text-slate-900 dark:text-slate-100
|
||||
border-slate-300 dark:border-slate-600
|
||||
focus:border-purple-500 focus:ring-purple-500
|
||||
focus:outline-none focus:ring-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end pb-2">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRegUseProxy}
|
||||
onChange={(e) => setAutoRegUseProxy(e.target.checked)}
|
||||
disabled={!globalProxy}
|
||||
className="h-4 w-4 rounded border-slate-300 text-purple-600 focus:ring-purple-500 disabled:opacity-50"
|
||||
/>
|
||||
<span>使用代理</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-4">
|
||||
<Button
|
||||
@@ -673,117 +792,6 @@ export default function Monitor() {
|
||||
</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>
|
||||
@@ -830,8 +838,6 @@ export default function Monitor() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{/* 实时日志 */}
|
||||
<LiveLogViewer className="mt-6" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
105
frontend/src/pages/S2AStats.tsx
Normal file
105
frontend/src/pages/S2AStats.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { RefreshCw, BarChart3 } from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button } from '../components/common'
|
||||
import LiveLogViewer from '../components/LiveLogViewer'
|
||||
import type { DashboardStats } from '../types'
|
||||
|
||||
export default function S2AStats() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const proxyBase = '/api/s2a/proxy'
|
||||
|
||||
const requestS2A = async (path: string, options: RequestInit = {}) => {
|
||||
const res = await fetch(`${proxyBase}${path}`, options)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const json = await res.json()
|
||||
if (json && typeof json === 'object' && 'code' in json) {
|
||||
if (json.code !== 0) throw new Error(json.message || 'API error')
|
||||
return json.data
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
const refreshStats = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
const data = await requestS2A('/dashboard/stats')
|
||||
if (data) {
|
||||
setStats(data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('刷新统计失败:', e)
|
||||
}
|
||||
setRefreshing(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refreshStats()
|
||||
}, [refreshStats])
|
||||
|
||||
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">S2A 统计与日志</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
查看 S2A 实时统计数据和系统日志
|
||||
</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>
|
||||
|
||||
{/* S2A 实时统计 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5 text-blue-500" />
|
||||
S2A 实时统计
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stats ? (
|
||||
<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>
|
||||
) : (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
<BarChart3 className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||
<p>加载统计数据中...</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 实时日志 */}
|
||||
<LiveLogViewer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
RefreshCw,
|
||||
FileJson,
|
||||
Users,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
|
||||
|
||||
interface TeamRegStatus {
|
||||
@@ -330,18 +332,24 @@ export default function TeamReg() {
|
||||
|
||||
{useProxy && proxy && (
|
||||
<div className="ml-7 p-2 rounded bg-slate-100 dark:bg-slate-700">
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300 font-mono break-all">
|
||||
{proxy}
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300 font-mono break-all flex items-center gap-2">
|
||||
{proxy === 'pool:random' || proxy === '[RANDOM]' ? (
|
||||
<><Zap className="h-3 w-3 text-purple-500" /> 代理池轮询模式</>
|
||||
) : proxy.startsWith('pool:id:') ? (
|
||||
<><CheckCircle className="h-3 w-3 text-blue-500" /> 指定池代理 (ID: {proxy.replace('pool:id:', '')})</>
|
||||
) : (
|
||||
proxy
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
如需修改,请前往 <a href="/config" className="text-blue-500 hover:underline">系统配置</a> 页面
|
||||
如需修改,请前往 <Link to="/config/codex-proxy" className="text-blue-500 hover:underline">代理池配置</Link> 页面
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{useProxy && !proxy && (
|
||||
<p className="ml-7 text-xs text-orange-500">
|
||||
⚠️ 未配置代理地址,请先在 <a href="/config" className="text-blue-500 hover:underline">系统配置</a> 中设置
|
||||
⚠️ 未配置代理地址,请先在 <Link to="/config/codex-proxy" className="text-blue-500 hover:underline">代理池配置</Link> 中设置
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -511,13 +519,23 @@ export default function TeamReg() {
|
||||
// 日志行组件 - 根据内容着色
|
||||
function LogLine({ log }: { log: string }) {
|
||||
let colorClass = 'text-slate-300'
|
||||
let fontClass = ''
|
||||
|
||||
if (log.includes('[OK]') || log.includes('成功')) {
|
||||
// 成功日志 - 绿色加粗
|
||||
if (log.includes('✓') || log.includes('入库成功') || log.includes('注册成功')) {
|
||||
colorClass = 'text-green-400'
|
||||
} else if (log.includes('[!]') || log.includes('重试') || log.includes('[错误]')) {
|
||||
fontClass = 'font-bold'
|
||||
} else if (log.includes('[OK]') || log.includes('成功') || log.includes('完成') || log.includes('结果已保存')) {
|
||||
colorClass = 'text-emerald-400'
|
||||
fontClass = 'font-medium'
|
||||
} else if (log.includes('[!]') || log.includes('重试') || log.includes('[错误]') || log.includes('失败')) {
|
||||
colorClass = 'text-orange-400'
|
||||
} else if (log.includes('[系统]') || log.includes('[输入]')) {
|
||||
colorClass = 'text-blue-400'
|
||||
} else if (log.includes('════════')) {
|
||||
// 分隔线 - 高亮显示
|
||||
colorClass = 'text-yellow-400'
|
||||
fontClass = 'font-bold'
|
||||
} else if (log.includes('[W1]')) {
|
||||
colorClass = 'text-cyan-400'
|
||||
} else if (log.includes('[W2]')) {
|
||||
@@ -526,12 +544,10 @@ function LogLine({ log }: { log: string }) {
|
||||
colorClass = 'text-yellow-400'
|
||||
} else if (log.includes('[W4]')) {
|
||||
colorClass = 'text-pink-400'
|
||||
} else if (log.includes('完成') || log.includes('结果已保存')) {
|
||||
colorClass = 'text-emerald-400 font-medium'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${colorClass} leading-relaxed whitespace-pre-wrap break-all`}>
|
||||
<div className={`${colorClass} ${fontClass} leading-relaxed whitespace-pre-wrap break-all`}>
|
||||
{log}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -9,5 +9,6 @@ export { default as Monitor } from './Monitor'
|
||||
export { default as Cleaner } from './Cleaner'
|
||||
export { default as TeamReg } from './TeamReg'
|
||||
export { default as CodexProxyConfig } from './CodexProxyConfig'
|
||||
export { default as S2AStats } from './S2AStats'
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user