386 lines
16 KiB
TypeScript
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>
|
|
)
|
|
}
|