后端: - 添加 GET /api/v1/stats/dashboard 聚合统计端点 (账号数/活跃服务商/今日请求/今日Token用量等7项指标) - 需要 account:admin 权限 Admin 前端 (Next.js 14 + shadcn/ui + Tailwind + Recharts): - 设计系统: Dark Mode OLED (#020617 背景, #22C55E CTA) - 登录页: 双栏布局, 品牌区 + 表单 - Dashboard 布局: Sidebar 导航 + Header + 主内容区 - 仪表盘: 4 统计卡片 + AreaChart 请求趋势 + BarChart Token用量 - 8 个 CRUD 页面: - 账号管理 (搜索/角色/状态筛选, 编辑/启用禁用) - 服务商 (CRUD + API Key masked) - 模型管理 (Provider筛选, CRUD) - API 密钥 (创建/撤销, 一次性显示token) - 用量统计 (LineChart + BarChart) - 中转任务 (状态筛选, 展开详情) - 系统配置 (分类Tab, 编辑) - 操作日志 (Action筛选, 展开详情) - 14 个 shadcn 风格 UI 组件 (手写实现) - 类型化 API 客户端 (SaaSClient, 20+ 方法, 401 自动跳转) - AuthGuard 路由保护 + useAuth() hook 验证: tsc --noEmit 零 error, pnpm build 13 页面成功, cargo test 21 通过
394 lines
13 KiB
TypeScript
394 lines
13 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState, useCallback } from 'react'
|
||
import {
|
||
Search,
|
||
Plus,
|
||
Loader2,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
Pencil,
|
||
Ban,
|
||
CheckCircle2,
|
||
} from 'lucide-react'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Label } from '@/components/ui/label'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from '@/components/ui/table'
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogFooter,
|
||
DialogDescription,
|
||
} from '@/components/ui/dialog'
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from '@/components/ui/select'
|
||
import { api } from '@/lib/api-client'
|
||
import { ApiRequestError } from '@/lib/api-client'
|
||
import { formatDate } from '@/lib/utils'
|
||
import type { AccountPublic } from '@/lib/types'
|
||
|
||
const PAGE_SIZE = 20
|
||
|
||
const roleLabels: Record<string, string> = {
|
||
super_admin: '超级管理员',
|
||
admin: '管理员',
|
||
user: '普通用户',
|
||
}
|
||
|
||
const statusColors: Record<string, 'success' | 'destructive' | 'warning'> = {
|
||
active: 'success',
|
||
disabled: 'destructive',
|
||
suspended: 'warning',
|
||
}
|
||
|
||
const statusLabels: Record<string, string> = {
|
||
active: '正常',
|
||
disabled: '已禁用',
|
||
suspended: '已暂停',
|
||
}
|
||
|
||
export default function AccountsPage() {
|
||
const [accounts, setAccounts] = useState<AccountPublic[]>([])
|
||
const [total, setTotal] = useState(0)
|
||
const [page, setPage] = useState(1)
|
||
const [search, setSearch] = useState('')
|
||
const [roleFilter, setRoleFilter] = useState<string>('all')
|
||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState('')
|
||
|
||
// 编辑 Dialog
|
||
const [editTarget, setEditTarget] = useState<AccountPublic | null>(null)
|
||
const [editForm, setEditForm] = useState({ display_name: '', email: '', role: 'user' })
|
||
const [editSaving, setEditSaving] = useState(false)
|
||
|
||
// 确认 Dialog
|
||
const [confirmTarget, setConfirmTarget] = useState<{ id: string; action: string; status: string } | null>(null)
|
||
const [confirmSaving, setConfirmSaving] = useState(false)
|
||
|
||
const fetchAccounts = useCallback(async () => {
|
||
setLoading(true)
|
||
setError('')
|
||
try {
|
||
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
|
||
if (search.trim()) params.search = search.trim()
|
||
if (roleFilter !== 'all') params.role = roleFilter
|
||
if (statusFilter !== 'all') params.status = statusFilter
|
||
|
||
const res = await api.accounts.list(params)
|
||
setAccounts(res.items)
|
||
setTotal(res.total)
|
||
} catch (err) {
|
||
if (err instanceof ApiRequestError) {
|
||
setError(err.body.message)
|
||
} else {
|
||
setError('加载失败')
|
||
}
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [page, search, roleFilter, statusFilter])
|
||
|
||
useEffect(() => {
|
||
fetchAccounts()
|
||
}, [fetchAccounts])
|
||
|
||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||
|
||
function openEditDialog(account: AccountPublic) {
|
||
setEditTarget(account)
|
||
setEditForm({
|
||
display_name: account.display_name,
|
||
email: account.email,
|
||
role: account.role,
|
||
})
|
||
}
|
||
|
||
async function handleEditSave() {
|
||
if (!editTarget) return
|
||
setEditSaving(true)
|
||
try {
|
||
await api.accounts.update(editTarget.id, {
|
||
display_name: editForm.display_name,
|
||
email: editForm.email,
|
||
role: editForm.role as AccountPublic['role'],
|
||
})
|
||
setEditTarget(null)
|
||
fetchAccounts()
|
||
} catch (err) {
|
||
if (err instanceof ApiRequestError) {
|
||
setError(err.body.message)
|
||
}
|
||
} finally {
|
||
setEditSaving(false)
|
||
}
|
||
}
|
||
|
||
function openConfirmDialog(account: AccountPublic) {
|
||
const newStatus = account.status === 'active' ? 'disabled' : 'active'
|
||
setConfirmTarget({
|
||
id: account.id,
|
||
action: newStatus === 'disabled' ? '禁用' : '启用',
|
||
status: newStatus,
|
||
})
|
||
}
|
||
|
||
async function handleConfirmSave() {
|
||
if (!confirmTarget) return
|
||
setConfirmSaving(true)
|
||
try {
|
||
await api.accounts.updateStatus(confirmTarget.id, {
|
||
status: confirmTarget.status as AccountPublic['status'],
|
||
})
|
||
setConfirmTarget(null)
|
||
fetchAccounts()
|
||
} catch (err) {
|
||
if (err instanceof ApiRequestError) {
|
||
setError(err.body.message)
|
||
}
|
||
} finally {
|
||
setConfirmSaving(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* 搜索和筛选 */}
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||
<Input
|
||
placeholder="搜索用户名 / 邮箱 / 显示名..."
|
||
value={search}
|
||
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
|
||
className="pl-10"
|
||
/>
|
||
</div>
|
||
<Select value={roleFilter} onValueChange={(v) => { setRoleFilter(v); setPage(1) }}>
|
||
<SelectTrigger className="w-[140px]">
|
||
<SelectValue placeholder="角色筛选" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">全部角色</SelectItem>
|
||
<SelectItem value="super_admin">超级管理员</SelectItem>
|
||
<SelectItem value="admin">管理员</SelectItem>
|
||
<SelectItem value="user">普通用户</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v); setPage(1) }}>
|
||
<SelectTrigger className="w-[140px]">
|
||
<SelectValue placeholder="状态筛选" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">全部状态</SelectItem>
|
||
<SelectItem value="active">正常</SelectItem>
|
||
<SelectItem value="disabled">已禁用</SelectItem>
|
||
<SelectItem value="suspended">已暂停</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* 错误提示 */}
|
||
{error && (
|
||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||
{error}
|
||
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">
|
||
关闭
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* 表格 */}
|
||
{loading ? (
|
||
<div className="flex h-64 items-center justify-center">
|
||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||
</div>
|
||
) : accounts.length === 0 ? (
|
||
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||
暂无数据
|
||
</div>
|
||
) : (
|
||
<>
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>用户名</TableHead>
|
||
<TableHead>邮箱</TableHead>
|
||
<TableHead>显示名</TableHead>
|
||
<TableHead>角色</TableHead>
|
||
<TableHead>状态</TableHead>
|
||
<TableHead>创建时间</TableHead>
|
||
<TableHead className="text-right">操作</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{accounts.map((account) => (
|
||
<TableRow key={account.id}>
|
||
<TableCell className="font-medium">{account.username}</TableCell>
|
||
<TableCell className="text-muted-foreground">{account.email}</TableCell>
|
||
<TableCell>{account.display_name || '-'}</TableCell>
|
||
<TableCell>
|
||
<Badge variant={account.role === 'super_admin' ? 'default' : account.role === 'admin' ? 'info' : 'secondary'}>
|
||
{roleLabels[account.role] || account.role}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Badge variant={statusColors[account.status] || 'secondary'}>
|
||
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-current" />
|
||
{statusLabels[account.status] || account.status}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||
{formatDate(account.created_at)}
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
<div className="flex items-center justify-end gap-1">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => openEditDialog(account)}
|
||
title="编辑"
|
||
>
|
||
<Pencil className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => openConfirmDialog(account)}
|
||
title={account.status === 'active' ? '禁用' : '启用'}
|
||
>
|
||
{account.status === 'active' ? (
|
||
<Ban className="h-4 w-4 text-destructive" />
|
||
) : (
|
||
<CheckCircle2 className="h-4 w-4 text-green-400" />
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
|
||
{/* 分页 */}
|
||
<div className="flex items-center justify-between text-sm">
|
||
<p className="text-muted-foreground">
|
||
第 {page} 页 / 共 {totalPages} 页 ({total} 条)
|
||
</p>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={page <= 1}
|
||
onClick={() => setPage(page - 1)}
|
||
>
|
||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||
上一页
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={page >= totalPages}
|
||
onClick={() => setPage(page + 1)}
|
||
>
|
||
下一页
|
||
<ChevronRight className="h-4 w-4 ml-1" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* 编辑 Dialog */}
|
||
<Dialog open={!!editTarget} onOpenChange={() => setEditTarget(null)}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>编辑账号</DialogTitle>
|
||
<DialogDescription>修改账号信息</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-4">
|
||
<div className="space-y-2">
|
||
<Label>显示名</Label>
|
||
<Input
|
||
value={editForm.display_name}
|
||
onChange={(e) => setEditForm({ ...editForm, display_name: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>邮箱</Label>
|
||
<Input
|
||
type="email"
|
||
value={editForm.email}
|
||
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>角色</Label>
|
||
<Select value={editForm.role} onValueChange={(v) => setEditForm({ ...editForm, role: v })}>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="user">普通用户</SelectItem>
|
||
<SelectItem value="admin">管理员</SelectItem>
|
||
<SelectItem value="super_admin">超级管理员</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setEditTarget(null)}>
|
||
取消
|
||
</Button>
|
||
<Button onClick={handleEditSave} disabled={editSaving}>
|
||
{editSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
保存
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 确认 Dialog */}
|
||
<Dialog open={!!confirmTarget} onOpenChange={() => setConfirmTarget(null)}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>确认{confirmTarget?.action}</DialogTitle>
|
||
<DialogDescription>
|
||
确定要{confirmTarget?.action}该账号吗?此操作将立即生效。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setConfirmTarget(null)}>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
variant={confirmTarget?.status === 'disabled' ? 'destructive' : 'default'}
|
||
onClick={handleConfirmSave}
|
||
disabled={confirmSaving}
|
||
>
|
||
{confirmSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
确认{confirmTarget?.action}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
)
|
||
}
|