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:
588
frontend/src/pages/Monitor.tsx
Normal file
588
frontend/src/pages/Monitor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user