Files
codexautopool/frontend/src/components/upload/OwnerList.tsx

386 lines
16 KiB
TypeScript

import { useState, useEffect } from 'react'
import { Users, Trash2, RefreshCw, ChevronLeft, ChevronRight, Key, CheckSquare, Square } from 'lucide-react'
import { Card, CardHeader, CardTitle, CardContent, Button } from '../common'
interface TeamOwner {
id: number
email: string
account_id: string
status: string
created_at: string
}
const statusColors: Record<string, string> = {
valid: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
registered: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
pooled: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
processing: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
invalid: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
used: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400',
}
const statusLabels: Record<string, string> = {
valid: '有效',
registered: '已注册',
pooled: '已入库',
processing: '处理中',
invalid: '无效',
used: '已使用',
}
interface OwnerListProps {
onStatsChange?: () => void
}
export default function OwnerList({ onStatsChange }: OwnerListProps) {
const [owners, setOwners] = useState<TeamOwner[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
const [page, setPage] = useState(0)
const [filter, setFilter] = useState<string>('')
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [deleting, setDeleting] = useState(false)
const limit = 20
const loadOwners = async () => {
setLoading(true)
try {
const params = new URLSearchParams({
limit: String(limit),
offset: String(page * limit),
})
if (filter) {
params.set('status', filter)
}
const res = await fetch(`/api/db/owners?${params}`)
const data = await res.json()
if (data.code === 0) {
setOwners(data.data.owners || [])
setTotal(data.data.total || 0)
}
} catch (e) {
console.error('Failed to load owners:', e)
} finally {
setLoading(false)
}
}
useEffect(() => {
loadOwners()
// 清除选择
setSelectedIds(new Set())
}, [page, filter])
// 单个删除
const handleDelete = async (id: number) => {
if (!confirm('确认删除此账号?')) return
try {
const res = await fetch(`/api/db/owners/delete/${id}`, { method: 'POST' })
const data = await res.json()
if (data.code === 0) {
loadOwners()
onStatsChange?.()
} else {
alert(data.message || '删除失败')
}
} catch (e) {
console.error('Failed to delete:', e)
alert('删除失败')
}
}
// 批量删除
const handleBatchDelete = async () => {
if (selectedIds.size === 0) {
alert('请先选择要删除的账号')
return
}
if (!confirm(`确认删除选中的 ${selectedIds.size} 个账号?`)) return
setDeleting(true)
try {
const res = await fetch('/api/db/owners/batch-delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: Array.from(selectedIds) }),
})
const data = await res.json()
if (data.code === 0) {
alert(data.data.message)
setSelectedIds(new Set())
loadOwners()
onStatsChange?.()
} else {
alert(data.message || '删除失败')
}
} catch (e) {
console.error('Failed to batch delete:', e)
alert('删除失败')
} finally {
setDeleting(false)
}
}
const handleClearAll = async () => {
if (!confirm('确认清空所有账号?此操作不可恢复!')) return
try {
await fetch('/api/db/owners/clear', { method: 'POST' })
loadOwners()
onStatsChange?.()
} catch (e) {
console.error('Failed to clear:', e)
}
}
const handleClearUsed = async () => {
if (!confirm('确认清理所有已使用的母号?')) return
try {
const res = await fetch('/api/db/owners/clear-used', { method: 'POST' })
const data = await res.json()
if (data.code === 0) {
alert(data.data.message)
loadOwners()
onStatsChange?.()
} else {
alert(data.message || '清理失败')
}
} catch (e) {
console.error('Failed to clear used:', e)
alert('清理失败')
}
}
const [refetching, setRefetching] = useState(false)
const handleRefetchAccountIds = async () => {
if (refetching) return
setRefetching(true)
try {
const res = await fetch('/api/db/owners/refetch-account-ids', { method: 'POST' })
const data = await res.json()
if (data.code === 0) {
const result = data.data
alert(`重新获取完成\n总数: ${result.total}\n成功: ${result.success}\n失败: ${result.fail}`)
loadOwners()
onStatsChange?.()
} else {
alert(`操作失败: ${data.message}`)
}
} catch (e) {
console.error('Failed to refetch account ids:', e)
alert('操作失败,请查看控制台')
} finally {
setRefetching(false)
}
}
// 选择逻辑
const toggleSelect = (id: number) => {
const newSet = new Set(selectedIds)
if (newSet.has(id)) {
newSet.delete(id)
} else {
newSet.add(id)
}
setSelectedIds(newSet)
}
const toggleSelectAll = () => {
if (selectedIds.size === owners.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(owners.map(o => o.id)))
}
}
const isAllSelected = owners.length > 0 && selectedIds.size === owners.length
const totalPages = Math.ceil(total / limit)
const formatTime = (ts: string) => {
try {
return new Date(ts).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
} catch {
return ''
}
}
return (
<Card className="h-full flex flex-col">
<CardHeader className="flex-shrink-0">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
({total})
</CardTitle>
<div className="flex gap-2">
<select
className="px-2 py-1 text-sm rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800"
value={filter}
onChange={(e) => {
setFilter(e.target.value)
setPage(0)
}}
>
<option value=""></option>
<option value="valid"></option>
<option value="registered"></option>
<option value="pooled"></option>
<option value="used">使</option>
<option value="invalid"></option>
</select>
<Button variant="ghost" size="sm" onClick={loadOwners} icon={<RefreshCw className="h-4 w-4" />}>
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleRefetchAccountIds}
disabled={refetching}
icon={<Key className={`h-4 w-4 ${refetching ? 'animate-pulse' : ''}`} />}
className="text-blue-500 hover:text-blue-600"
>
{refetching ? '获取中...' : '重新获取ID'}
</Button>
{selectedIds.size > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleBatchDelete}
disabled={deleting}
icon={<Trash2 className="h-4 w-4" />}
className="text-orange-500 hover:text-orange-600"
>
{deleting ? '删除中...' : `删除选中 (${selectedIds.size})`}
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={handleClearUsed}
icon={<Trash2 className="h-4 w-4" />}
className="text-yellow-600 hover:text-yellow-700"
>
使
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleClearAll}
icon={<Trash2 className="h-4 w-4" />}
className="text-red-500 hover:text-red-600"
>
</Button>
</div>
</div>
</CardHeader>
<CardContent className="flex-1 overflow-hidden p-0">
<div className="h-full overflow-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 dark:bg-slate-800 sticky top-0">
<tr>
<th className="text-center p-3 w-10">
<button onClick={toggleSelectAll} className="hover:text-blue-500">
{isAllSelected ? (
<CheckSquare className="h-4 w-4 text-blue-500" />
) : (
<Square className="h-4 w-4" />
)}
</button>
</th>
<th className="text-left p-3 font-medium text-slate-600 dark:text-slate-400"></th>
<th className="text-left p-3 font-medium text-slate-600 dark:text-slate-400">Account ID</th>
<th className="text-left p-3 font-medium text-slate-600 dark:text-slate-400"></th>
<th className="text-left p-3 font-medium text-slate-600 dark:text-slate-400"></th>
<th className="text-center p-3 font-medium text-slate-600 dark:text-slate-400"></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={6} className="text-center py-8 text-slate-500">
...
</td>
</tr>
) : owners.length === 0 ? (
<tr>
<td colSpan={6} className="text-center py-8 text-slate-500">
</td>
</tr>
) : (
owners.map((owner) => (
<tr key={owner.id} className={`border-t border-slate-100 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50 ${selectedIds.has(owner.id) ? 'bg-blue-50 dark:bg-blue-900/20' : ''}`}>
<td className="p-3 text-center">
<button onClick={() => toggleSelect(owner.id)} className="hover:text-blue-500">
{selectedIds.has(owner.id) ? (
<CheckSquare className="h-4 w-4 text-blue-500" />
) : (
<Square className="h-4 w-4" />
)}
</button>
</td>
<td className="p-3 text-slate-900 dark:text-slate-100">{owner.email}</td>
<td className="p-3 font-mono text-xs text-slate-500">
{owner.account_id ? `${owner.account_id.slice(0, 20)}...` : '-'}
</td>
<td className="p-3">
<span className={`px-2 py-0.5 rounded-full text-xs ${statusColors[owner.status] || 'bg-slate-100 text-slate-700'}`}>
{statusLabels[owner.status] || owner.status}
</span>
</td>
<td className="p-3 text-slate-500 text-xs">{formatTime(owner.created_at)}</td>
<td className="p-3 text-center">
<button
onClick={() => handleDelete(owner.id)}
className="text-red-400 hover:text-red-500"
>
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</CardContent>
{/* Pagination */}
{totalPages > 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">
{page + 1} / {totalPages}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
icon={<ChevronLeft className="h-4 w-4" />}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
icon={<ChevronRight className="h-4 w-4" />}
>
</Button>
</div>
</div>
)}
</Card>
)
}