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:
2026-01-30 07:40:35 +08:00
commit f4448bbef2
106 changed files with 19282 additions and 0 deletions

View File

@@ -0,0 +1,263 @@
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>
)
}