264 lines
9.4 KiB
TypeScript
264 lines
9.4 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react'
|
||
import { Link } from 'react-router-dom'
|
||
import { RefreshCw, Search, Settings, ChevronLeft, ChevronRight } from 'lucide-react'
|
||
import {
|
||
Card,
|
||
CardHeader,
|
||
CardTitle,
|
||
CardContent,
|
||
Button,
|
||
Input,
|
||
Select,
|
||
Table,
|
||
TableHeader,
|
||
TableBody,
|
||
TableRow,
|
||
TableHead,
|
||
TableCell,
|
||
StatusBadge,
|
||
} from '../components/common'
|
||
import { useS2AApi } from '../hooks/useS2AApi'
|
||
import { useConfig } from '../hooks/useConfig'
|
||
import type { S2AAccount, AccountListParams } from '../types'
|
||
import { formatDateTime } from '../utils/format'
|
||
|
||
export default function Accounts() {
|
||
const { config } = useConfig()
|
||
const { getAccounts, loading, error } = useS2AApi()
|
||
|
||
const [accounts, setAccounts] = useState<S2AAccount[]>([])
|
||
const [total, setTotal] = useState(0)
|
||
const [page, setPage] = useState(1)
|
||
const [pageSize] = useState(20)
|
||
const [search, setSearch] = useState('')
|
||
const [statusFilter, setStatusFilter] = useState<string>('')
|
||
const [refreshing, setRefreshing] = useState(false)
|
||
|
||
const hasConfig = config.s2a.apiBase && config.s2a.adminKey
|
||
|
||
const fetchAccounts = useCallback(async () => {
|
||
if (!hasConfig) return
|
||
|
||
setRefreshing(true)
|
||
const params: AccountListParams = {
|
||
page,
|
||
page_size: pageSize,
|
||
platform: 'openai',
|
||
}
|
||
|
||
if (search) params.search = search
|
||
if (statusFilter) params.status = statusFilter as 'active' | 'inactive' | 'error'
|
||
|
||
const result = await getAccounts(params)
|
||
if (result) {
|
||
setAccounts(result.data)
|
||
setTotal(result.total)
|
||
}
|
||
setRefreshing(false)
|
||
}, [hasConfig, page, pageSize, search, statusFilter, getAccounts])
|
||
|
||
useEffect(() => {
|
||
fetchAccounts()
|
||
}, [fetchAccounts])
|
||
|
||
const handleSearch = (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
setPage(1)
|
||
fetchAccounts()
|
||
}
|
||
|
||
const totalPages = Math.ceil(total / pageSize)
|
||
|
||
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">查看 S2A 号池中的账号列表</p>
|
||
</div>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={fetchAccounts}
|
||
disabled={!hasConfig || refreshing}
|
||
icon={<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />}
|
||
>
|
||
刷新
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Connection warning */}
|
||
{!hasConfig && (
|
||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-xl p-4">
|
||
<div className="flex items-start gap-3">
|
||
<Settings className="h-5 w-5 text-yellow-600 dark:text-yellow-400 mt-0.5" />
|
||
<div>
|
||
<p className="font-medium text-yellow-800 dark:text-yellow-200">请先配置 S2A 连接</p>
|
||
<p className="mt-1 text-sm text-yellow-700 dark:text-yellow-300">
|
||
查看账号列表需要配置 S2A API 连接信息
|
||
</p>
|
||
<Link to="/config" className="mt-3 inline-block">
|
||
<Button size="sm" variant="outline">
|
||
前往配置
|
||
</Button>
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Filters */}
|
||
{hasConfig && (
|
||
<Card>
|
||
<CardContent>
|
||
<form onSubmit={handleSearch} className="flex flex-wrap items-end gap-4">
|
||
<div className="flex-1 min-w-[200px]">
|
||
<Input
|
||
placeholder="搜索账号名称..."
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
className="w-full"
|
||
/>
|
||
</div>
|
||
<div className="w-40">
|
||
<Select
|
||
value={statusFilter}
|
||
onChange={(e) => {
|
||
setStatusFilter(e.target.value)
|
||
setPage(1)
|
||
}}
|
||
options={[
|
||
{ value: '', label: '全部状态' },
|
||
{ value: 'active', label: '正常' },
|
||
{ value: 'inactive', label: '停用' },
|
||
{ value: 'error', label: '错误' },
|
||
]}
|
||
/>
|
||
</div>
|
||
<Button type="submit" icon={<Search className="h-4 w-4" />}>
|
||
搜索
|
||
</Button>
|
||
</form>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Error */}
|
||
{error && (
|
||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4">
|
||
<p className="text-red-700 dark:text-red-400">{error}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Account List */}
|
||
{hasConfig && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>账号列表</CardTitle>
|
||
<span className="text-sm text-slate-500 dark:text-slate-400">共 {total} 个账号</span>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{loading && accounts.length === 0 ? (
|
||
<div className="space-y-3">
|
||
{[1, 2, 3, 4, 5].map((i) => (
|
||
<div
|
||
key={i}
|
||
className="h-16 bg-slate-100 dark:bg-slate-700 rounded-lg animate-pulse"
|
||
/>
|
||
))}
|
||
</div>
|
||
) : accounts.length === 0 ? (
|
||
<div className="text-center py-12">
|
||
<p className="text-slate-500 dark:text-slate-400">暂无账号</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow hoverable={false}>
|
||
<TableHead>ID</TableHead>
|
||
<TableHead>名称</TableHead>
|
||
<TableHead>类型</TableHead>
|
||
<TableHead>状态</TableHead>
|
||
<TableHead className="text-right">并发</TableHead>
|
||
<TableHead className="text-right">优先级</TableHead>
|
||
<TableHead>创建时间</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{accounts.map((account) => (
|
||
<TableRow key={account.id}>
|
||
<TableCell>
|
||
<span className="font-mono text-sm">{account.id}</span>
|
||
</TableCell>
|
||
<TableCell>
|
||
<span className="font-medium">{account.name}</span>
|
||
</TableCell>
|
||
<TableCell>
|
||
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||
{account.type}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell>
|
||
<StatusBadge status={account.status} />
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
{account.current_concurrency !== undefined ? (
|
||
<span>
|
||
{account.current_concurrency}/{account.concurrency}
|
||
</span>
|
||
) : (
|
||
account.concurrency
|
||
)}
|
||
</TableCell>
|
||
<TableCell className="text-right">{account.priority}</TableCell>
|
||
<TableCell>
|
||
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||
{formatDateTime(account.created_at)}
|
||
</span>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
|
||
{/* Pagination */}
|
||
{totalPages > 1 && (
|
||
<div className="flex items-center justify-between mt-4">
|
||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||
第 {page} 页,共 {totalPages} 页
|
||
</p>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||
disabled={page === 1}
|
||
icon={<ChevronLeft className="h-4 w-4" />}
|
||
>
|
||
上一页
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||
disabled={page === totalPages}
|
||
icon={<ChevronRight className="h-4 w-4" />}
|
||
>
|
||
下一页
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|