519 lines
24 KiB
TypeScript
519 lines
24 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react'
|
||
import { Trash2, Clock, Loader2, Save, RefreshCw, CheckCircle, XCircle, ToggleLeft, ToggleRight, AlertTriangle, ChevronLeft, ChevronRight, ScrollText } from 'lucide-react'
|
||
import { Card, CardHeader, CardTitle, CardContent, Button } from '../components/common'
|
||
|
||
interface CleanerStatus {
|
||
running: boolean
|
||
enabled: boolean
|
||
interval: number
|
||
last_clean_time: string
|
||
}
|
||
|
||
interface CleanerLogEntry {
|
||
timestamp: string
|
||
level: string
|
||
message: string
|
||
email?: string
|
||
module?: 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 [logEntries, setLogEntries] = useState<CleanerLogEntry[]>([])
|
||
const [logPage, setLogPage] = useState(1)
|
||
const [logTotalPages, setLogTotalPages] = useState(0)
|
||
const [logTotal, setLogTotal] = useState(0)
|
||
const [logLoading, setLogLoading] = useState(false)
|
||
const logPageSize = 5
|
||
|
||
// 加载清理设置
|
||
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)
|
||
}
|
||
}
|
||
|
||
// 加载清理日志
|
||
const fetchCleanerLogs = useCallback(async (page = 1) => {
|
||
setLogLoading(true)
|
||
try {
|
||
const params = new URLSearchParams({
|
||
module: 'cleaner',
|
||
page: String(page),
|
||
page_size: String(logPageSize),
|
||
})
|
||
const res = await fetch(`/api/logs/query?${params}`)
|
||
const data = await res.json()
|
||
if (data.code === 0 && data.data) {
|
||
setLogEntries(data.data.logs || [])
|
||
setLogTotalPages(data.data.total_pages || 0)
|
||
setLogTotal(data.data.total || 0)
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to fetch cleaner logs:', error)
|
||
} finally {
|
||
setLogLoading(false)
|
||
}
|
||
}, [logPageSize])
|
||
|
||
useEffect(() => {
|
||
fetchCleanerSettings()
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
fetchCleanerLogs(logPage)
|
||
}, [logPage, fetchCleanerLogs])
|
||
|
||
// 保存清理设置
|
||
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()
|
||
fetchCleanerLogs(logPage)
|
||
} 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 '未知'
|
||
}
|
||
}
|
||
|
||
// 计算下次清理时间
|
||
const getNextCleanTime = (): string => {
|
||
if (!cleanEnabled) return '未启用'
|
||
if (!status?.last_clean_time || status.last_clean_time === '0001-01-01T00:00:00Z') {
|
||
return '即将执行'
|
||
}
|
||
try {
|
||
const lastTime = new Date(status.last_clean_time)
|
||
const nextTime = new Date(lastTime.getTime() + cleanInterval * 1000)
|
||
// 如果下次时间已过,说明即将执行
|
||
if (nextTime <= new Date()) {
|
||
return '即将执行'
|
||
}
|
||
return nextTime.toLocaleString('zh-CN', {
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
})
|
||
} catch {
|
||
return '未知'
|
||
}
|
||
}
|
||
|
||
// 日志级别样式
|
||
const levelColors: Record<string, string> = {
|
||
success: 'text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/20',
|
||
error: 'text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20',
|
||
warning: 'text-yellow-600 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-900/20',
|
||
info: 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20',
|
||
}
|
||
|
||
const levelLabels: Record<string, string> = {
|
||
success: 'SUCCESS',
|
||
error: 'ERROR',
|
||
warning: 'WARN',
|
||
info: 'INFO',
|
||
}
|
||
|
||
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>
|
||
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1">
|
||
下次清理: {getNextCleanTime()}
|
||
</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>
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<ScrollText className="h-5 w-5 text-blue-500" />
|
||
清理日志
|
||
</CardTitle>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||
共 {logTotal} 条
|
||
</span>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={async () => {
|
||
if (!confirm('确定要清空所有清理日志吗?此操作不可撤销。')) return
|
||
try {
|
||
const res = await fetch('/api/logs/clear-module?module=cleaner', { method: 'POST' })
|
||
const data = await res.json()
|
||
if (data.code === 0) {
|
||
setLogEntries([])
|
||
setLogTotal(0)
|
||
setLogTotalPages(0)
|
||
setMessage({ type: 'success', text: data.data.message || '日志已清空' })
|
||
} else {
|
||
setMessage({ type: 'error', text: data.message || '清空失败' })
|
||
}
|
||
} catch {
|
||
setMessage({ type: 'error', text: '网络错误' })
|
||
}
|
||
}}
|
||
icon={<Trash2 className="h-3 w-3" />}
|
||
className="text-red-500 hover:text-red-600 border-red-300 hover:border-red-400 dark:border-red-800"
|
||
>
|
||
清空日志
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => fetchCleanerLogs(logPage)}
|
||
icon={<RefreshCw className={`h-3 w-3 ${logLoading ? 'animate-spin' : ''}`} />}
|
||
>
|
||
刷新
|
||
</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{logLoading && logEntries.length === 0 ? (
|
||
<div className="flex items-center justify-center py-8">
|
||
<Loader2 className="h-5 w-5 animate-spin text-slate-400" />
|
||
<span className="ml-2 text-sm text-slate-500">加载中...</span>
|
||
</div>
|
||
) : logEntries.length === 0 ? (
|
||
<div className="text-center py-8 text-slate-400">
|
||
<ScrollText className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||
<p className="text-sm">暂无清理日志</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-1">
|
||
{logEntries.map((log, i) => (
|
||
<div key={`${log.timestamp}-${i}`} className="flex items-start gap-3 text-sm py-2.5 border-b border-slate-100 dark:border-slate-800 last:border-0">
|
||
<span className="text-xs text-slate-400 flex-shrink-0 mt-0.5 font-mono whitespace-nowrap">
|
||
{new Date(log.timestamp).toLocaleString('zh-CN', {
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit'
|
||
})}
|
||
</span>
|
||
<span className={`text-xs font-medium flex-shrink-0 mt-0.5 px-1.5 py-0.5 rounded ${levelColors[log.level] || 'text-slate-500 bg-slate-50 dark:bg-slate-800'}`}>
|
||
{levelLabels[log.level] || log.level?.toUpperCase()}
|
||
</span>
|
||
<span className="text-slate-700 dark:text-slate-300 break-all">
|
||
{log.message}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
{logTotalPages > 1 && (
|
||
<div className="flex-shrink-0 p-3 border-t border-slate-100 dark:border-slate-800 flex items-center justify-between">
|
||
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||
第 {logPage} / {logTotalPages} 页
|
||
</span>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setLogPage(p => Math.max(1, p - 1))}
|
||
disabled={logPage <= 1}
|
||
icon={<ChevronLeft className="h-4 w-4" />}
|
||
>
|
||
上一页
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setLogPage(p => Math.min(logTotalPages, p + 1))}
|
||
disabled={logPage >= logTotalPages}
|
||
icon={<ChevronRight className="h-4 w-4" />}
|
||
>
|
||
下一页
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</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>
|
||
)
|
||
}
|