feat: Initialize core application structure with backend configuration, database, API, and a comprehensive frontend UI for account pooling and management.

This commit is contained in:
2026-01-31 01:48:07 +08:00
parent 74bdcae836
commit 92383f2f20
14 changed files with 776 additions and 47 deletions

View File

@@ -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 } from './pages'
import { Dashboard, Upload, Records, Accounts, Config, S2AConfig, EmailConfig, Monitor, Cleaner } from './pages'
function App() {
return (
@@ -14,6 +14,7 @@ function App() {
<Route path="records" element={<Records />} />
<Route path="accounts" element={<Accounts />} />
<Route path="monitor" element={<Monitor />} />
<Route path="cleaner" element={<Cleaner />} />
<Route path="config" element={<Config />} />
<Route path="config/s2a" element={<S2AConfig />} />
<Route path="config/email" element={<EmailConfig />} />

View File

@@ -1,5 +1,6 @@
import { NavLink, useLocation } from 'react-router-dom'
import { useState } from 'react'
import { useConfig } from '../../hooks/useConfig'
import {
LayoutDashboard,
Upload,
@@ -12,6 +13,7 @@ import {
Server,
Mail,
Cog,
Trash2,
} from 'lucide-react'
interface SidebarProps {
@@ -32,6 +34,7 @@ const navItems: NavItem[] = [
{ to: '/records', icon: History, label: '加号记录' },
{ to: '/accounts', icon: Users, label: '号池账号' },
{ to: '/monitor', icon: Activity, label: '号池监控' },
{ to: '/cleaner', icon: Trash2, label: '定期清理' },
{
to: '/config',
icon: Settings,
@@ -47,6 +50,7 @@ const navItems: NavItem[] = [
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
const location = useLocation()
const [expandedItems, setExpandedItems] = useState<string[]>(['/config'])
const { siteName } = useConfig()
const toggleExpand = (path: string) => {
setExpandedItems(prev =>
@@ -132,9 +136,9 @@ export default function Sidebar({ isOpen, onClose }: SidebarProps) {
<div className="flex items-center justify-between h-16 px-6 border-b border-slate-200/50 dark:border-slate-800/50 lg:hidden">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-xl bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center shadow-lg shadow-blue-500/30">
<span className="text-white font-bold text-sm">CP</span>
<span className="text-white font-bold text-sm">{siteName.slice(0, 2).toUpperCase()}</span>
</div>
<span className="font-bold text-lg text-slate-900 dark:text-slate-100 tracking-tight">Codex Pool</span>
<span className="font-bold text-lg text-slate-900 dark:text-slate-100 tracking-tight truncate">{siteName}</span>
</div>
<button
onClick={onClose}
@@ -149,9 +153,9 @@ export default function Sidebar({ isOpen, onClose }: SidebarProps) {
<div className="hidden lg:flex items-center h-16 px-6 border-b border-slate-200/50 dark:border-slate-800/50">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-xl bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center shadow-lg shadow-blue-500/30">
<span className="text-white font-bold text-sm">CP</span>
<span className="text-white font-bold text-sm">{siteName.slice(0, 2).toUpperCase()}</span>
</div>
<span className="font-bold text-lg text-slate-900 dark:text-slate-100 tracking-tight">Codex Pool</span>
<span className="font-bold text-lg text-slate-900 dark:text-slate-100 tracking-tight truncate">{siteName}</span>
</div>
</div>

View File

@@ -230,8 +230,7 @@ export default function OwnerList({ onStatsChange }: OwnerListProps) {
>
<option value=""></option>
<option value="valid"></option>
<option value="registered"></option>
<option value="pooled"></option>
<option value="processing"></option>
<option value="used">使</option>
<option value="invalid"></option>
</select>

View File

@@ -15,6 +15,7 @@ interface ConfigContextValue {
testConnection: () => Promise<boolean>
s2aClient: S2AClient | null
refreshConfig: () => Promise<void>
siteName: string
}
const ConfigContext = createContext<ConfigContextValue | null>(null)
@@ -23,6 +24,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
const [config, setConfig] = useState<AppConfig>(defaultConfig)
const [isConnected, setIsConnected] = useState(false)
const [s2aClient, setS2aClient] = useState<S2AClient | null>(null)
const [siteName, setSiteName] = useState('Codex Pool')
// Load config from server on mount
const refreshConfig = useCallback(async () => {
@@ -45,6 +47,10 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
groupIds: serverConfig.group_ids || [],
},
}))
// 更新站点名称
if (serverConfig.site_name) {
setSiteName(serverConfig.site_name)
}
}
} catch (error) {
console.error('Failed to load config from server:', error)
@@ -158,6 +164,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
testConnection,
s2aClient,
refreshConfig,
siteName,
}}
>
{children}

View File

@@ -0,0 +1,322 @@
import { useState, useEffect } from 'react'
import { Trash2, Clock, Loader2, Save, RefreshCw, CheckCircle, XCircle, ToggleLeft, ToggleRight, AlertTriangle } from 'lucide-react'
import { Card, CardHeader, CardTitle, CardContent, Button } from '../components/common'
interface CleanerStatus {
running: boolean
enabled: boolean
interval: number
last_clean_time: string
}
export default function Cleaner() {
const [loading, setLoading] = useState(true)
const [cleanEnabled, setCleanEnabled] = useState(false)
const [cleanInterval, setCleanInterval] = useState(3600)
const [savingClean, setSavingClean] = useState(false)
const [cleaning, setCleaning] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null)
const [status, setStatus] = useState<CleanerStatus | null>(null)
// 加载清理设置
const fetchCleanerSettings = async () => {
setLoading(true)
try {
const res = await fetch('/api/s2a/cleaner/settings')
const data = await res.json()
if (data.code === 0 && data.data) {
setCleanEnabled(data.data.enabled || false)
setCleanInterval(data.data.interval || 3600)
setStatus(data.data.status || null)
}
} catch (error) {
console.error('Failed to fetch cleaner settings:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchCleanerSettings()
}, [])
// 保存清理设置
const handleSaveCleanerSettings = async () => {
setSavingClean(true)
setMessage(null)
try {
const res = await fetch('/api/s2a/cleaner/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: cleanEnabled,
interval: cleanInterval,
}),
})
const data = await res.json()
if (data.code === 0) {
setMessage({ type: 'success', text: '清理设置已保存' })
// 刷新状态
fetchCleanerSettings()
} else {
setMessage({ type: 'error', text: data.message || '保存失败' })
}
} catch {
setMessage({ type: 'error', text: '网络错误' })
} finally {
setSavingClean(false)
}
}
// 手动清理错误账号
const handleCleanNow = async () => {
if (!confirm('确认立即清理所有错误账号?\n\n此操作将删除 S2A 号池中所有状态为"错误"的账号。')) return
setCleaning(true)
setMessage(null)
try {
const res = await fetch('/api/s2a/clean-errors', { method: 'POST' })
const data = await res.json()
if (data.code === 0) {
setMessage({ type: 'success', text: data.data.message || '清理完成' })
// 刷新状态
fetchCleanerSettings()
} else {
setMessage({ type: 'error', text: data.message || '清理失败' })
}
} catch {
setMessage({ type: 'error', text: '网络错误' })
} finally {
setCleaning(false)
}
}
// 格式化时间间隔
const formatInterval = (seconds: number): string => {
if (seconds < 60) return `${seconds}`
if (seconds < 3600) return `${Math.floor(seconds / 60)} 分钟`
return `${Math.floor(seconds / 3600)} 小时`
}
// 格式化上次清理时间
const formatLastCleanTime = (timeStr: string): string => {
if (!timeStr || timeStr === '0001-01-01T00:00:00Z') return '从未执行'
try {
const date = new Date(timeStr)
return date.toLocaleString('zh-CN')
} catch {
return '未知'
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
</div>
)
}
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">
<Trash2 className="h-7 w-7 text-red-500" />
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400"> S2A </p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={fetchCleanerSettings}
icon={<RefreshCw className="h-4 w-4" />}
>
</Button>
<Button
onClick={handleSaveCleanerSettings}
disabled={savingClean}
icon={savingClean ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
>
{savingClean ? '保存中...' : '保存设置'}
</Button>
</div>
</div>
{/* Message */}
{message && (
<div className={`p-3 rounded-lg text-sm flex items-center gap-2 ${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.type === 'success' ? (
<CheckCircle className="h-4 w-4" />
) : (
<XCircle className="h-4 w-4" />
)}
{message.text}
</div>
)}
{/* Status Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* 清理状态 */}
<Card className="stat-card">
<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 ${cleanEnabled ? 'text-green-600' : 'text-slate-400'}`}>
{cleanEnabled ? '已启用' : '已禁用'}
</p>
</div>
<div className={`h-12 w-12 rounded-xl flex items-center justify-center ${cleanEnabled ? 'bg-green-100 dark:bg-green-900/30' : 'bg-slate-100 dark:bg-slate-800'
}`}>
{cleanEnabled ? (
<CheckCircle className="h-6 w-6 text-green-500" />
) : (
<XCircle className="h-6 w-6 text-slate-400" />
)}
</div>
</div>
</CardContent>
</Card>
{/* 清理间隔 */}
<Card className="stat-card">
<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">
{formatInterval(cleanInterval)}
</p>
</div>
<div className="h-12 w-12 rounded-xl bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<Clock className="h-6 w-6 text-blue-500" />
</div>
</div>
</CardContent>
</Card>
{/* 上次清理 */}
<Card className="stat-card">
<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-medium text-slate-900 dark:text-slate-100">
{status ? formatLastCleanTime(status.last_clean_time) : '从未执行'}
</p>
</div>
<div className="h-12 w-12 rounded-xl bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
<Trash2 className="h-6 w-6 text-purple-500" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* 清理设置 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trash2 className="h-5 w-5 text-red-500" />
</CardTitle>
<button
onClick={() => setCleanEnabled(!cleanEnabled)}
className="flex items-center gap-2 text-sm"
>
{cleanEnabled ? (
<>
<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>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 清理间隔选择 */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
</label>
<select
value={cleanInterval}
onChange={(e) => setCleanInterval(Number(e.target.value))}
disabled={!cleanEnabled}
className={`w-full px-4 py-3 text-sm rounded-xl 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
${!cleanEnabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<option value={300}>5 </option>
<option value={600}>10 </option>
<option value={1800}>30 </option>
<option value={3600}>1 </option>
<option value={7200}>2 </option>
<option value={14400}>4 </option>
<option value={21600}>6 </option>
<option value={43200}>12 </option>
<option value={86400}>24 </option>
</select>
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
S2A
</p>
</div>
{/* 手动清理 */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
</label>
<Button
onClick={handleCleanNow}
disabled={cleaning}
variant="outline"
icon={cleaning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
className="w-full h-12 text-red-500 hover:text-red-600 border-red-300 hover:border-red-400 dark:border-red-800 dark:hover:border-red-600"
>
{cleaning ? '清理中...' : '立即清理所有错误账号'}
</Button>
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
</p>
</div>
</div>
</CardContent>
</Card>
{/* 说明信息 */}
<Card>
<CardContent className="py-4">
<div className="flex items-start gap-3 text-sm text-slate-600 dark:text-slate-400">
<AlertTriangle className="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" />
<div className="space-y-2">
<p className="font-medium text-slate-700 dark:text-slate-300"></p>
<ul className="list-disc list-inside space-y-1">
<li> S2A "error"</li>
<li><strong></strong></li>
<li></li>
<li>"号池监控"</li>
<li>"保存设置"</li>
</ul>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,3 +1,4 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import {
Server,
@@ -6,13 +7,59 @@ import {
Settings,
RefreshCw,
CheckCircle,
XCircle
XCircle,
Save,
Loader2,
Globe
} from 'lucide-react'
import { Card, CardContent, Button } from '../components/common'
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
import { useConfig } from '../hooks/useConfig'
export default function Config() {
const { config, isConnected, refreshConfig } = useConfig()
const [siteName, setSiteName] = useState('')
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null)
// 加载站点名称配置
useEffect(() => {
const fetchSiteName = async () => {
try {
const res = await fetch('/api/config')
const data = await res.json()
if (data.code === 0 && data.data) {
setSiteName(data.data.site_name || 'Codex Pool')
}
} catch (error) {
console.error('Failed to fetch site name:', error)
}
}
fetchSiteName()
}, [])
// 保存站点名称
const handleSaveSiteName = async () => {
setSaving(true)
setMessage(null)
try {
const res = await fetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ site_name: siteName }),
})
const data = await res.json()
if (data.code === 0) {
setMessage({ type: 'success', text: '站点名称已保存' })
refreshConfig()
} else {
setMessage({ type: 'error', text: data.message || '保存失败' })
}
} catch {
setMessage({ type: 'error', text: '网络错误' })
} finally {
setSaving(false)
}
}
const configItems = [
{
@@ -82,6 +129,58 @@ export default function Config() {
))}
</div>
{/* Site Settings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5 text-purple-500" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Message */}
{message && (
<div className={`p-3 rounded-lg text-sm flex items-center gap-2 ${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.type === 'success' ? (
<CheckCircle className="h-4 w-4" />
) : (
<XCircle className="h-4 w-4" />
)}
{message.text}
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
</label>
<div className="flex gap-3">
<Input
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
placeholder="输入站点名称,如:我的号池"
className="flex-1"
/>
<Button
size="sm"
onClick={handleSaveSiteName}
disabled={saving}
icon={saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
className="shrink-0"
>
{saving ? '保存中...' : '保存名称'}
</Button>
</div>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
</p>
</div>
</CardContent>
</Card>
{/* Info */}
<Card>
<CardContent>

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { RefreshCw, Calendar, TrendingUp, CheckCircle, Clock, AlertCircle } from 'lucide-react'
import { useState, useEffect, useMemo } from 'react'
import { RefreshCw, Calendar, TrendingUp, CheckCircle, Clock, AlertCircle, Trash2 } from 'lucide-react'
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
interface BatchRun {
@@ -60,6 +60,28 @@ export default function Records() {
fetchData()
}, [])
// 检查是否有卡住的运行中记录
const stuckRunningCount = useMemo(() => {
return runs.filter(r => r.status === 'running').length
}, [runs])
// 清理卡住的记录
const handleCleanup = async () => {
if (!window.confirm('确定要将所有"运行中"状态的记录标记为完成吗?')) return
try {
const res = await fetch('/api/batch/cleanup', { method: 'POST' })
if (res.ok) {
const data = await res.json()
if (data.code === 0) {
alert(`已清理 ${data.data.affected} 条记录`)
fetchData()
}
}
} catch (e) {
console.error('清理失败:', e)
}
}
// 筛选记录
const filteredRuns = runs.filter((run) => {
if (!startDate && !endDate) return true
@@ -97,15 +119,28 @@ export default function Records() {
<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>
<Button
variant="outline"
size="sm"
onClick={fetchData}
loading={loading}
icon={<RefreshCw className="h-4 w-4" />}
>
</Button>
<div className="flex gap-2">
{stuckRunningCount > 0 && (
<Button
variant="outline"
size="sm"
onClick={handleCleanup}
icon={<Trash2 className="h-4 w-4" />}
className="text-orange-600 border-orange-300 hover:bg-orange-50 dark:text-orange-400 dark:border-orange-700 dark:hover:bg-orange-900/20"
>
({stuckRunningCount})
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={fetchData}
loading={loading}
icon={<RefreshCw className="h-4 w-4" />}
>
</Button>
</div>
</div>
{/* Stats Cards */}

View File

@@ -6,4 +6,5 @@ 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 Cleaner } from './Cleaner'