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