Compare commits
3 Commits
a0d59b1947
...
15450ca895
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15450ca895 | ||
|
|
a66b675675 | ||
|
|
d760b9ca10 |
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -7432,6 +7432,7 @@ dependencies = [
|
|||||||
"axum-extra",
|
"axum-extra",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
|
"futures",
|
||||||
"hex",
|
"hex",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"libsqlite3-sys",
|
"libsqlite3-sys",
|
||||||
|
|||||||
2
admin/.gitignore
vendored
Normal file
2
admin/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.next/
|
||||||
|
node_modules/
|
||||||
5
admin/next-env.d.ts
vendored
Normal file
5
admin/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/// <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.
|
||||||
4
admin/next.config.js
Normal file
4
admin/next.config.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
||||||
37
admin/package.json
Normal file
37
admin/package.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"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-switch": "^1.2.5",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
|
"@radix-ui/react-separator": "^1.1.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",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
2171
admin/pnpm-lock.yaml
generated
Normal file
2171
admin/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
admin/postcss.config.js
Normal file
6
admin/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
393
admin/src/app/(dashboard)/accounts/page.tsx
Normal file
393
admin/src/app/(dashboard)/accounts/page.tsx
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
Loader2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Pencil,
|
||||||
|
Ban,
|
||||||
|
CheckCircle2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
import { formatDate } from '@/lib/utils'
|
||||||
|
import type { AccountPublic } from '@/lib/types'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
const roleLabels: Record<string, string> = {
|
||||||
|
super_admin: '超级管理员',
|
||||||
|
admin: '管理员',
|
||||||
|
user: '普通用户',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, 'success' | 'destructive' | 'warning'> = {
|
||||||
|
active: 'success',
|
||||||
|
disabled: 'destructive',
|
||||||
|
suspended: 'warning',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
active: '正常',
|
||||||
|
disabled: '已禁用',
|
||||||
|
suspended: '已暂停',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AccountsPage() {
|
||||||
|
const [accounts, setAccounts] = useState<AccountPublic[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [roleFilter, setRoleFilter] = useState<string>('all')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// 编辑 Dialog
|
||||||
|
const [editTarget, setEditTarget] = useState<AccountPublic | null>(null)
|
||||||
|
const [editForm, setEditForm] = useState({ display_name: '', email: '', role: 'user' })
|
||||||
|
const [editSaving, setEditSaving] = useState(false)
|
||||||
|
|
||||||
|
// 确认 Dialog
|
||||||
|
const [confirmTarget, setConfirmTarget] = useState<{ id: string; action: string; status: string } | null>(null)
|
||||||
|
const [confirmSaving, setConfirmSaving] = useState(false)
|
||||||
|
|
||||||
|
const fetchAccounts = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
|
||||||
|
if (search.trim()) params.search = search.trim()
|
||||||
|
if (roleFilter !== 'all') params.role = roleFilter
|
||||||
|
if (statusFilter !== 'all') params.status = statusFilter
|
||||||
|
|
||||||
|
const res = await api.accounts.list(params)
|
||||||
|
setAccounts(res.items)
|
||||||
|
setTotal(res.total)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) {
|
||||||
|
setError(err.body.message)
|
||||||
|
} else {
|
||||||
|
setError('加载失败')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [page, search, roleFilter, statusFilter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAccounts()
|
||||||
|
}, [fetchAccounts])
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
|
||||||
|
function openEditDialog(account: AccountPublic) {
|
||||||
|
setEditTarget(account)
|
||||||
|
setEditForm({
|
||||||
|
display_name: account.display_name,
|
||||||
|
email: account.email,
|
||||||
|
role: account.role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEditSave() {
|
||||||
|
if (!editTarget) return
|
||||||
|
setEditSaving(true)
|
||||||
|
try {
|
||||||
|
await api.accounts.update(editTarget.id, {
|
||||||
|
display_name: editForm.display_name,
|
||||||
|
email: editForm.email,
|
||||||
|
role: editForm.role as AccountPublic['role'],
|
||||||
|
})
|
||||||
|
setEditTarget(null)
|
||||||
|
fetchAccounts()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) {
|
||||||
|
setError(err.body.message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setEditSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openConfirmDialog(account: AccountPublic) {
|
||||||
|
const newStatus = account.status === 'active' ? 'disabled' : 'active'
|
||||||
|
setConfirmTarget({
|
||||||
|
id: account.id,
|
||||||
|
action: newStatus === 'disabled' ? '禁用' : '启用',
|
||||||
|
status: newStatus,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirmSave() {
|
||||||
|
if (!confirmTarget) return
|
||||||
|
setConfirmSaving(true)
|
||||||
|
try {
|
||||||
|
await api.accounts.updateStatus(confirmTarget.id, {
|
||||||
|
status: confirmTarget.status as AccountPublic['status'],
|
||||||
|
})
|
||||||
|
setConfirmTarget(null)
|
||||||
|
fetchAccounts()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) {
|
||||||
|
setError(err.body.message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setConfirmSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 搜索和筛选 */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="搜索用户名 / 邮箱 / 显示名..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={roleFilter} onValueChange={(v) => { setRoleFilter(v); setPage(1) }}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="角色筛选" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部角色</SelectItem>
|
||||||
|
<SelectItem value="super_admin">超级管理员</SelectItem>
|
||||||
|
<SelectItem value="admin">管理员</SelectItem>
|
||||||
|
<SelectItem value="user">普通用户</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v); setPage(1) }}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="状态筛选" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部状态</SelectItem>
|
||||||
|
<SelectItem value="active">正常</SelectItem>
|
||||||
|
<SelectItem value="disabled">已禁用</SelectItem>
|
||||||
|
<SelectItem value="suspended">已暂停</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 表格 */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : accounts.length === 0 ? (
|
||||||
|
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>用户名</TableHead>
|
||||||
|
<TableHead>邮箱</TableHead>
|
||||||
|
<TableHead>显示名</TableHead>
|
||||||
|
<TableHead>角色</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>创建时间</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<TableRow key={account.id}>
|
||||||
|
<TableCell className="font-medium">{account.username}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{account.email}</TableCell>
|
||||||
|
<TableCell>{account.display_name || '-'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={account.role === 'super_admin' ? 'default' : account.role === 'admin' ? 'info' : 'secondary'}>
|
||||||
|
{roleLabels[account.role] || account.role}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusColors[account.status] || 'secondary'}>
|
||||||
|
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-current" />
|
||||||
|
{statusLabels[account.status] || account.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{formatDate(account.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => openEditDialog(account)}
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => openConfirmDialog(account)}
|
||||||
|
title={account.status === 'active' ? '禁用' : '启用'}
|
||||||
|
>
|
||||||
|
{account.status === 'active' ? (
|
||||||
|
<Ban className="h-4 w-4 text-destructive" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-400" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{/* 分页 */}
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
第 {page} 页 / 共 {totalPages} 页 ({total} 条)
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage(page - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
<ChevronRight className="h-4 w-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 编辑 Dialog */}
|
||||||
|
<Dialog open={!!editTarget} onOpenChange={() => setEditTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>编辑账号</DialogTitle>
|
||||||
|
<DialogDescription>修改账号信息</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>显示名</Label>
|
||||||
|
<Input
|
||||||
|
value={editForm.display_name}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, display_name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>邮箱</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={editForm.email}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>角色</Label>
|
||||||
|
<Select value={editForm.role} onValueChange={(v) => setEditForm({ ...editForm, role: v })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="user">普通用户</SelectItem>
|
||||||
|
<SelectItem value="admin">管理员</SelectItem>
|
||||||
|
<SelectItem value="super_admin">超级管理员</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditTarget(null)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleEditSave} disabled={editSaving}>
|
||||||
|
{editSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 确认 Dialog */}
|
||||||
|
<Dialog open={!!confirmTarget} onOpenChange={() => setConfirmTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>确认{confirmTarget?.action}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
确定要{confirmTarget?.action}该账号吗?此操作将立即生效。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setConfirmTarget(null)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={confirmTarget?.status === 'disabled' ? 'destructive' : 'default'}
|
||||||
|
onClick={handleConfirmSave}
|
||||||
|
disabled={confirmSaving}
|
||||||
|
>
|
||||||
|
{confirmSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
确认{confirmTarget?.action}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
351
admin/src/app/(dashboard)/api-keys/page.tsx
Normal file
351
admin/src/app/(dashboard)/api-keys/page.tsx
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
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 { ApiRequestError } from '@/lib/api-client'
|
||||||
|
import { formatDate } from '@/lib/utils'
|
||||||
|
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 [tokens, setTokens] = useState<TokenInfo[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// 创建 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 fetchTokens = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await api.tokens.list({ page, page_size: PAGE_SIZE })
|
||||||
|
setTokens(res.items)
|
||||||
|
setTotal(res.total)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
else setError('加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [page])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTokens()
|
||||||
|
}, [fetchTokens])
|
||||||
|
|
||||||
|
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'] })
|
||||||
|
fetchTokens()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevoke() {
|
||||||
|
if (!revokeTarget) return
|
||||||
|
setRevoking(true)
|
||||||
|
try {
|
||||||
|
await api.tokens.revoke(revokeTarget.id)
|
||||||
|
setRevokeTarget(null)
|
||||||
|
fetchTokens()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(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 && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : tokens.length === 0 ? (
|
||||||
|
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>名称</TableHead>
|
||||||
|
<TableHead>前缀</TableHead>
|
||||||
|
<TableHead>权限</TableHead>
|
||||||
|
<TableHead>最后使用</TableHead>
|
||||||
|
<TableHead>过期时间</TableHead>
|
||||||
|
<TableHead>创建时间</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{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>
|
||||||
|
)
|
||||||
|
}
|
||||||
270
admin/src/app/(dashboard)/config/page.tsx
Normal file
270
admin/src/app/(dashboard)/config/page.tsx
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
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 { 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 [configs, setConfigs] = useState<ConfigItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [activeTab, setActiveTab] = useState('all')
|
||||||
|
|
||||||
|
// 编辑 Dialog
|
||||||
|
const [editTarget, setEditTarget] = useState<ConfigItem | null>(null)
|
||||||
|
const [editValue, setEditValue] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const fetchConfigs = useCallback(async (category?: string) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const params: Record<string, unknown> = {}
|
||||||
|
if (category && category !== 'all') params.category = category
|
||||||
|
const res = await api.config.list(params)
|
||||||
|
setConfigs(res)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
else setError('加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfigs(activeTab)
|
||||||
|
}, [fetchConfigs, activeTab])
|
||||||
|
|
||||||
|
function openEditDialog(config: ConfigItem) {
|
||||||
|
setEditTarget(config)
|
||||||
|
setEditValue(config.current_value !== undefined ? String(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)
|
||||||
|
fetchConfigs(activeTab)
|
||||||
|
} 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 categories = ['all', 'auth', 'relay', 'model', 'system']
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 分类 Tabs */}
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
|
<TabsList>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<TabsTrigger key={cat} value={cat}>
|
||||||
|
{cat === 'all' ? '全部' : cat}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : configs.length === 0 ? (
|
||||||
|
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无配置项
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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 !== undefined && (
|
||||||
|
<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 !== undefined) {
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
218
admin/src/app/(dashboard)/layout.tsx
Normal file
218
admin/src/app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
'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,
|
||||||
|
LogOut,
|
||||||
|
ChevronLeft,
|
||||||
|
Menu,
|
||||||
|
Bell,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { AuthGuard, useAuth } from '@/components/auth-guard'
|
||||||
|
import { logout } from '@/lib/auth'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: '/', label: '仪表盘', icon: LayoutDashboard },
|
||||||
|
{ href: '/accounts', label: '账号管理', icon: Users },
|
||||||
|
{ href: '/providers', label: '服务商', icon: Server },
|
||||||
|
{ href: '/models', label: '模型管理', icon: Cpu },
|
||||||
|
{ href: '/api-keys', label: 'API 密钥', icon: Key },
|
||||||
|
{ href: '/usage', label: '用量统计', icon: BarChart3 },
|
||||||
|
{ href: '/relay', label: '中转任务', icon: ArrowLeftRight },
|
||||||
|
{ href: '/config', label: '系统配置', icon: Settings },
|
||||||
|
{ href: '/logs', label: '操作日志', icon: FileText },
|
||||||
|
]
|
||||||
|
|
||||||
|
function Sidebar({
|
||||||
|
collapsed,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
collapsed: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const router = useRouter()
|
||||||
|
const { account } = useAuth()
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
logout()
|
||||||
|
router.replace('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
{navItems.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>
|
||||||
|
)
|
||||||
|
}
|
||||||
436
admin/src/app/(dashboard)/models/page.tsx
Normal file
436
admin/src/app/(dashboard)/models/page.tsx
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
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 { 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 [models, setModels] = useState<Model[]>([])
|
||||||
|
const [providers, setProviders] = useState<Provider[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [providerFilter, setProviderFilter] = useState<string>('all')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// 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 fetchModels = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
|
||||||
|
if (providerFilter !== 'all') params.provider_id = providerFilter
|
||||||
|
const res = await api.models.list(params)
|
||||||
|
setModels(res.items)
|
||||||
|
setTotal(res.total)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
else setError('加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [page, providerFilter])
|
||||||
|
|
||||||
|
const fetchProviders = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.providers.list({ page: 1, page_size: 100 })
|
||||||
|
setProviders(res.items)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchModels()
|
||||||
|
fetchProviders()
|
||||||
|
}, [fetchModels, fetchProviders])
|
||||||
|
|
||||||
|
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)
|
||||||
|
fetchModels()
|
||||||
|
} 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)
|
||||||
|
fetchModels()
|
||||||
|
} 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 && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : models.length === 0 ? (
|
||||||
|
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
336
admin/src/app/(dashboard)/page.tsx
Normal file
336
admin/src/app/(dashboard)/page.tsx
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Server,
|
||||||
|
ArrowLeftRight,
|
||||||
|
Zap,
|
||||||
|
Loader2,
|
||||||
|
TrendingUp,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
Legend,
|
||||||
|
} from 'recharts'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
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 [stats, setStats] = useState<DashboardStats | null>(null)
|
||||||
|
const [usageData, setUsageData] = useState<UsageRecord[]>([])
|
||||||
|
const [recentLogs, setRecentLogs] = useState<OperationLog[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchData() {
|
||||||
|
try {
|
||||||
|
const [statsRes, usageRes, logsRes] = await Promise.allSettled([
|
||||||
|
api.stats.dashboard(),
|
||||||
|
api.usage.daily({ days: 30 }),
|
||||||
|
api.logs.list({ page: 1, page_size: 5 }),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (statsRes.status === 'fulfilled') setStats(statsRes.value)
|
||||||
|
if (usageRes.status === 'fulfilled') setUsageData(usageRes.value)
|
||||||
|
if (logsRes.status === 'fulfilled') setRecentLogs(logsRes.value.items)
|
||||||
|
} catch (err) {
|
||||||
|
setError('加载数据失败,请检查后端服务是否启动')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchData()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[60vh] items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<p className="text-sm text-muted-foreground">加载中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[60vh] items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-destructive">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="mt-4 text-sm text-primary hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
重新加载
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData = usageData.map((r) => ({
|
||||||
|
day: r.day.slice(5), // MM-DD
|
||||||
|
请求量: r.count,
|
||||||
|
Input: r.input_tokens,
|
||||||
|
Output: r.output_tokens,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<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">
|
||||||
|
{/* 请求趋势 */}
|
||||||
|
<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 用量 */}
|
||||||
|
<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>
|
||||||
|
{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>
|
||||||
|
)
|
||||||
|
}
|
||||||
386
admin/src/app/(dashboard)/providers/page.tsx
Normal file
386
admin/src/app/(dashboard)/providers/page.tsx
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
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 { api } from '@/lib/api-client'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
import { formatDate, maskApiKey } from '@/lib/utils'
|
||||||
|
import type { Provider } 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 [providers, setProviders] = useState<Provider[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// 创建/编辑 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)
|
||||||
|
|
||||||
|
const fetchProviders = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await api.providers.list({ page, page_size: PAGE_SIZE })
|
||||||
|
setProviders(res.items)
|
||||||
|
setTotal(res.total)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
else setError('加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [page])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProviders()
|
||||||
|
}, [fetchProviders])
|
||||||
|
|
||||||
|
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)
|
||||||
|
fetchProviders()
|
||||||
|
} 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)
|
||||||
|
fetchProviders()
|
||||||
|
} 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">
|
||||||
|
<div />
|
||||||
|
<Button onClick={openCreateDialog}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
新建服务商
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : providers.length === 0 ? (
|
||||||
|
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>名称</TableHead>
|
||||||
|
<TableHead>显示名</TableHead>
|
||||||
|
<TableHead>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={() => 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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
245
admin/src/app/(dashboard)/relay/page.tsx
Normal file
245
admin/src/app/(dashboard)/relay/page.tsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
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 } from '@/lib/utils'
|
||||||
|
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 [tasks, setTasks] = useState<RelayTask[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const fetchTasks = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
|
||||||
|
if (statusFilter !== 'all') params.status = statusFilter
|
||||||
|
const res = await api.relay.list(params)
|
||||||
|
setTasks(res.items)
|
||||||
|
setTotal(res.total)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
else setError('加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [page, statusFilter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTasks()
|
||||||
|
}, [fetchTasks])
|
||||||
|
|
||||||
|
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 && (
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : tasks.length === 0 ? (
|
||||||
|
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
235
admin/src/app/(dashboard)/usage/page.tsx
Normal file
235
admin/src/app/(dashboard)/usage/page.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { Loader2, Zap } 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 {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { ApiRequestError } from '@/lib/api-client'
|
||||||
|
import { formatNumber } from '@/lib/utils'
|
||||||
|
import type { UsageRecord, UsageByModel } from '@/lib/types'
|
||||||
|
|
||||||
|
export default function UsagePage() {
|
||||||
|
const [days, setDays] = useState(7)
|
||||||
|
const [dailyData, setDailyData] = useState<UsageRecord[]>([])
|
||||||
|
const [modelData, setModelData] = useState<UsageByModel[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const [dailyRes, modelRes] = await Promise.allSettled([
|
||||||
|
api.usage.daily({ days }),
|
||||||
|
api.usage.byModel({ days }),
|
||||||
|
])
|
||||||
|
if (dailyRes.status === 'fulfilled') setDailyData(dailyRes.value)
|
||||||
|
else throw new Error('Failed to fetch daily usage')
|
||||||
|
if (modelRes.status === 'fulfilled') setModelData(modelRes.value)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||||
|
else setError('加载数据失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [days])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
}, [fetchData])
|
||||||
|
|
||||||
|
const lineChartData = dailyData.map((r) => ({
|
||||||
|
day: r.day.slice(5),
|
||||||
|
Input: r.input_tokens,
|
||||||
|
Output: r.output_tokens,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const barChartData = modelData.map((r) => ({
|
||||||
|
model: r.model_id,
|
||||||
|
请求量: r.count,
|
||||||
|
Input: r.input_tokens,
|
||||||
|
Output: r.output_tokens,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const totalInput = dailyData.reduce((s, r) => s + r.input_tokens, 0)
|
||||||
|
const totalOutput = dailyData.reduce((s, r) => s + r.output_tokens, 0)
|
||||||
|
const totalRequests = dailyData.reduce((s, r) => s + r.count, 0)
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[60vh] items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<p className="text-sm text-muted-foreground">加载中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[60vh] items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-destructive">{error}</p>
|
||||||
|
<button onClick={() => fetchData()} className="mt-4 text-sm text-primary hover:underline cursor-pointer">
|
||||||
|
重新加载
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 时间范围 */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 汇总统计 */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Token 用量趋势 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Zap className="h-4 w-4 text-primary" />
|
||||||
|
Token 用量趋势
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{lineChartData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
|
<LineChart data={lineChartData}>
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[320px] items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 按模型分布 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">按模型分布</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{barChartData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
|
<BarChart data={barChartData} 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>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[320px] items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
admin/src/app/globals.css
Normal file
66
admin/src/app/globals.css
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
27
admin/src/app/layout.tsx
Normal file
27
admin/src/app/layout.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
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">
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
199
admin/src/app/login/page.tsx
Normal file
199
admin/src/app/login/page.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { Lock, User, Loader2, Eye, EyeOff } 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 [showPassword, setShowPassword] = 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 })
|
||||||
|
login(res.token, res.account)
|
||||||
|
router.replace('/')
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) {
|
||||||
|
setError(err.body.message || '登录失败,请检查用户名和密码')
|
||||||
|
} 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>
|
||||||
|
|
||||||
|
{/* 记住我 */}
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
48
admin/src/components/auth-guard.tsx
Normal file
48
admin/src/components/auth-guard.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, type ReactNode } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { isAuthenticated, getAccount } from '@/lib/auth'
|
||||||
|
import type { AccountPublic } from '@/lib/types'
|
||||||
|
|
||||||
|
interface AuthGuardProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthGuard({ children }: AuthGuardProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [authorized, setAuthorized] = useState(false)
|
||||||
|
const [account, setAccount] = useState<AccountPublic | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated()) {
|
||||||
|
router.replace('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAccount(getAccount())
|
||||||
|
setAuthorized(true)
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
if (!authorized) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-screen items-center justify-center bg-background">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const [account, setAccount] = useState<AccountPublic | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const acc = getAccount()
|
||||||
|
setAccount(acc)
|
||||||
|
setLoading(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { account, loading, isAuthenticated: isAuthenticated() }
|
||||||
|
}
|
||||||
42
admin/src/components/ui/badge.tsx
Normal file
42
admin/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
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 }
|
||||||
56
admin/src/components/ui/button.tsx
Normal file
56
admin/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
'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 }
|
||||||
75
admin/src/components/ui/card.tsx
Normal file
75
admin/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
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 }
|
||||||
118
admin/src/components/ui/dialog.tsx
Normal file
118
admin/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
'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,
|
||||||
|
}
|
||||||
28
admin/src/components/ui/input.tsx
Normal file
28
admin/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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 }
|
||||||
23
admin/src/components/ui/label.tsx
Normal file
23
admin/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
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 }
|
||||||
100
admin/src/components/ui/select.tsx
Normal file
100
admin/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
'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,
|
||||||
|
}
|
||||||
30
admin/src/components/ui/separator.tsx
Normal file
30
admin/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
'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 }
|
||||||
32
admin/src/components/ui/switch.tsx
Normal file
32
admin/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
'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 }
|
||||||
119
admin/src/components/ui/table.tsx
Normal file
119
admin/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
57
admin/src/components/ui/tabs.tsx
Normal file
57
admin/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
'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 }
|
||||||
31
admin/src/components/ui/tooltip.tsx
Normal file
31
admin/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
'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 }
|
||||||
284
admin/src/lib/api-client.ts
Normal file
284
admin/src/lib/api-client.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ZCLAW SaaS Admin — 类型化 HTTP 客户端
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { getToken, logout } from './auth'
|
||||||
|
import type {
|
||||||
|
AccountPublic,
|
||||||
|
ApiError,
|
||||||
|
ConfigItem,
|
||||||
|
CreateTokenRequest,
|
||||||
|
DashboardStats,
|
||||||
|
LoginRequest,
|
||||||
|
LoginResponse,
|
||||||
|
Model,
|
||||||
|
OperationLog,
|
||||||
|
PaginatedResponse,
|
||||||
|
Provider,
|
||||||
|
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 || 'http://localhost:8080'
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API 客户端 ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
// ── 认证 ──────────────────────────────────────────────
|
||||||
|
auth: {
|
||||||
|
async login(data: LoginRequest): Promise<LoginResponse> {
|
||||||
|
return request<LoginResponse>('POST', '/api/auth/login', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async register(data: {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
email: string
|
||||||
|
display_name?: string
|
||||||
|
}): Promise<LoginResponse> {
|
||||||
|
return request<LoginResponse>('POST', '/api/auth/register', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async me(): Promise<AccountPublic> {
|
||||||
|
return request<AccountPublic>('GET', '/api/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', `/api/accounts${qs}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(id: string): Promise<AccountPublic> {
|
||||||
|
return request<AccountPublic>('GET', `/api/accounts/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
data: Partial<Pick<AccountPublic, 'display_name' | 'email' | 'role'>>,
|
||||||
|
): Promise<AccountPublic> {
|
||||||
|
return request<AccountPublic>('PATCH', `/api/accounts/${id}`, data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateStatus(
|
||||||
|
id: string,
|
||||||
|
data: { status: AccountPublic['status'] },
|
||||||
|
): Promise<void> {
|
||||||
|
return request<void>('PATCH', `/api/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', `/api/providers${qs}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>): Promise<Provider> {
|
||||||
|
return request<Provider>('POST', '/api/providers', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>,
|
||||||
|
): Promise<Provider> {
|
||||||
|
return request<Provider>('PATCH', `/api/providers/${id}`, data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
return request<void>('DELETE', `/api/providers/${id}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 模型管理 ──────────────────────────────────────────
|
||||||
|
models: {
|
||||||
|
async list(params?: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
provider_id?: string
|
||||||
|
}): Promise<PaginatedResponse<Model>> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<PaginatedResponse<Model>>('GET', `/api/models${qs}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(data: Partial<Omit<Model, 'id'>>): Promise<Model> {
|
||||||
|
return request<Model>('POST', '/api/models', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id: string, data: Partial<Omit<Model, 'id'>>): Promise<Model> {
|
||||||
|
return request<Model>('PATCH', `/api/models/${id}`, data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
return request<void>('DELETE', `/api/models/${id}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── API 密钥 ──────────────────────────────────────────
|
||||||
|
tokens: {
|
||||||
|
async list(params?: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
}): Promise<PaginatedResponse<TokenInfo>> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<PaginatedResponse<TokenInfo>>('GET', `/api/tokens${qs}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(data: CreateTokenRequest): Promise<TokenInfo> {
|
||||||
|
return request<TokenInfo>('POST', '/api/tokens', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async revoke(id: string): Promise<void> {
|
||||||
|
return request<void>('DELETE', `/api/tokens/${id}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 用量统计 ──────────────────────────────────────────
|
||||||
|
usage: {
|
||||||
|
async daily(params?: { days?: number }): Promise<UsageRecord[]> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<UsageRecord[]>('GET', `/api/usage/daily${qs}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async byModel(params?: { days?: number }): Promise<UsageByModel[]> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<UsageByModel[]>('GET', `/api/usage/by-model${qs}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 中转任务 ──────────────────────────────────────────
|
||||||
|
relay: {
|
||||||
|
async list(params?: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
status?: string
|
||||||
|
}): Promise<PaginatedResponse<RelayTask>> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<PaginatedResponse<RelayTask>>('GET', `/api/relay/tasks${qs}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(id: string): Promise<RelayTask> {
|
||||||
|
return request<RelayTask>('GET', `/api/relay/tasks/${id}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 系统配置 ──────────────────────────────────────────
|
||||||
|
config: {
|
||||||
|
async list(params?: {
|
||||||
|
category?: string
|
||||||
|
}): Promise<ConfigItem[]> {
|
||||||
|
const qs = buildQueryString(params)
|
||||||
|
return request<ConfigItem[]>('GET', `/api/config${qs}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id: string, data: { value: string | number | boolean }): Promise<ConfigItem> {
|
||||||
|
return request<ConfigItem>('PATCH', `/api/config/${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', `/api/logs${qs}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 仪表盘 ────────────────────────────────────────────
|
||||||
|
stats: {
|
||||||
|
async dashboard(): Promise<DashboardStats> {
|
||||||
|
return request<DashboardStats>('GET', '/api/stats/dashboard')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 工具函数 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
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}`
|
||||||
|
}
|
||||||
45
admin/src/lib/auth.ts
Normal file
45
admin/src/lib/auth.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取 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()
|
||||||
|
}
|
||||||
169
admin/src/lib/types.ts
Normal file
169
admin/src/lib/types.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 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
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录请求 */
|
||||||
|
export interface LoginRequest {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录响应 */
|
||||||
|
export interface LoginResponse {
|
||||||
|
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: 'openai' | 'anthropic'
|
||||||
|
enabled: boolean
|
||||||
|
rate_limit_rpm?: number
|
||||||
|
rate_limit_tpm?: number
|
||||||
|
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: 'queued' | 'processing' | 'completed' | 'failed'
|
||||||
|
priority: number
|
||||||
|
attempt_count: number
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
error_message?: string
|
||||||
|
queued_at?: string
|
||||||
|
started_at?: string
|
||||||
|
completed_at?: string
|
||||||
|
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' | 'number' | 'boolean'
|
||||||
|
current_value?: string | number | boolean
|
||||||
|
default_value?: string | number | boolean
|
||||||
|
source: 'default' | 'env' | 'db'
|
||||||
|
description?: string
|
||||||
|
requires_restart: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 操作日志 */
|
||||||
|
export interface OperationLog {
|
||||||
|
id: string
|
||||||
|
account_id: string
|
||||||
|
action: string
|
||||||
|
target_type: string
|
||||||
|
target_id: string
|
||||||
|
details?: string
|
||||||
|
ip_address?: string
|
||||||
|
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
|
||||||
|
}
|
||||||
34
admin/src/lib/utils.ts
Normal file
34
admin/src/lib/utils.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
62
admin/tailwind.config.ts
Normal file
62
admin/tailwind.config.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
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
|
||||||
21
admin/tsconfig.json
Normal file
21
admin/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ path = "src/main.rs"
|
|||||||
zclaw-types = { workspace = true }
|
zclaw-types = { workspace = true }
|
||||||
|
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
futures = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
|
|||||||
@@ -130,3 +130,39 @@ pub async fn list_operation_logs(
|
|||||||
|
|
||||||
Ok(Json(items))
|
Ok(Json(items))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/stats/dashboard — 仪表盘聚合统计 (需要 admin 权限)
|
||||||
|
pub async fn dashboard_stats(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(ctx): Extension<AuthContext>,
|
||||||
|
) -> SaasResult<Json<serde_json::Value>> {
|
||||||
|
require_admin(&ctx)?;
|
||||||
|
|
||||||
|
let total_accounts: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM accounts")
|
||||||
|
.fetch_one(&state.db).await?;
|
||||||
|
let active_accounts: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM accounts WHERE status = 'active'")
|
||||||
|
.fetch_one(&state.db).await?;
|
||||||
|
let tasks_today: (i64,) = sqlx::query_as(
|
||||||
|
"SELECT COUNT(*) FROM relay_tasks WHERE date(created_at) = date('now')"
|
||||||
|
).fetch_one(&state.db).await?;
|
||||||
|
let active_providers: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM providers WHERE enabled = 1")
|
||||||
|
.fetch_one(&state.db).await?;
|
||||||
|
let active_models: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM models WHERE enabled = 1")
|
||||||
|
.fetch_one(&state.db).await?;
|
||||||
|
let tokens_today_input: (i64,) = sqlx::query_as(
|
||||||
|
"SELECT COALESCE(SUM(input_tokens), 0) FROM usage_records WHERE date(created_at) = date('now')"
|
||||||
|
).fetch_one(&state.db).await?;
|
||||||
|
let tokens_today_output: (i64,) = sqlx::query_as(
|
||||||
|
"SELECT COALESCE(SUM(output_tokens), 0) FROM usage_records WHERE date(created_at) = date('now')"
|
||||||
|
).fetch_one(&state.db).await?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"total_accounts": total_accounts.0,
|
||||||
|
"active_accounts": active_accounts.0,
|
||||||
|
"tasks_today": tasks_today.0,
|
||||||
|
"active_providers": active_providers.0,
|
||||||
|
"active_models": active_models.0,
|
||||||
|
"tokens_today_input": tokens_today_input.0,
|
||||||
|
"tokens_today_output": tokens_today_output.0,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,4 +16,5 @@ pub fn routes() -> axum::Router<crate::state::AppState> {
|
|||||||
.route("/api/v1/tokens", post(handlers::create_token))
|
.route("/api/v1/tokens", post(handlers::create_token))
|
||||||
.route("/api/v1/tokens/{id}", delete(handlers::revoke_token))
|
.route("/api/v1/tokens/{id}", delete(handlers::revoke_token))
|
||||||
.route("/api/v1/logs/operations", get(handlers::list_operation_logs))
|
.route("/api/v1/logs/operations", get(handlers::list_operation_logs))
|
||||||
|
.route("/api/v1/stats/dashboard", get(handlers::dashboard_stats))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,7 +136,29 @@ pub async fn refresh(
|
|||||||
Ok(Json(serde_json::json!({ "token": token })))
|
Ok(Json(serde_json::json!({ "token": token })))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_role_permissions(db: &sqlx::SqlitePool, role: &str) -> SaasResult<Vec<String>> {
|
/// GET /api/v1/auth/me — 返回当前认证用户的公开信息
|
||||||
|
pub async fn me(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
axum::extract::Extension(ctx): axum::extract::Extension<AuthContext>,
|
||||||
|
) -> SaasResult<Json<AccountPublic>> {
|
||||||
|
let row: Option<(String, String, String, String, String, String, bool, String)> =
|
||||||
|
sqlx::query_as(
|
||||||
|
"SELECT id, username, email, display_name, role, status, totp_enabled, created_at
|
||||||
|
FROM accounts WHERE id = ?1"
|
||||||
|
)
|
||||||
|
.bind(&ctx.account_id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (id, username, email, display_name, role, status, totp_enabled, created_at) =
|
||||||
|
row.ok_or_else(|| SaasError::NotFound("账号不存在".into()))?;
|
||||||
|
|
||||||
|
Ok(Json(AccountPublic {
|
||||||
|
id, username, email, display_name, role, status, totp_enabled, created_at,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn get_role_permissions(db: &sqlx::SqlitePool, role: &str) -> SaasResult<Vec<String>> {
|
||||||
let row: Option<(String,)> = sqlx::query_as(
|
let row: Option<(String,)> = sqlx::query_as(
|
||||||
"SELECT permissions FROM roles WHERE id = ?1"
|
"SELECT permissions FROM roles WHERE id = ?1"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,70 @@ use crate::error::SaasError;
|
|||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use types::AuthContext;
|
use types::AuthContext;
|
||||||
|
|
||||||
|
/// 通过 API Token 验证身份
|
||||||
|
///
|
||||||
|
/// 流程: SHA-256 哈希 → 查 api_tokens 表 → 检查有效期 → 获取关联账号角色权限 → 更新 last_used_at
|
||||||
|
async fn verify_api_token(state: &AppState, raw_token: &str) -> Result<AuthContext, SaasError> {
|
||||||
|
use sha2::{Sha256, Digest};
|
||||||
|
|
||||||
|
let token_hash = hex::encode(Sha256::digest(raw_token.as_bytes()));
|
||||||
|
|
||||||
|
let row: Option<(String, Option<String>, String)> = sqlx::query_as(
|
||||||
|
"SELECT account_id, expires_at, permissions FROM api_tokens
|
||||||
|
WHERE token_hash = ?1 AND revoked_at IS NULL"
|
||||||
|
)
|
||||||
|
.bind(&token_hash)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (account_id, expires_at, permissions_json) = row
|
||||||
|
.ok_or(SaasError::Unauthorized)?;
|
||||||
|
|
||||||
|
// 检查是否过期
|
||||||
|
if let Some(ref exp) = expires_at {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
if let Ok(exp_time) = chrono::DateTime::parse_from_rfc3339(exp) {
|
||||||
|
if now >= exp_time.with_timezone(&chrono::Utc) {
|
||||||
|
return Err(SaasError::Unauthorized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询关联账号的角色
|
||||||
|
let (role,): (String,) = sqlx::query_as(
|
||||||
|
"SELECT role FROM accounts WHERE id = ?1 AND status = 'active'"
|
||||||
|
)
|
||||||
|
.bind(&account_id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(SaasError::Unauthorized)?;
|
||||||
|
|
||||||
|
// 合并 token 权限与角色权限(去重)
|
||||||
|
let role_permissions = handlers::get_role_permissions(&state.db, &role).await?;
|
||||||
|
let token_permissions: Vec<String> = serde_json::from_str(&permissions_json).unwrap_or_default();
|
||||||
|
let mut permissions = role_permissions;
|
||||||
|
for p in token_permissions {
|
||||||
|
if !permissions.contains(&p) {
|
||||||
|
permissions.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步更新 last_used_at(不阻塞请求)
|
||||||
|
let db = state.db.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
let _ = sqlx::query("UPDATE api_tokens SET last_used_at = ?1 WHERE token_hash = ?2")
|
||||||
|
.bind(&now).bind(&token_hash)
|
||||||
|
.execute(&db).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(AuthContext {
|
||||||
|
account_id,
|
||||||
|
role,
|
||||||
|
permissions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// 认证中间件: 从 JWT 或 API Token 提取身份
|
/// 认证中间件: 从 JWT 或 API Token 提取身份
|
||||||
pub async fn auth_middleware(
|
pub async fn auth_middleware(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
@@ -28,13 +92,19 @@ pub async fn auth_middleware(
|
|||||||
|
|
||||||
let result = if let Some(auth) = auth_header {
|
let result = if let Some(auth) = auth_header {
|
||||||
if let Some(token) = auth.strip_prefix("Bearer ") {
|
if let Some(token) = auth.strip_prefix("Bearer ") {
|
||||||
jwt::verify_token(token, state.jwt_secret.expose_secret())
|
if token.starts_with("zclaw_") {
|
||||||
.map(|claims| AuthContext {
|
// API Token 路径
|
||||||
account_id: claims.sub,
|
verify_api_token(&state, token).await
|
||||||
role: claims.role,
|
} else {
|
||||||
permissions: claims.permissions,
|
// JWT 路径
|
||||||
})
|
jwt::verify_token(token, state.jwt_secret.expose_secret())
|
||||||
.map_err(|_| SaasError::Unauthorized)
|
.map(|claims| AuthContext {
|
||||||
|
account_id: claims.sub,
|
||||||
|
role: claims.role,
|
||||||
|
permissions: claims.permissions,
|
||||||
|
})
|
||||||
|
.map_err(|_| SaasError::Unauthorized)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Err(SaasError::Unauthorized)
|
Err(SaasError::Unauthorized)
|
||||||
}
|
}
|
||||||
@@ -62,8 +132,9 @@ pub fn routes() -> axum::Router<AppState> {
|
|||||||
|
|
||||||
/// 需要认证的路由
|
/// 需要认证的路由
|
||||||
pub fn protected_routes() -> axum::Router<AppState> {
|
pub fn protected_routes() -> axum::Router<AppState> {
|
||||||
use axum::routing::post;
|
use axum::routing::{get, post};
|
||||||
|
|
||||||
axum::Router::new()
|
axum::Router::new()
|
||||||
.route("/api/v1/auth/refresh", post(handlers::refresh))
|
.route("/api/v1/auth/refresh", post(handlers::refresh))
|
||||||
|
.route("/api/v1/auth/me", get(handlers::me))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ pub struct SaaSConfig {
|
|||||||
pub database: DatabaseConfig,
|
pub database: DatabaseConfig,
|
||||||
pub auth: AuthConfig,
|
pub auth: AuthConfig,
|
||||||
pub relay: RelayConfig,
|
pub relay: RelayConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub rate_limit: RateLimitConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 服务器配置
|
/// 服务器配置
|
||||||
@@ -66,6 +68,29 @@ fn default_batch_window() -> u64 { 50 }
|
|||||||
fn default_retry_delay() -> u64 { 1000 }
|
fn default_retry_delay() -> u64 { 1000 }
|
||||||
fn default_max_attempts() -> u32 { 3 }
|
fn default_max_attempts() -> u32 { 3 }
|
||||||
|
|
||||||
|
/// 速率限制配置
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RateLimitConfig {
|
||||||
|
/// 每分钟最大请求数 (滑动窗口)
|
||||||
|
#[serde(default = "default_rpm")]
|
||||||
|
pub requests_per_minute: u32,
|
||||||
|
/// 突发允许的额外请求数
|
||||||
|
#[serde(default = "default_burst")]
|
||||||
|
pub burst: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_rpm() -> u32 { 60 }
|
||||||
|
fn default_burst() -> u32 { 10 }
|
||||||
|
|
||||||
|
impl Default for RateLimitConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
requests_per_minute: default_rpm(),
|
||||||
|
burst: default_burst(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for SaaSConfig {
|
impl Default for SaaSConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -73,6 +98,7 @@ impl Default for SaaSConfig {
|
|||||||
database: DatabaseConfig::default(),
|
database: DatabaseConfig::default(),
|
||||||
auth: AuthConfig::default(),
|
auth: AuthConfig::default(),
|
||||||
relay: RelayConfig::default(),
|
relay: RelayConfig::default(),
|
||||||
|
rate_limit: RateLimitConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod middleware;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ fn build_router(state: AppState) -> axum::Router {
|
|||||||
.merge(zclaw_saas::model_config::routes())
|
.merge(zclaw_saas::model_config::routes())
|
||||||
.merge(zclaw_saas::relay::routes())
|
.merge(zclaw_saas::relay::routes())
|
||||||
.merge(zclaw_saas::migration::routes())
|
.merge(zclaw_saas::migration::routes())
|
||||||
|
.layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
zclaw_saas::middleware::rate_limit_middleware,
|
||||||
|
))
|
||||||
.layer(middleware::from_fn_with_state(
|
.layer(middleware::from_fn_with_state(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
zclaw_saas::auth::auth_middleware,
|
zclaw_saas::auth::auth_middleware,
|
||||||
|
|||||||
81
crates/zclaw-saas/src/middleware.rs
Normal file
81
crates/zclaw-saas/src/middleware.rs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
//! 通用中间件
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Request, State},
|
||||||
|
http::StatusCode,
|
||||||
|
middleware::Next,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// 滑动窗口速率限制中间件
|
||||||
|
///
|
||||||
|
/// 按 account_id (从 AuthContext 提取) 做 per-minute 限流。
|
||||||
|
/// 超限时返回 429 Too Many Requests + Retry-After header。
|
||||||
|
pub async fn rate_limit_middleware(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
req: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
// 从 AuthContext 提取 account_id(由 auth_middleware 在此之前注入)
|
||||||
|
let account_id = req
|
||||||
|
.extensions()
|
||||||
|
.get::<crate::auth::types::AuthContext>()
|
||||||
|
.map(|ctx| ctx.account_id.clone());
|
||||||
|
|
||||||
|
let account_id = match account_id {
|
||||||
|
Some(id) => id,
|
||||||
|
None => return next.run(req).await,
|
||||||
|
};
|
||||||
|
|
||||||
|
let config = state.config.read().await;
|
||||||
|
let rpm = config.rate_limit.requests_per_minute as u64;
|
||||||
|
let burst = config.rate_limit.burst as u64;
|
||||||
|
let max_requests = rpm + burst;
|
||||||
|
drop(config);
|
||||||
|
|
||||||
|
let now = Instant::now();
|
||||||
|
let window_start = now - std::time::Duration::from_secs(60);
|
||||||
|
|
||||||
|
// 滑动窗口: 清理过期条目 + 计数
|
||||||
|
let current_count = {
|
||||||
|
let mut entries = state.rate_limit_entries.entry(account_id.clone()).or_default();
|
||||||
|
entries.retain(|&ts| ts > window_start);
|
||||||
|
let count = entries.len() as u64;
|
||||||
|
if count < max_requests {
|
||||||
|
entries.push(now);
|
||||||
|
0 // 未超限
|
||||||
|
} else {
|
||||||
|
count
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if current_count >= max_requests {
|
||||||
|
// 计算最早条目的过期时间作为 Retry-After
|
||||||
|
let retry_after = if let Some(mut entries) = state.rate_limit_entries.get_mut(&account_id) {
|
||||||
|
entries.sort();
|
||||||
|
let earliest = *entries.first().unwrap_or(&now);
|
||||||
|
let elapsed = now.duration_since(earliest).as_secs();
|
||||||
|
60u64.saturating_sub(elapsed)
|
||||||
|
} else {
|
||||||
|
60
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
[
|
||||||
|
("Retry-After", retry_after.to_string()),
|
||||||
|
("Content-Type", "application/json".to_string()),
|
||||||
|
],
|
||||||
|
axum::Json(serde_json::json!({
|
||||||
|
"error": "RATE_LIMITED",
|
||||||
|
"message": format!("请求过于频繁,请在 {} 秒后重试", retry_after),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
next.run(req).await
|
||||||
|
}
|
||||||
@@ -96,7 +96,15 @@ pub async fn chat_completions(
|
|||||||
None, "success", None,
|
None, "success", None,
|
||||||
).await?;
|
).await?;
|
||||||
|
|
||||||
Ok((StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "text/event-stream")], body).into_response())
|
// 流式响应: 直接转发 axum::body::Body
|
||||||
|
let response = axum::response::Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(axum::http::header::CONTENT_TYPE, "text/event-stream")
|
||||||
|
.header("Cache-Control", "no-cache")
|
||||||
|
.header("Connection", "keep-alive")
|
||||||
|
.body(body)
|
||||||
|
.unwrap();
|
||||||
|
Ok(response)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
model_service::record_usage(
|
model_service::record_usage(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
use crate::error::{SaasError, SaasResult};
|
use crate::error::{SaasError, SaasResult};
|
||||||
use super::types::*;
|
use super::types::*;
|
||||||
|
use futures::StreamExt;
|
||||||
|
|
||||||
// ============ Relay Task Management ============
|
// ============ Relay Task Management ============
|
||||||
|
|
||||||
@@ -127,7 +128,7 @@ pub async fn execute_relay(
|
|||||||
let _start = std::time::Instant::now();
|
let _start = std::time::Instant::now();
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(if stream { 300 } else { 30 }))
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| SaasError::Internal(format!("HTTP 客户端构建失败: {}", e)))?;
|
.map_err(|e| SaasError::Internal(format!("HTTP 客户端构建失败: {}", e)))?;
|
||||||
let mut req_builder = client.post(&url)
|
let mut req_builder = client.post(&url)
|
||||||
@@ -143,7 +144,11 @@ pub async fn execute_relay(
|
|||||||
match result {
|
match result {
|
||||||
Ok(resp) if resp.status().is_success() => {
|
Ok(resp) if resp.status().is_success() => {
|
||||||
if stream {
|
if stream {
|
||||||
let body = resp.text().await.unwrap_or_default();
|
// 真实 SSE 流式: 使用 bytes_stream 而非 text().await 缓冲
|
||||||
|
let stream = resp.bytes_stream()
|
||||||
|
.map(|result| result.map_err(std::io::Error::other));
|
||||||
|
let body = axum::body::Body::from_stream(stream);
|
||||||
|
// 流式模式下无法提取 token usage,标记为 completed (usage=0)
|
||||||
update_task_status(db, task_id, "completed", None, None, None).await?;
|
update_task_status(db, task_id, "completed", None, None, None).await?;
|
||||||
Ok(RelayResponse::Sse(body))
|
Ok(RelayResponse::Sse(body))
|
||||||
} else {
|
} else {
|
||||||
@@ -173,7 +178,7 @@ pub async fn execute_relay(
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum RelayResponse {
|
pub enum RelayResponse {
|
||||||
Json(String),
|
Json(String),
|
||||||
Sse(String),
|
Sse(axum::body::Body),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Helpers ============
|
// ============ Helpers ============
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use crate::config::SaaSConfig;
|
use crate::config::SaaSConfig;
|
||||||
|
|
||||||
@@ -14,6 +15,8 @@ pub struct AppState {
|
|||||||
pub config: Arc<RwLock<SaaSConfig>>,
|
pub config: Arc<RwLock<SaaSConfig>>,
|
||||||
/// JWT 密钥
|
/// JWT 密钥
|
||||||
pub jwt_secret: secrecy::SecretString,
|
pub jwt_secret: secrecy::SecretString,
|
||||||
|
/// 速率限制: account_id → 请求时间戳列表
|
||||||
|
pub rate_limit_entries: Arc<dashmap::DashMap<String, Vec<Instant>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@@ -23,6 +26,7 @@ impl AppState {
|
|||||||
db,
|
db,
|
||||||
config: Arc::new(RwLock::new(config)),
|
config: Arc::new(RwLock::new(config)),
|
||||||
jwt_secret,
|
jwt_secret,
|
||||||
|
rate_limit_entries: Arc::new(dashmap::DashMap::new()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
295
desktop/src/components/SaaS/SaaSLogin.tsx
Normal file
295
desktop/src/components/SaaS/SaaSLogin.tsx
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { LogIn, UserPlus, Globe, Eye, EyeOff, Loader2, AlertCircle, Mail } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SaaSLoginProps {
|
||||||
|
onLogin: (saasUrl: string, username: string, password: string) => Promise<void>;
|
||||||
|
onRegister?: (saasUrl: string, username: string, email: string, password: string, displayName?: string) => Promise<void>;
|
||||||
|
initialUrl?: string;
|
||||||
|
isLoggingIn?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SaaSLogin({ onLogin, onRegister, initialUrl, isLoggingIn, error }: SaaSLoginProps) {
|
||||||
|
const [serverUrl, setServerUrl] = useState(initialUrl || '');
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [displayName, setDisplayName] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [isRegister, setIsRegister] = useState(false);
|
||||||
|
const [localError, setLocalError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLocalError(null);
|
||||||
|
|
||||||
|
if (!serverUrl.trim()) {
|
||||||
|
setLocalError('请输入服务器地址');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!username.trim()) {
|
||||||
|
setLocalError('请输入用户名');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
setLocalError('请输入密码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRegister) {
|
||||||
|
if (!email.trim()) {
|
||||||
|
setLocalError('请输入邮箱地址');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
|
||||||
|
setLocalError('邮箱格式不正确');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 6) {
|
||||||
|
setLocalError('密码长度至少 6 个字符');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setLocalError('两次输入的密码不一致');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onRegister) {
|
||||||
|
try {
|
||||||
|
await onRegister(
|
||||||
|
serverUrl.trim(),
|
||||||
|
username.trim(),
|
||||||
|
email.trim(),
|
||||||
|
password,
|
||||||
|
displayName.trim() || undefined,
|
||||||
|
);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
setLocalError(message);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onLogin(serverUrl.trim(), username.trim(), password);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
setLocalError(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayError = error || localError;
|
||||||
|
|
||||||
|
const handleTabSwitch = (register: boolean) => {
|
||||||
|
setIsRegister(register);
|
||||||
|
setLocalError(null);
|
||||||
|
setConfirmPassword('');
|
||||||
|
setEmail('');
|
||||||
|
setDisplayName('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-1">
|
||||||
|
{isRegister ? '注册 SaaS 账号' : '登录 SaaS 平台'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 mb-5">
|
||||||
|
{isRegister
|
||||||
|
? '创建账号以使用 ZCLAW 云端服务'
|
||||||
|
: '连接到 ZCLAW SaaS 平台,解锁云端能力'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Tab Switcher */}
|
||||||
|
<div className="flex mb-5 border-b border-gray-200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleTabSwitch(false)}
|
||||||
|
className={`px-4 py-2.5 text-sm font-medium cursor-pointer transition-colors border-b-2 ${
|
||||||
|
!isRegister
|
||||||
|
? 'border-emerald-500 text-emerald-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<LogIn className="w-3.5 h-3.5" />
|
||||||
|
登录
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{onRegister && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleTabSwitch(true)}
|
||||||
|
className={`px-4 py-2.5 text-sm font-medium cursor-pointer transition-colors border-b-2 ${
|
||||||
|
isRegister
|
||||||
|
? 'border-emerald-500 text-emerald-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<UserPlus className="w-3.5 h-3.5" />
|
||||||
|
注册
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* Server URL */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="saas-url" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||||
|
服务器地址
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
id="saas-url"
|
||||||
|
type="url"
|
||||||
|
value={serverUrl}
|
||||||
|
onChange={(e) => setServerUrl(e.target.value)}
|
||||||
|
placeholder="https://saas.zclaw.com"
|
||||||
|
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
|
||||||
|
disabled={isLoggingIn}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Username */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="saas-username" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||||
|
用户名
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="saas-username"
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="your-username"
|
||||||
|
autoComplete="username"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
|
||||||
|
disabled={isLoggingIn}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email (Register only) */}
|
||||||
|
{isRegister && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="saas-email" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||||
|
邮箱
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
id="saas-email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
autoComplete="email"
|
||||||
|
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
|
||||||
|
disabled={isLoggingIn}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Display Name (Register only, optional) */}
|
||||||
|
{isRegister && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="saas-display-name" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||||
|
显示名称 <span className="text-gray-400 font-normal">(可选)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="saas-display-name"
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
placeholder="ZCLAW User"
|
||||||
|
autoComplete="name"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
|
||||||
|
disabled={isLoggingIn}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="saas-password" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||||
|
密码
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="saas-password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder={isRegister ? '至少 6 个字符' : 'Enter password'}
|
||||||
|
autoComplete={isRegister ? 'new-password' : 'current-password'}
|
||||||
|
className="w-full px-3 pr-10 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
|
||||||
|
disabled={isLoggingIn}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 cursor-pointer"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm Password (Register only) */}
|
||||||
|
{isRegister && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="saas-confirm-password" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||||
|
确认密码
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="saas-confirm-password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="Re-enter password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
|
||||||
|
disabled={isLoggingIn}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Display */}
|
||||||
|
{displayError && (
|
||||||
|
<div className="flex items-start gap-2 text-sm text-red-600 bg-red-50 rounded-lg p-3">
|
||||||
|
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||||
|
<span>{displayError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoggingIn}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||||
|
>
|
||||||
|
{isLoggingIn ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
{isRegister ? '注册中...' : '登录中...'}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{isRegister ? (
|
||||||
|
<><UserPlus className="w-4 h-4" />注册</>
|
||||||
|
) : (
|
||||||
|
<><LogIn className="w-4 h-4" />登录</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
desktop/src/components/SaaS/SaaSSettings.tsx
Normal file
158
desktop/src/components/SaaS/SaaSSettings.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useSaaSStore } from '../../store/saasStore';
|
||||||
|
import { SaaSLogin } from './SaaSLogin';
|
||||||
|
import { SaaSStatus } from './SaaSStatus';
|
||||||
|
import { Cloud, Info } from 'lucide-react';
|
||||||
|
|
||||||
|
export function SaaSSettings() {
|
||||||
|
const isLoggedIn = useSaaSStore((s) => s.isLoggedIn);
|
||||||
|
const account = useSaaSStore((s) => s.account);
|
||||||
|
const saasUrl = useSaaSStore((s) => s.saasUrl);
|
||||||
|
const connectionMode = useSaaSStore((s) => s.connectionMode);
|
||||||
|
const login = useSaaSStore((s) => s.login);
|
||||||
|
const register = useSaaSStore((s) => s.register);
|
||||||
|
const logout = useSaaSStore((s) => s.logout);
|
||||||
|
|
||||||
|
const [showLogin, setShowLogin] = useState(!isLoggedIn);
|
||||||
|
const [loginError, setLoginError] = useState<string | null>(null);
|
||||||
|
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||||
|
|
||||||
|
const handleLogin = async (url: string, username: string, password: string) => {
|
||||||
|
setIsLoggingIn(true);
|
||||||
|
setLoginError(null);
|
||||||
|
try {
|
||||||
|
await login(url, username, password);
|
||||||
|
setShowLogin(false);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : '登录失败';
|
||||||
|
setLoginError(message);
|
||||||
|
} finally {
|
||||||
|
setIsLoggingIn(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegister = async (
|
||||||
|
url: string,
|
||||||
|
username: string,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
displayName?: string,
|
||||||
|
) => {
|
||||||
|
setIsLoggingIn(true);
|
||||||
|
setLoginError(null);
|
||||||
|
try {
|
||||||
|
await register(url, username, email, password, displayName);
|
||||||
|
// register auto-logs in, no need for separate login call
|
||||||
|
setShowLogin(false);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : '注册失败';
|
||||||
|
setLoginError(message);
|
||||||
|
} finally {
|
||||||
|
setIsLoggingIn(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
setShowLogin(true);
|
||||||
|
setLoginError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-emerald-100 flex items-center justify-center">
|
||||||
|
<Cloud className="w-5 h-5 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">SaaS 账号</h1>
|
||||||
|
<p className="text-sm text-gray-500">管理 ZCLAW 云端平台连接</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connection mode info */}
|
||||||
|
<div className="flex items-start gap-2 text-sm text-gray-500 bg-blue-50 rounded-lg border border-blue-100 p-3 mb-5">
|
||||||
|
<Info className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" />
|
||||||
|
<span>
|
||||||
|
当前模式: <strong className="text-gray-700">{connectionMode === 'saas' ? 'SaaS 云端' : connectionMode === 'gateway' ? 'Gateway' : '本地 Tauri'}</strong>。
|
||||||
|
{connectionMode !== 'saas' && '连接 SaaS 平台可解锁云端同步、团队协作等高级功能。'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login form or status display */}
|
||||||
|
{!showLogin ? (
|
||||||
|
<SaaSStatus
|
||||||
|
isLoggedIn={isLoggedIn}
|
||||||
|
account={account}
|
||||||
|
saasUrl={saasUrl}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
onLogin={() => setShowLogin(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SaaSLogin
|
||||||
|
onLogin={handleLogin}
|
||||||
|
onRegister={handleRegister}
|
||||||
|
initialUrl={saasUrl}
|
||||||
|
isLoggingIn={isLoggingIn}
|
||||||
|
error={loginError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Features list when logged in */}
|
||||||
|
{isLoggedIn && !showLogin && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3">
|
||||||
|
云端功能
|
||||||
|
</h2>
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<CloudFeatureRow
|
||||||
|
name="云端同步"
|
||||||
|
description="对话记录和配置自动同步到云端"
|
||||||
|
status="active"
|
||||||
|
/>
|
||||||
|
<CloudFeatureRow
|
||||||
|
name="团队协作"
|
||||||
|
description="与团队成员共享 Agent 和技能"
|
||||||
|
status={account?.role === 'admin' || account?.role === 'pro' ? 'active' : 'inactive'}
|
||||||
|
/>
|
||||||
|
<CloudFeatureRow
|
||||||
|
name="高级分析"
|
||||||
|
description="使用统计和用量分析仪表板"
|
||||||
|
status={account?.role === 'admin' || account?.role === 'pro' ? 'active' : 'inactive'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CloudFeatureRow({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
status: 'active' | 'inactive';
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between py-1">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900">{name}</div>
|
||||||
|
<div className="text-xs text-gray-500">{description}</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||||
|
status === 'active'
|
||||||
|
? 'bg-emerald-100 text-emerald-700'
|
||||||
|
: 'bg-gray-100 text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{status === 'active' ? '可用' : '需要订阅'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
desktop/src/components/SaaS/SaaSStatus.tsx
Normal file
172
desktop/src/components/SaaS/SaaSStatus.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { SaaSAccountInfo, SaaSModelInfo } from '../../lib/saas-client';
|
||||||
|
import { Cloud, CloudOff, LogOut, RefreshCw, Cpu, CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||||
|
import { useSaaSStore } from '../../store/saasStore';
|
||||||
|
|
||||||
|
interface SaaSStatusProps {
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
account: SaaSAccountInfo | null;
|
||||||
|
saasUrl: string;
|
||||||
|
onLogout: () => void;
|
||||||
|
onLogin: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SaaSStatus({ isLoggedIn, account, saasUrl, onLogout, onLogin }: SaaSStatusProps) {
|
||||||
|
const availableModels = useSaaSStore((s) => s.availableModels);
|
||||||
|
const fetchAvailableModels = useSaaSStore((s) => s.fetchAvailableModels);
|
||||||
|
|
||||||
|
const [checkingHealth, setCheckingHealth] = useState(false);
|
||||||
|
const [healthOk, setHealthOk] = useState<boolean | null>(null);
|
||||||
|
const [showDetails, setShowDetails] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoggedIn) {
|
||||||
|
fetchAvailableModels();
|
||||||
|
}
|
||||||
|
}, [isLoggedIn, fetchAvailableModels]);
|
||||||
|
|
||||||
|
async function checkHealth() {
|
||||||
|
setCheckingHealth(true);
|
||||||
|
setHealthOk(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${saasUrl}/api/health`, {
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
setHealthOk(response.ok);
|
||||||
|
} catch {
|
||||||
|
setHealthOk(false);
|
||||||
|
} finally {
|
||||||
|
setCheckingHealth(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoggedIn && account) {
|
||||||
|
const displayName = account.display_name || account.username;
|
||||||
|
const initial = displayName[0].toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Main status bar */}
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-emerald-200 bg-emerald-50 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-full bg-emerald-500 flex items-center justify-center text-white font-semibold text-sm flex-shrink-0">
|
||||||
|
{initial}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-medium text-gray-900 text-sm">{displayName}</div>
|
||||||
|
<div className="text-xs text-gray-500 truncate">{saasUrl}</div>
|
||||||
|
<span className="inline-block mt-0.5 text-xs px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-700 font-medium">
|
||||||
|
{account.role}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-1.5 text-emerald-600 text-xs">
|
||||||
|
<Cloud className="w-3.5 h-3.5" />
|
||||||
|
<span>已连接</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDetails(!showDetails)}
|
||||||
|
className="px-2 py-1.5 text-xs text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onLogout}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<LogOut className="w-3.5 h-3.5" />
|
||||||
|
登出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expandable details */}
|
||||||
|
{showDetails && (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm space-y-4">
|
||||||
|
{/* Health Check */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-700">服务健康</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{healthOk === null && !checkingHealth && (
|
||||||
|
<span className="text-xs text-gray-400">未检测</span>
|
||||||
|
)}
|
||||||
|
{checkingHealth && <Loader2 className="w-4 h-4 animate-spin text-gray-400" />}
|
||||||
|
{healthOk === true && (
|
||||||
|
<div className="flex items-center gap-1 text-green-600 text-sm">
|
||||||
|
<CheckCircle className="w-4 h-4" />正常
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{healthOk === false && (
|
||||||
|
<div className="flex items-center gap-1 text-red-500 text-sm">
|
||||||
|
<XCircle className="w-4 h-4" />不可达
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={checkHealth}
|
||||||
|
disabled={checkingHealth}
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600 cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 ${checkingHealth ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Available Models */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Cpu className="w-4 h-4 text-gray-500" />
|
||||||
|
<span className="text-sm font-medium text-gray-700">
|
||||||
|
可用模型 ({availableModels.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{availableModels.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400 pl-6">
|
||||||
|
暂无可用模型,请确认管理员已在后台配置 Provider 和 Model
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1 pl-6">
|
||||||
|
{availableModels.map((model) => (
|
||||||
|
<ModelRow key={model.id} model={model} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CloudOff className="w-5 h-5 text-gray-400" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900 text-sm">SaaS 平台</div>
|
||||||
|
<div className="text-xs text-gray-500">未连接</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onLogin}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-white bg-emerald-500 rounded-lg hover:bg-emerald-600 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<Cloud className="w-3.5 h-3.5" />
|
||||||
|
连接
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelRow({ model }: { model: SaaSModelInfo }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between py-1.5 px-3 bg-gray-50 rounded-lg">
|
||||||
|
<span className="text-sm text-gray-800">{model.alias || model.id}</span>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||||
|
{model.supports_streaming && <span>流式</span>}
|
||||||
|
{model.supports_vision && <span>视觉</span>}
|
||||||
|
<span className="font-mono">{(model.context_window / 1000).toFixed(0)}k</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Heart,
|
Heart,
|
||||||
Key,
|
Key,
|
||||||
Database,
|
Database,
|
||||||
|
Cloud,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { silentErrorHandler } from '../../lib/error-utils';
|
import { silentErrorHandler } from '../../lib/error-utils';
|
||||||
import { General } from './General';
|
import { General } from './General';
|
||||||
@@ -37,6 +38,7 @@ import { TaskList } from '../TaskList';
|
|||||||
import { HeartbeatConfig } from '../HeartbeatConfig';
|
import { HeartbeatConfig } from '../HeartbeatConfig';
|
||||||
import { SecureStorage } from './SecureStorage';
|
import { SecureStorage } from './SecureStorage';
|
||||||
import { VikingPanel } from '../VikingPanel';
|
import { VikingPanel } from '../VikingPanel';
|
||||||
|
import { SaaSSettings } from '../SaaS/SaaSSettings';
|
||||||
|
|
||||||
interface SettingsLayoutProps {
|
interface SettingsLayoutProps {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
@@ -54,6 +56,7 @@ type SettingsPage =
|
|||||||
| 'privacy'
|
| 'privacy'
|
||||||
| 'security'
|
| 'security'
|
||||||
| 'storage'
|
| 'storage'
|
||||||
|
| 'saas'
|
||||||
| 'viking'
|
| 'viking'
|
||||||
| 'audit'
|
| 'audit'
|
||||||
| 'tasks'
|
| 'tasks'
|
||||||
@@ -72,6 +75,7 @@ const menuItems: { id: SettingsPage; label: string; icon: React.ReactNode }[] =
|
|||||||
{ id: 'workspace', label: '工作区', icon: <FolderOpen className="w-4 h-4" /> },
|
{ id: 'workspace', label: '工作区', icon: <FolderOpen className="w-4 h-4" /> },
|
||||||
{ id: 'privacy', label: '数据与隐私', icon: <Shield className="w-4 h-4" /> },
|
{ id: 'privacy', label: '数据与隐私', icon: <Shield className="w-4 h-4" /> },
|
||||||
{ id: 'storage', label: '安全存储', icon: <Key className="w-4 h-4" /> },
|
{ id: 'storage', label: '安全存储', icon: <Key className="w-4 h-4" /> },
|
||||||
|
{ id: 'saas', label: 'SaaS 平台', icon: <Cloud className="w-4 h-4" /> },
|
||||||
{ id: 'viking', label: '语义记忆', icon: <Database className="w-4 h-4" /> },
|
{ id: 'viking', label: '语义记忆', icon: <Database className="w-4 h-4" /> },
|
||||||
{ id: 'security', label: '安全状态', icon: <Shield className="w-4 h-4" /> },
|
{ id: 'security', label: '安全状态', icon: <Shield className="w-4 h-4" /> },
|
||||||
{ id: 'audit', label: '审计日志', icon: <ClipboardList className="w-4 h-4" /> },
|
{ id: 'audit', label: '审计日志', icon: <ClipboardList className="w-4 h-4" /> },
|
||||||
@@ -97,6 +101,7 @@ export function SettingsLayout({ onBack }: SettingsLayoutProps) {
|
|||||||
case 'workspace': return <Workspace />;
|
case 'workspace': return <Workspace />;
|
||||||
case 'privacy': return <Privacy />;
|
case 'privacy': return <Privacy />;
|
||||||
case 'storage': return <SecureStorage />;
|
case 'storage': return <SecureStorage />;
|
||||||
|
case 'saas': return <SaaSSettings />;
|
||||||
case 'security': return (
|
case 'security': return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { DEFAULT_MODEL_ID, DEFAULT_OPENAI_BASE_URL } from '../constants/models';
|
|||||||
|
|
||||||
// === Types ===
|
// === Types ===
|
||||||
|
|
||||||
export type LLMProvider = 'openai' | 'volcengine' | 'gateway' | 'mock';
|
export type LLMProvider = 'openai' | 'volcengine' | 'gateway' | 'saas' | 'mock';
|
||||||
|
|
||||||
export interface LLMConfig {
|
export interface LLMConfig {
|
||||||
provider: LLMProvider;
|
provider: LLMProvider;
|
||||||
@@ -77,6 +77,12 @@ const DEFAULT_CONFIGS: Record<LLMProvider, LLMConfig> = {
|
|||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
},
|
},
|
||||||
|
saas: {
|
||||||
|
provider: 'saas',
|
||||||
|
maxTokens: 4096,
|
||||||
|
temperature: 0.7,
|
||||||
|
timeout: 300000, // 5 min for streaming
|
||||||
|
},
|
||||||
mock: {
|
mock: {
|
||||||
provider: 'mock',
|
provider: 'mock',
|
||||||
maxTokens: 100,
|
maxTokens: 100,
|
||||||
@@ -412,6 +418,85 @@ class GatewayLLMAdapter implements LLMServiceAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === SaaS Relay Adapter (via SaaS backend) ===
|
||||||
|
|
||||||
|
class SaasLLMAdapter implements LLMServiceAdapter {
|
||||||
|
private config: LLMConfig;
|
||||||
|
|
||||||
|
constructor(config: LLMConfig) {
|
||||||
|
this.config = { ...DEFAULT_CONFIGS.saas, ...config };
|
||||||
|
}
|
||||||
|
|
||||||
|
async complete(messages: LLMMessage[], options?: Partial<LLMConfig>): Promise<LLMResponse> {
|
||||||
|
const config = { ...this.config, ...options };
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Dynamic import to avoid circular dependency
|
||||||
|
const { useSaaSStore } = await import('../store/saasStore');
|
||||||
|
const { saasUrl, authToken } = useSaaSStore.getState();
|
||||||
|
|
||||||
|
if (!saasUrl || !authToken) {
|
||||||
|
throw new Error('[SaaS] 未登录 SaaS 平台,请先在设置中登录');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic import of SaaSClient singleton
|
||||||
|
const { saasClient } = await import('./saas-client');
|
||||||
|
saasClient.setBaseUrl(saasUrl);
|
||||||
|
saasClient.setToken(authToken);
|
||||||
|
|
||||||
|
const openaiBody = {
|
||||||
|
model: config.model || 'default',
|
||||||
|
messages,
|
||||||
|
max_tokens: config.maxTokens || 4096,
|
||||||
|
temperature: config.temperature ?? 0.7,
|
||||||
|
stream: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await saasClient.chatCompletion(
|
||||||
|
openaiBody,
|
||||||
|
AbortSignal.timeout(config.timeout || 300000),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({
|
||||||
|
error: 'unknown',
|
||||||
|
message: `SaaS relay 请求失败 (${response.status})`,
|
||||||
|
}));
|
||||||
|
throw new Error(
|
||||||
|
`[SaaS] ${errorData.message || errorData.error || `请求失败: ${response.status}`}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const latencyMs = Date.now() - startTime;
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: data.choices?.[0]?.message?.content || '',
|
||||||
|
tokensUsed: {
|
||||||
|
input: data.usage?.prompt_tokens || 0,
|
||||||
|
output: data.usage?.completion_tokens || 0,
|
||||||
|
},
|
||||||
|
model: data.model,
|
||||||
|
latencyMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
isAvailable(): boolean {
|
||||||
|
// Check synchronously via localStorage for availability check
|
||||||
|
// Dynamic import would be async, so we use a simpler check
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('zclaw-saas-token');
|
||||||
|
return !!token;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getProvider(): LLMProvider {
|
||||||
|
return 'saas';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// === Factory ===
|
// === Factory ===
|
||||||
|
|
||||||
let cachedAdapter: LLMServiceAdapter | null = null;
|
let cachedAdapter: LLMServiceAdapter | null = null;
|
||||||
@@ -427,6 +512,8 @@ export function createLLMAdapter(config?: Partial<LLMConfig>): LLMServiceAdapter
|
|||||||
return new VolcengineLLMAdapter(finalConfig);
|
return new VolcengineLLMAdapter(finalConfig);
|
||||||
case 'gateway':
|
case 'gateway':
|
||||||
return new GatewayLLMAdapter(finalConfig);
|
return new GatewayLLMAdapter(finalConfig);
|
||||||
|
case 'saas':
|
||||||
|
return new SaasLLMAdapter(finalConfig);
|
||||||
case 'mock':
|
case 'mock':
|
||||||
default:
|
default:
|
||||||
return new MockLLMAdapter(finalConfig);
|
return new MockLLMAdapter(finalConfig);
|
||||||
|
|||||||
361
desktop/src/lib/saas-client.ts
Normal file
361
desktop/src/lib/saas-client.ts
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
/**
|
||||||
|
* ZCLAW SaaS Client
|
||||||
|
*
|
||||||
|
* Typed HTTP client for the ZCLAW SaaS backend API (v1).
|
||||||
|
* Handles authentication, model listing, chat relay, and config management.
|
||||||
|
*
|
||||||
|
* API base path: /api/v1/...
|
||||||
|
* Auth: Bearer token in Authorization header
|
||||||
|
*/
|
||||||
|
|
||||||
|
// === Storage Keys ===
|
||||||
|
|
||||||
|
const SAASTOKEN_KEY = 'zclaw-saas-token';
|
||||||
|
const SAASURL_KEY = 'zclaw-saas-url';
|
||||||
|
const SAASACCOUNT_KEY = 'zclaw-saas-account';
|
||||||
|
const SAASMODE_KEY = 'zclaw-connection-mode';
|
||||||
|
|
||||||
|
// === Types ===
|
||||||
|
|
||||||
|
/** Public account info returned by the SaaS backend */
|
||||||
|
export interface SaaSAccountInfo {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
display_name: string;
|
||||||
|
role: string;
|
||||||
|
status: string;
|
||||||
|
totp_enabled: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A model available for relay through the SaaS backend */
|
||||||
|
export interface SaaSModelInfo {
|
||||||
|
id: string;
|
||||||
|
provider_id: string;
|
||||||
|
alias: string;
|
||||||
|
context_window: number;
|
||||||
|
max_output_tokens: number;
|
||||||
|
supports_streaming: boolean;
|
||||||
|
supports_vision: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Config item from the SaaS backend */
|
||||||
|
export interface SaaSConfigItem {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SaaS API error shape */
|
||||||
|
export interface SaaSErrorResponse {
|
||||||
|
error: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Login response from POST /api/v1/auth/login */
|
||||||
|
export interface SaaSLoginResponse {
|
||||||
|
token: string;
|
||||||
|
account: SaaSAccountInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh response from POST /api/v1/auth/refresh */
|
||||||
|
interface SaaSRefreshResponse {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Error Class ===
|
||||||
|
|
||||||
|
export class SaaSApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
public readonly code: string,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'SaaSApiError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Session Persistence ===
|
||||||
|
|
||||||
|
export interface SaaSSession {
|
||||||
|
token: string;
|
||||||
|
account: SaaSAccountInfo | null;
|
||||||
|
saasUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a persisted SaaS session from localStorage.
|
||||||
|
* Returns null if no valid session exists.
|
||||||
|
*/
|
||||||
|
export function loadSaaSSession(): SaaSSession | null {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem(SAASTOKEN_KEY);
|
||||||
|
const saasUrl = localStorage.getItem(SAASURL_KEY);
|
||||||
|
const accountRaw = localStorage.getItem(SAASACCOUNT_KEY);
|
||||||
|
|
||||||
|
if (!token || !saasUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const account: SaaSAccountInfo | null = accountRaw
|
||||||
|
? (JSON.parse(accountRaw) as SaaSAccountInfo)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { token, account, saasUrl };
|
||||||
|
} catch {
|
||||||
|
// Corrupted data - clear all
|
||||||
|
clearSaaSSession();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a SaaS session to localStorage.
|
||||||
|
*/
|
||||||
|
export function saveSaaSSession(session: SaaSSession): void {
|
||||||
|
localStorage.setItem(SAASTOKEN_KEY, session.token);
|
||||||
|
localStorage.setItem(SAASURL_KEY, session.saasUrl);
|
||||||
|
if (session.account) {
|
||||||
|
localStorage.setItem(SAASACCOUNT_KEY, JSON.stringify(session.account));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the persisted SaaS session from localStorage.
|
||||||
|
*/
|
||||||
|
export function clearSaaSSession(): void {
|
||||||
|
localStorage.removeItem(SAASTOKEN_KEY);
|
||||||
|
localStorage.removeItem(SAASURL_KEY);
|
||||||
|
localStorage.removeItem(SAASACCOUNT_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the connection mode to localStorage.
|
||||||
|
*/
|
||||||
|
export function saveConnectionMode(mode: string): void {
|
||||||
|
localStorage.setItem(SAASMODE_KEY, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the connection mode from localStorage.
|
||||||
|
* Returns null if not set.
|
||||||
|
*/
|
||||||
|
export function loadConnectionMode(): string | null {
|
||||||
|
return localStorage.getItem(SAASMODE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Client Implementation ===
|
||||||
|
|
||||||
|
export class SaaSClient {
|
||||||
|
private baseUrl: string;
|
||||||
|
private token: string | null = null;
|
||||||
|
|
||||||
|
constructor(baseUrl: string) {
|
||||||
|
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the base URL (e.g. when user changes server address) */
|
||||||
|
setBaseUrl(url: string): void {
|
||||||
|
this.baseUrl = url.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the current base URL */
|
||||||
|
getBaseUrl(): string {
|
||||||
|
return this.baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set or clear the auth token */
|
||||||
|
setToken(token: string | null): void {
|
||||||
|
this.token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if the client has an auth token */
|
||||||
|
isAuthenticated(): boolean {
|
||||||
|
return !!this.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Core HTTP ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make an authenticated request and parse the JSON response.
|
||||||
|
* Throws SaaSApiError on non-ok responses.
|
||||||
|
*/
|
||||||
|
private async request<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
timeoutMs = 15000,
|
||||||
|
): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
if (this.token) {
|
||||||
|
headers['Authorization'] = `Bearer ${this.token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle 401 specially - caller may want to trigger re-auth
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new SaaSApiError(401, 'UNAUTHORIZED', '认证已过期,请重新登录');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorBody = (await response.json().catch(() => null)) as SaaSErrorResponse | null;
|
||||||
|
throw new SaaSApiError(
|
||||||
|
response.status,
|
||||||
|
errorBody?.error || 'UNKNOWN',
|
||||||
|
errorBody?.message || `请求失败 (${response.status})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 204 No Content
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Health ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick connectivity check against the SaaS backend.
|
||||||
|
*/
|
||||||
|
async healthCheck(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.request<unknown>('GET', '/api/health', undefined, 5000);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auth Endpoints ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login with username and password.
|
||||||
|
* Auto-sets the client token on success.
|
||||||
|
*/
|
||||||
|
async login(username: string, password: string): Promise<SaaSLoginResponse> {
|
||||||
|
const data = await this.request<SaaSLoginResponse>(
|
||||||
|
'POST', '/api/v1/auth/login', { username, password },
|
||||||
|
);
|
||||||
|
this.token = data.token;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new account.
|
||||||
|
* Auto-sets the client token on success.
|
||||||
|
*/
|
||||||
|
async register(data: {
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
display_name?: string;
|
||||||
|
}): Promise<SaaSLoginResponse> {
|
||||||
|
const result = await this.request<SaaSLoginResponse>(
|
||||||
|
'POST', '/api/v1/auth/register', data,
|
||||||
|
);
|
||||||
|
this.token = result.token;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current authenticated user's account info.
|
||||||
|
*/
|
||||||
|
async me(): Promise<SaaSAccountInfo> {
|
||||||
|
return this.request<SaaSAccountInfo>('GET', '/api/v1/auth/me');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the current token.
|
||||||
|
* Auto-updates the client token on success.
|
||||||
|
*/
|
||||||
|
async refreshToken(): Promise<string> {
|
||||||
|
const data = await this.request<SaaSRefreshResponse>('POST', '/api/v1/auth/refresh');
|
||||||
|
this.token = data.token;
|
||||||
|
return data.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Model Endpoints ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List available models for relay.
|
||||||
|
* Only returns enabled models from enabled providers.
|
||||||
|
*/
|
||||||
|
async listModels(): Promise<SaaSModelInfo[]> {
|
||||||
|
return this.request<SaaSModelInfo[]>('GET', '/api/v1/relay/models');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Chat Relay ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a chat completion request via the SaaS relay.
|
||||||
|
* Returns the raw Response object to support both streaming and non-streaming.
|
||||||
|
*
|
||||||
|
* The caller is responsible for:
|
||||||
|
* - Reading the response body (JSON or SSE stream)
|
||||||
|
* - Handling errors from the response
|
||||||
|
*/
|
||||||
|
async chatCompletion(
|
||||||
|
body: unknown,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<Response> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
if (this.token) {
|
||||||
|
headers['Authorization'] = `Bearer ${this.token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use caller's AbortSignal if provided, otherwise default 5min timeout
|
||||||
|
const effectiveSignal = signal ?? AbortSignal.timeout(300_000);
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${this.baseUrl}/api/v1/relay/chat/completions`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: effectiveSignal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Config Endpoints ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List config items, optionally filtered by category.
|
||||||
|
*/
|
||||||
|
async listConfig(category?: string): Promise<SaaSConfigItem[]> {
|
||||||
|
const qs = category ? `?category=${encodeURIComponent(category)}` : '';
|
||||||
|
return this.request<SaaSConfigItem[]>('GET', `/api/v1/config/items${qs}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Singleton ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global SaaS client singleton.
|
||||||
|
* Initialized with a default URL; the URL and token are updated on login.
|
||||||
|
*/
|
||||||
|
export const saasClient = new SaaSClient('https://saas.zclaw.com');
|
||||||
@@ -213,6 +213,37 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
|
|||||||
try {
|
try {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
|
|
||||||
|
// === SaaS Relay Mode ===
|
||||||
|
// Check connection mode from localStorage (set by saasStore).
|
||||||
|
// This takes priority over Tauri/Gateway when the user has selected SaaS mode.
|
||||||
|
const savedMode = localStorage.getItem('zclaw-connection-mode');
|
||||||
|
if (savedMode === 'saas') {
|
||||||
|
const { loadSaaSSession, saasClient } = await import('../lib/saas-client');
|
||||||
|
const session = loadSaaSSession();
|
||||||
|
|
||||||
|
if (!session || !session.token || !session.saasUrl) {
|
||||||
|
throw new Error('SaaS 模式未登录,请先在设置中登录 SaaS 平台');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug('Using SaaS relay mode:', session.saasUrl);
|
||||||
|
|
||||||
|
// Configure the singleton client
|
||||||
|
saasClient.setBaseUrl(session.saasUrl);
|
||||||
|
saasClient.setToken(session.token);
|
||||||
|
|
||||||
|
// Health check via GET /api/v1/relay/models
|
||||||
|
try {
|
||||||
|
await saasClient.listModels();
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
throw new Error(`SaaS 平台连接失败: ${errMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ connectionState: 'connected', gatewayVersion: 'saas-relay' });
|
||||||
|
log.debug('Connected to SaaS relay');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// === Internal Kernel Mode (Tauri) ===
|
// === Internal Kernel Mode (Tauri) ===
|
||||||
// Check at RUNTIME, not at module load time, to ensure __TAURI_INTERNALS__ is available
|
// Check at RUNTIME, not at module load time, to ensure __TAURI_INTERNALS__ is available
|
||||||
const useInternalKernel = isTauriRuntime();
|
const useInternalKernel = isTauriRuntime();
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ export type { SessionStore, SessionStateSlice, SessionActionsSlice, Session, Ses
|
|||||||
export { useMemoryGraphStore } from './memoryGraphStore';
|
export { useMemoryGraphStore } from './memoryGraphStore';
|
||||||
export type { MemoryGraphStore, GraphNode, GraphEdge, GraphFilter, GraphLayout } from './memoryGraphStore';
|
export type { MemoryGraphStore, GraphNode, GraphEdge, GraphFilter, GraphLayout } from './memoryGraphStore';
|
||||||
|
|
||||||
|
// === SaaS Store ===
|
||||||
|
export { useSaaSStore } from './saasStore';
|
||||||
|
export type { SaaSStore, SaaSStateSlice, SaaSActionsSlice, ConnectionMode } from './saasStore';
|
||||||
|
|
||||||
|
|
||||||
// === Browser Hand Store ===
|
// === Browser Hand Store ===
|
||||||
export { useBrowserHandStore } from './browserHandStore';
|
export { useBrowserHandStore } from './browserHandStore';
|
||||||
|
|||||||
293
desktop/src/store/saasStore.ts
Normal file
293
desktop/src/store/saasStore.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
/**
|
||||||
|
* SaaS Store - SaaS Platform Connection State Management
|
||||||
|
*
|
||||||
|
* Manages SaaS login state, account info, connection mode,
|
||||||
|
* and available models. Persists auth state to localStorage
|
||||||
|
* via saas-client helpers.
|
||||||
|
*
|
||||||
|
* Connection modes:
|
||||||
|
* - 'tauri': Local Kernel via Tauri (default)
|
||||||
|
* - 'gateway': External Gateway via WebSocket
|
||||||
|
* - 'saas': SaaS backend relay
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import {
|
||||||
|
saasClient,
|
||||||
|
SaaSApiError,
|
||||||
|
loadSaaSSession,
|
||||||
|
saveSaaSSession,
|
||||||
|
clearSaaSSession,
|
||||||
|
saveConnectionMode,
|
||||||
|
loadConnectionMode,
|
||||||
|
type SaaSAccountInfo,
|
||||||
|
type SaaSModelInfo,
|
||||||
|
type SaaSLoginResponse,
|
||||||
|
} from '../lib/saas-client';
|
||||||
|
import { createLogger } from '../lib/logger';
|
||||||
|
|
||||||
|
const log = createLogger('SaaSStore');
|
||||||
|
|
||||||
|
// === Types ===
|
||||||
|
|
||||||
|
export type ConnectionMode = 'tauri' | 'gateway' | 'saas';
|
||||||
|
|
||||||
|
export interface SaaSStateSlice {
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
account: SaaSAccountInfo | null;
|
||||||
|
saasUrl: string;
|
||||||
|
authToken: string | null;
|
||||||
|
connectionMode: ConnectionMode;
|
||||||
|
availableModels: SaaSModelInfo[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaaSActionsSlice {
|
||||||
|
login: (saasUrl: string, username: string, password: string) => Promise<void>;
|
||||||
|
register: (saasUrl: string, username: string, email: string, password: string, displayName?: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
setConnectionMode: (mode: ConnectionMode) => void;
|
||||||
|
fetchAvailableModels: () => Promise<void>;
|
||||||
|
clearError: () => void;
|
||||||
|
restoreSession: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SaaSStore = SaaSStateSlice & SaaSActionsSlice;
|
||||||
|
|
||||||
|
// === Constants ===
|
||||||
|
|
||||||
|
const DEFAULT_SAAS_URL = 'https://saas.zclaw.com';
|
||||||
|
|
||||||
|
// === Helpers ===
|
||||||
|
|
||||||
|
/** Determine the initial connection mode from persisted state */
|
||||||
|
function resolveInitialMode(session: ReturnType<typeof loadSaaSSession>): ConnectionMode {
|
||||||
|
const persistedMode = loadConnectionMode();
|
||||||
|
if (persistedMode === 'tauri' || persistedMode === 'gateway' || persistedMode === 'saas') {
|
||||||
|
return persistedMode;
|
||||||
|
}
|
||||||
|
return session ? 'saas' : 'tauri';
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Store Implementation ===
|
||||||
|
|
||||||
|
export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||||
|
// Restore session from localStorage on init
|
||||||
|
const session = loadSaaSSession();
|
||||||
|
const initialMode = resolveInitialMode(session);
|
||||||
|
|
||||||
|
// If session exists, configure the singleton client
|
||||||
|
if (session) {
|
||||||
|
saasClient.setBaseUrl(session.saasUrl);
|
||||||
|
saasClient.setToken(session.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// === Initial State ===
|
||||||
|
isLoggedIn: session !== null,
|
||||||
|
account: session?.account ?? null,
|
||||||
|
saasUrl: session?.saasUrl ?? DEFAULT_SAAS_URL,
|
||||||
|
authToken: session?.token ?? null,
|
||||||
|
connectionMode: initialMode,
|
||||||
|
availableModels: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
// === Actions ===
|
||||||
|
|
||||||
|
login: async (saasUrl: string, username: string, password: string) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const trimmedUrl = saasUrl.trim();
|
||||||
|
const trimmedUsername = username.trim();
|
||||||
|
|
||||||
|
if (!trimmedUrl) {
|
||||||
|
throw new Error('请输入服务器地址');
|
||||||
|
}
|
||||||
|
if (!trimmedUsername) {
|
||||||
|
throw new Error('请输入用户名');
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
throw new Error('请输入密码');
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedUrl = trimmedUrl.replace(/\/+$/, '');
|
||||||
|
|
||||||
|
// Configure singleton client and attempt login
|
||||||
|
saasClient.setBaseUrl(normalizedUrl);
|
||||||
|
const loginData: SaaSLoginResponse = await saasClient.login(trimmedUsername, password);
|
||||||
|
|
||||||
|
// Persist session
|
||||||
|
const sessionData = {
|
||||||
|
token: loginData.token,
|
||||||
|
account: loginData.account,
|
||||||
|
saasUrl: normalizedUrl,
|
||||||
|
};
|
||||||
|
saveSaaSSession(sessionData);
|
||||||
|
saveConnectionMode('saas');
|
||||||
|
|
||||||
|
set({
|
||||||
|
isLoggedIn: true,
|
||||||
|
account: loginData.account,
|
||||||
|
saasUrl: normalizedUrl,
|
||||||
|
authToken: loginData.token,
|
||||||
|
connectionMode: 'saas',
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch available models in background (non-blocking)
|
||||||
|
get().fetchAvailableModels().catch((err: unknown) => {
|
||||||
|
log.warn('Failed to fetch models after login:', err);
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof SaaSApiError
|
||||||
|
? err.message
|
||||||
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: String(err);
|
||||||
|
|
||||||
|
const isNetworkError = message.includes('Failed to fetch')
|
||||||
|
|| message.includes('NetworkError')
|
||||||
|
|| message.includes('ECONNREFUSED')
|
||||||
|
|| message.includes('timeout');
|
||||||
|
|
||||||
|
const userMessage = isNetworkError
|
||||||
|
? `无法连接到 SaaS 服务器: ${get().saasUrl}`
|
||||||
|
: message;
|
||||||
|
|
||||||
|
set({ isLoading: false, error: userMessage });
|
||||||
|
throw new Error(userMessage);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
register: async (saasUrl: string, username: string, email: string, password: string, displayName?: string) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const trimmedUrl = saasUrl.trim();
|
||||||
|
if (!trimmedUrl) {
|
||||||
|
throw new Error('请输入服务器地址');
|
||||||
|
}
|
||||||
|
if (!username.trim()) {
|
||||||
|
throw new Error('请输入用户名');
|
||||||
|
}
|
||||||
|
if (!email.trim()) {
|
||||||
|
throw new Error('请输入邮箱');
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
throw new Error('请输入密码');
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedUrl = trimmedUrl.replace(/\/+$/, '');
|
||||||
|
|
||||||
|
saasClient.setBaseUrl(normalizedUrl);
|
||||||
|
const registerData: SaaSLoginResponse = await saasClient.register({
|
||||||
|
username: username.trim(),
|
||||||
|
email: email.trim(),
|
||||||
|
password,
|
||||||
|
display_name: displayName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionData = {
|
||||||
|
token: registerData.token,
|
||||||
|
account: registerData.account,
|
||||||
|
saasUrl: normalizedUrl,
|
||||||
|
};
|
||||||
|
saveSaaSSession(sessionData);
|
||||||
|
saveConnectionMode('saas');
|
||||||
|
|
||||||
|
set({
|
||||||
|
isLoggedIn: true,
|
||||||
|
account: registerData.account,
|
||||||
|
saasUrl: normalizedUrl,
|
||||||
|
authToken: registerData.token,
|
||||||
|
connectionMode: 'saas',
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
get().fetchAvailableModels().catch((err: unknown) => {
|
||||||
|
log.warn('Failed to fetch models after register:', err);
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof SaaSApiError
|
||||||
|
? err.message
|
||||||
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: String(err);
|
||||||
|
|
||||||
|
set({ isLoading: false, error: message });
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: () => {
|
||||||
|
saasClient.setToken(null);
|
||||||
|
clearSaaSSession();
|
||||||
|
saveConnectionMode('tauri');
|
||||||
|
|
||||||
|
set({
|
||||||
|
isLoggedIn: false,
|
||||||
|
account: null,
|
||||||
|
authToken: null,
|
||||||
|
connectionMode: 'tauri',
|
||||||
|
availableModels: [],
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setConnectionMode: (mode: ConnectionMode) => {
|
||||||
|
const { isLoggedIn } = get();
|
||||||
|
|
||||||
|
// Cannot switch to SaaS mode if not logged in
|
||||||
|
if (mode === 'saas' && !isLoggedIn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveConnectionMode(mode);
|
||||||
|
set({ connectionMode: mode });
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchAvailableModels: async () => {
|
||||||
|
const { isLoggedIn, authToken, saasUrl } = get();
|
||||||
|
|
||||||
|
if (!isLoggedIn || !authToken) {
|
||||||
|
set({ availableModels: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
saasClient.setBaseUrl(saasUrl);
|
||||||
|
saasClient.setToken(authToken);
|
||||||
|
const models = await saasClient.listModels();
|
||||||
|
set({ availableModels: models });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
log.warn('Failed to fetch available models:', err);
|
||||||
|
// Do not set error state - model fetch failure is non-critical
|
||||||
|
set({ availableModels: [] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => {
|
||||||
|
set({ error: null });
|
||||||
|
},
|
||||||
|
|
||||||
|
restoreSession: () => {
|
||||||
|
const restored = loadSaaSSession();
|
||||||
|
if (restored) {
|
||||||
|
saasClient.setBaseUrl(restored.saasUrl);
|
||||||
|
saasClient.setToken(restored.token);
|
||||||
|
set({
|
||||||
|
isLoggedIn: true,
|
||||||
|
account: restored.account,
|
||||||
|
saasUrl: restored.saasUrl,
|
||||||
|
authToken: restored.token,
|
||||||
|
connectionMode: loadConnectionMode() === 'saas' ? 'saas' : 'tauri',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user