Files
codexautopool/frontend/src/pages/Cleaner.tsx

519 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}