refactor(admin): 迁移 admin 项目到 admin-v2 并移除旧代码
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
重构 admin 项目为 admin-v2,移除 Next.js 相关代码,添加 Vite 配置和环境变量 删除所有 UI 组件、工具函数、API 客户端和类型定义 新增 ErrorBoundary 组件处理错误边界 调整代理配置支持 SSE 长连接超时设置
This commit is contained in:
1
admin-v2/.env.development
Normal file
1
admin-v2/.env.development
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=http://localhost:8080/api/v1
|
||||||
1
admin-v2/.env.production
Normal file
1
admin-v2/.env.production
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=/api/v1
|
||||||
53
admin-v2/src/components/ErrorBoundary.tsx
Normal file
53
admin-v2/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Component, type ReactNode } from 'react'
|
||||||
|
import { Result, Button } from 'antd'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean
|
||||||
|
error: Error | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props)
|
||||||
|
this.state = { hasError: false, error: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||||
|
console.error('[ErrorBoundary] Unhandled error:', error, info.componentStack)
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleReload = () => {
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleReset = () => {
|
||||||
|
this.setState({ hasError: false, error: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title="页面出现错误"
|
||||||
|
subTitle={this.state.error?.message ?? '发生了未知错误,请刷新页面重试'}
|
||||||
|
extra={[
|
||||||
|
<Button key="retry" onClick={this.handleReset}>重试</Button>,
|
||||||
|
<Button key="reload" type="primary" onClick={this.handleReload}>刷新页面</Button>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,13 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
// SSE relay 端点需要长超时(流式响应可持续数分钟)
|
||||||
|
'/api/v1/relay/chat/completions': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
timeout: 600_000,
|
||||||
|
proxyTimeout: 600_000,
|
||||||
|
},
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8080',
|
target: 'http://localhost:8080',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
|||||||
2
admin/.gitignore
vendored
2
admin/.gitignore
vendored
@@ -1,2 +0,0 @@
|
|||||||
.next/
|
|
||||||
node_modules/
|
|
||||||
5
admin/next-env.d.ts
vendored
5
admin/next-env.d.ts
vendored
@@ -1,5 +0,0 @@
|
|||||||
/// <reference types="next" />
|
|
||||||
/// <reference types="next/image-types/global" />
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
|
||||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
|
||||||
const nextConfig = {
|
|
||||||
async rewrites() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
source: '/api/:path*',
|
|
||||||
destination: 'http://localhost:8080/api/:path*',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = nextConfig
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "zclaw-admin",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"dev": "next dev",
|
|
||||||
"build": "next build",
|
|
||||||
"start": "next start",
|
|
||||||
"lint": "next lint"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
|
||||||
"@radix-ui/react-switch": "^1.2.5",
|
|
||||||
"@radix-ui/react-tabs": "^1.1.12",
|
|
||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"lucide-react": "^0.484.0",
|
|
||||||
"next": "14.2.29",
|
|
||||||
"react": "^18.3.1",
|
|
||||||
"react-dom": "^18.3.1",
|
|
||||||
"recharts": "^2.15.3",
|
|
||||||
"swr": "^2.4.1",
|
|
||||||
"tailwind-merge": "^3.0.2"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^20.17.19",
|
|
||||||
"@types/react": "^18.3.18",
|
|
||||||
"@types/react-dom": "^18.3.5",
|
|
||||||
"autoprefixer": "^10.4.20",
|
|
||||||
"postcss": "^8.5.3",
|
|
||||||
"tailwindcss": "^3.4.17",
|
|
||||||
"typescript": "^5.7.3"
|
|
||||||
},
|
|
||||||
"packageManager": "pnpm@10.30.2"
|
|
||||||
}
|
|
||||||
2200
admin/pnpm-lock.yaml
generated
2200
admin/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,373 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import useSWR from 'swr'
|
|
||||||
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, getSwrErrorMessage } from '@/lib/utils'
|
|
||||||
import { ErrorBanner, EmptyState } from '@/components/ui/state'
|
|
||||||
import { TableSkeleton } from '@/components/ui/skeleton'
|
|
||||||
import { useDebounce } from '@/hooks/use-debounce'
|
|
||||||
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 [page, setPage] = useState(1)
|
|
||||||
const [search, setSearch] = useState('')
|
|
||||||
const [roleFilter, setRoleFilter] = useState<string>('all')
|
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
|
||||||
const [mutationError, setMutationError] = useState('')
|
|
||||||
|
|
||||||
const debouncedSearch = useDebounce(search, 300)
|
|
||||||
|
|
||||||
const { data, error: swrError, isLoading, mutate } = useSWR(
|
|
||||||
['accounts', page, debouncedSearch, roleFilter, statusFilter],
|
|
||||||
() => {
|
|
||||||
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
|
|
||||||
if (debouncedSearch.trim()) params.search = debouncedSearch.trim()
|
|
||||||
if (roleFilter !== 'all') params.role = roleFilter
|
|
||||||
if (statusFilter !== 'all') params.status = statusFilter
|
|
||||||
return api.accounts.list(params)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const accounts = data?.items ?? []
|
|
||||||
const total = data?.total ?? 0
|
|
||||||
const error = getSwrErrorMessage(swrError) || mutationError
|
|
||||||
|
|
||||||
// 编辑 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 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)
|
|
||||||
mutate()
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiRequestError) {
|
|
||||||
setMutationError(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)
|
|
||||||
mutate()
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiRequestError) {
|
|
||||||
setMutationError(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 && <ErrorBanner message={error} onDismiss={() => { setMutationError('') }} />}
|
|
||||||
|
|
||||||
{/* 表格 */}
|
|
||||||
{isLoading ? (
|
|
||||||
<TableSkeleton rows={6} cols={7} />
|
|
||||||
) : error ? null : accounts.length === 0 ? (
|
|
||||||
<EmptyState />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import useSWR from 'swr'
|
|
||||||
import { api } from '@/lib/api-client'
|
|
||||||
import type { AgentTemplate } from '@/lib/types'
|
|
||||||
import { ErrorBanner, EmptyState } from '@/components/ui/state'
|
|
||||||
import { TableSkeleton } from '@/components/ui/skeleton'
|
|
||||||
|
|
||||||
export default function AgentTemplatesPage() {
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const { data, isLoading, mutate } = useSWR(
|
|
||||||
['agentTemplates.list', page],
|
|
||||||
() => api.agentTemplates.list({ page, page_size: 50 }),
|
|
||||||
)
|
|
||||||
|
|
||||||
const templates = data?.items ?? []
|
|
||||||
const total = data?.total ?? 0
|
|
||||||
|
|
||||||
const handleCreate = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault()
|
|
||||||
const fd = new FormData(e.currentTarget)
|
|
||||||
try {
|
|
||||||
const tools = (fd.get('tools') as string || '').split(',').map(s => s.trim()).filter(Boolean)
|
|
||||||
const capabilities = (fd.get('capabilities') as string || '').split(',').map(s => s.trim()).filter(Boolean)
|
|
||||||
await api.agentTemplates.create({
|
|
||||||
name: fd.get('name') as string,
|
|
||||||
description: (fd.get('description') as string) || undefined,
|
|
||||||
category: (fd.get('category') as string) || 'general',
|
|
||||||
model: (fd.get('model') as string) || undefined,
|
|
||||||
system_prompt: (fd.get('system_prompt') as string) || undefined,
|
|
||||||
tools: tools.length > 0 ? tools : undefined,
|
|
||||||
capabilities: capabilities.length > 0 ? capabilities : undefined,
|
|
||||||
temperature: (fd.get('temperature') as string) ? parseFloat(fd.get('temperature') as string) : undefined,
|
|
||||||
max_tokens: (fd.get('max_tokens') as string) ? parseInt(fd.get('max_tokens') as string, 10) : undefined,
|
|
||||||
visibility: (fd.get('visibility') as string) || 'public',
|
|
||||||
})
|
|
||||||
setShowCreate(false)
|
|
||||||
mutate()
|
|
||||||
} catch {
|
|
||||||
setError('创建失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleArchive = async (id: string, name: string) => {
|
|
||||||
if (!confirm(`确认归档模板 "${name}"?`)) return
|
|
||||||
try {
|
|
||||||
await api.agentTemplates.archive(id)
|
|
||||||
mutate()
|
|
||||||
} catch {
|
|
||||||
setError('归档失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusBadge = (status: string) => {
|
|
||||||
const colors: Record<string, string> = {
|
|
||||||
active: 'bg-emerald-500/20 text-emerald-400',
|
|
||||||
archived: 'bg-zinc-500/20 text-zinc-400',
|
|
||||||
}
|
|
||||||
return <span className={`px-2 py-0.5 text-xs rounded-full ${colors[status] || colors.archived}`}>{status}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceBadge = (source: string) => {
|
|
||||||
const colors: Record<string, string> = {
|
|
||||||
builtin: 'bg-blue-500/20 text-blue-400',
|
|
||||||
custom: 'bg-purple-500/20 text-purple-400',
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className={`px-2 py-0.5 text-xs rounded-full ${colors[source] || ''}`}>
|
|
||||||
{source === 'builtin' ? '内置' : '自定义'}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-white">Agent 配置模板</h1>
|
|
||||||
<p className="text-sm text-zinc-400 mt-1">管理 Agent 配置模板,支持团队共享和一键复用</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowCreate(true)}
|
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
|
|
||||||
>
|
|
||||||
+ 新建模板
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <ErrorBanner message={error} onDismiss={() => setError('')} />}
|
|
||||||
|
|
||||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-zinc-800">
|
|
||||||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">名称</th>
|
|
||||||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">分类</th>
|
|
||||||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">来源</th>
|
|
||||||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">模型</th>
|
|
||||||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">工具数</th>
|
|
||||||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">可见性</th>
|
|
||||||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">状态</th>
|
|
||||||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">更新时间</th>
|
|
||||||
<th className="text-right px-4 py-3 text-zinc-400 font-medium">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{isLoading ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={9}>
|
|
||||||
<TableSkeleton rows={5} cols={9} hasToolbar={false} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : templates.length === 0 ? (
|
|
||||||
<tr><td colSpan={9}><EmptyState message="暂无 Agent 模板" /></td></tr>
|
|
||||||
) : (
|
|
||||||
templates.map(t => (
|
|
||||||
<tr key={t.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div>
|
|
||||||
<span className="text-white font-medium">{t.name}</span>
|
|
||||||
{t.description && (
|
|
||||||
<p className="text-xs text-zinc-500 mt-0.5 truncate max-w-[200px]">{t.description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-zinc-400">{t.category}</td>
|
|
||||||
<td className="px-4 py-3">{sourceBadge(t.source)}</td>
|
|
||||||
<td className="px-4 py-3 text-zinc-300 font-mono text-xs">{t.model || '-'}</td>
|
|
||||||
<td className="px-4 py-3 text-zinc-400">{t.tools.length}</td>
|
|
||||||
<td className="px-4 py-3 text-zinc-400">{t.visibility}</td>
|
|
||||||
<td className="px-4 py-3">{statusBadge(t.status)}</td>
|
|
||||||
<td className="px-4 py-3 text-zinc-500 text-xs">
|
|
||||||
{new Date(t.updated_at).toLocaleString('zh-CN')}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right">
|
|
||||||
<button
|
|
||||||
onClick={() => setEditingId(editingId === t.id ? null : t.id)}
|
|
||||||
className="text-zinc-400 hover:text-white mr-2"
|
|
||||||
>
|
|
||||||
详情
|
|
||||||
</button>
|
|
||||||
{t.source === 'custom' && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleArchive(t.id, t.name)}
|
|
||||||
className="text-red-400 hover:text-red-300"
|
|
||||||
>
|
|
||||||
归档
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div className="px-4 py-2 text-xs text-zinc-500 border-t border-zinc-800">
|
|
||||||
共 {total} 个模板
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 展开详情 */}
|
|
||||||
{editingId && (() => {
|
|
||||||
const t = templates.find(t => t.id === editingId)
|
|
||||||
if (!t) return null
|
|
||||||
return (
|
|
||||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-4">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h2 className="text-lg font-semibold text-white">{t.name} — 详情</h2>
|
|
||||||
<button onClick={() => setEditingId(null)} className="text-zinc-400 hover:text-white text-sm">关闭</button>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="text-zinc-500">分类:</span>
|
|
||||||
<span className="text-zinc-300">{t.category}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-zinc-500">模型:</span>
|
|
||||||
<span className="text-zinc-300 font-mono">{t.model || '未指定'}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-zinc-500">温度:</span>
|
|
||||||
<span className="text-zinc-300">{t.temperature?.toFixed(2) || '默认'}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-zinc-500">最大 Token:</span>
|
|
||||||
<span className="text-zinc-300">{t.max_tokens || '未限制'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<span className="text-zinc-500">工具:</span>
|
|
||||||
<div className="flex flex-wrap gap-1 mt-1">
|
|
||||||
{t.tools.length > 0 ? t.tools.map(tool => (
|
|
||||||
<span key={tool} className="px-2 py-0.5 bg-zinc-800 rounded text-xs text-zinc-300">{tool}</span>
|
|
||||||
)) : <span className="text-zinc-600">无</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<span className="text-zinc-500">能力:</span>
|
|
||||||
<div className="flex flex-wrap gap-1 mt-1">
|
|
||||||
{t.capabilities.length > 0 ? t.capabilities.map(cap => (
|
|
||||||
<span key={cap} className="px-2 py-0.5 bg-blue-500/10 rounded text-xs text-blue-400">{cap}</span>
|
|
||||||
)) : <span className="text-zinc-600">无</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{t.system_prompt && (
|
|
||||||
<div className="col-span-2">
|
|
||||||
<span className="text-zinc-500">系统提示词:</span>
|
|
||||||
<pre className="text-xs text-zinc-400 bg-zinc-800/50 rounded p-2 mt-1 overflow-x-auto max-h-32">
|
|
||||||
{t.system_prompt.substring(0, 500)}{t.system_prompt.length > 500 ? '...' : ''}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Create Modal */}
|
|
||||||
{showCreate && (
|
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
||||||
<form onSubmit={handleCreate} className="bg-zinc-900 rounded-xl border border-zinc-700 p-6 w-full max-w-lg space-y-4 max-h-[80vh] overflow-y-auto">
|
|
||||||
<h2 className="text-lg font-semibold text-white">新建 Agent 模板</h2>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-zinc-400 mb-1">名称 *</label>
|
|
||||||
<input name="name" required className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="my_agent" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-zinc-400 mb-1">描述</label>
|
|
||||||
<input name="description" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="可选" />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-zinc-400 mb-1">分类</label>
|
|
||||||
<select name="category" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm">
|
|
||||||
<option value="general">通用</option>
|
|
||||||
<option value="coding">编程</option>
|
|
||||||
<option value="research">研究</option>
|
|
||||||
<option value="creative">创意</option>
|
|
||||||
<option value="assistant">助手</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-zinc-400 mb-1">模型</label>
|
|
||||||
<input name="model" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="如 glm-4-plus" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-zinc-400 mb-1">系统提示词</label>
|
|
||||||
<textarea name="system_prompt" rows={4} className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm font-mono" placeholder="Agent 系统提示词" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-zinc-400 mb-1">工具(逗号分隔)</label>
|
|
||||||
<input name="tools" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="browser, file_system, code_execute" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-zinc-400 mb-1">能力(逗号分隔)</label>
|
|
||||||
<input name="capabilities" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="streaming, vision, function_calling" />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-zinc-400 mb-1">温度</label>
|
|
||||||
<input name="temperature" type="number" step="0.1" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="默认" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-zinc-400 mb-1">最大 Token</label>
|
|
||||||
<input name="max_tokens" type="number" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="不限" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-zinc-400 mb-1">可见性</label>
|
|
||||||
<select name="visibility" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm">
|
|
||||||
<option value="public">公开</option>
|
|
||||||
<option value="team">团队</option>
|
|
||||||
<option value="private">私有</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 justify-end">
|
|
||||||
<button type="button" onClick={() => setShowCreate(false)} className="px-4 py-2 bg-zinc-700 text-white rounded-lg hover:bg-zinc-600 text-sm">取消</button>
|
|
||||||
<button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm">创建</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import useSWR from 'swr'
|
|
||||||
import {
|
|
||||||
Plus,
|
|
||||||
Loader2,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
Trash2,
|
|
||||||
Copy,
|
|
||||||
Check,
|
|
||||||
AlertTriangle,
|
|
||||||
} 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 { api } from '@/lib/api-client'
|
|
||||||
import { ErrorBanner, EmptyState } from '@/components/ui/state'
|
|
||||||
import { ApiRequestError } from '@/lib/api-client'
|
|
||||||
import { formatDate, getSwrErrorMessage } from '@/lib/utils'
|
|
||||||
import { TableSkeleton } from '@/components/ui/skeleton'
|
|
||||||
import type { TokenInfo } from '@/lib/types'
|
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
|
||||||
|
|
||||||
const allPermissions = [
|
|
||||||
{ key: 'chat', label: '对话' },
|
|
||||||
{ key: 'relay', label: '中转' },
|
|
||||||
{ key: 'admin', label: '管理' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function ApiKeysPage() {
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [mutationError, setMutationError] = useState('')
|
|
||||||
|
|
||||||
const { data, error: swrError, isLoading, mutate } = useSWR(
|
|
||||||
['tokens', page],
|
|
||||||
() => api.tokens.list({ page, page_size: PAGE_SIZE }),
|
|
||||||
)
|
|
||||||
|
|
||||||
const tokens = data?.items ?? []
|
|
||||||
const total = data?.total ?? 0
|
|
||||||
const error = getSwrErrorMessage(swrError) || mutationError
|
|
||||||
|
|
||||||
// 创建 Dialog
|
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
|
||||||
const [createForm, setCreateForm] = useState({ name: '', expires_days: '', permissions: ['chat'] as string[] })
|
|
||||||
const [creating, setCreating] = useState(false)
|
|
||||||
|
|
||||||
// 创建成功显示 token
|
|
||||||
const [createdToken, setCreatedToken] = useState<TokenInfo | null>(null)
|
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
|
|
||||||
// 撤销确认
|
|
||||||
const [revokeTarget, setRevokeTarget] = useState<TokenInfo | null>(null)
|
|
||||||
const [revoking, setRevoking] = useState(false)
|
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
|
||||||
|
|
||||||
function togglePermission(perm: string) {
|
|
||||||
setCreateForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
permissions: prev.permissions.includes(perm)
|
|
||||||
? prev.permissions.filter((p) => p !== perm)
|
|
||||||
: [...prev.permissions, perm],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreate() {
|
|
||||||
if (!createForm.name.trim() || createForm.permissions.length === 0) return
|
|
||||||
setCreating(true)
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
name: createForm.name.trim(),
|
|
||||||
expires_days: createForm.expires_days ? parseInt(createForm.expires_days, 10) : undefined,
|
|
||||||
permissions: createForm.permissions,
|
|
||||||
}
|
|
||||||
const res = await api.tokens.create(payload)
|
|
||||||
setCreateOpen(false)
|
|
||||||
setCreatedToken(res)
|
|
||||||
setCreateForm({ name: '', expires_days: '', permissions: ['chat'] })
|
|
||||||
mutate()
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiRequestError) setMutationError(err.body.message)
|
|
||||||
} finally {
|
|
||||||
setCreating(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRevoke() {
|
|
||||||
if (!revokeTarget) return
|
|
||||||
setRevoking(true)
|
|
||||||
try {
|
|
||||||
await api.tokens.revoke(revokeTarget.id)
|
|
||||||
setRevokeTarget(null)
|
|
||||||
mutate()
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiRequestError) setMutationError(err.body.message)
|
|
||||||
} finally {
|
|
||||||
setRevoking(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyToken() {
|
|
||||||
if (!createdToken?.token) return
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(createdToken.token)
|
|
||||||
setCopied(true)
|
|
||||||
setTimeout(() => setCopied(false), 2000)
|
|
||||||
} catch {
|
|
||||||
// Fallback
|
|
||||||
const textarea = document.createElement('textarea')
|
|
||||||
textarea.value = createdToken.token
|
|
||||||
document.body.appendChild(textarea)
|
|
||||||
textarea.select()
|
|
||||||
document.execCommand('copy')
|
|
||||||
document.body.removeChild(textarea)
|
|
||||||
setCopied(true)
|
|
||||||
setTimeout(() => setCopied(false), 2000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div />
|
|
||||||
<Button onClick={() => setCreateOpen(true)}>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
新建密钥
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <ErrorBanner message={error} onDismiss={() => setMutationError('')} />}
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<TableSkeleton rows={6} cols={7} />
|
|
||||||
) : error ? null : tokens.length === 0 ? (
|
|
||||||
<EmptyState />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>名称</TableHead>
|
|
||||||
<TableHead>前缀</TableHead>
|
|
||||||
<TableHead>权限</TableHead>
|
|
||||||
<TableHead>最后使用</TableHead>
|
|
||||||
<TableHead>过期时间</TableHead>
|
|
||||||
<TableHead>创建时间</TableHead>
|
|
||||||
<TableHead className="text-right">操作</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{tokens.map((t) => (
|
|
||||||
<TableRow key={t.id}>
|
|
||||||
<TableCell className="font-medium">{t.name}</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
||||||
{t.token_prefix}...
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{t.permissions.map((p) => (
|
|
||||||
<Badge key={p} variant="outline" className="text-xs">
|
|
||||||
{p}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
||||||
{t.last_used_at ? formatDate(t.last_used_at) : '未使用'}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
||||||
{t.expires_at ? formatDate(t.expires_at) : '永不过期'}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
||||||
{formatDate(t.created_at)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => setRevokeTarget(t)} title="撤销">
|
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</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={createOpen} onOpenChange={setCreateOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>新建 API 密钥</DialogTitle>
|
|
||||||
<DialogDescription>创建新的 API 密钥用于接口调用</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>名称 *</Label>
|
|
||||||
<Input
|
|
||||||
value={createForm.name}
|
|
||||||
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
|
|
||||||
placeholder="例如: 生产环境"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>过期天数 (留空则永不过期)</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={createForm.expires_days}
|
|
||||||
onChange={(e) => setCreateForm({ ...createForm, expires_days: e.target.value })}
|
|
||||||
placeholder="365"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>权限 *</Label>
|
|
||||||
<div className="flex flex-wrap gap-3 mt-1">
|
|
||||||
{allPermissions.map((perm) => (
|
|
||||||
<label
|
|
||||||
key={perm.key}
|
|
||||||
className="flex items-center gap-2 cursor-pointer"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={createForm.permissions.includes(perm.key)}
|
|
||||||
onChange={() => togglePermission(perm.key)}
|
|
||||||
className="h-4 w-4 rounded border-input bg-transparent accent-primary cursor-pointer"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-foreground">{perm.label}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>取消</Button>
|
|
||||||
<Button onClick={handleCreate} disabled={creating || !createForm.name.trim() || createForm.permissions.length === 0}>
|
|
||||||
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
创建
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* 创建成功 Dialog */}
|
|
||||||
<Dialog open={!!createdToken} onOpenChange={() => setCreatedToken(null)}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<AlertTriangle className="h-5 w-5 text-yellow-400" />
|
|
||||||
密钥已创建
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
请立即复制并安全保存此密钥,关闭后将无法再次查看完整密钥。
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="rounded-md bg-muted p-4">
|
|
||||||
<p className="text-xs text-muted-foreground mb-2">完整密钥</p>
|
|
||||||
<p className="font-mono text-sm break-all text-foreground">
|
|
||||||
{createdToken?.token}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md bg-yellow-500/10 border border-yellow-500/20 p-3 text-sm text-yellow-400">
|
|
||||||
此密钥仅显示一次。请确保已保存到安全的位置。
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button onClick={copyToken} variant="outline">
|
|
||||||
{copied ? <Check className="h-4 w-4 mr-2" /> : <Copy className="h-4 w-4 mr-2" />}
|
|
||||||
{copied ? '已复制' : '复制密钥'}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => setCreatedToken(null)}>我已保存</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* 撤销确认 */}
|
|
||||||
<Dialog open={!!revokeTarget} onOpenChange={() => setRevokeTarget(null)}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>确认撤销</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
确定要撤销密钥 "{revokeTarget?.name}" 吗?使用此密钥的应用将立即失去访问权限。
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setRevokeTarget(null)}>取消</Button>
|
|
||||||
<Button variant="destructive" onClick={handleRevoke} disabled={revoking}>
|
|
||||||
{revoking && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
撤销
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import useSWR from 'swr'
|
|
||||||
import {
|
|
||||||
Loader2,
|
|
||||||
Pencil,
|
|
||||||
RotateCcw,
|
|
||||||
} 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 {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table'
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
DialogDescription,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
||||||
import { api } from '@/lib/api-client'
|
|
||||||
import { TableSkeleton } from '@/components/ui/skeleton'
|
|
||||||
import { ErrorBanner, EmptyState } from '@/components/ui/state'
|
|
||||||
import { ApiRequestError } from '@/lib/api-client'
|
|
||||||
import type { ConfigItem } from '@/lib/types'
|
|
||||||
|
|
||||||
const sourceLabels: Record<string, string> = {
|
|
||||||
default: '默认值',
|
|
||||||
env: '环境变量',
|
|
||||||
db: '数据库',
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceVariants: Record<string, 'secondary' | 'info' | 'default'> = {
|
|
||||||
default: 'secondary',
|
|
||||||
env: 'info',
|
|
||||||
db: 'default',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ConfigPage() {
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
const [activeTab, setActiveTab] = useState('all')
|
|
||||||
|
|
||||||
// SWR for config list
|
|
||||||
const { data: configs = [], isLoading, mutate } = useSWR(
|
|
||||||
['config', activeTab],
|
|
||||||
() => {
|
|
||||||
const params: Record<string, unknown> = {}
|
|
||||||
if (activeTab !== 'all') params.category = activeTab
|
|
||||||
return api.config.list(params)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// 编辑 Dialog
|
|
||||||
const [editTarget, setEditTarget] = useState<ConfigItem | null>(null)
|
|
||||||
const [editValue, setEditValue] = useState('')
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
|
|
||||||
function openEditDialog(config: ConfigItem) {
|
|
||||||
setEditTarget(config)
|
|
||||||
setEditValue(config.current_value ?? '')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
if (!editTarget) return
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
|
||||||
let parsedValue: string | number | boolean = editValue
|
|
||||||
if (editTarget.value_type === 'number') {
|
|
||||||
parsedValue = parseFloat(editValue) || 0
|
|
||||||
} else if (editTarget.value_type === 'boolean') {
|
|
||||||
parsedValue = editValue === 'true'
|
|
||||||
}
|
|
||||||
await api.config.update(editTarget.id, { value: parsedValue })
|
|
||||||
setEditTarget(null)
|
|
||||||
mutate()
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatValue(value: unknown): string {
|
|
||||||
if (value === undefined || value === null) return '-'
|
|
||||||
if (typeof value === 'boolean') return value ? 'true' : 'false'
|
|
||||||
return String(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const categoryLabels: Record<string, string> = {
|
|
||||||
all: '全部',
|
|
||||||
server: '服务器',
|
|
||||||
agent: 'Agent',
|
|
||||||
memory: '记忆',
|
|
||||||
llm: 'LLM',
|
|
||||||
security: '安全策略',
|
|
||||||
}
|
|
||||||
const categories = Object.keys(categoryLabels)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* 分类 Tabs */}
|
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
||||||
<TabsList>
|
|
||||||
{categories.map((cat) => (
|
|
||||||
<TabsTrigger key={cat} value={cat}>
|
|
||||||
{categoryLabels[cat] || cat}
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{error && <ErrorBanner message={error} onDismiss={() => setError('')} />}
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<TableSkeleton rows={8} cols={8} hasToolbar={false} />
|
|
||||||
) : error ? null : configs.length === 0 ? (
|
|
||||||
<EmptyState message="暂无配置项" />
|
|
||||||
) : (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>分类</TableHead>
|
|
||||||
<TableHead>Key</TableHead>
|
|
||||||
<TableHead>当前值</TableHead>
|
|
||||||
<TableHead>默认值</TableHead>
|
|
||||||
<TableHead>来源</TableHead>
|
|
||||||
<TableHead>需重启</TableHead>
|
|
||||||
<TableHead>描述</TableHead>
|
|
||||||
<TableHead className="text-right">操作</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{configs.map((config) => (
|
|
||||||
<TableRow key={config.id}>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="outline">{config.category}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-sm">{config.key_path}</TableCell>
|
|
||||||
<TableCell className="font-mono text-sm max-w-[200px] truncate">
|
|
||||||
{formatValue(config.current_value)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground max-w-[200px] truncate">
|
|
||||||
{formatValue(config.default_value)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={sourceVariants[config.source] || 'secondary'}>
|
|
||||||
{sourceLabels[config.source] || config.source}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{config.requires_restart ? (
|
|
||||||
<Badge variant="warning">是</Badge>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">否</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground max-w-[250px] truncate">
|
|
||||||
{config.description || '-'}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => openEditDialog(config)} title="编辑">
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 编辑 Dialog */}
|
|
||||||
<Dialog open={!!editTarget} onOpenChange={() => setEditTarget(null)}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>编辑配置</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
修改 {editTarget?.key_path} 的值
|
|
||||||
{editTarget?.requires_restart && (
|
|
||||||
<span className="block mt-1 text-yellow-400 text-xs">
|
|
||||||
注意: 修改此配置需要重启服务才能生效
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Key</Label>
|
|
||||||
<Input value={editTarget?.key_path || ''} disabled />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>类型</Label>
|
|
||||||
<Input value={editTarget?.value_type || ''} disabled />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>
|
|
||||||
新值 {editTarget?.default_value != null && (
|
|
||||||
<span className="text-xs text-muted-foreground ml-2">
|
|
||||||
(默认: {formatValue(editTarget.default_value)})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Label>
|
|
||||||
{editTarget?.value_type === 'boolean' ? (
|
|
||||||
<Select value={editValue} onValueChange={setEditValue}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="true">true</SelectItem>
|
|
||||||
<SelectItem value="false">false</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
type={editTarget?.value_type === 'number' ? 'number' : 'text'}
|
|
||||||
value={editValue}
|
|
||||||
onChange={(e) => setEditValue(e.target.value)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
if (editTarget?.default_value != null) {
|
|
||||||
setEditValue(String(editTarget.default_value))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RotateCcw className="h-4 w-4 mr-2" />
|
|
||||||
恢复默认
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" onClick={() => setEditTarget(null)}>取消</Button>
|
|
||||||
<Button onClick={handleSave} disabled={saving}>
|
|
||||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, type ReactNode } from 'react'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { usePathname, useRouter } from 'next/navigation'
|
|
||||||
import {
|
|
||||||
LayoutDashboard,
|
|
||||||
Users,
|
|
||||||
Server,
|
|
||||||
Cpu,
|
|
||||||
Key,
|
|
||||||
BarChart3,
|
|
||||||
ArrowLeftRight,
|
|
||||||
Settings,
|
|
||||||
FileText,
|
|
||||||
MessageSquare,
|
|
||||||
Bot,
|
|
||||||
LogOut,
|
|
||||||
ChevronLeft,
|
|
||||||
Menu,
|
|
||||||
Bell,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { AuthGuard, useAuth } from '@/components/auth-guard'
|
|
||||||
import { logout } from '@/lib/auth'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
/** 权限常量 — 与后端 db.rs SEED_ROLES 保持同步 */
|
|
||||||
const ROLE_PERMISSIONS: Record<string, string[]> = {
|
|
||||||
super_admin: ['admin:full', 'account:admin', 'provider:manage', 'model:manage', 'relay:admin', 'config:write', 'prompt:read', 'prompt:write', 'prompt:publish', 'prompt:admin'],
|
|
||||||
admin: ['account:read', 'account:admin', 'provider:manage', 'model:read', 'model:manage', 'relay:use', 'relay:admin', 'config:read', 'config:write', 'prompt:read', 'prompt:write', 'prompt:publish'],
|
|
||||||
user: ['model:read', 'relay:use', 'config:read', 'prompt:read'],
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 根据 role 获取权限列表 */
|
|
||||||
function getPermissionsForRole(role: string): string[] {
|
|
||||||
return ROLE_PERMISSIONS[role] ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
const navItems = [
|
|
||||||
{ href: '/', label: '仪表盘', icon: LayoutDashboard },
|
|
||||||
{ href: '/accounts', label: '账号管理', icon: Users, permission: 'account:admin' },
|
|
||||||
{ href: '/providers', label: '服务商', icon: Server, permission: 'provider:manage' },
|
|
||||||
{ href: '/models', label: '模型管理', icon: Cpu, permission: 'model:read' },
|
|
||||||
{ href: '/agent-templates', label: 'Agent 模板', icon: Bot, permission: 'model:read' },
|
|
||||||
{ href: '/api-keys', label: 'API 密钥', icon: Key, permission: 'admin:full' },
|
|
||||||
{ href: '/usage', label: '用量统计', icon: BarChart3, permission: 'admin:full' },
|
|
||||||
{ href: '/relay', label: '中转任务', icon: ArrowLeftRight, permission: 'relay:use' },
|
|
||||||
{ href: '/config', label: '系统配置', icon: Settings, permission: 'config:read' },
|
|
||||||
{ href: '/prompts', label: '提示词管理', icon: MessageSquare, permission: 'prompt:read' },
|
|
||||||
{ href: '/logs', label: '操作日志', icon: FileText, permission: 'admin:full' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function Sidebar({
|
|
||||||
collapsed,
|
|
||||||
onToggle,
|
|
||||||
}: {
|
|
||||||
collapsed: boolean
|
|
||||||
onToggle: () => void
|
|
||||||
}) {
|
|
||||||
const pathname = usePathname()
|
|
||||||
const router = useRouter()
|
|
||||||
const { account } = useAuth()
|
|
||||||
|
|
||||||
const permissions = account ? getPermissionsForRole(account.role) : []
|
|
||||||
|
|
||||||
function handleLogout() {
|
|
||||||
logout()
|
|
||||||
router.replace('/login')
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredNavItems = navItems.filter((item) => {
|
|
||||||
if (!item.permission) return true
|
|
||||||
return permissions.includes(item.permission) || permissions.includes('admin:full')
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside
|
|
||||||
className={cn(
|
|
||||||
'fixed left-0 top-0 z-40 flex h-screen flex-col border-r border-border bg-card transition-all duration-300',
|
|
||||||
collapsed ? 'w-16' : 'w-64',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Logo */}
|
|
||||||
<div className="flex h-14 items-center border-b border-border px-4">
|
|
||||||
<Link href="/" className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary text-primary-foreground font-bold text-sm">
|
|
||||||
Z
|
|
||||||
</div>
|
|
||||||
{!collapsed && (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-bold text-foreground">ZCLAW</span>
|
|
||||||
<span className="text-[10px] text-muted-foreground">Admin</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 导航 */}
|
|
||||||
<nav className="flex-1 overflow-y-auto scrollbar-thin py-2 px-2">
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{filteredNavItems.map((item) => {
|
|
||||||
const isActive =
|
|
||||||
item.href === '/'
|
|
||||||
? pathname === '/'
|
|
||||||
: pathname.startsWith(item.href)
|
|
||||||
const Icon = item.icon
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li key={item.href}>
|
|
||||||
<Link
|
|
||||||
href={item.href}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors duration-200 cursor-pointer',
|
|
||||||
isActive
|
|
||||||
? 'bg-muted text-green-400'
|
|
||||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
|
||||||
collapsed && 'justify-center px-2',
|
|
||||||
)}
|
|
||||||
title={collapsed ? item.label : undefined}
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4 shrink-0" />
|
|
||||||
{!collapsed && <span>{item.label}</span>}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* 底部折叠按钮 */}
|
|
||||||
<div className="border-t border-border p-2">
|
|
||||||
<button
|
|
||||||
onClick={onToggle}
|
|
||||||
className="flex w-full items-center justify-center rounded-md px-3 py-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-200 cursor-pointer"
|
|
||||||
>
|
|
||||||
<ChevronLeft
|
|
||||||
className={cn(
|
|
||||||
'h-4 w-4 transition-transform duration-200',
|
|
||||||
collapsed && 'rotate-180',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 用户信息 */}
|
|
||||||
{!collapsed && (
|
|
||||||
<div className="border-t border-border p-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-foreground">
|
|
||||||
{account?.display_name?.[0] || account?.username?.[0] || 'A'}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="truncate text-sm font-medium text-foreground">
|
|
||||||
{account?.display_name || account?.username || 'Admin'}
|
|
||||||
</p>
|
|
||||||
<p className="truncate text-xs text-muted-foreground">
|
|
||||||
{account?.role || 'admin'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-destructive transition-colors duration-200 cursor-pointer"
|
|
||||||
title="退出登录"
|
|
||||||
>
|
|
||||||
<LogOut className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Header() {
|
|
||||||
const pathname = usePathname()
|
|
||||||
const currentNav = navItems.find(
|
|
||||||
(item) =>
|
|
||||||
item.href === '/'
|
|
||||||
? pathname === '/'
|
|
||||||
: pathname.startsWith(item.href),
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className="sticky top-0 z-30 flex h-14 items-center border-b border-border bg-background/80 backdrop-blur-sm px-6">
|
|
||||||
{/* 移动端菜单按钮 */}
|
|
||||||
<MobileMenuButton />
|
|
||||||
|
|
||||||
{/* 页面标题 */}
|
|
||||||
<h1 className="text-lg font-semibold text-foreground">
|
|
||||||
{currentNav?.label || '仪表盘'}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
|
||||||
{/* 通知 */}
|
|
||||||
<button
|
|
||||||
className="relative rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-200 cursor-pointer"
|
|
||||||
title="通知"
|
|
||||||
>
|
|
||||||
<Bell className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MobileMenuButton() {
|
|
||||||
// Placeholder for mobile menu toggle
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className="mr-3 rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-200 lg:hidden cursor-pointer"
|
|
||||||
>
|
|
||||||
<Menu className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthGuard>
|
|
||||||
<div className="flex min-h-screen">
|
|
||||||
<Sidebar
|
|
||||||
collapsed={sidebarCollapsed}
|
|
||||||
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex flex-1 flex-col transition-all duration-300',
|
|
||||||
sidebarCollapsed ? 'ml-16' : 'ml-64',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Header />
|
|
||||||
<main className="flex-1 overflow-auto p-6 scrollbar-thin">
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AuthGuard>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,414 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import useSWR from 'swr'
|
|
||||||
import {
|
|
||||||
Plus,
|
|
||||||
Loader2,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
Pencil,
|
|
||||||
Trash2,
|
|
||||||
} 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 { Switch } from '@/components/ui/switch'
|
|
||||||
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 { TableSkeleton } from '@/components/ui/skeleton'
|
|
||||||
import { ErrorBanner, EmptyState } from '@/components/ui/state'
|
|
||||||
import { api } from '@/lib/api-client'
|
|
||||||
import { ApiRequestError } from '@/lib/api-client'
|
|
||||||
import { formatNumber } from '@/lib/utils'
|
|
||||||
import type { Model, Provider } from '@/lib/types'
|
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
|
||||||
|
|
||||||
interface ModelForm {
|
|
||||||
provider_id: string
|
|
||||||
model_id: string
|
|
||||||
alias: string
|
|
||||||
context_window: string
|
|
||||||
max_output_tokens: string
|
|
||||||
supports_streaming: boolean
|
|
||||||
supports_vision: boolean
|
|
||||||
enabled: boolean
|
|
||||||
pricing_input: string
|
|
||||||
pricing_output: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const emptyForm: ModelForm = {
|
|
||||||
provider_id: '',
|
|
||||||
model_id: '',
|
|
||||||
alias: '',
|
|
||||||
context_window: '4096',
|
|
||||||
max_output_tokens: '4096',
|
|
||||||
supports_streaming: true,
|
|
||||||
supports_vision: false,
|
|
||||||
enabled: true,
|
|
||||||
pricing_input: '',
|
|
||||||
pricing_output: '',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ModelsPage() {
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [providerFilter, setProviderFilter] = useState<string>('all')
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
|
|
||||||
// SWR for models list
|
|
||||||
const { data, isLoading, mutate } = useSWR(
|
|
||||||
['models', page, providerFilter],
|
|
||||||
() => {
|
|
||||||
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
|
|
||||||
if (providerFilter !== 'all') params.provider_id = providerFilter
|
|
||||||
return api.models.list(params)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
const models = data?.items ?? []
|
|
||||||
const total = data?.total ?? 0
|
|
||||||
|
|
||||||
// SWR for providers list (dropdown)
|
|
||||||
const { data: providersData } = useSWR(
|
|
||||||
['providers.all'],
|
|
||||||
() => api.providers.list({ page: 1, page_size: 100 })
|
|
||||||
)
|
|
||||||
const providers = providersData?.items ?? []
|
|
||||||
|
|
||||||
// Dialog
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
|
||||||
const [editTarget, setEditTarget] = useState<Model | null>(null)
|
|
||||||
const [form, setForm] = useState<ModelForm>(emptyForm)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
|
|
||||||
// 删除
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Model | null>(null)
|
|
||||||
const [deleting, setDeleting] = useState(false)
|
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
|
||||||
|
|
||||||
const providerMap = new Map(providers.map((p) => [p.id, p.display_name || p.name]))
|
|
||||||
|
|
||||||
function openCreateDialog() {
|
|
||||||
setEditTarget(null)
|
|
||||||
setForm(emptyForm)
|
|
||||||
setDialogOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEditDialog(model: Model) {
|
|
||||||
setEditTarget(model)
|
|
||||||
setForm({
|
|
||||||
provider_id: model.provider_id,
|
|
||||||
model_id: model.model_id,
|
|
||||||
alias: model.alias,
|
|
||||||
context_window: model.context_window.toString(),
|
|
||||||
max_output_tokens: model.max_output_tokens.toString(),
|
|
||||||
supports_streaming: model.supports_streaming,
|
|
||||||
supports_vision: model.supports_vision,
|
|
||||||
enabled: model.enabled,
|
|
||||||
pricing_input: model.pricing_input.toString(),
|
|
||||||
pricing_output: model.pricing_output.toString(),
|
|
||||||
})
|
|
||||||
setDialogOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
if (!form.model_id.trim() || !form.provider_id) return
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
provider_id: form.provider_id,
|
|
||||||
model_id: form.model_id.trim(),
|
|
||||||
alias: form.alias.trim(),
|
|
||||||
context_window: parseInt(form.context_window, 10) || 4096,
|
|
||||||
max_output_tokens: parseInt(form.max_output_tokens, 10) || 4096,
|
|
||||||
supports_streaming: form.supports_streaming,
|
|
||||||
supports_vision: form.supports_vision,
|
|
||||||
enabled: form.enabled,
|
|
||||||
pricing_input: parseFloat(form.pricing_input) || 0,
|
|
||||||
pricing_output: parseFloat(form.pricing_output) || 0,
|
|
||||||
}
|
|
||||||
if (editTarget) {
|
|
||||||
await api.models.update(editTarget.id, payload)
|
|
||||||
} else {
|
|
||||||
await api.models.create(payload)
|
|
||||||
}
|
|
||||||
setDialogOpen(false)
|
|
||||||
mutate()
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete() {
|
|
||||||
if (!deleteTarget) return
|
|
||||||
setDeleting(true)
|
|
||||||
try {
|
|
||||||
await api.models.delete(deleteTarget.id)
|
|
||||||
setDeleteTarget(null)
|
|
||||||
mutate()
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
|
||||||
} finally {
|
|
||||||
setDeleting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Select value={providerFilter} onValueChange={(v) => { setProviderFilter(v); setPage(1) }}>
|
|
||||||
<SelectTrigger className="w-[200px]">
|
|
||||||
<SelectValue placeholder="按服务商筛选" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">全部服务商</SelectItem>
|
|
||||||
{providers.map((p) => (
|
|
||||||
<SelectItem key={p.id} value={p.id}>
|
|
||||||
{p.display_name || p.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Button onClick={openCreateDialog}>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
新建模型
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <ErrorBanner message={error} onDismiss={() => setError('')} />}
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<TableSkeleton rows={8} cols={9} hasToolbar={false} />
|
|
||||||
) : error ? null : models.length === 0 ? (
|
|
||||||
<EmptyState />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>模型 ID</TableHead>
|
|
||||||
<TableHead>别名</TableHead>
|
|
||||||
<TableHead>服务商</TableHead>
|
|
||||||
<TableHead>上下文窗口</TableHead>
|
|
||||||
<TableHead>最大输出</TableHead>
|
|
||||||
<TableHead>流式</TableHead>
|
|
||||||
<TableHead>视觉</TableHead>
|
|
||||||
<TableHead>启用</TableHead>
|
|
||||||
<TableHead className="text-right">操作</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{models.map((m) => (
|
|
||||||
<TableRow key={m.id}>
|
|
||||||
<TableCell className="font-mono text-sm">{m.model_id}</TableCell>
|
|
||||||
<TableCell>{m.alias || '-'}</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">
|
|
||||||
{providerMap.get(m.provider_id) || m.provider_id.slice(0, 8)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
||||||
{formatNumber(m.context_window)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
||||||
{formatNumber(m.max_output_tokens)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={m.supports_streaming ? 'success' : 'secondary'}>
|
|
||||||
{m.supports_streaming ? '是' : '否'}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={m.supports_vision ? 'success' : 'secondary'}>
|
|
||||||
{m.supports_vision ? '是' : '否'}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={m.enabled ? 'success' : 'destructive'}>
|
|
||||||
{m.enabled ? '启用' : '禁用'}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<div className="flex items-center justify-end gap-1">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => openEditDialog(m)} title="编辑">
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => setDeleteTarget(m)} title="删除">
|
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
|
||||||
</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={dialogOpen} onOpenChange={setDialogOpen}>
|
|
||||||
<DialogContent className="max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{editTarget ? '编辑模型' : '新建模型'}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{editTarget ? '修改模型配置' : '添加新的 AI 模型'}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto scrollbar-thin pr-1">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>服务商 *</Label>
|
|
||||||
<Select value={form.provider_id} onValueChange={(v) => setForm({ ...form, provider_id: v })} disabled={!!editTarget}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="选择服务商" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{providers.map((p) => (
|
|
||||||
<SelectItem key={p.id} value={p.id}>
|
|
||||||
{p.display_name || p.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>模型 ID *</Label>
|
|
||||||
<Input
|
|
||||||
value={form.model_id}
|
|
||||||
onChange={(e) => setForm({ ...form, model_id: e.target.value })}
|
|
||||||
placeholder="gpt-4o"
|
|
||||||
disabled={!!editTarget}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>别名</Label>
|
|
||||||
<Input
|
|
||||||
value={form.alias}
|
|
||||||
onChange={(e) => setForm({ ...form, alias: e.target.value })}
|
|
||||||
placeholder="GPT-4o"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>上下文窗口</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={form.context_window}
|
|
||||||
onChange={(e) => setForm({ ...form, context_window: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>最大输出 Tokens</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={form.max_output_tokens}
|
|
||||||
onChange={(e) => setForm({ ...form, max_output_tokens: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Input 定价 ($/1M tokens)</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
value={form.pricing_input}
|
|
||||||
onChange={(e) => setForm({ ...form, pricing_input: e.target.value })}
|
|
||||||
placeholder="0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Output 定价 ($/1M tokens)</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
value={form.pricing_output}
|
|
||||||
onChange={(e) => setForm({ ...form, pricing_output: e.target.value })}
|
|
||||||
placeholder="0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-6">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch checked={form.supports_streaming} onCheckedChange={(v) => setForm({ ...form, supports_streaming: v })} />
|
|
||||||
<Label>流式</Label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch checked={form.supports_vision} onCheckedChange={(v) => setForm({ ...form, supports_vision: v })} />
|
|
||||||
<Label>视觉</Label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch checked={form.enabled} onCheckedChange={(v) => setForm({ ...form, enabled: v })} />
|
|
||||||
<Label>启用</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
|
||||||
<Button onClick={handleSave} disabled={saving || !form.model_id.trim() || !form.provider_id}>
|
|
||||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* 删除确认 */}
|
|
||||||
<Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>确认删除</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
确定要删除模型 "{deleteTarget?.alias || deleteTarget?.model_id}" 吗?此操作不可撤销。
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>取消</Button>
|
|
||||||
<Button variant="destructive" onClick={handleDelete} disabled={deleting}>
|
|
||||||
{deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import {
|
|
||||||
Users,
|
|
||||||
Server,
|
|
||||||
ArrowLeftRight,
|
|
||||||
Zap,
|
|
||||||
TrendingUp,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import {
|
|
||||||
AreaChart,
|
|
||||||
Area,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
Legend,
|
|
||||||
} from 'recharts'
|
|
||||||
import useSWR from 'swr'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { StatsSkeleton } from '@/components/ui/skeleton'
|
|
||||||
import { ChartSkeleton } from '@/components/ui/skeleton'
|
|
||||||
import { TableSkeleton } from '@/components/ui/skeleton'
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table'
|
|
||||||
import { api } from '@/lib/api-client'
|
|
||||||
import { formatNumber, formatDate } from '@/lib/utils'
|
|
||||||
import type {
|
|
||||||
DashboardStats,
|
|
||||||
UsageRecord,
|
|
||||||
OperationLog,
|
|
||||||
} from '@/lib/types'
|
|
||||||
|
|
||||||
interface StatCardProps {
|
|
||||||
title: string
|
|
||||||
value: string | number
|
|
||||||
icon: React.ReactNode
|
|
||||||
color: string
|
|
||||||
subtitle?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatCard({ title, value, icon, color, subtitle }: StatCardProps) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">{title}</p>
|
|
||||||
<p className="mt-1 text-2xl font-bold text-foreground">{value}</p>
|
|
||||||
{subtitle && (
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">{subtitle}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`flex h-10 w-10 items-center justify-center rounded-lg ${color}`}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: string }) {
|
|
||||||
const variantMap: Record<string, 'success' | 'destructive' | 'warning' | 'info' | 'secondary'> = {
|
|
||||||
active: 'success',
|
|
||||||
completed: 'success',
|
|
||||||
disabled: 'destructive',
|
|
||||||
failed: 'destructive',
|
|
||||||
processing: 'info',
|
|
||||||
queued: 'warning',
|
|
||||||
suspended: 'destructive',
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Badge variant={variantMap[status] || 'secondary'}>{status}</Badge>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
|
||||||
const { data: stats, isLoading: statsLoading } = useSWR(
|
|
||||||
['stats.dashboard'],
|
|
||||||
() => api.stats.dashboard(),
|
|
||||||
)
|
|
||||||
|
|
||||||
const { data: usageData = [], isLoading: usageLoading } = useSWR(
|
|
||||||
['usage.daily.30'],
|
|
||||||
() => api.usage.daily({ days: 30 }),
|
|
||||||
)
|
|
||||||
|
|
||||||
const { data: logsData, isLoading: logsLoading } = useSWR(
|
|
||||||
['logs.recent'],
|
|
||||||
() => api.logs.list({ page: 1, page_size: 5 }),
|
|
||||||
)
|
|
||||||
|
|
||||||
const recentLogs: OperationLog[] = logsData?.items ?? []
|
|
||||||
|
|
||||||
const chartData = usageData.map((r: UsageRecord) => ({
|
|
||||||
day: r.day.slice(5), // MM-DD
|
|
||||||
请求量: r.count,
|
|
||||||
Input: r.input_tokens,
|
|
||||||
Output: r.output_tokens,
|
|
||||||
}))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* 统计卡片 */}
|
|
||||||
{statsLoading ? (
|
|
||||||
<StatsSkeleton count={4} />
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<StatCard
|
|
||||||
title="总账号数"
|
|
||||||
value={stats?.total_accounts ?? '-'}
|
|
||||||
icon={<Users className="h-5 w-5 text-blue-400" />}
|
|
||||||
color="bg-blue-500/10"
|
|
||||||
subtitle={`活跃 ${stats?.active_accounts ?? 0}`}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="活跃服务商"
|
|
||||||
value={stats?.active_providers ?? '-'}
|
|
||||||
icon={<Server className="h-5 w-5 text-green-400" />}
|
|
||||||
color="bg-green-500/10"
|
|
||||||
subtitle={`模型 ${stats?.active_models ?? 0}`}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="今日请求"
|
|
||||||
value={stats?.tasks_today ?? '-'}
|
|
||||||
icon={<ArrowLeftRight className="h-5 w-5 text-purple-400" />}
|
|
||||||
color="bg-purple-500/10"
|
|
||||||
subtitle="中转任务"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="今日 Token"
|
|
||||||
value={formatNumber((stats?.tokens_today_input ?? 0) + (stats?.tokens_today_output ?? 0))}
|
|
||||||
icon={<Zap className="h-5 w-5 text-orange-400" />}
|
|
||||||
color="bg-orange-500/10"
|
|
||||||
subtitle={`In: ${formatNumber(stats?.tokens_today_input ?? 0)} / Out: ${formatNumber(stats?.tokens_today_output ?? 0)}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 图表 */}
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
|
||||||
{/* 请求趋势 */}
|
|
||||||
{usageLoading ? (
|
|
||||||
<ChartSkeleton height={280} />
|
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
|
||||||
<TrendingUp className="h-4 w-4 text-primary" />
|
|
||||||
请求趋势 (30 天)
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{chartData.length > 0 ? (
|
|
||||||
<ResponsiveContainer width="100%" height={280}>
|
|
||||||
<AreaChart data={chartData}>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="colorRequests" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="5%" stopColor="#22C55E" stopOpacity={0.3} />
|
|
||||||
<stop offset="95%" stopColor="#22C55E" stopOpacity={0} />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="day"
|
|
||||||
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
|
||||||
axisLine={{ stroke: '#1E293B' }}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
|
||||||
axisLine={{ stroke: '#1E293B' }}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: '#0F172A',
|
|
||||||
border: '1px solid #1E293B',
|
|
||||||
borderRadius: '8px',
|
|
||||||
color: '#F8FAFC',
|
|
||||||
fontSize: '12px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="请求量"
|
|
||||||
stroke="#22C55E"
|
|
||||||
fillOpacity={1}
|
|
||||||
fill="url(#colorRequests)"
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-[280px] items-center justify-center text-muted-foreground text-sm">
|
|
||||||
暂无数据
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Token 用量 */}
|
|
||||||
{usageLoading ? (
|
|
||||||
<ChartSkeleton height={280} />
|
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
|
||||||
<Zap className="h-4 w-4 text-orange-400" />
|
|
||||||
Token 用量 (30 天)
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{chartData.length > 0 ? (
|
|
||||||
<ResponsiveContainer width="100%" height={280}>
|
|
||||||
<BarChart data={chartData}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="day"
|
|
||||||
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
|
||||||
axisLine={{ stroke: '#1E293B' }}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
|
||||||
axisLine={{ stroke: '#1E293B' }}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: '#0F172A',
|
|
||||||
border: '1px solid #1E293B',
|
|
||||||
borderRadius: '8px',
|
|
||||||
color: '#F8FAFC',
|
|
||||||
fontSize: '12px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Legend
|
|
||||||
wrapperStyle={{ fontSize: '12px', color: '#94A3B8' }}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="Input" fill="#3B82F6" radius={[2, 2, 0, 0]} />
|
|
||||||
<Bar dataKey="Output" fill="#F97316" radius={[2, 2, 0, 0]} />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-[280px] items-center justify-center text-muted-foreground text-sm">
|
|
||||||
暂无数据
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 最近操作日志 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">最近操作</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{logsLoading ? (
|
|
||||||
<TableSkeleton rows={5} cols={5} hasToolbar={false} />
|
|
||||||
) : recentLogs.length > 0 ? (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>时间</TableHead>
|
|
||||||
<TableHead>账号 ID</TableHead>
|
|
||||||
<TableHead>操作</TableHead>
|
|
||||||
<TableHead>目标类型</TableHead>
|
|
||||||
<TableHead>目标 ID</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{recentLogs.map((log) => (
|
|
||||||
<TableRow key={log.id}>
|
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
||||||
{formatDate(log.created_at)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs">
|
|
||||||
{log.account_id.slice(0, 8)}...
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="outline">{log.action}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">
|
|
||||||
{log.target_type}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
||||||
{log.target_id.slice(0, 8)}...
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-32 items-center justify-center text-muted-foreground text-sm">
|
|
||||||
暂无操作日志
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,341 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import useSWR from 'swr'
|
|
||||||
import { api } from '@/lib/api-client'
|
|
||||||
import type { PromptTemplate, PromptVersion } from '@/lib/types'
|
|
||||||
import { EmptyState } from '@/components/ui/state'
|
|
||||||
import { TableSkeleton } from '@/components/ui/skeleton'
|
|
||||||
|
|
||||||
export default function PromptsPage() {
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [selectedName, setSelectedName] = useState<string | null>(null)
|
|
||||||
const [versions, setVersions] = useState<PromptVersion[]>([])
|
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
|
||||||
const [showNewVersion, setShowNewVersion] = useState(false)
|
|
||||||
const [filter, setFilter] = useState<{ source?: string; status?: string }>({})
|
|
||||||
|
|
||||||
const { data, error, isLoading, mutate } = useSWR(
|
|
||||||
['prompts.list', page, filter.source, filter.status],
|
|
||||||
() => api.prompts.list({ page, page_size: 50, ...filter }),
|
|
||||||
)
|
|
||||||
|
|
||||||
const templates = data?.items ?? []
|
|
||||||
const total = data?.total ?? 0
|
|
||||||
|
|
||||||
const fetchVersions = async (name: string) => {
|
|
||||||
try {
|
|
||||||
const res = await api.prompts.listVersions(name)
|
|
||||||
setVersions(res)
|
|
||||||
setSelectedName(name)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch versions:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreate = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault()
|
|
||||||
const fd = new FormData(e.currentTarget)
|
|
||||||
try {
|
|
||||||
await api.prompts.create({
|
|
||||||
name: fd.get('name') as string,
|
|
||||||
category: fd.get('category') as string,
|
|
||||||
description: (fd.get('description') as string) || undefined,
|
|
||||||
source: 'custom',
|
|
||||||
system_prompt: fd.get('system_prompt') as string,
|
|
||||||
})
|
|
||||||
setShowCreate(false)
|
|
||||||
mutate()
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to create prompt:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleNewVersion = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!selectedName) return
|
|
||||||
const fd = new FormData(e.currentTarget)
|
|
||||||
try {
|
|
||||||
await api.prompts.createVersion(selectedName, {
|
|
||||||
system_prompt: fd.get('system_prompt') as string,
|
|
||||||
changelog: (fd.get('changelog') as string) || undefined,
|
|
||||||
})
|
|
||||||
setShowNewVersion(false)
|
|
||||||
fetchVersions(selectedName)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to create version:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRollback = async (name: string, version: number) => {
|
|
||||||
if (!confirm(`确认回退到版本 ${version}?`)) return
|
|
||||||
try {
|
|
||||||
await api.prompts.rollback(name, version)
|
|
||||||
fetchVersions(name)
|
|
||||||
mutate()
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to rollback:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleArchive = async (name: string) => {
|
|
||||||
if (!confirm(`确认归档 ${name}?`)) return
|
|
||||||
try {
|
|
||||||
await api.prompts.archive(name)
|
|
||||||
mutate()
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to archive:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusBadge = (status: string) => {
|
|
||||||
const colors: Record<string, string> = {
|
|
||||||
active: 'bg-emerald-500/20 text-emerald-400',
|
|
||||||
deprecated: 'bg-amber-500/20 text-amber-400',
|
|
||||||
archived: 'bg-zinc-500/20 text-zinc-400',
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className={`px-2 py-0.5 text-xs rounded-full ${colors[status] || colors.archived}`}>
|
|
||||||
{status}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceBadge = (source: string) => {
|
|
||||||
const colors: Record<string, string> = {
|
|
||||||
builtin: 'bg-blue-500/20 text-blue-400',
|
|
||||||
custom: 'bg-purple-500/20 text-purple-400',
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className={`px-2 py-0.5 text-xs rounded-full ${colors[source] || ''}`}>
|
|
||||||
{source === 'builtin' ? '内置' : '自定义'}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-white">提示词管理</h1>
|
|
||||||
<p className="text-sm text-zinc-400 mt-1">管理内置和自定义提示词模板,支持版本控制和 OTA 分发</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowCreate(true)}
|
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
|
|
||||||
>
|
|
||||||
+ 新建模板
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{(['all', 'builtin', 'custom'] as const).map(s => (
|
|
||||||
<button
|
|
||||||
key={s}
|
|
||||||
onClick={() => setFilter(s === 'all' ? {} : { source: s })}
|
|
||||||
className={`px-3 py-1 text-sm rounded-lg transition-colors ${
|
|
||||||
(filter.source || 'all') === s
|
|
||||||
? 'bg-zinc-700 text-white'
|
|
||||||
: 'bg-zinc-800 text-zinc-400 hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{s === 'all' ? '全部' : s === 'builtin' ? '内置' : '自定义'}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Template List */}
|
|
||||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-zinc-800">
|
|
||||||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">名称</th>
|
|
||||||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">分类</th>
|
|
||||||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">来源</th>
|
|
||||||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">版本</th>
|
|
||||||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">状态</th>
|
|
||||||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">更新时间</th>
|
|
||||||
<th className="text-right px-4 py-3 text-zinc-400 font-medium">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{isLoading ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7}>
|
|
||||||
<TableSkeleton rows={5} cols={7} hasToolbar={false} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : error ? (
|
|
||||||
<tr><td colSpan={7} className="px-4 py-8 text-center text-red-400">加载失败</td></tr>
|
|
||||||
) : templates.length === 0 ? (
|
|
||||||
<tr><td colSpan={7}><EmptyState message="暂无提示词模板" /></td></tr>
|
|
||||||
) : (
|
|
||||||
templates.map(t => (
|
|
||||||
<tr key={t.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<button
|
|
||||||
onClick={() => fetchVersions(t.name)}
|
|
||||||
className="text-blue-400 hover:text-blue-300 font-mono"
|
|
||||||
>
|
|
||||||
{t.name}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-zinc-400">{t.category}</td>
|
|
||||||
<td className="px-4 py-3">{sourceBadge(t.source)}</td>
|
|
||||||
<td className="px-4 py-3 text-zinc-300">v{t.current_version}</td>
|
|
||||||
<td className="px-4 py-3">{statusBadge(t.status)}</td>
|
|
||||||
<td className="px-4 py-3 text-zinc-500 text-xs">
|
|
||||||
{new Date(t.updated_at).toLocaleString('zh-CN')}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right">
|
|
||||||
<button
|
|
||||||
onClick={() => fetchVersions(t.name)}
|
|
||||||
className="text-zinc-400 hover:text-white mr-2"
|
|
||||||
>
|
|
||||||
历史
|
|
||||||
</button>
|
|
||||||
{t.source === 'custom' && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleArchive(t.name)}
|
|
||||||
className="text-red-400 hover:text-red-300"
|
|
||||||
>
|
|
||||||
归档
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div className="px-4 py-2 text-xs text-zinc-500 border-t border-zinc-800">
|
|
||||||
共 {total} 个模板
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Version History Panel */}
|
|
||||||
{selectedName && (
|
|
||||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-4">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-lg font-semibold text-white">
|
|
||||||
{selectedName} — 版本历史
|
|
||||||
</h2>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowNewVersion(true)}
|
|
||||||
className="px-3 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-xs"
|
|
||||||
>
|
|
||||||
发布新版本
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { setSelectedName(null); setVersions([]) }}
|
|
||||||
className="px-3 py-1.5 bg-zinc-700 text-white rounded-lg hover:bg-zinc-600 text-xs"
|
|
||||||
>
|
|
||||||
关闭
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{versions.map(v => (
|
|
||||||
<div key={v.id} className="bg-zinc-800/50 rounded-lg p-3">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="text-sm font-mono text-zinc-300">v{v.version}</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-zinc-500">
|
|
||||||
{new Date(v.created_at).toLocaleString('zh-CN')}
|
|
||||||
</span>
|
|
||||||
{v.changelog && (
|
|
||||||
<span className="text-xs text-zinc-400">— {v.changelog}</span>
|
|
||||||
)}
|
|
||||||
{v.min_app_version && (
|
|
||||||
<span className="text-xs text-amber-400">最低版本: {v.min_app_version}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<pre className="text-xs text-zinc-400 bg-zinc-900 rounded p-2 overflow-x-auto max-h-32">
|
|
||||||
{v.system_prompt.substring(0, 300)}{v.system_prompt.length > 300 ? '...' : ''}
|
|
||||||
</pre>
|
|
||||||
<div className="mt-2 flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
navigator.clipboard.writeText(v.system_prompt)
|
|
||||||
}}
|
|
||||||
className="text-xs text-zinc-500 hover:text-white"
|
|
||||||
>
|
|
||||||
复制
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleRollback(selectedName, v.version)}
|
|
||||||
className="text-xs text-amber-500 hover:text-amber-400"
|
|
||||||
>
|
|
||||||
回退到此版本
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{versions.length === 0 && (
|
|
||||||
<EmptyState message="暂无版本历史" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Create Modal */}
|
|
||||||
{showCreate && (
|
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
||||||
<form onSubmit={handleCreate} className="bg-zinc-900 rounded-xl border border-zinc-700 p-6 w-full max-w-lg space-y-4">
|
|
||||||
<h2 className="text-lg font-semibold text-white">新建提示词模板</h2>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-zinc-400 mb-1">名称</label>
|
|
||||||
<input name="name" required className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="my_prompt" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-zinc-400 mb-1">分类</label>
|
|
||||||
<select name="category" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm">
|
|
||||||
<option value="custom_system">系统提示词</option>
|
|
||||||
<option value="custom_extraction">提取提示词</option>
|
|
||||||
<option value="custom_compaction">压缩提示词</option>
|
|
||||||
<option value="custom_other">其他</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-zinc-400 mb-1">描述</label>
|
|
||||||
<input name="description" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="可选" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-zinc-400 mb-1">系统提示词</label>
|
|
||||||
<textarea name="system_prompt" required rows={6} className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm font-mono" />
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 justify-end">
|
|
||||||
<button type="button" onClick={() => setShowCreate(false)} className="px-4 py-2 bg-zinc-700 text-white rounded-lg hover:bg-zinc-600 text-sm">取消</button>
|
|
||||||
<button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm">创建</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* New Version Modal */}
|
|
||||||
{showNewVersion && selectedName && (
|
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
||||||
<form onSubmit={handleNewVersion} className="bg-zinc-900 rounded-xl border border-zinc-700 p-6 w-full max-w-lg space-y-4">
|
|
||||||
<h2 className="text-lg font-semibold text-white">发布 {selectedName} 新版本</h2>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-zinc-400 mb-1">系统提示词</label>
|
|
||||||
<textarea name="system_prompt" required rows={6} className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm font-mono" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-zinc-400 mb-1">变更说明</label>
|
|
||||||
<input name="changelog" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="描述本次变更" />
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 justify-end">
|
|
||||||
<button type="button" onClick={() => setShowNewVersion(false)} className="px-4 py-2 bg-zinc-700 text-white rounded-lg hover:bg-zinc-600 text-sm">取消</button>
|
|
||||||
<button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm">发布</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,605 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import useSWR from 'swr'
|
|
||||||
import {
|
|
||||||
Plus,
|
|
||||||
Loader2,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
Pencil,
|
|
||||||
Trash2,
|
|
||||||
KeyRound,
|
|
||||||
Power,
|
|
||||||
PowerOff,
|
|
||||||
} 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 { Switch } from '@/components/ui/switch'
|
|
||||||
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 { TableSkeleton } from '@/components/ui/skeleton'
|
|
||||||
import { ErrorBanner, EmptyState } from '@/components/ui/state'
|
|
||||||
import { api } from '@/lib/api-client'
|
|
||||||
import { ApiRequestError } from '@/lib/api-client'
|
|
||||||
import { formatDate, maskApiKey } from '@/lib/utils'
|
|
||||||
|
|
||||||
function formatTokens(tokens: number): string {
|
|
||||||
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
|
|
||||||
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}K`
|
|
||||||
return String(tokens)
|
|
||||||
}
|
|
||||||
import type { Provider, ProviderKey } from '@/lib/types'
|
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
|
||||||
|
|
||||||
interface ProviderForm {
|
|
||||||
name: string
|
|
||||||
display_name: string
|
|
||||||
base_url: string
|
|
||||||
api_protocol: 'openai' | 'anthropic'
|
|
||||||
api_key: string
|
|
||||||
enabled: boolean
|
|
||||||
rate_limit_rpm: string
|
|
||||||
rate_limit_tpm: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const emptyForm: ProviderForm = {
|
|
||||||
name: '',
|
|
||||||
display_name: '',
|
|
||||||
base_url: '',
|
|
||||||
api_protocol: 'openai',
|
|
||||||
api_key: '',
|
|
||||||
enabled: true,
|
|
||||||
rate_limit_rpm: '',
|
|
||||||
rate_limit_tpm: '',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProvidersPage() {
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
|
|
||||||
// SWR for providers list
|
|
||||||
const { data, isLoading, mutate } = useSWR(
|
|
||||||
['providers', page],
|
|
||||||
() => api.providers.list({ page, page_size: PAGE_SIZE })
|
|
||||||
)
|
|
||||||
const providers = data?.items ?? []
|
|
||||||
const total = data?.total ?? 0
|
|
||||||
|
|
||||||
// 创建/编辑 Dialog
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
|
||||||
const [editTarget, setEditTarget] = useState<Provider | null>(null)
|
|
||||||
const [form, setForm] = useState<ProviderForm>(emptyForm)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
|
|
||||||
// 删除确认 Dialog
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Provider | null>(null)
|
|
||||||
const [deleting, setDeleting] = useState(false)
|
|
||||||
|
|
||||||
// Key Pool 管理
|
|
||||||
const [keyPoolProvider, setKeyPoolProvider] = useState<Provider | null>(null)
|
|
||||||
const [showAddKey, setShowAddKey] = useState(false)
|
|
||||||
const [addKeyForm, setAddKeyForm] = useState({
|
|
||||||
key_label: '',
|
|
||||||
key_value: '',
|
|
||||||
priority: 0,
|
|
||||||
max_rpm: '',
|
|
||||||
max_tpm: '',
|
|
||||||
quota_reset_interval: '',
|
|
||||||
})
|
|
||||||
const [addingKey, setAddingKey] = useState(false)
|
|
||||||
|
|
||||||
// SWR for key pool — only fetches when dialog is open
|
|
||||||
const { data: providerKeys = [], isLoading: keysLoading, mutate: mutateKeys } = useSWR(
|
|
||||||
keyPoolProvider ? ['provider.keys', keyPoolProvider.id] : null,
|
|
||||||
() => api.providers.listKeys(keyPoolProvider!.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
|
||||||
|
|
||||||
function openCreateDialog() {
|
|
||||||
setEditTarget(null)
|
|
||||||
setForm(emptyForm)
|
|
||||||
setDialogOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEditDialog(provider: Provider) {
|
|
||||||
setEditTarget(provider)
|
|
||||||
setForm({
|
|
||||||
name: provider.name,
|
|
||||||
display_name: provider.display_name,
|
|
||||||
base_url: provider.base_url,
|
|
||||||
api_protocol: provider.api_protocol,
|
|
||||||
api_key: provider.api_key || '',
|
|
||||||
enabled: provider.enabled,
|
|
||||||
rate_limit_rpm: provider.rate_limit_rpm?.toString() || '',
|
|
||||||
rate_limit_tpm: provider.rate_limit_tpm?.toString() || '',
|
|
||||||
})
|
|
||||||
setDialogOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
if (!form.name.trim() || !form.base_url.trim()) return
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
name: form.name.trim(),
|
|
||||||
display_name: form.display_name.trim(),
|
|
||||||
base_url: form.base_url.trim(),
|
|
||||||
api_protocol: form.api_protocol,
|
|
||||||
api_key: form.api_key.trim() || undefined,
|
|
||||||
enabled: form.enabled,
|
|
||||||
rate_limit_rpm: form.rate_limit_rpm ? parseInt(form.rate_limit_rpm, 10) : undefined,
|
|
||||||
rate_limit_tpm: form.rate_limit_tpm ? parseInt(form.rate_limit_tpm, 10) : undefined,
|
|
||||||
}
|
|
||||||
if (editTarget) {
|
|
||||||
await api.providers.update(editTarget.id, payload)
|
|
||||||
} else {
|
|
||||||
await api.providers.create(payload)
|
|
||||||
}
|
|
||||||
setDialogOpen(false)
|
|
||||||
mutate()
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete() {
|
|
||||||
if (!deleteTarget) return
|
|
||||||
setDeleting(true)
|
|
||||||
try {
|
|
||||||
await api.providers.delete(deleteTarget.id)
|
|
||||||
setDeleteTarget(null)
|
|
||||||
mutate()
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
|
||||||
} finally {
|
|
||||||
setDeleting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Key Pool 管理 ─────────────────────────────────────
|
|
||||||
|
|
||||||
function openKeyPool(provider: Provider) {
|
|
||||||
setKeyPoolProvider(provider)
|
|
||||||
setShowAddKey(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAddKey() {
|
|
||||||
if (!keyPoolProvider || !addKeyForm.key_label.trim() || !addKeyForm.key_value.trim()) return
|
|
||||||
setAddingKey(true)
|
|
||||||
try {
|
|
||||||
await api.providers.addKey(keyPoolProvider.id, {
|
|
||||||
key_label: addKeyForm.key_label.trim(),
|
|
||||||
key_value: addKeyForm.key_value.trim(),
|
|
||||||
priority: addKeyForm.priority,
|
|
||||||
max_rpm: addKeyForm.max_rpm ? parseInt(addKeyForm.max_rpm, 10) : undefined,
|
|
||||||
max_tpm: addKeyForm.max_tpm ? parseInt(addKeyForm.max_tpm, 10) : undefined,
|
|
||||||
quota_reset_interval: addKeyForm.quota_reset_interval.trim() || undefined,
|
|
||||||
})
|
|
||||||
setAddKeyForm({ key_label: '', key_value: '', priority: 0, max_rpm: '', max_tpm: '', quota_reset_interval: '' })
|
|
||||||
setShowAddKey(false)
|
|
||||||
mutateKeys()
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
|
||||||
} finally {
|
|
||||||
setAddingKey(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleToggleKey(keyId: string, active: boolean) {
|
|
||||||
if (!keyPoolProvider) return
|
|
||||||
try {
|
|
||||||
await api.providers.toggleKey(keyPoolProvider.id, keyId, active)
|
|
||||||
mutateKeys()
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteKey(keyId: string) {
|
|
||||||
if (!keyPoolProvider || !confirm('确认删除此 Key?')) return
|
|
||||||
try {
|
|
||||||
await api.providers.deleteKey(keyPoolProvider.id, keyId)
|
|
||||||
mutateKeys()
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* 工具栏 */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div />
|
|
||||||
<Button onClick={openCreateDialog}>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
新建服务商
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <ErrorBanner message={error} onDismiss={() => setError('')} />}
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<TableSkeleton rows={6} cols={9} hasToolbar={false} />
|
|
||||||
) : error ? null : providers.length === 0 ? (
|
|
||||||
<EmptyState />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>名称</TableHead>
|
|
||||||
<TableHead>显示名</TableHead>
|
|
||||||
<TableHead>Base URL</TableHead>
|
|
||||||
<TableHead>协议</TableHead>
|
|
||||||
<TableHead>API Key</TableHead>
|
|
||||||
<TableHead>启用</TableHead>
|
|
||||||
<TableHead>RPM 限制</TableHead>
|
|
||||||
<TableHead>创建时间</TableHead>
|
|
||||||
<TableHead className="text-right">操作</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{providers.map((p) => (
|
|
||||||
<TableRow key={p.id}>
|
|
||||||
<TableCell className="font-medium">{p.name}</TableCell>
|
|
||||||
<TableCell>{p.display_name || '-'}</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground max-w-[200px] truncate">
|
|
||||||
{p.base_url}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={p.api_protocol === 'openai' ? 'default' : 'info'}>
|
|
||||||
{p.api_protocol}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
||||||
{maskApiKey(p.api_key)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={p.enabled ? 'success' : 'secondary'}>
|
|
||||||
{p.enabled ? '是' : '否'}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">
|
|
||||||
{p.rate_limit_rpm ?? '-'}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
||||||
{formatDate(p.created_at)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<div className="flex items-center justify-end gap-1">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => openKeyPool(p)} title="Key Pool">
|
|
||||||
<KeyRound className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => openEditDialog(p)} title="编辑">
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => setDeleteTarget(p)} title="删除">
|
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
|
||||||
</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={dialogOpen} onOpenChange={setDialogOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{editTarget ? '编辑服务商' : '新建服务商'}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{editTarget ? '修改服务商配置' : '添加新的 AI 服务商'}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto scrollbar-thin pr-1">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>名称 *</Label>
|
|
||||||
<Input
|
|
||||||
value={form.name}
|
|
||||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
||||||
placeholder="例如: openai"
|
|
||||||
disabled={!!editTarget}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>显示名</Label>
|
|
||||||
<Input
|
|
||||||
value={form.display_name}
|
|
||||||
onChange={(e) => setForm({ ...form, display_name: e.target.value })}
|
|
||||||
placeholder="例如: OpenAI"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Base URL *</Label>
|
|
||||||
<Input
|
|
||||||
value={form.base_url}
|
|
||||||
onChange={(e) => setForm({ ...form, base_url: e.target.value })}
|
|
||||||
placeholder="https://api.openai.com/v1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>API 协议</Label>
|
|
||||||
<Select value={form.api_protocol} onValueChange={(v) => setForm({ ...form, api_protocol: v as 'openai' | 'anthropic' })}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="openai">OpenAI</SelectItem>
|
|
||||||
<SelectItem value="anthropic">Anthropic</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>API Key</Label>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
value={form.api_key}
|
|
||||||
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
|
|
||||||
placeholder={editTarget ? '留空则不修改' : 'sk-...'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Switch
|
|
||||||
checked={form.enabled}
|
|
||||||
onCheckedChange={(v) => setForm({ ...form, enabled: v })}
|
|
||||||
/>
|
|
||||||
<Label>启用</Label>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>RPM 限制</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={form.rate_limit_rpm}
|
|
||||||
onChange={(e) => setForm({ ...form, rate_limit_rpm: e.target.value })}
|
|
||||||
placeholder="不限"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>TPM 限制</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={form.rate_limit_tpm}
|
|
||||||
onChange={(e) => setForm({ ...form, rate_limit_tpm: e.target.value })}
|
|
||||||
placeholder="不限"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
|
||||||
<Button onClick={handleSave} disabled={saving || !form.name.trim() || !form.base_url.trim()}>
|
|
||||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* 删除确认 Dialog */}
|
|
||||||
<Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>确认删除</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
确定要删除服务商 "{deleteTarget?.display_name || deleteTarget?.name}" 吗?此操作不可撤销。
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>取消</Button>
|
|
||||||
<Button variant="destructive" onClick={handleDelete} disabled={deleting}>
|
|
||||||
{deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Key Pool 管理 Dialog */}
|
|
||||||
<Dialog open={!!keyPoolProvider} onOpenChange={() => setKeyPoolProvider(null)}>
|
|
||||||
<DialogContent className="max-w-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Key Pool 管理 — {keyPoolProvider?.display_name || keyPoolProvider?.name}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
管理此服务商的多个 API Key,实现智能轮转绕过限额。优先级数字越小越优先。
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="max-h-[50vh] overflow-y-auto scrollbar-thin">
|
|
||||||
{keysLoading ? (
|
|
||||||
<TableSkeleton rows={4} cols={8} hasToolbar={false} />
|
|
||||||
) : providerKeys.length === 0 && !showAddKey ? (
|
|
||||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
|
||||||
<p>尚未配置 Key Pool</p>
|
|
||||||
<p className="mt-1 text-xs">将使用服务商主 API Key 作为回退</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>标签</TableHead>
|
|
||||||
<TableHead>优先级</TableHead>
|
|
||||||
<TableHead>RPM</TableHead>
|
|
||||||
<TableHead>TPM</TableHead>
|
|
||||||
<TableHead>状态</TableHead>
|
|
||||||
<TableHead>请求/Token</TableHead>
|
|
||||||
<TableHead>最后 429</TableHead>
|
|
||||||
<TableHead className="text-right">操作</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{providerKeys.map((k) => {
|
|
||||||
const isCooling = k.cooldown_until && new Date(k.cooldown_until) > new Date()
|
|
||||||
return (
|
|
||||||
<TableRow key={k.id} className={isCooling ? 'opacity-60' : ''}>
|
|
||||||
<TableCell className="font-medium">{k.key_label}</TableCell>
|
|
||||||
<TableCell>{k.priority}</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">{k.max_rpm ?? '-'}</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">{k.max_tpm ?? '-'}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={k.is_active ? 'success' : 'secondary'}>
|
|
||||||
{isCooling ? '冷却中' : k.is_active ? '活跃' : '禁用'}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
|
||||||
{k.total_requests} / {formatTokens(k.total_tokens)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
|
||||||
{k.last_429_at ? formatDate(k.last_429_at) : '-'}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<div className="flex items-center justify-end gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => handleToggleKey(k.id, !k.is_active)}
|
|
||||||
title={k.is_active ? '禁用' : '启用'}
|
|
||||||
>
|
|
||||||
{k.is_active ? <PowerOff className="h-3.5 w-3.5 text-amber-500" /> : <Power className="h-3.5 w-3.5 text-green-500" />}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => handleDeleteKey(k.id)}
|
|
||||||
title="删除"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!showAddKey ? (
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setKeyPoolProvider(null)}>关闭</Button>
|
|
||||||
<Button onClick={() => setShowAddKey(true)}>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
添加 Key
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3 border-t pt-4">
|
|
||||||
<p className="text-sm font-medium">添加新 Key</p>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">标签 *</Label>
|
|
||||||
<Input
|
|
||||||
value={addKeyForm.key_label}
|
|
||||||
onChange={(e) => setAddKeyForm({ ...addKeyForm, key_label: e.target.value })}
|
|
||||||
placeholder="如 zhipu-coding-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">优先级</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={addKeyForm.priority}
|
|
||||||
onChange={(e) => setAddKeyForm({ ...addKeyForm, priority: parseInt(e.target.value, 10) || 0 })}
|
|
||||||
placeholder="0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2 space-y-1">
|
|
||||||
<Label className="text-xs">API Key *</Label>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
value={addKeyForm.key_value}
|
|
||||||
onChange={(e) => setAddKeyForm({ ...addKeyForm, key_value: e.target.value })}
|
|
||||||
placeholder="输入 API Key"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">RPM 限额</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={addKeyForm.max_rpm}
|
|
||||||
onChange={(e) => setAddKeyForm({ ...addKeyForm, max_rpm: e.target.value })}
|
|
||||||
placeholder="不限"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">TPM 限额</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={addKeyForm.max_tpm}
|
|
||||||
onChange={(e) => setAddKeyForm({ ...addKeyForm, max_tpm: e.target.value })}
|
|
||||||
placeholder="不限"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2 space-y-1">
|
|
||||||
<Label className="text-xs">限额重置周期</Label>
|
|
||||||
<Input
|
|
||||||
value={addKeyForm.quota_reset_interval}
|
|
||||||
onChange={(e) => setAddKeyForm({ ...addKeyForm, quota_reset_interval: e.target.value })}
|
|
||||||
placeholder="如 5h, 1d(可选)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => { setShowAddKey(false); setAddKeyForm({ key_label: '', key_value: '', priority: 0, max_rpm: '', max_tpm: '', quota_reset_interval: '' }) }}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleAddKey} disabled={addingKey || !addKeyForm.key_label.trim() || !addKeyForm.key_value.trim()}>
|
|
||||||
{addingKey && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
添加
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import useSWR from 'swr'
|
|
||||||
import {
|
|
||||||
Search,
|
|
||||||
Loader2,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table'
|
|
||||||
import { api } from '@/lib/api-client'
|
|
||||||
import { ApiRequestError } from '@/lib/api-client'
|
|
||||||
import { formatDate, formatNumber, getSwrErrorMessage } from '@/lib/utils'
|
|
||||||
import { ErrorBanner, EmptyState } from '@/components/ui/state'
|
|
||||||
import { TableSkeleton } from '@/components/ui/skeleton'
|
|
||||||
import type { RelayTask } from '@/lib/types'
|
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
|
||||||
|
|
||||||
const statusVariants: Record<string, 'success' | 'info' | 'warning' | 'destructive' | 'secondary'> = {
|
|
||||||
queued: 'warning',
|
|
||||||
processing: 'info',
|
|
||||||
completed: 'success',
|
|
||||||
failed: 'destructive',
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
|
||||||
queued: '排队中',
|
|
||||||
processing: '处理中',
|
|
||||||
completed: '已完成',
|
|
||||||
failed: '失败',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RelayPage() {
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const { data, error: swrError, isLoading } = useSWR(
|
|
||||||
['relay', page, statusFilter],
|
|
||||||
() => {
|
|
||||||
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
|
|
||||||
if (statusFilter !== 'all') params.status = statusFilter
|
|
||||||
return api.relay.list(params)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const tasks = data?.items ?? []
|
|
||||||
const total = data?.total ?? 0
|
|
||||||
const error = getSwrErrorMessage(swrError)
|
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
|
||||||
|
|
||||||
function toggleExpand(id: string) {
|
|
||||||
setExpandedId((prev) => (prev === id ? null : id))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* 筛选 */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v); setPage(1) }}>
|
|
||||||
<SelectTrigger className="w-[140px]">
|
|
||||||
<SelectValue placeholder="状态筛选" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">全部状态</SelectItem>
|
|
||||||
<SelectItem value="queued">排队中</SelectItem>
|
|
||||||
<SelectItem value="processing">处理中</SelectItem>
|
|
||||||
<SelectItem value="completed">已完成</SelectItem>
|
|
||||||
<SelectItem value="failed">失败</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <ErrorBanner message={error} onDismiss={() => {}} />}
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<TableSkeleton rows={6} cols={10} />
|
|
||||||
) : error ? null : tasks.length === 0 ? (
|
|
||||||
<EmptyState />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="w-8" />
|
|
||||||
<TableHead>任务 ID</TableHead>
|
|
||||||
<TableHead>模型</TableHead>
|
|
||||||
<TableHead>状态</TableHead>
|
|
||||||
<TableHead>优先级</TableHead>
|
|
||||||
<TableHead>重试次数</TableHead>
|
|
||||||
<TableHead>Input Tokens</TableHead>
|
|
||||||
<TableHead>Output Tokens</TableHead>
|
|
||||||
<TableHead>错误信息</TableHead>
|
|
||||||
<TableHead>创建时间</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{tasks.map((task) => (
|
|
||||||
<>
|
|
||||||
<TableRow key={task.id} className="cursor-pointer" onClick={() => toggleExpand(task.id)}>
|
|
||||||
<TableCell>
|
|
||||||
{expandedId === task.id ? (
|
|
||||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs">
|
|
||||||
{task.id.slice(0, 8)}...
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs">
|
|
||||||
{task.model_id}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={statusVariants[task.status] || 'secondary'}>
|
|
||||||
{statusLabels[task.status] || task.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">{task.priority}</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">{task.attempt_count}</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
||||||
{formatNumber(task.input_tokens)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
||||||
{formatNumber(task.output_tokens)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="max-w-[200px] truncate text-xs text-destructive">
|
|
||||||
{task.error_message || '-'}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
||||||
{formatDate(task.created_at)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
{expandedId === task.id && (
|
|
||||||
<TableRow key={`${task.id}-detail`}>
|
|
||||||
<TableCell colSpan={10} className="bg-muted/20 px-8 py-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">任务 ID</p>
|
|
||||||
<p className="font-mono text-xs">{task.id}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">账号 ID</p>
|
|
||||||
<p className="font-mono text-xs">{task.account_id}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">服务商 ID</p>
|
|
||||||
<p className="font-mono text-xs">{task.provider_id}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">模型 ID</p>
|
|
||||||
<p className="font-mono text-xs">{task.model_id}</p>
|
|
||||||
</div>
|
|
||||||
{task.queued_at && (
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">排队时间</p>
|
|
||||||
<p className="font-mono text-xs">{formatDate(task.queued_at)}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{task.started_at && (
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">开始时间</p>
|
|
||||||
<p className="font-mono text-xs">{formatDate(task.started_at)}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{task.completed_at && (
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">完成时间</p>
|
|
||||||
<p className="font-mono text-xs">{formatDate(task.completed_at)}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{task.error_message && (
|
|
||||||
<div className="col-span-2">
|
|
||||||
<p className="text-muted-foreground">错误信息</p>
|
|
||||||
<p className="text-xs text-destructive mt-1">{task.error_message}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import useSWR from 'swr'
|
|
||||||
import { Zap, Monitor, Smartphone } from 'lucide-react'
|
|
||||||
import {
|
|
||||||
LineChart,
|
|
||||||
Line,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
Legend,
|
|
||||||
} from 'recharts'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { ErrorBanner, EmptyState } from '@/components/ui/state'
|
|
||||||
import { TableSkeleton, ChartSkeleton } from '@/components/ui/skeleton'
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
||||||
import { api } from '@/lib/api-client'
|
|
||||||
import { formatNumber } from '@/lib/utils'
|
|
||||||
import type { UsageRecord, UsageByModel, ModelUsageStat, DailyUsageStat } from '@/lib/types'
|
|
||||||
|
|
||||||
export default function UsagePage() {
|
|
||||||
const [days, setDays] = useState(7)
|
|
||||||
const [activeTab, setActiveTab] = useState('relay')
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
|
|
||||||
// 4 parallel SWR calls — each loads independently
|
|
||||||
const { data: dailyData = [], isLoading: dailyLoading } = useSWR(
|
|
||||||
['usage.daily', days],
|
|
||||||
() => api.usage.daily({ days })
|
|
||||||
)
|
|
||||||
const { data: modelData = [], isLoading: modelLoading } = useSWR(
|
|
||||||
['usage.byModel', days],
|
|
||||||
() => api.usage.byModel({ days })
|
|
||||||
)
|
|
||||||
const { data: telemetryModels = [] } = useSWR(
|
|
||||||
['telemetry.modelStats'],
|
|
||||||
() => api.telemetry.modelStats()
|
|
||||||
)
|
|
||||||
const { data: telemetryDaily = [] } = useSWR(
|
|
||||||
['telemetry.dailyStats', days],
|
|
||||||
() => api.telemetry.dailyStats({ days })
|
|
||||||
)
|
|
||||||
|
|
||||||
const relayLoading = dailyLoading || modelLoading
|
|
||||||
const telemetryLoading = !telemetryModels.length && !telemetryDaily.length && (dailyLoading || modelLoading)
|
|
||||||
|
|
||||||
// === Relay 用量图表数据 ===
|
|
||||||
|
|
||||||
const relayLineData = dailyData.map((r) => ({
|
|
||||||
day: r.day.slice(5),
|
|
||||||
Input: r.input_tokens,
|
|
||||||
Output: r.output_tokens,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const relayBarData = modelData.map((r) => ({
|
|
||||||
model: r.model_id,
|
|
||||||
请求量: r.count,
|
|
||||||
Input: r.input_tokens,
|
|
||||||
Output: r.output_tokens,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const relayTotalInput = dailyData.reduce((s, r) => s + r.input_tokens, 0)
|
|
||||||
const relayTotalOutput = dailyData.reduce((s, r) => s + r.output_tokens, 0)
|
|
||||||
const relayTotalRequests = dailyData.reduce((s, r) => s + r.count, 0)
|
|
||||||
|
|
||||||
// === 遥测图表数据 ===
|
|
||||||
|
|
||||||
const telemetryLineData = telemetryDaily.map((r) => ({
|
|
||||||
day: r.day.slice(5),
|
|
||||||
Input: r.input_tokens,
|
|
||||||
Output: r.output_tokens,
|
|
||||||
设备数: r.unique_devices,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const telemetryTotalInput = telemetryDaily.reduce((s, r) => s + r.input_tokens, 0)
|
|
||||||
const telemetryTotalOutput = telemetryDaily.reduce((s, r) => s + r.output_tokens, 0)
|
|
||||||
const telemetryTotalRequests = telemetryDaily.reduce((s, r) => s + r.request_count, 0)
|
|
||||||
|
|
||||||
// === 合计 ===
|
|
||||||
|
|
||||||
const totalInput = relayTotalInput + telemetryTotalInput
|
|
||||||
const totalOutput = relayTotalOutput + telemetryTotalOutput
|
|
||||||
const totalRequests = relayTotalRequests + telemetryTotalRequests
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{error && <ErrorBanner message={error} onDismiss={() => setError('')} />}
|
|
||||||
{/* 时间范围 */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-sm text-muted-foreground">时间范围:</span>
|
|
||||||
<Select value={String(days)} onValueChange={(v) => setDays(Number(v))}>
|
|
||||||
<SelectTrigger className="w-[140px]">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="7">最近 7 天</SelectItem>
|
|
||||||
<SelectItem value="30">最近 30 天</SelectItem>
|
|
||||||
<SelectItem value="90">最近 90 天</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 汇总统计 — render immediately, use 0 while loading */}
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<p className="text-sm text-muted-foreground">总请求数</p>
|
|
||||||
<p className="mt-1 text-2xl font-bold text-foreground">
|
|
||||||
{formatNumber(totalRequests)}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<p className="text-sm text-muted-foreground">总 Input Tokens</p>
|
|
||||||
<p className="mt-1 text-2xl font-bold text-blue-400">
|
|
||||||
{formatNumber(totalInput)}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<p className="text-sm text-muted-foreground">总 Output Tokens</p>
|
|
||||||
<p className="mt-1 text-2xl font-bold text-orange-400">
|
|
||||||
{formatNumber(totalOutput)}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Monitor className="h-4 w-4 text-green-400" />
|
|
||||||
<p className="text-sm text-muted-foreground">中转请求</p>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-2xl font-bold text-green-400">
|
|
||||||
{formatNumber(relayTotalRequests)}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Smartphone className="h-4 w-4 text-purple-400" />
|
|
||||||
<p className="text-sm text-muted-foreground">桌面端调用</p>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-2xl font-bold text-purple-400">
|
|
||||||
{formatNumber(telemetryTotalRequests)}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab 切换 */}
|
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="relay">
|
|
||||||
<Monitor className="h-4 w-4 mr-1" />
|
|
||||||
中转用量
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="telemetry">
|
|
||||||
<Smartphone className="h-4 w-4 mr-1" />
|
|
||||||
桌面端遥测
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{/* Relay 用量 Tab */}
|
|
||||||
<TabsContent value="relay" className="space-y-6">
|
|
||||||
{relayLoading ? (
|
|
||||||
<>
|
|
||||||
<ChartSkeleton height={320} />
|
|
||||||
<ChartSkeleton height={280} />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
|
||||||
<Zap className="h-4 w-4 text-primary" />
|
|
||||||
中转 Token 用量趋势
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{relayLineData.length > 0 ? (
|
|
||||||
<ResponsiveContainer width="100%" height={320}>
|
|
||||||
<LineChart data={relayLineData}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
|
|
||||||
<XAxis dataKey="day" tick={{ fontSize: 12, fill: '#94A3B8' }} axisLine={{ stroke: '#1E293B' }} />
|
|
||||||
<YAxis tick={{ fontSize: 12, fill: '#94A3B8' }} axisLine={{ stroke: '#1E293B' }} />
|
|
||||||
<Tooltip contentStyle={{ backgroundColor: '#0F172A', border: '1px solid #1E293B', borderRadius: '8px', color: '#F8FAFC', fontSize: '12px' }} />
|
|
||||||
<Legend wrapperStyle={{ fontSize: '12px', color: '#94A3B8' }} />
|
|
||||||
<Line type="monotone" dataKey="Input" stroke="#3B82F6" strokeWidth={2} dot={false} />
|
|
||||||
<Line type="monotone" dataKey="Output" stroke="#F97316" strokeWidth={2} dot={false} />
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<EmptyState message="暂无中转数据" />
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">中转按模型分布</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{relayBarData.length > 0 ? (
|
|
||||||
<ResponsiveContainer width="100%" height={Math.max(200, relayBarData.length * 40)}>
|
|
||||||
<BarChart data={relayBarData} layout="vertical">
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
|
|
||||||
<XAxis type="number" tick={{ fontSize: 12, fill: '#94A3B8' }} axisLine={{ stroke: '#1E293B' }} />
|
|
||||||
<YAxis type="category" dataKey="model" tick={{ fontSize: 12, fill: '#94A3B8' }} axisLine={{ stroke: '#1E293B' }} width={120} />
|
|
||||||
<Tooltip contentStyle={{ backgroundColor: '#0F172A', border: '1px solid #1E293B', borderRadius: '8px', color: '#F8FAFC', fontSize: '12px' }} />
|
|
||||||
<Legend wrapperStyle={{ fontSize: '12px', color: '#94A3B8' }} />
|
|
||||||
<Bar dataKey="Input" fill="#3B82F6" radius={[0, 2, 2, 0]} />
|
|
||||||
<Bar dataKey="Output" fill="#F97316" radius={[0, 2, 2, 0]} />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<EmptyState />
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* 遥测 Tab */}
|
|
||||||
<TabsContent value="telemetry" className="space-y-6">
|
|
||||||
{telemetryLoading ? (
|
|
||||||
<>
|
|
||||||
<ChartSkeleton height={320} />
|
|
||||||
<TableSkeleton rows={5} cols={6} hasToolbar={false} />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
|
||||||
<Smartphone className="h-4 w-4 text-purple-400" />
|
|
||||||
桌面端 Token 用量趋势
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{telemetryLineData.length > 0 ? (
|
|
||||||
<ResponsiveContainer width="100%" height={320}>
|
|
||||||
<LineChart data={telemetryLineData}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
|
|
||||||
<XAxis dataKey="day" tick={{ fontSize: 12, fill: '#94A3B8' }} axisLine={{ stroke: '#1E293B' }} />
|
|
||||||
<YAxis tick={{ fontSize: 12, fill: '#94A3B8' }} axisLine={{ stroke: '#1E293B' }} />
|
|
||||||
<Tooltip contentStyle={{ backgroundColor: '#0F172A', border: '1px solid #1E293B', borderRadius: '8px', color: '#F8FAFC', fontSize: '12px' }} />
|
|
||||||
<Legend wrapperStyle={{ fontSize: '12px', color: '#94A3B8' }} />
|
|
||||||
<Line type="monotone" dataKey="Input" stroke="#3B82F6" strokeWidth={2} dot={false} />
|
|
||||||
<Line type="monotone" dataKey="Output" stroke="#F97316" strokeWidth={2} dot={false} />
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<EmptyState message="暂无桌面端遥测数据(需要桌面端上报)" />
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">桌面端按模型统计</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{telemetryModels.length > 0 ? (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>模型</TableHead>
|
|
||||||
<TableHead className="text-right">请求数</TableHead>
|
|
||||||
<TableHead className="text-right">Input Tokens</TableHead>
|
|
||||||
<TableHead className="text-right">Output Tokens</TableHead>
|
|
||||||
<TableHead className="text-right">平均延迟</TableHead>
|
|
||||||
<TableHead className="text-right">成功率</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{telemetryModels.map((stat) => (
|
|
||||||
<TableRow key={stat.model_id}>
|
|
||||||
<TableCell className="font-mono text-sm">{stat.model_id}</TableCell>
|
|
||||||
<TableCell className="text-right">{formatNumber(stat.request_count)}</TableCell>
|
|
||||||
<TableCell className="text-right text-blue-400">{formatNumber(stat.input_tokens)}</TableCell>
|
|
||||||
<TableCell className="text-right text-orange-400">{formatNumber(stat.output_tokens)}</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{stat.avg_latency_ms !== null ? `${Math.round(stat.avg_latency_ms)}ms` : '-'}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<Badge variant={stat.success_rate >= 0.95 ? 'default' : 'destructive'}>
|
|
||||||
{(stat.success_rate * 100).toFixed(1)}%
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
) : (
|
|
||||||
<EmptyState />
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
:root {
|
|
||||||
--background: 222 47% 5%;
|
|
||||||
--foreground: 210 40% 98%;
|
|
||||||
--card: 222 47% 8%;
|
|
||||||
--card-foreground: 210 40% 98%;
|
|
||||||
--primary: 142 71% 45%;
|
|
||||||
--primary-foreground: 222 47% 5%;
|
|
||||||
--muted: 217 33% 17%;
|
|
||||||
--muted-foreground: 215 20% 65%;
|
|
||||||
--accent: 215 28% 23%;
|
|
||||||
--accent-foreground: 210 40% 98%;
|
|
||||||
--destructive: 0 84% 60%;
|
|
||||||
--destructive-foreground: 210 40% 98%;
|
|
||||||
--border: 217 33% 17%;
|
|
||||||
--input: 217 33% 17%;
|
|
||||||
--ring: 142 71% 45%;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
border-color: hsl(var(--border));
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: hsl(var(--background));
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer utilities {
|
|
||||||
.scrollbar-thin {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: hsl(var(--muted)) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollbar-thin::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollbar-thin::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
|
||||||
background-color: hsl(var(--muted));
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
|
||||||
background-color: hsl(var(--accent));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer components {
|
|
||||||
.glass-card {
|
|
||||||
@apply bg-card/80 backdrop-blur-sm border border-border rounded-lg;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
|
||||||
<rect width="32" height="32" rx="6" fill="#0f172a"/>
|
|
||||||
<text x="16" y="22" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#60a5fa" text-anchor="middle">Z</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 282 B |
@@ -1,30 +0,0 @@
|
|||||||
import type { Metadata } from 'next'
|
|
||||||
import { SWRProvider } from '@/lib/swr-provider'
|
|
||||||
import './globals.css'
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'ZCLAW Admin',
|
|
||||||
description: 'ZCLAW AI Agent 管理平台',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RootLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<html lang="zh-CN" className="dark">
|
|
||||||
<head>
|
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
</head>
|
|
||||||
<body className="min-h-screen bg-background font-sans antialiased">
|
|
||||||
<SWRProvider>
|
|
||||||
{children}
|
|
||||||
</SWRProvider>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, type FormEvent } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { Lock, User, Loader2, Eye, EyeOff, ShieldCheck } from 'lucide-react'
|
|
||||||
import { api } from '@/lib/api-client'
|
|
||||||
import { login } from '@/lib/auth'
|
|
||||||
import { ApiRequestError } from '@/lib/api-client'
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
|
||||||
const router = useRouter()
|
|
||||||
const [username, setUsername] = useState('')
|
|
||||||
const [password, setPassword] = useState('')
|
|
||||||
const [totpCode, setTotpCode] = useState('')
|
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
|
||||||
const [needTotp, setNeedTotp] = useState(false)
|
|
||||||
const [remember, setRemember] = useState(false)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
|
|
||||||
async function handleSubmit(e: FormEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
setError('')
|
|
||||||
|
|
||||||
if (!username.trim()) {
|
|
||||||
setError('请输入用户名')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!password.trim()) {
|
|
||||||
setError('请输入密码')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const res = await api.auth.login({
|
|
||||||
username: username.trim(),
|
|
||||||
password,
|
|
||||||
totp_code: totpCode.trim() || undefined,
|
|
||||||
})
|
|
||||||
login(res.token, res.account)
|
|
||||||
// 用 window.location.href 替代 router.replace 避免 Next.js RSC flight
|
|
||||||
// 导致 client component 树重建和 SWR abort 循环
|
|
||||||
window.location.href = '/'
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiRequestError) {
|
|
||||||
const msg = err.body.message || ''
|
|
||||||
// 后端返回 "需要 TOTP" 时显示 TOTP 输入框
|
|
||||||
if (msg.includes('TOTP') || msg.includes('totp') || msg.includes('2FA') || msg.includes('验证码') || err.status === 403) {
|
|
||||||
setNeedTotp(true)
|
|
||||||
setError(msg || '请输入两步验证码')
|
|
||||||
} else {
|
|
||||||
setError(msg || '登录失败,请检查用户名和密码')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setError('网络错误,请稍后重试')
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen">
|
|
||||||
{/* 左侧品牌区域 */}
|
|
||||||
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
|
||||||
{/* 装饰性背景 */}
|
|
||||||
<div className="absolute inset-0">
|
|
||||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-green-500/5 rounded-full blur-3xl" />
|
|
||||||
<div className="absolute bottom-1/4 right-1/4 w-64 h-64 bg-green-500/8 rounded-full blur-3xl" />
|
|
||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] border border-green-500/10 rounded-full" />
|
|
||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[400px] h-[400px] border border-green-500/10 rounded-full" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 品牌内容 */}
|
|
||||||
<div className="relative z-10 flex flex-col items-center justify-center w-full p-12">
|
|
||||||
<div className="text-center">
|
|
||||||
<h1 className="text-6xl font-bold tracking-tight text-foreground mb-4">
|
|
||||||
ZCLAW
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl text-muted-foreground font-light">
|
|
||||||
AI Agent 管理平台
|
|
||||||
</p>
|
|
||||||
<div className="mt-8 flex items-center justify-center gap-2">
|
|
||||||
<div className="h-px w-12 bg-green-500/50" />
|
|
||||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
|
||||||
<div className="h-px w-12 bg-green-500/50" />
|
|
||||||
</div>
|
|
||||||
<p className="mt-6 text-sm text-muted-foreground/60 max-w-sm">
|
|
||||||
统一管理 AI 服务商、模型配置、API 密钥、用量监控与系统配置
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 右侧登录表单 */}
|
|
||||||
<div className="flex w-full lg:w-1/2 items-center justify-center p-8">
|
|
||||||
<div className="w-full max-w-sm space-y-8">
|
|
||||||
{/* 移动端 Logo */}
|
|
||||||
<div className="lg:hidden text-center">
|
|
||||||
<h1 className="text-4xl font-bold tracking-tight text-foreground mb-2">
|
|
||||||
ZCLAW
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">AI Agent 管理平台</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-semibold text-foreground">登录</h2>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
输入您的账号信息以继续
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
{/* 用户名 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label
|
|
||||||
htmlFor="username"
|
|
||||||
className="text-sm font-medium text-foreground"
|
|
||||||
>
|
|
||||||
用户名
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<input
|
|
||||||
id="username"
|
|
||||||
type="text"
|
|
||||||
placeholder="请输入用户名"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-transparent pl-10 pr-3 py-2 text-sm shadow-sm transition-colors duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
||||||
autoComplete="username"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 密码 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label
|
|
||||||
htmlFor="password"
|
|
||||||
className="text-sm font-medium text-foreground"
|
|
||||||
>
|
|
||||||
密码
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
type={showPassword ? 'text' : 'password'}
|
|
||||||
placeholder="请输入密码"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-transparent pl-10 pr-10 py-2 text-sm shadow-sm transition-colors duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
||||||
autoComplete="current-password"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors duration-200 cursor-pointer"
|
|
||||||
>
|
|
||||||
{showPassword ? (
|
|
||||||
<EyeOff className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* TOTP 验证码 */}
|
|
||||||
{needTotp && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label
|
|
||||||
htmlFor="totp"
|
|
||||||
className="text-sm font-medium text-foreground"
|
|
||||||
>
|
|
||||||
两步验证码
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<ShieldCheck className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<input
|
|
||||||
id="totp"
|
|
||||||
type="text"
|
|
||||||
placeholder="请输入 6 位验证码"
|
|
||||||
value={totpCode}
|
|
||||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
||||||
maxLength={6}
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-transparent pl-10 pr-3 py-2 text-sm shadow-sm transition-colors duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring tracking-widest"
|
|
||||||
autoComplete="one-time-code"
|
|
||||||
inputMode="numeric"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
请使用身份验证器 App(如 Google Authenticator)扫描二维码后生成的验证码
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 记住我 */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
id="remember"
|
|
||||||
type="checkbox"
|
|
||||||
checked={remember}
|
|
||||||
onChange={(e) => setRemember(e.target.checked)}
|
|
||||||
className="h-4 w-4 rounded border-input bg-transparent accent-primary cursor-pointer"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor="remember"
|
|
||||||
className="text-sm text-muted-foreground cursor-pointer select-none"
|
|
||||||
>
|
|
||||||
记住我
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 错误信息 */}
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 登录按钮 */}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="flex h-10 w-full items-center justify-center rounded-md bg-primary text-primary-foreground font-medium text-sm shadow-sm transition-colors duration-200 hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 cursor-pointer"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
登录中...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'登录'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, type ReactNode } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { isAuthenticated, clearAuth } from '@/lib/auth'
|
|
||||||
import { api, ApiRequestError } from '@/lib/api-client'
|
|
||||||
|
|
||||||
interface AuthGuardProps {
|
|
||||||
children: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AuthGuard — 纯 useEffect redirect,始终渲染 children
|
|
||||||
*
|
|
||||||
* 不做任何 loading/authorized 状态切换,避免组件卸载。
|
|
||||||
* useEffect 在客户端 hydration 后执行,检查认证状态。
|
|
||||||
*/
|
|
||||||
export function AuthGuard({ children }: AuthGuardProps) {
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isAuthenticated()) {
|
|
||||||
router.replace('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 后台验证 token
|
|
||||||
api.auth.me().catch((err) => {
|
|
||||||
if (err instanceof ApiRequestError && (err.status === 401 || err.status === 403)) {
|
|
||||||
clearAuth()
|
|
||||||
router.replace('/login')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [router])
|
|
||||||
|
|
||||||
return <>{children}</>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuth() {
|
|
||||||
// 简化版 — 直接读 localStorage
|
|
||||||
const account = typeof window !== 'undefined'
|
|
||||||
? JSON.parse(localStorage.getItem('zclaw_admin_account') || 'null')
|
|
||||||
: null
|
|
||||||
return { account, loading: false, isAuthenticated: !!localStorage.getItem('zclaw_admin_token') }
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import * as React from 'react'
|
|
||||||
import { cva, type VariantProps } from 'class-variance-authority'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
const badgeVariants = cva(
|
|
||||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default:
|
|
||||||
'border-transparent bg-primary/15 text-primary',
|
|
||||||
secondary:
|
|
||||||
'border-transparent bg-muted text-muted-foreground',
|
|
||||||
destructive:
|
|
||||||
'border-transparent bg-destructive/15 text-destructive',
|
|
||||||
outline:
|
|
||||||
'text-foreground border-border',
|
|
||||||
success:
|
|
||||||
'border-transparent bg-green-500/15 text-green-400',
|
|
||||||
warning:
|
|
||||||
'border-transparent bg-yellow-500/15 text-yellow-400',
|
|
||||||
info:
|
|
||||||
'border-transparent bg-blue-500/15 text-blue-400',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: 'default',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
export interface BadgeProps
|
|
||||||
extends React.HTMLAttributes<HTMLDivElement>,
|
|
||||||
VariantProps<typeof badgeVariants> {}
|
|
||||||
|
|
||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
|
||||||
return (
|
|
||||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Badge, badgeVariants }
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import * as React from 'react'
|
|
||||||
import { cva, type VariantProps } from 'class-variance-authority'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
const buttonVariants = cva(
|
|
||||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50',
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default:
|
|
||||||
'bg-primary text-primary-foreground hover:bg-primary-hover shadow-sm',
|
|
||||||
secondary:
|
|
||||||
'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
|
||||||
destructive:
|
|
||||||
'bg-destructive text-destructive-foreground hover:bg-red-600 shadow-sm',
|
|
||||||
outline:
|
|
||||||
'border border-border bg-transparent hover:bg-accent hover:text-accent-foreground',
|
|
||||||
ghost:
|
|
||||||
'hover:bg-accent hover:text-accent-foreground',
|
|
||||||
link:
|
|
||||||
'text-primary underline-offset-4 hover:underline',
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default: 'h-9 px-4 py-2',
|
|
||||||
sm: 'h-8 rounded-md px-3 text-xs',
|
|
||||||
lg: 'h-10 rounded-md px-8',
|
|
||||||
icon: 'h-9 w-9',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: 'default',
|
|
||||||
size: 'default',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
export interface ButtonProps
|
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
||||||
VariantProps<typeof buttonVariants> {}
|
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
||||||
({ className, variant, size, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
Button.displayName = 'Button'
|
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import * as React from 'react'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
const Card = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
'rounded-lg border border-border bg-card text-card-foreground shadow-sm',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
Card.displayName = 'Card'
|
|
||||||
|
|
||||||
const CardHeader = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
CardHeader.displayName = 'CardHeader'
|
|
||||||
|
|
||||||
const CardTitle = React.forwardRef<
|
|
||||||
HTMLParagraphElement,
|
|
||||||
React.HTMLAttributes<HTMLHeadingElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<h3
|
|
||||||
ref={ref}
|
|
||||||
className={cn('font-semibold leading-none tracking-tight', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
CardTitle.displayName = 'CardTitle'
|
|
||||||
|
|
||||||
const CardDescription = React.forwardRef<
|
|
||||||
HTMLParagraphElement,
|
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<p
|
|
||||||
ref={ref}
|
|
||||||
className={cn('text-sm text-muted-foreground', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
CardDescription.displayName = 'CardDescription'
|
|
||||||
|
|
||||||
const CardContent = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
|
||||||
))
|
|
||||||
CardContent.displayName = 'CardContent'
|
|
||||||
|
|
||||||
const CardFooter = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn('flex items-center p-6 pt-0', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
CardFooter.displayName = 'CardFooter'
|
|
||||||
|
|
||||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import * as React from 'react'
|
|
||||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
|
||||||
import { X } from 'lucide-react'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
const Dialog = DialogPrimitive.Root
|
|
||||||
const DialogTrigger = DialogPrimitive.Trigger
|
|
||||||
const DialogPortal = DialogPrimitive.Portal
|
|
||||||
const DialogClose = DialogPrimitive.Close
|
|
||||||
|
|
||||||
const DialogOverlay = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<DialogPrimitive.Overlay
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
|
|
||||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
|
||||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
|
||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<DialogPortal>
|
|
||||||
<DialogOverlay />
|
|
||||||
<DialogPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%]',
|
|
||||||
'gap-4 border border-border bg-card p-6 shadow-lg duration-200',
|
|
||||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
|
||||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
|
||||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
|
||||||
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
|
|
||||||
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
|
||||||
'rounded-lg',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</DialogPrimitive.Close>
|
|
||||||
</DialogPrimitive.Content>
|
|
||||||
</DialogPortal>
|
|
||||||
))
|
|
||||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
|
||||||
|
|
||||||
const DialogHeader = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
||||||
<div
|
|
||||||
className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
DialogHeader.displayName = 'DialogHeader'
|
|
||||||
|
|
||||||
const DialogFooter = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
||||||
<div
|
|
||||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
DialogFooter.displayName = 'DialogFooter'
|
|
||||||
|
|
||||||
const DialogTitle = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<DialogPrimitive.Title
|
|
||||||
ref={ref}
|
|
||||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
|
||||||
|
|
||||||
const DialogDescription = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<DialogPrimitive.Description
|
|
||||||
ref={ref}
|
|
||||||
className={cn('text-sm text-muted-foreground', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
|
||||||
|
|
||||||
export {
|
|
||||||
Dialog,
|
|
||||||
DialogPortal,
|
|
||||||
DialogOverlay,
|
|
||||||
DialogClose,
|
|
||||||
DialogTrigger,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogFooter,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import * as React from 'react'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
export interface InputProps
|
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
||||||
({ className, type, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type={type}
|
|
||||||
className={cn(
|
|
||||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors duration-200',
|
|
||||||
'file:border-0 file:bg-transparent file:text-sm file:font-medium',
|
|
||||||
'placeholder:text-muted-foreground',
|
|
||||||
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
|
||||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
Input.displayName = 'Input'
|
|
||||||
|
|
||||||
export { Input }
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import * as React from 'react'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
export interface LabelProps
|
|
||||||
extends React.LabelHTMLAttributes<HTMLLabelElement> {}
|
|
||||||
|
|
||||||
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
|
|
||||||
({ className, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
Label.displayName = 'Label'
|
|
||||||
|
|
||||||
export { Label }
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import * as React from 'react'
|
|
||||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
|
||||||
import { Check, ChevronDown } from 'lucide-react'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
const Select = SelectPrimitive.Root
|
|
||||||
const SelectGroup = SelectPrimitive.Group
|
|
||||||
const SelectValue = SelectPrimitive.Value
|
|
||||||
|
|
||||||
const SelectTrigger = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<SelectPrimitive.Trigger
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background',
|
|
||||||
'placeholder:text-muted-foreground',
|
|
||||||
'focus:outline-none focus:ring-1 focus:ring-ring',
|
|
||||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
||||||
'[&>span]:line-clamp-1',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<SelectPrimitive.Icon asChild>
|
|
||||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
|
||||||
</SelectPrimitive.Icon>
|
|
||||||
</SelectPrimitive.Trigger>
|
|
||||||
))
|
|
||||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
|
||||||
|
|
||||||
const SelectContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
|
||||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
|
||||||
<SelectPrimitive.Portal>
|
|
||||||
<SelectPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-card text-foreground shadow-md',
|
|
||||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
|
||||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
|
||||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
|
||||||
position === 'popper' &&
|
|
||||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
position={position}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<SelectPrimitive.Viewport
|
|
||||||
className={cn(
|
|
||||||
'p-1',
|
|
||||||
position === 'popper' &&
|
|
||||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SelectPrimitive.Viewport>
|
|
||||||
</SelectPrimitive.Content>
|
|
||||||
</SelectPrimitive.Portal>
|
|
||||||
))
|
|
||||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
|
||||||
|
|
||||||
const SelectItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<SelectPrimitive.Item
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none',
|
|
||||||
'focus:bg-accent focus:text-accent-foreground',
|
|
||||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
||||||
<SelectPrimitive.ItemIndicator>
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
</SelectPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
|
||||||
</SelectPrimitive.Item>
|
|
||||||
))
|
|
||||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
|
||||||
|
|
||||||
export {
|
|
||||||
Select,
|
|
||||||
SelectGroup,
|
|
||||||
SelectValue,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import * as React from 'react'
|
|
||||||
import * as SeparatorPrimitive from '@radix-ui/react-separator'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
const Separator = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
|
||||||
>(
|
|
||||||
(
|
|
||||||
{ className, orientation = 'horizontal', decorative = true, ...props },
|
|
||||||
ref,
|
|
||||||
) => (
|
|
||||||
<SeparatorPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
decorative={decorative}
|
|
||||||
orientation={orientation}
|
|
||||||
className={cn(
|
|
||||||
'shrink-0 bg-border',
|
|
||||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
)
|
|
||||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
|
||||||
|
|
||||||
export { Separator }
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// Skeleton 组件 — 替代全屏 spinner 的骨架屏
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
function SkeletonBase({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'animate-pulse rounded-md bg-muted',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 表格骨架屏 */
|
|
||||||
export function TableSkeleton({
|
|
||||||
rows = 5,
|
|
||||||
cols = 5,
|
|
||||||
hasToolbar = true,
|
|
||||||
}: {
|
|
||||||
rows?: number
|
|
||||||
cols?: number
|
|
||||||
hasToolbar?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{hasToolbar && (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<SkeletonBase className="h-9 w-[200px]" />
|
|
||||||
<SkeletonBase className="h-9 w-[120px]" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="rounded-md border border-border overflow-hidden">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="border-b border-border bg-muted/30 px-4 py-3">
|
|
||||||
<div className="flex gap-4">
|
|
||||||
{Array.from({ length: cols }).map((_, i) => (
|
|
||||||
<SkeletonBase
|
|
||||||
key={i}
|
|
||||||
className={cn(
|
|
||||||
'h-4',
|
|
||||||
i === 0 ? 'w-[120px]' : i === cols - 1 ? 'w-[80px]' : 'w-[100px]',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Rows */}
|
|
||||||
{Array.from({ length: rows }).map((_, rowIdx) => (
|
|
||||||
<div
|
|
||||||
key={rowIdx}
|
|
||||||
className={cn(
|
|
||||||
'px-4 py-3',
|
|
||||||
rowIdx < rows - 1 && 'border-b border-border',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
{Array.from({ length: cols }).map((_, colIdx) => (
|
|
||||||
<SkeletonBase
|
|
||||||
key={colIdx}
|
|
||||||
className={cn(
|
|
||||||
'h-4',
|
|
||||||
colIdx === 0 ? 'w-[120px]' : colIdx === cols - 1 ? 'w-[80px]' : 'w-[100px]',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{/* Pagination */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<SkeletonBase className="h-4 w-[140px]" />
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<SkeletonBase className="h-8 w-[80px]" />
|
|
||||||
<SkeletonBase className="h-8 w-[80px]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 统计卡片骨架屏 */
|
|
||||||
export function StatsSkeleton({ count = 4 }: { count?: number }) {
|
|
||||||
return (
|
|
||||||
<div className={`grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-${count}`}>
|
|
||||||
{Array.from({ length: count }).map((_, i) => (
|
|
||||||
<div key={i} className="rounded-lg border border-border p-6">
|
|
||||||
<SkeletonBase className="h-4 w-[80px]" />
|
|
||||||
<SkeletonBase className="mt-2 h-8 w-[100px]" />
|
|
||||||
<SkeletonBase className="mt-1 h-3 w-[120px]" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 图表骨架屏 */
|
|
||||||
export function ChartSkeleton({ height }: { height?: number }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-border">
|
|
||||||
<div className="border-b border-border px-6 py-4">
|
|
||||||
<SkeletonBase className="h-5 w-[140px]" />
|
|
||||||
</div>
|
|
||||||
<div className="p-6">
|
|
||||||
<SkeletonBase className="w-full" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { SkeletonBase as Skeleton }
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { AlertCircle, Inbox } from 'lucide-react'
|
|
||||||
|
|
||||||
/** 统一的错误提示横幅 */
|
|
||||||
export function ErrorBanner({
|
|
||||||
message,
|
|
||||||
onDismiss,
|
|
||||||
}: {
|
|
||||||
message: string
|
|
||||||
onDismiss?: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive flex items-center gap-2">
|
|
||||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
|
||||||
<span className="flex-1">{message}</span>
|
|
||||||
{onDismiss && (
|
|
||||||
<button
|
|
||||||
onClick={onDismiss}
|
|
||||||
className="underline cursor-pointer shrink-0"
|
|
||||||
>
|
|
||||||
关闭
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 统一的空状态占位 */
|
|
||||||
export function EmptyState({
|
|
||||||
message = '暂无数据',
|
|
||||||
}: {
|
|
||||||
message?: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-64 flex-col items-center justify-center gap-2 text-muted-foreground">
|
|
||||||
<Inbox className="h-8 w-8" />
|
|
||||||
<span className="text-sm">{message}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 统一的加载失败提示 + 重试 */
|
|
||||||
export function ErrorRetry({
|
|
||||||
message = '请求失败,请重试',
|
|
||||||
onRetry,
|
|
||||||
}: {
|
|
||||||
message?: string
|
|
||||||
onRetry: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-64 flex-col items-center justify-center gap-3 text-muted-foreground">
|
|
||||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
|
||||||
<span className="text-sm">{message}</span>
|
|
||||||
<button
|
|
||||||
onClick={onRetry}
|
|
||||||
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
重新加载
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import * as React from 'react'
|
|
||||||
import * as SwitchPrimitive from '@radix-ui/react-switch'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
const Switch = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SwitchPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SwitchPrimitive.Root
|
|
||||||
className={cn(
|
|
||||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors duration-200',
|
|
||||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
|
||||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
||||||
'data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
ref={ref}
|
|
||||||
>
|
|
||||||
<SwitchPrimitive.Thumb
|
|
||||||
className={cn(
|
|
||||||
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform duration-200',
|
|
||||||
'data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</SwitchPrimitive.Root>
|
|
||||||
))
|
|
||||||
Switch.displayName = SwitchPrimitive.Root.displayName
|
|
||||||
|
|
||||||
export { Switch }
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import * as React from 'react'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
const Table = React.forwardRef<
|
|
||||||
HTMLTableElement,
|
|
||||||
React.HTMLAttributes<HTMLTableElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div className="relative w-full overflow-auto scrollbar-thin">
|
|
||||||
<table
|
|
||||||
ref={ref}
|
|
||||||
className={cn('w-full caption-bottom text-sm', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
Table.displayName = 'Table'
|
|
||||||
|
|
||||||
const TableHeader = React.forwardRef<
|
|
||||||
HTMLTableSectionElement,
|
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
|
||||||
))
|
|
||||||
TableHeader.displayName = 'TableHeader'
|
|
||||||
|
|
||||||
const TableBody = React.forwardRef<
|
|
||||||
HTMLTableSectionElement,
|
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<tbody
|
|
||||||
ref={ref}
|
|
||||||
className={cn('[&_tr:last-child]:border-0', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TableBody.displayName = 'TableBody'
|
|
||||||
|
|
||||||
const TableFooter = React.forwardRef<
|
|
||||||
HTMLTableSectionElement,
|
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<tfoot
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TableFooter.displayName = 'TableFooter'
|
|
||||||
|
|
||||||
const TableRow = React.forwardRef<
|
|
||||||
HTMLTableRowElement,
|
|
||||||
React.HTMLAttributes<HTMLTableRowElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<tr
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
'border-b border-border transition-colors duration-200 hover:bg-muted/50',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TableRow.displayName = 'TableRow'
|
|
||||||
|
|
||||||
const TableHead = React.forwardRef<
|
|
||||||
HTMLTableCellElement,
|
|
||||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<th
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
'h-10 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TableHead.displayName = 'TableHead'
|
|
||||||
|
|
||||||
const TableCell = React.forwardRef<
|
|
||||||
HTMLTableCellElement,
|
|
||||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<td
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
'p-4 align-middle [&:has([role=checkbox])]:pr-0',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TableCell.displayName = 'TableCell'
|
|
||||||
|
|
||||||
const TableCaption = React.forwardRef<
|
|
||||||
HTMLTableCaptionElement,
|
|
||||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<caption
|
|
||||||
ref={ref}
|
|
||||||
className={cn('mt-4 text-sm text-muted-foreground', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TableCaption.displayName = 'TableCaption'
|
|
||||||
|
|
||||||
export {
|
|
||||||
Table,
|
|
||||||
TableHeader,
|
|
||||||
TableBody,
|
|
||||||
TableFooter,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
TableCell,
|
|
||||||
TableCaption,
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import * as React from 'react'
|
|
||||||
import * as TabsPrimitive from '@radix-ui/react-tabs'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
const Tabs = TabsPrimitive.Root
|
|
||||||
|
|
||||||
const TabsList = React.forwardRef<
|
|
||||||
React.ElementRef<typeof TabsPrimitive.List>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<TabsPrimitive.List
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TabsList.displayName = TabsPrimitive.List.displayName
|
|
||||||
|
|
||||||
const TabsTrigger = React.forwardRef<
|
|
||||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<TabsPrimitive.Trigger
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all duration-200',
|
|
||||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
|
||||||
'disabled:pointer-events-none disabled:opacity-50',
|
|
||||||
'data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
|
||||||
|
|
||||||
const TabsContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<TabsPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
|
||||||
|
|
||||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import * as React from 'react'
|
|
||||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
const TooltipProvider = TooltipPrimitive.Provider
|
|
||||||
const Tooltip = TooltipPrimitive.Root
|
|
||||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
|
||||||
|
|
||||||
const TooltipContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
|
||||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
|
||||||
<TooltipPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
sideOffset={sideOffset}
|
|
||||||
className={cn(
|
|
||||||
'z-50 overflow-hidden rounded-md bg-card border border-border px-3 py-1.5 text-sm text-foreground shadow-md',
|
|
||||||
'animate-in fade-in-0 zoom-in-95',
|
|
||||||
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
|
||||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
|
||||||
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
|
||||||
|
|
||||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// useDebounce — 防抖 hook
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
|
|
||||||
export function useDebounce<T>(value: T, delay = 300): T {
|
|
||||||
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = setTimeout(() => setDebouncedValue(value), delay)
|
|
||||||
return () => clearTimeout(handler)
|
|
||||||
}, [value, delay])
|
|
||||||
|
|
||||||
return debouncedValue
|
|
||||||
}
|
|
||||||
@@ -1,535 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// ZCLAW SaaS Admin — 类型化 HTTP 客户端
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { getToken, login as saveToken, logout, getAccount } from './auth'
|
|
||||||
import type {
|
|
||||||
AccountPublic,
|
|
||||||
AgentTemplate,
|
|
||||||
ApiError,
|
|
||||||
ConfigItem,
|
|
||||||
CreateTokenRequest,
|
|
||||||
DashboardStats,
|
|
||||||
DailyUsageStat,
|
|
||||||
LoginRequest,
|
|
||||||
LoginResponse,
|
|
||||||
Model,
|
|
||||||
ModelUsageStat,
|
|
||||||
OperationLog,
|
|
||||||
PaginatedResponse,
|
|
||||||
PromptTemplate,
|
|
||||||
PromptVersion,
|
|
||||||
Provider,
|
|
||||||
ProviderKey,
|
|
||||||
RelayTask,
|
|
||||||
TokenInfo,
|
|
||||||
UsageByModel,
|
|
||||||
UsageRecord,
|
|
||||||
} from './types'
|
|
||||||
|
|
||||||
// ── 错误类 ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export class ApiRequestError extends Error {
|
|
||||||
constructor(
|
|
||||||
public status: number,
|
|
||||||
public body: ApiError,
|
|
||||||
) {
|
|
||||||
super(body.message || `Request failed with status ${status}`)
|
|
||||||
this.name = 'ApiRequestError'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 基础请求 ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
const BASE_URL = process.env.NEXT_PUBLIC_SAAS_API_URL || '/api/v1'
|
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 10_000
|
|
||||||
const MAX_RETRIES = 2
|
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise(resolve => setTimeout(resolve, ms))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 判断是否为可重试的网络错误(不含 AbortError) */
|
|
||||||
function isRetryableNetworkError(err: unknown): boolean {
|
|
||||||
// AbortError 不重试:可能是组件卸载或路由切换导致的外部取消
|
|
||||||
if (err instanceof DOMException && err.name === 'AbortError') return false
|
|
||||||
if (err instanceof TypeError) {
|
|
||||||
const msg = (err as TypeError).message
|
|
||||||
return msg.includes('Failed to fetch') || msg.includes('NetworkError') || msg.includes('ECONNREFUSED')
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 尝试刷新 Token,成功返回新 token,失败返回 null */
|
|
||||||
async function tryRefreshToken(): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const token = getToken()
|
|
||||||
if (!token) return null
|
|
||||||
|
|
||||||
const res = await fetch(`${BASE_URL}/auth/refresh`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res.ok) return null
|
|
||||||
|
|
||||||
const data = await res.json()
|
|
||||||
const newToken = data.token as string
|
|
||||||
const account = getAccount()
|
|
||||||
if (account && newToken) {
|
|
||||||
saveToken(newToken, account)
|
|
||||||
}
|
|
||||||
return newToken
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function request<T>(
|
|
||||||
method: string,
|
|
||||||
path: string,
|
|
||||||
body?: unknown,
|
|
||||||
_isRetry = false,
|
|
||||||
externalSignal?: AbortSignal,
|
|
||||||
): Promise<T> {
|
|
||||||
let lastError: unknown
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
||||||
// Merge external signal (e.g. from SWR) with a timeout signal
|
|
||||||
const signals: AbortSignal[] = [AbortSignal.timeout(DEFAULT_TIMEOUT_MS)]
|
|
||||||
if (externalSignal) signals.push(externalSignal)
|
|
||||||
const signal = signals.length === 1 ? signals[0] : AbortSignal.any(signals)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const token = getToken()
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(`${BASE_URL}${path}`, {
|
|
||||||
method,
|
|
||||||
headers,
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 401: 尝试刷新 Token 后重试
|
|
||||||
if (res.status === 401 && !_isRetry) {
|
|
||||||
const newToken = await tryRefreshToken()
|
|
||||||
if (newToken) {
|
|
||||||
return request<T>(method, path, body, true)
|
|
||||||
}
|
|
||||||
logout()
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.location.href = '/login'
|
|
||||||
}
|
|
||||||
throw new ApiRequestError(401, { error: 'unauthorized', message: '登录已过期,请重新登录' })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
let errorBody: ApiError
|
|
||||||
try {
|
|
||||||
errorBody = await res.json()
|
|
||||||
} catch {
|
|
||||||
errorBody = { error: 'unknown', message: `请求失败 (${res.status})` }
|
|
||||||
}
|
|
||||||
throw new ApiRequestError(res.status, errorBody)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 204 No Content
|
|
||||||
if (res.status === 204) {
|
|
||||||
return undefined as T
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json() as Promise<T>
|
|
||||||
} catch (err) {
|
|
||||||
// API 错误和外部取消的 AbortError 直接抛出,不重试
|
|
||||||
if (err instanceof ApiRequestError) throw err
|
|
||||||
if (err instanceof DOMException && err.name === 'AbortError') throw err
|
|
||||||
|
|
||||||
lastError = err
|
|
||||||
|
|
||||||
// 仅对可重试的网络错误重试
|
|
||||||
if (attempt < MAX_RETRIES && isRetryableNetworkError(err)) {
|
|
||||||
await sleep(1000 * Math.pow(2, attempt))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw lastError
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── API 客户端 ────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const api = {
|
|
||||||
// ── 认证 ──────────────────────────────────────────────
|
|
||||||
auth: {
|
|
||||||
async login(data: LoginRequest): Promise<LoginResponse> {
|
|
||||||
return request<LoginResponse>('POST', '/auth/login', data)
|
|
||||||
},
|
|
||||||
|
|
||||||
async register(data: {
|
|
||||||
username: string
|
|
||||||
password: string
|
|
||||||
email: string
|
|
||||||
display_name?: string
|
|
||||||
}): Promise<LoginResponse> {
|
|
||||||
return request<LoginResponse>('POST', '/auth/register', data)
|
|
||||||
},
|
|
||||||
|
|
||||||
async me(): Promise<AccountPublic> {
|
|
||||||
return request<AccountPublic>('GET', '/auth/me')
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── 账号管理 ──────────────────────────────────────────
|
|
||||||
accounts: {
|
|
||||||
async list(params?: {
|
|
||||||
page?: number
|
|
||||||
page_size?: number
|
|
||||||
search?: string
|
|
||||||
role?: string
|
|
||||||
status?: string
|
|
||||||
}): Promise<PaginatedResponse<AccountPublic>> {
|
|
||||||
const qs = buildQueryString(params)
|
|
||||||
return request<PaginatedResponse<AccountPublic>>('GET', `/accounts${qs}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
async get(id: string): Promise<AccountPublic> {
|
|
||||||
return request<AccountPublic>('GET', `/accounts/${id}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
async update(
|
|
||||||
id: string,
|
|
||||||
data: Partial<Pick<AccountPublic, 'display_name' | 'email' | 'role'>>,
|
|
||||||
): Promise<AccountPublic> {
|
|
||||||
return request<AccountPublic>('PATCH', `/accounts/${id}`, data)
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateStatus(
|
|
||||||
id: string,
|
|
||||||
data: { status: AccountPublic['status'] },
|
|
||||||
): Promise<void> {
|
|
||||||
return request<void>('PATCH', `/accounts/${id}/status`, data)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── 服务商管理 ────────────────────────────────────────
|
|
||||||
providers: {
|
|
||||||
async list(params?: {
|
|
||||||
page?: number
|
|
||||||
page_size?: number
|
|
||||||
}): Promise<PaginatedResponse<Provider>> {
|
|
||||||
const qs = buildQueryString(params)
|
|
||||||
return request<PaginatedResponse<Provider>>('GET', `/providers${qs}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
async create(data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>): Promise<Provider> {
|
|
||||||
return request<Provider>('POST', '/providers', data)
|
|
||||||
},
|
|
||||||
|
|
||||||
async update(
|
|
||||||
id: string,
|
|
||||||
data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>,
|
|
||||||
): Promise<Provider> {
|
|
||||||
return request<Provider>('PATCH', `/providers/${id}`, data)
|
|
||||||
},
|
|
||||||
|
|
||||||
async delete(id: string): Promise<void> {
|
|
||||||
return request<void>('DELETE', `/providers/${id}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
// Key Pool 管理
|
|
||||||
async listKeys(providerId: string): Promise<ProviderKey[]> {
|
|
||||||
return request<ProviderKey[]>('GET', `/providers/${providerId}/keys`)
|
|
||||||
},
|
|
||||||
|
|
||||||
async addKey(providerId: string, data: {
|
|
||||||
key_label: string
|
|
||||||
key_value: string
|
|
||||||
priority?: number
|
|
||||||
max_rpm?: number
|
|
||||||
max_tpm?: number
|
|
||||||
quota_reset_interval?: string
|
|
||||||
}): Promise<{ ok: boolean; key_id: string }> {
|
|
||||||
return request<{ ok: boolean; key_id: string }>('POST', `/providers/${providerId}/keys`, data)
|
|
||||||
},
|
|
||||||
|
|
||||||
async toggleKey(providerId: string, keyId: string, active: boolean): Promise<{ ok: boolean }> {
|
|
||||||
return request<{ ok: boolean }>('PUT', `/providers/${providerId}/keys/${keyId}/toggle`, { active })
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteKey(providerId: string, keyId: string): Promise<{ ok: boolean }> {
|
|
||||||
return request<{ ok: boolean }>('DELETE', `/providers/${providerId}/keys/${keyId}`)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── 模型管理 ──────────────────────────────────────────
|
|
||||||
models: {
|
|
||||||
async list(params?: {
|
|
||||||
page?: number
|
|
||||||
page_size?: number
|
|
||||||
provider_id?: string
|
|
||||||
}): Promise<PaginatedResponse<Model>> {
|
|
||||||
const qs = buildQueryString(params)
|
|
||||||
return request<PaginatedResponse<Model>>('GET', `/models${qs}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
async create(data: Partial<Omit<Model, 'id'>>): Promise<Model> {
|
|
||||||
return request<Model>('POST', '/models', data)
|
|
||||||
},
|
|
||||||
|
|
||||||
async update(id: string, data: Partial<Omit<Model, 'id'>>): Promise<Model> {
|
|
||||||
return request<Model>('PATCH', `/models/${id}`, data)
|
|
||||||
},
|
|
||||||
|
|
||||||
async delete(id: string): Promise<void> {
|
|
||||||
return request<void>('DELETE', `/models/${id}`)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── API 密钥 ──────────────────────────────────────────
|
|
||||||
tokens: {
|
|
||||||
async list(params?: {
|
|
||||||
page?: number
|
|
||||||
page_size?: number
|
|
||||||
}): Promise<PaginatedResponse<TokenInfo>> {
|
|
||||||
const qs = buildQueryString(params)
|
|
||||||
return request<PaginatedResponse<TokenInfo>>('GET', `/keys${qs}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
async create(data: CreateTokenRequest): Promise<TokenInfo> {
|
|
||||||
return request<TokenInfo>('POST', '/keys', data)
|
|
||||||
},
|
|
||||||
|
|
||||||
async revoke(id: string): Promise<void> {
|
|
||||||
return request<void>('DELETE', `/keys/${id}`)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── 用量统计 ──────────────────────────────────────────
|
|
||||||
usage: {
|
|
||||||
async daily(params?: { days?: number }): Promise<UsageRecord[]> {
|
|
||||||
const qs = buildQueryString({ ...params, group_by: 'day' })
|
|
||||||
const result = await request<{ by_day: UsageRecord[] }>('GET', `/usage${qs}`)
|
|
||||||
return result.by_day || []
|
|
||||||
},
|
|
||||||
|
|
||||||
async byModel(params?: { days?: number }): Promise<UsageByModel[]> {
|
|
||||||
const qs = buildQueryString({ ...params, group_by: 'model' })
|
|
||||||
const result = await request<{ by_model: UsageByModel[] }>('GET', `/usage${qs}`)
|
|
||||||
return result.by_model || []
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── 中转任务 ──────────────────────────────────────────
|
|
||||||
relay: {
|
|
||||||
async list(params?: {
|
|
||||||
page?: number
|
|
||||||
page_size?: number
|
|
||||||
status?: string
|
|
||||||
}): Promise<PaginatedResponse<RelayTask>> {
|
|
||||||
const qs = buildQueryString(params)
|
|
||||||
return request<PaginatedResponse<RelayTask>>('GET', `/relay/tasks${qs}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
async get(id: string): Promise<RelayTask> {
|
|
||||||
return request<RelayTask>('GET', `/relay/tasks/${id}`)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── 系统配置 ──────────────────────────────────────────
|
|
||||||
config: {
|
|
||||||
async list(params?: {
|
|
||||||
category?: string
|
|
||||||
page?: number
|
|
||||||
page_size?: number
|
|
||||||
}): Promise<ConfigItem[]> {
|
|
||||||
const qs = buildQueryString(params)
|
|
||||||
const result = await request<PaginatedResponse<ConfigItem>>('GET', `/config/items${qs}`)
|
|
||||||
return result.items
|
|
||||||
},
|
|
||||||
|
|
||||||
async update(id: string, data: { value: string | number | boolean }): Promise<ConfigItem> {
|
|
||||||
return request<ConfigItem>('PATCH', `/config/items/${id}`, data)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── 操作日志 ──────────────────────────────────────────
|
|
||||||
logs: {
|
|
||||||
async list(params?: {
|
|
||||||
page?: number
|
|
||||||
page_size?: number
|
|
||||||
action?: string
|
|
||||||
}): Promise<PaginatedResponse<OperationLog>> {
|
|
||||||
const qs = buildQueryString(params)
|
|
||||||
return request<PaginatedResponse<OperationLog>>('GET', `/logs/operations${qs}`)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── 仪表盘 ────────────────────────────────────────────
|
|
||||||
stats: {
|
|
||||||
async dashboard(): Promise<DashboardStats> {
|
|
||||||
return request<DashboardStats>('GET', '/stats/dashboard')
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── 提示词管理 ────────────────────────────────────────
|
|
||||||
prompts: {
|
|
||||||
async list(params?: {
|
|
||||||
category?: string
|
|
||||||
source?: string
|
|
||||||
status?: string
|
|
||||||
page?: number
|
|
||||||
page_size?: number
|
|
||||||
}): Promise<PaginatedResponse<PromptTemplate>> {
|
|
||||||
const qs = buildQueryString(params)
|
|
||||||
return request<PaginatedResponse<PromptTemplate>>('GET', `/prompts${qs}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
async get(name: string): Promise<PromptTemplate> {
|
|
||||||
return request<PromptTemplate>('GET', `/prompts/${encodeURIComponent(name)}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
async create(data: {
|
|
||||||
name: string
|
|
||||||
category: string
|
|
||||||
description?: string
|
|
||||||
source?: string
|
|
||||||
system_prompt: string
|
|
||||||
user_prompt_template?: string
|
|
||||||
variables?: unknown[]
|
|
||||||
min_app_version?: string
|
|
||||||
}): Promise<PromptTemplate> {
|
|
||||||
return request<PromptTemplate>('POST', '/prompts', data)
|
|
||||||
},
|
|
||||||
|
|
||||||
async update(name: string, data: {
|
|
||||||
description?: string
|
|
||||||
status?: string
|
|
||||||
}): Promise<PromptTemplate> {
|
|
||||||
return request<PromptTemplate>('PUT', `/prompts/${encodeURIComponent(name)}`, data)
|
|
||||||
},
|
|
||||||
|
|
||||||
async archive(name: string): Promise<PromptTemplate> {
|
|
||||||
return request<PromptTemplate>('DELETE', `/prompts/${encodeURIComponent(name)}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
async listVersions(name: string): Promise<PromptVersion[]> {
|
|
||||||
return request<PromptVersion[]>('GET', `/prompts/${encodeURIComponent(name)}/versions`)
|
|
||||||
},
|
|
||||||
|
|
||||||
async createVersion(name: string, data: {
|
|
||||||
system_prompt: string
|
|
||||||
user_prompt_template?: string
|
|
||||||
variables?: unknown[]
|
|
||||||
changelog?: string
|
|
||||||
min_app_version?: string
|
|
||||||
}): Promise<PromptVersion> {
|
|
||||||
return request<PromptVersion>('POST', `/prompts/${encodeURIComponent(name)}/versions`, data)
|
|
||||||
},
|
|
||||||
|
|
||||||
async rollback(name: string, version: number): Promise<PromptTemplate> {
|
|
||||||
return request<PromptTemplate>('POST', `/prompts/${encodeURIComponent(name)}/rollback/${version}`)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Agent 配置模板 ──────────────────────────────────
|
|
||||||
agentTemplates: {
|
|
||||||
async list(params?: {
|
|
||||||
category?: string
|
|
||||||
source?: string
|
|
||||||
visibility?: string
|
|
||||||
status?: string
|
|
||||||
page?: number
|
|
||||||
page_size?: number
|
|
||||||
}): Promise<PaginatedResponse<AgentTemplate>> {
|
|
||||||
const qs = buildQueryString(params)
|
|
||||||
return request<PaginatedResponse<AgentTemplate>>('GET', `/agent-templates${qs}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
async get(id: string): Promise<AgentTemplate> {
|
|
||||||
return request<AgentTemplate>('GET', `/agent-templates/${id}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
async create(data: {
|
|
||||||
name: string
|
|
||||||
description?: string
|
|
||||||
category?: string
|
|
||||||
source?: string
|
|
||||||
model?: string
|
|
||||||
system_prompt?: string
|
|
||||||
tools?: string[]
|
|
||||||
capabilities?: string[]
|
|
||||||
temperature?: number
|
|
||||||
max_tokens?: number
|
|
||||||
visibility?: string
|
|
||||||
}): Promise<AgentTemplate> {
|
|
||||||
return request<AgentTemplate>('POST', '/agent-templates', data)
|
|
||||||
},
|
|
||||||
|
|
||||||
async update(id: string, data: {
|
|
||||||
description?: string
|
|
||||||
model?: string
|
|
||||||
system_prompt?: string
|
|
||||||
tools?: string[]
|
|
||||||
capabilities?: string[]
|
|
||||||
temperature?: number
|
|
||||||
max_tokens?: number
|
|
||||||
visibility?: string
|
|
||||||
status?: string
|
|
||||||
}): Promise<AgentTemplate> {
|
|
||||||
return request<AgentTemplate>('POST', `/agent-templates/${id}`, data)
|
|
||||||
},
|
|
||||||
|
|
||||||
async archive(id: string): Promise<AgentTemplate> {
|
|
||||||
return request<AgentTemplate>('DELETE', `/agent-templates/${id}`)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── 遥测统计 ──────────────────────────────────────────
|
|
||||||
telemetry: {
|
|
||||||
/** 按模型聚合用量统计 */
|
|
||||||
async modelStats(params?: {
|
|
||||||
from?: string
|
|
||||||
to?: string
|
|
||||||
model_id?: string
|
|
||||||
connection_mode?: string
|
|
||||||
}): Promise<ModelUsageStat[]> {
|
|
||||||
const qs = buildQueryString(params)
|
|
||||||
return request<ModelUsageStat[]>('GET', `/telemetry/stats${qs}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
/** 按天聚合用量统计 */
|
|
||||||
async dailyStats(params?: {
|
|
||||||
days?: number
|
|
||||||
}): Promise<DailyUsageStat[]> {
|
|
||||||
const qs = buildQueryString(params)
|
|
||||||
return request<DailyUsageStat[]>('GET', `/telemetry/daily${qs}`)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 工具函数 ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
function buildQueryString(params?: Record<string, unknown>): string {
|
|
||||||
if (!params) return ''
|
|
||||||
const entries = Object.entries(params).filter(
|
|
||||||
([, v]) => v !== undefined && v !== null && v !== '',
|
|
||||||
)
|
|
||||||
if (entries.length === 0) return ''
|
|
||||||
const qs = entries
|
|
||||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
|
|
||||||
.join('&')
|
|
||||||
return `?${qs}`
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// API Error 类 — 与 swr-fetcher 共享
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export class ApiRequestError extends Error {
|
|
||||||
constructor(
|
|
||||||
public status: number,
|
|
||||||
public body: { error?: string; message?: string },
|
|
||||||
) {
|
|
||||||
super(body.message || `Request failed with status ${status}`)
|
|
||||||
this.name = 'ApiRequestError'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// ZCLAW SaaS Admin — JWT Token 管理
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import type { AccountPublic } from './types'
|
|
||||||
|
|
||||||
const TOKEN_KEY = 'zclaw_admin_token'
|
|
||||||
const ACCOUNT_KEY = 'zclaw_admin_account'
|
|
||||||
|
|
||||||
/** 保存登录凭证 */
|
|
||||||
export function login(token: string, account: AccountPublic): void {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
localStorage.setItem(TOKEN_KEY, token)
|
|
||||||
localStorage.setItem(ACCOUNT_KEY, JSON.stringify(account))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 清除登录凭证 */
|
|
||||||
export function logout(): void {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
localStorage.removeItem(TOKEN_KEY)
|
|
||||||
localStorage.removeItem(ACCOUNT_KEY)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 清除认证状态(用于 Token 验证失败时) */
|
|
||||||
export function clearAuth(): void {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
localStorage.removeItem(TOKEN_KEY)
|
|
||||||
localStorage.removeItem(ACCOUNT_KEY)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取 JWT token */
|
|
||||||
export function getToken(): string | null {
|
|
||||||
if (typeof window === 'undefined') return null
|
|
||||||
return localStorage.getItem(TOKEN_KEY)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取当前登录用户信息 */
|
|
||||||
export function getAccount(): AccountPublic | null {
|
|
||||||
if (typeof window === 'undefined') return null
|
|
||||||
const raw = localStorage.getItem(ACCOUNT_KEY)
|
|
||||||
if (!raw) return null
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw) as AccountPublic
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 是否已认证 */
|
|
||||||
export function isAuthenticated(): boolean {
|
|
||||||
return !!getToken()
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// SWR fetcher — 将 SWR key 映射到 api-client 调用
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { api } from './api-client'
|
|
||||||
import { ApiRequestError } from './api-client'
|
|
||||||
|
|
||||||
type ApiMethod = typeof api
|
|
||||||
|
|
||||||
/** SWR fetcher: key 可以是字符串或 [method-path, params] 元组 */
|
|
||||||
type SwrKey =
|
|
||||||
| string
|
|
||||||
| [string, ...unknown[]]
|
|
||||||
|
|
||||||
/** SWR fetcher 支持 AbortSignal 传递 */
|
|
||||||
type SwrFetcherArgs = { signal?: AbortSignal } | null
|
|
||||||
|
|
||||||
async function resolveApiCall(key: SwrKey, args: SwrFetcherArgs): Promise<unknown> {
|
|
||||||
if (typeof key === 'string') {
|
|
||||||
// 简单字符串 key,直接 fetch
|
|
||||||
return fetchGeneric(key, args?.signal)
|
|
||||||
}
|
|
||||||
|
|
||||||
const [path, ...rest] = key
|
|
||||||
return callByPath(path, rest, args?.signal)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchGeneric(path: string, signal?: AbortSignal): Promise<unknown> {
|
|
||||||
const res = await fetch(path, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.json().catch(() => ({ error: 'unknown', message: `请求失败 (${res.status})` }))
|
|
||||||
throw new ApiRequestError(res.status, body)
|
|
||||||
}
|
|
||||||
if (res.status === 204) return null
|
|
||||||
return res.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 根据 path 调用对应的 api 方法 */
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
async function callByPath(path: string, callArgs: unknown[], signal?: AbortSignal): Promise<unknown> {
|
|
||||||
const parts = path.split('.')
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
let target: any = api
|
|
||||||
for (const part of parts) {
|
|
||||||
target = target[part]
|
|
||||||
if (!target) throw new Error(`API method not found: ${path}`)
|
|
||||||
}
|
|
||||||
// Append signal as last argument if the target is the request function
|
|
||||||
// For api.xxx() calls that ultimately use request(), we pass signal through
|
|
||||||
// The simplest approach: pass signal as part of an options bag
|
|
||||||
return target(...callArgs, signal ? { signal } : undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SWR fetcher — 接受 SWR 自动传入的 AbortSignal
|
|
||||||
*
|
|
||||||
* 用法: useSWR(key, swrFetcher)
|
|
||||||
* SWR 会自动在组件卸载或 key 变化时 abort 请求
|
|
||||||
*/
|
|
||||||
export function swrFetcher<T = unknown>(key: SwrKey, args: SwrFetcherArgs): Promise<T> {
|
|
||||||
return resolveApiCall(key, args) as Promise<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 创建 SWR key helper — 类型安全 */
|
|
||||||
export function createKey<TMethod extends string>(
|
|
||||||
method: TMethod,
|
|
||||||
...args: unknown[]
|
|
||||||
): [TMethod, ...unknown[]] {
|
|
||||||
return [method, ...args]
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { SWRConfig } from 'swr'
|
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
|
|
||||||
/** 判断是否为请求被中断(页面导航等场景) */
|
|
||||||
function isAbortError(err: unknown): boolean {
|
|
||||||
if (err instanceof DOMException && err.name === 'AbortError') return true
|
|
||||||
if (err instanceof Error && err.message?.includes('aborted')) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SWRProvider({ children }: { children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<SWRConfig
|
|
||||||
value={{
|
|
||||||
// 关闭所有自动 revalidation — 只在手动 mutate 或 key 变化时刷新
|
|
||||||
revalidateOnFocus: false,
|
|
||||||
revalidateOnReconnect: false,
|
|
||||||
|
|
||||||
// 60s 去重窗口:Dashboard 数据变化不频繁,避免短时间内重复请求
|
|
||||||
dedupingInterval: 60_000,
|
|
||||||
|
|
||||||
// 保留旧数据直到新数据返回,避免 loading 闪烁
|
|
||||||
keepPreviousData: true,
|
|
||||||
|
|
||||||
// 最多重试 1 次,间隔 3s
|
|
||||||
errorRetryCount: 1,
|
|
||||||
errorRetryInterval: 3000,
|
|
||||||
|
|
||||||
shouldRetryOnError: (err: unknown) => {
|
|
||||||
if (isAbortError(err)) return false
|
|
||||||
if (err && typeof err === 'object' && 'status' in err) {
|
|
||||||
const status = (err as { status: number }).status
|
|
||||||
return status !== 401 && status !== 403 && status !== 404
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
onError: (err: unknown) => {
|
|
||||||
if (isAbortError(err)) return
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SWRConfig>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// ZCLAW SaaS Admin — 全局类型定义
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
/** 公共账号信息 */
|
|
||||||
export interface AccountPublic {
|
|
||||||
id: string
|
|
||||||
username: string
|
|
||||||
email: string
|
|
||||||
display_name: string
|
|
||||||
role: 'super_admin' | 'admin' | 'user'
|
|
||||||
status: 'active' | 'disabled' | 'suspended'
|
|
||||||
totp_enabled: boolean
|
|
||||||
last_login_at: string | null
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 登录请求 */
|
|
||||||
export interface LoginRequest {
|
|
||||||
username: string
|
|
||||||
password: string
|
|
||||||
totp_code?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 登录响应 */
|
|
||||||
export interface LoginResponse {
|
|
||||||
token: string
|
|
||||||
refresh_token: string
|
|
||||||
account: AccountPublic
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 注册请求 */
|
|
||||||
export interface RegisterRequest {
|
|
||||||
username: string
|
|
||||||
password: string
|
|
||||||
email: string
|
|
||||||
display_name?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 分页响应 */
|
|
||||||
export interface PaginatedResponse<T> {
|
|
||||||
items: T[]
|
|
||||||
total: number
|
|
||||||
page: number
|
|
||||||
page_size: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 服务商 (Provider) */
|
|
||||||
export interface Provider {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
display_name: string
|
|
||||||
api_key?: string
|
|
||||||
base_url: string
|
|
||||||
api_protocol: string
|
|
||||||
enabled: boolean
|
|
||||||
rate_limit_rpm: number | null
|
|
||||||
rate_limit_tpm: number | null
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 模型 */
|
|
||||||
export interface Model {
|
|
||||||
id: string
|
|
||||||
provider_id: string
|
|
||||||
model_id: string
|
|
||||||
alias: string
|
|
||||||
context_window: number
|
|
||||||
max_output_tokens: number
|
|
||||||
supports_streaming: boolean
|
|
||||||
supports_vision: boolean
|
|
||||||
enabled: boolean
|
|
||||||
pricing_input: number
|
|
||||||
pricing_output: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** API 密钥信息 */
|
|
||||||
export interface TokenInfo {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
token_prefix: string
|
|
||||||
permissions: string[]
|
|
||||||
last_used_at?: string
|
|
||||||
expires_at?: string
|
|
||||||
created_at: string
|
|
||||||
token?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 创建 Token 请求 */
|
|
||||||
export interface CreateTokenRequest {
|
|
||||||
name: string
|
|
||||||
expires_days?: number
|
|
||||||
permissions: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 中转任务 */
|
|
||||||
export interface RelayTask {
|
|
||||||
id: string
|
|
||||||
account_id: string
|
|
||||||
provider_id: string
|
|
||||||
model_id: string
|
|
||||||
status: string
|
|
||||||
priority: number
|
|
||||||
attempt_count: number
|
|
||||||
max_attempts: number
|
|
||||||
input_tokens: number
|
|
||||||
output_tokens: number
|
|
||||||
error_message: string | null
|
|
||||||
queued_at: string
|
|
||||||
started_at: string | null
|
|
||||||
completed_at: string | null
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 用量记录 */
|
|
||||||
export interface UsageRecord {
|
|
||||||
day: string
|
|
||||||
count: number
|
|
||||||
input_tokens: number
|
|
||||||
output_tokens: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 按模型用量 */
|
|
||||||
export interface UsageByModel {
|
|
||||||
model_id: string
|
|
||||||
count: number
|
|
||||||
input_tokens: number
|
|
||||||
output_tokens: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 系统配置项 */
|
|
||||||
export interface ConfigItem {
|
|
||||||
id: string
|
|
||||||
category: string
|
|
||||||
key_path: string
|
|
||||||
value_type: string
|
|
||||||
current_value: string | null
|
|
||||||
default_value: string | null
|
|
||||||
source: string
|
|
||||||
description: string | null
|
|
||||||
requires_restart: boolean
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 操作日志 */
|
|
||||||
export interface OperationLog {
|
|
||||||
id: number
|
|
||||||
account_id: string | null
|
|
||||||
action: string
|
|
||||||
target_type: string | null
|
|
||||||
target_id: string | null
|
|
||||||
details: Record<string, unknown> | null
|
|
||||||
ip_address: string | null
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 仪表盘统计 */
|
|
||||||
export interface DashboardStats {
|
|
||||||
total_accounts: number
|
|
||||||
active_accounts: number
|
|
||||||
tasks_today: number
|
|
||||||
active_providers: number
|
|
||||||
active_models: number
|
|
||||||
tokens_today_input: number
|
|
||||||
tokens_today_output: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** API 错误响应 */
|
|
||||||
export interface ApiError {
|
|
||||||
error: string
|
|
||||||
message: string
|
|
||||||
status?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 提示词模板 ────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** 提示词模板 */
|
|
||||||
export interface PromptTemplate {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
category: string
|
|
||||||
description?: string
|
|
||||||
source: 'builtin' | 'custom'
|
|
||||||
current_version: number
|
|
||||||
status: 'active' | 'deprecated' | 'archived'
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 提示词版本 */
|
|
||||||
export interface PromptVersion {
|
|
||||||
id: string
|
|
||||||
template_id: string
|
|
||||||
version: number
|
|
||||||
system_prompt: string
|
|
||||||
user_prompt_template?: string
|
|
||||||
variables: PromptVariable[]
|
|
||||||
changelog?: string
|
|
||||||
min_app_version?: string
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 提示词变量定义 */
|
|
||||||
export interface PromptVariable {
|
|
||||||
name: string
|
|
||||||
type: 'string' | 'number' | 'select' | 'boolean'
|
|
||||||
default_value?: string
|
|
||||||
description?: string
|
|
||||||
required?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/** OTA 更新检查请求 */
|
|
||||||
export interface PromptCheckRequest {
|
|
||||||
device_id: string
|
|
||||||
versions: Record<string, number>
|
|
||||||
}
|
|
||||||
|
|
||||||
/** OTA 更新响应 */
|
|
||||||
export interface PromptCheckResponse {
|
|
||||||
updates: PromptUpdatePayload[]
|
|
||||||
server_time: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 单个更新载荷 */
|
|
||||||
export interface PromptUpdatePayload {
|
|
||||||
name: string
|
|
||||||
version: number
|
|
||||||
system_prompt: string
|
|
||||||
user_prompt_template?: string
|
|
||||||
variables: PromptVariable[]
|
|
||||||
source: string
|
|
||||||
min_app_version?: string
|
|
||||||
changelog?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Agent 配置模板 ────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Agent 模板 */
|
|
||||||
export interface AgentTemplate {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
description?: string
|
|
||||||
category: string
|
|
||||||
source: 'builtin' | 'custom'
|
|
||||||
model?: string
|
|
||||||
system_prompt?: string
|
|
||||||
tools: string[]
|
|
||||||
capabilities: string[]
|
|
||||||
temperature?: number
|
|
||||||
max_tokens?: number
|
|
||||||
visibility: 'public' | 'team' | 'private'
|
|
||||||
status: 'active' | 'archived'
|
|
||||||
current_version: number
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Provider Key Pool ─────────────────────────────────────
|
|
||||||
|
|
||||||
/** Provider Key */
|
|
||||||
export interface ProviderKey {
|
|
||||||
id: string
|
|
||||||
provider_id: string
|
|
||||||
key_label: string
|
|
||||||
priority: number
|
|
||||||
max_rpm?: number
|
|
||||||
max_tpm?: number
|
|
||||||
quota_reset_interval?: string
|
|
||||||
is_active: boolean
|
|
||||||
last_429_at?: string
|
|
||||||
cooldown_until?: string
|
|
||||||
total_requests: number
|
|
||||||
total_tokens: number
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 遥测统计 ────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** 按模型聚合的用量统计 */
|
|
||||||
export interface ModelUsageStat {
|
|
||||||
model_id: string
|
|
||||||
request_count: number
|
|
||||||
input_tokens: number
|
|
||||||
output_tokens: number
|
|
||||||
avg_latency_ms: number | null
|
|
||||||
success_rate: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 按天的用量统计 */
|
|
||||||
export interface DailyUsageStat {
|
|
||||||
day: string
|
|
||||||
request_count: number
|
|
||||||
input_tokens: number
|
|
||||||
output_tokens: number
|
|
||||||
unique_devices: number
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { type ClassValue, clsx } from 'clsx'
|
|
||||||
import { twMerge } from 'tailwind-merge'
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDate(date: string | Date): string {
|
|
||||||
const d = new Date(date)
|
|
||||||
return d.toLocaleString('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatNumber(n: number): string {
|
|
||||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
|
||||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
|
|
||||||
return n.toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function maskApiKey(key?: string): string {
|
|
||||||
if (!key) return '-'
|
|
||||||
if (key.length <= 8) return '****'
|
|
||||||
return `${key.slice(0, 4)}${'*'.repeat(key.length - 8)}${key.slice(-4)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise(resolve => setTimeout(resolve, ms))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 从 SWR error 中提取用户可见消息,过滤 abort 错误 */
|
|
||||||
export function getSwrErrorMessage(err: unknown): string | undefined {
|
|
||||||
if (!err) return undefined
|
|
||||||
if (err instanceof DOMException && err.name === 'AbortError') return undefined
|
|
||||||
if (err instanceof Error) {
|
|
||||||
if (err.name === 'AbortError' || err.message?.includes('aborted')) return undefined
|
|
||||||
return err.message
|
|
||||||
}
|
|
||||||
return String(err)
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import type { Config } from 'tailwindcss'
|
|
||||||
|
|
||||||
const config: Config = {
|
|
||||||
darkMode: 'class',
|
|
||||||
content: [
|
|
||||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
|
||||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
|
||||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
background: '#020617',
|
|
||||||
foreground: '#F8FAFC',
|
|
||||||
card: {
|
|
||||||
DEFAULT: '#0F172A',
|
|
||||||
foreground: '#F8FAFC',
|
|
||||||
},
|
|
||||||
primary: {
|
|
||||||
DEFAULT: '#22C55E',
|
|
||||||
foreground: '#020617',
|
|
||||||
hover: '#16A34A',
|
|
||||||
},
|
|
||||||
muted: {
|
|
||||||
DEFAULT: '#1E293B',
|
|
||||||
foreground: '#94A3B8',
|
|
||||||
},
|
|
||||||
accent: {
|
|
||||||
DEFAULT: '#334155',
|
|
||||||
foreground: '#F8FAFC',
|
|
||||||
},
|
|
||||||
destructive: {
|
|
||||||
DEFAULT: '#EF4444',
|
|
||||||
foreground: '#F8FAFC',
|
|
||||||
},
|
|
||||||
border: '#1E293B',
|
|
||||||
input: '#1E293B',
|
|
||||||
ring: '#22C55E',
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
|
||||||
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
|
||||||
},
|
|
||||||
keyframes: {
|
|
||||||
'fade-in': {
|
|
||||||
'0%': { opacity: '0', transform: 'translateY(4px)' },
|
|
||||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
|
||||||
},
|
|
||||||
'slide-in': {
|
|
||||||
'0%': { opacity: '0', transform: 'translateX(-8px)' },
|
|
||||||
'100%': { opacity: '1', transform: 'translateX(0)' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
animation: {
|
|
||||||
'fade-in': 'fade-in 0.2s ease-out',
|
|
||||||
'slide-in': 'slide-in 0.2s ease-out',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
}
|
|
||||||
export default config
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "es2017",
|
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
|
||||||
"allowJs": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"strict": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"module": "esnext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"jsx": "preserve",
|
|
||||||
"incremental": true,
|
|
||||||
"plugins": [{ "name": "next" }],
|
|
||||||
"paths": { "@/*": ["./src/*"] }
|
|
||||||
},
|
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
||||||
"exclude": ["node_modules"]
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user