feat: 增强SaaS后端功能与安全性
refactor: 重构数据库连接使用PostgreSQL替代SQLite feat(auth): 增加JWT验证的audience和issuer检查 feat(crypto): 添加AES-256-GCM字段加密支持 feat(api): 集成utoipa实现OpenAPI文档 fix(admin): 修复配置项表单验证逻辑 style: 统一代码格式与类型定义 docs: 更新技术栈文档说明PostgreSQL
This commit is contained in:
2
admin/.gitignore
vendored
2
admin/.gitignore
vendored
@@ -1,2 +1,4 @@
|
||||
.next/
|
||||
node_modules/
|
||||
.env.local
|
||||
.env*.local
|
||||
|
||||
@@ -1,4 +1,44 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {}
|
||||
const nextConfig = {
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/(.*)',
|
||||
headers: [
|
||||
{
|
||||
key: 'X-Frame-Options',
|
||||
value: 'DENY',
|
||||
},
|
||||
{
|
||||
key: 'X-Content-Type-Options',
|
||||
value: 'nosniff',
|
||||
},
|
||||
{
|
||||
key: 'Referrer-Policy',
|
||||
value: 'strict-origin-when-cross-origin',
|
||||
},
|
||||
{
|
||||
key: 'Content-Security-Policy',
|
||||
value: [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-eval' 'unsafe-inline'",
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"font-src 'self' https://fonts.gstatic.com",
|
||||
"img-src 'self' data: blob:",
|
||||
"connect-src 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
].join('; '),
|
||||
},
|
||||
{
|
||||
key: 'Permissions-Policy',
|
||||
value: 'camera=(), microphone=(), geolocation=()',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
|
||||
@@ -11,10 +11,10 @@
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.484.0",
|
||||
@@ -22,6 +22,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"recharts": "^2.15.3",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
14
admin/pnpm-lock.yaml
generated
14
admin/pnpm-lock.yaml
generated
@@ -47,6 +47,9 @@ importers:
|
||||
recharts:
|
||||
specifier: ^2.15.3
|
||||
version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
sonner:
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
tailwind-merge:
|
||||
specifier: ^3.0.2
|
||||
version: 3.5.0
|
||||
@@ -1063,6 +1066,12 @@ packages:
|
||||
scheduler@0.23.2:
|
||||
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
|
||||
|
||||
sonner@2.0.7:
|
||||
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2052,6 +2061,11 @@ snapshots:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
|
||||
sonner@2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
streamsearch@1.1.0: {}
|
||||
|
||||
@@ -68,6 +68,13 @@ export default function AccountsPage() {
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
// 搜索 debounce: 输入后 300ms 再触发请求
|
||||
const [debouncedSearchState, setDebouncedSearchState] = useState('')
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedSearchState(search), 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [search])
|
||||
const [roleFilter, setRoleFilter] = useState<string>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -87,7 +94,7 @@ export default function AccountsPage() {
|
||||
setError('')
|
||||
try {
|
||||
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
|
||||
if (search.trim()) params.search = search.trim()
|
||||
if (debouncedSearchState.trim()) params.search = debouncedSearchState.trim()
|
||||
if (roleFilter !== 'all') params.role = roleFilter
|
||||
if (statusFilter !== 'all') params.status = statusFilter
|
||||
|
||||
@@ -103,7 +110,7 @@ export default function AccountsPage() {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, search, roleFilter, statusFilter])
|
||||
}, [page, debouncedSearchState, roleFilter, statusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts()
|
||||
|
||||
@@ -88,6 +88,19 @@ export default function ConfigPage() {
|
||||
|
||||
async function handleSave() {
|
||||
if (!editTarget) return
|
||||
// 表单验证
|
||||
if (editValue.trim() === '') {
|
||||
setError('配置值不能为空')
|
||||
return
|
||||
}
|
||||
if (editTarget.value_type === 'number' && isNaN(Number(editValue))) {
|
||||
setError('请输入有效的数字')
|
||||
return
|
||||
}
|
||||
if (editTarget.value_type === 'boolean' && editValue !== 'true' && editValue !== 'false') {
|
||||
setError('布尔值只能为 true 或 false')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
let parsedValue: string | number | boolean = editValue
|
||||
@@ -96,7 +109,7 @@ export default function ConfigPage() {
|
||||
} else if (editTarget.value_type === 'boolean') {
|
||||
parsedValue = editValue === 'true'
|
||||
}
|
||||
await api.config.update(editTarget.id, { value: parsedValue })
|
||||
await api.config.update(editTarget.id, { current_value: parsedValue })
|
||||
setEditTarget(null)
|
||||
fetchConfigs(activeTab)
|
||||
} catch (err) {
|
||||
|
||||
125
admin/src/app/(dashboard)/devices/page.tsx
Normal file
125
admin/src/app/(dashboard)/devices/page.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Monitor, Loader2, RefreshCw } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { ApiRequestError } from '@/lib/api-client'
|
||||
import type { DeviceInfo } from '@/lib/types'
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const now = Date.now()
|
||||
const then = new Date(dateStr).getTime()
|
||||
const diffMs = now - then
|
||||
const diffMin = Math.floor(diffMs / 60000)
|
||||
const diffHour = Math.floor(diffMs / 3600000)
|
||||
const diffDay = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (diffMin < 1) return '刚刚'
|
||||
if (diffMin < 60) return `${diffMin} 分钟前`
|
||||
if (diffHour < 24) return `${diffHour} 小时前`
|
||||
return `${diffDay} 天前`
|
||||
}
|
||||
|
||||
function isOnline(lastSeen: string): boolean {
|
||||
return Date.now() - new Date(lastSeen).getTime() < 5 * 60 * 1000
|
||||
}
|
||||
|
||||
export default function DevicesPage() {
|
||||
const [devices, setDevices] = useState<DeviceInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function fetchDevices() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.devices.list()
|
||||
setDevices(res)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
else setError('加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchDevices() }, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-foreground">设备管理</h2>
|
||||
<button
|
||||
onClick={fetchDevices}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 rounded-md border border-border px-3 py-1.5 text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && !devices.length ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : devices.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Monitor className="h-10 w-10 mb-3" />
|
||||
<p className="text-sm">暂无已注册设备</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>设备名称</TableHead>
|
||||
<TableHead>平台</TableHead>
|
||||
<TableHead>版本</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>最后活跃</TableHead>
|
||||
<TableHead>注册时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{devices.map((d) => (
|
||||
<TableRow key={d.id}>
|
||||
<TableCell className="font-medium">
|
||||
{d.device_name || d.device_id}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{d.platform || 'unknown'}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{d.app_version || '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={isOnline(d.last_seen_at) ? 'success' : 'outline'}>
|
||||
{isOnline(d.last_seen_at) ? '在线' : '离线'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{formatRelativeTime(d.last_seen_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs">
|
||||
{new Date(d.created_at).toLocaleString('zh-CN')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { useState, useEffect, type ReactNode } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import {
|
||||
@@ -17,46 +17,71 @@ import {
|
||||
ChevronLeft,
|
||||
Menu,
|
||||
Bell,
|
||||
UserCog,
|
||||
ShieldCheck,
|
||||
Monitor,
|
||||
} 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 },
|
||||
{ href: '/', label: '仪表盘', icon: LayoutDashboard, permission: null },
|
||||
{ href: '/accounts', label: '账号管理', icon: Users, permission: 'account:admin' },
|
||||
{ href: '/providers', label: '服务商', icon: Server, permission: 'model:admin' },
|
||||
{ href: '/models', label: '模型管理', icon: Cpu, permission: 'model:admin' },
|
||||
{ href: '/api-keys', label: 'API 密钥', icon: Key, permission: null },
|
||||
{ href: '/usage', label: '用量统计', icon: BarChart3, permission: null },
|
||||
{ href: '/relay', label: '中转任务', icon: ArrowLeftRight, permission: 'relay:admin' },
|
||||
{ href: '/config', label: '系统配置', icon: Settings, permission: 'admin:full' },
|
||||
{ href: '/logs', label: '操作日志', icon: FileText, permission: 'admin:full' },
|
||||
{ href: '/profile', label: '个人设置', icon: UserCog, permission: null },
|
||||
{ href: '/security', label: '安全设置', icon: ShieldCheck, permission: null },
|
||||
{ href: '/devices', label: '设备管理', icon: Monitor, permission: null },
|
||||
]
|
||||
|
||||
function Sidebar({
|
||||
collapsed,
|
||||
onToggle,
|
||||
mobileOpen,
|
||||
onMobileClose,
|
||||
}: {
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
mobileOpen: boolean
|
||||
onMobileClose: () => void
|
||||
}) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const { account } = useAuth()
|
||||
|
||||
// 路由变化时关闭移动端菜单
|
||||
useEffect(() => {
|
||||
onMobileClose()
|
||||
}, [pathname, onMobileClose])
|
||||
|
||||
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',
|
||||
<>
|
||||
{/* 移动端 overlay */}
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
onClick={onMobileClose}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed left-0 top-0 z-50 flex h-screen flex-col border-r border-border bg-card transition-all duration-300',
|
||||
collapsed ? 'w-16' : 'w-64',
|
||||
'lg:z-40',
|
||||
mobileOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0',
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex h-14 items-center border-b border-border px-4">
|
||||
<Link href="/" className="flex items-center gap-2 cursor-pointer">
|
||||
@@ -75,7 +100,15 @@ function Sidebar({
|
||||
{/* 导航 */}
|
||||
<nav className="flex-1 overflow-y-auto scrollbar-thin py-2 px-2">
|
||||
<ul className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
{navItems
|
||||
.filter((item) => {
|
||||
if (!item.permission) return true
|
||||
if (!account) return false
|
||||
// super_admin 拥有所有权限
|
||||
if (account.role === 'super_admin') return true
|
||||
return account.permissions?.includes(item.permission) ?? false
|
||||
})
|
||||
.map((item) => {
|
||||
const isActive =
|
||||
item.href === '/'
|
||||
? pathname === '/'
|
||||
@@ -119,6 +152,19 @@ function Sidebar({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 折叠时显示退出按钮 */}
|
||||
{collapsed && (
|
||||
<div className="border-t border-border p-2">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center justify-center rounded-md px-3 py-2 text-muted-foreground hover:bg-muted hover:text-destructive transition-colors duration-200 cursor-pointer"
|
||||
title="退出登录"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 用户信息 */}
|
||||
{!collapsed && (
|
||||
<div className="border-t border-border p-3">
|
||||
@@ -145,10 +191,11 @@ function Sidebar({
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Header() {
|
||||
function Header({ children }: { children?: ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
const currentNav = navItems.find(
|
||||
(item) =>
|
||||
@@ -160,7 +207,7 @@ function Header() {
|
||||
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 />
|
||||
{children}
|
||||
|
||||
{/* 页面标题 */}
|
||||
<h1 className="text-lg font-semibold text-foreground">
|
||||
@@ -180,10 +227,10 @@ function Header() {
|
||||
)
|
||||
}
|
||||
|
||||
function MobileMenuButton() {
|
||||
// Placeholder for mobile menu toggle
|
||||
function MobileMenuButton({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
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" />
|
||||
@@ -191,28 +238,68 @@ function MobileMenuButton() {
|
||||
)
|
||||
}
|
||||
|
||||
/** 路由级权限守卫:隐藏导航项但用户直接访问 URL 时拦截 */
|
||||
function PageGuard({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const { account } = useAuth()
|
||||
|
||||
const matchedNav = navItems.find((item) =>
|
||||
item.href === '/' ? pathname === '/' : pathname.startsWith(item.href),
|
||||
)
|
||||
|
||||
if (matchedNav?.permission && account) {
|
||||
if (account.role !== 'super_admin' && !(account.permissions?.includes(matchedNav.permission) ?? false)) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="text-center space-y-3">
|
||||
<p className="text-lg font-medium text-muted-foreground">权限不足</p>
|
||||
<p className="text-sm text-muted-foreground">您没有访问「{matchedNav.label}」的权限</p>
|
||||
<button
|
||||
onClick={() => router.replace('/')}
|
||||
className="text-sm text-primary hover:underline cursor-pointer"
|
||||
>
|
||||
返回仪表盘
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<div className="flex min-h-screen">
|
||||
<PageGuard>
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
mobileOpen={mobileOpen}
|
||||
onMobileClose={() => setMobileOpen(false)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 flex-col transition-all duration-300',
|
||||
sidebarCollapsed ? 'ml-16' : 'ml-64',
|
||||
'ml-0 lg:transition-all',
|
||||
sidebarCollapsed ? 'lg:ml-16' : 'lg:ml-64',
|
||||
)}
|
||||
>
|
||||
<Header />
|
||||
<Header>
|
||||
<MobileMenuButton onClick={() => setMobileOpen(true)} />
|
||||
</Header>
|
||||
<main className="flex-1 overflow-auto p-6 scrollbar-thin">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</PageGuard>
|
||||
</AuthGuard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -108,8 +108,8 @@ export default function ModelsPage() {
|
||||
|
||||
const fetchProviders = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.providers.list({ page: 1, page_size: 100 })
|
||||
setProviders(res.items)
|
||||
const res = await api.providers.list()
|
||||
setProviders(res)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ import { api } from '@/lib/api-client'
|
||||
import { formatNumber, formatDate } from '@/lib/utils'
|
||||
import type {
|
||||
DashboardStats,
|
||||
UsageRecord,
|
||||
UsageStats,
|
||||
OperationLog,
|
||||
} from '@/lib/types'
|
||||
|
||||
@@ -87,7 +87,7 @@ function StatusBadge({ status }: { status: string }) {
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const [usageData, setUsageData] = useState<UsageRecord[]>([])
|
||||
const [usageStats, setUsageStats] = useState<UsageStats | null>(null)
|
||||
const [recentLogs, setRecentLogs] = useState<OperationLog[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
@@ -97,15 +97,17 @@ export default function DashboardPage() {
|
||||
try {
|
||||
const [statsRes, usageRes, logsRes] = await Promise.allSettled([
|
||||
api.stats.dashboard(),
|
||||
api.usage.daily({ days: 30 }),
|
||||
api.usage.get(),
|
||||
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('加载数据失败,请检查后端服务是否启动')
|
||||
if (usageRes.status === 'fulfilled') setUsageStats(usageRes.value)
|
||||
if (logsRes.status === 'fulfilled') setRecentLogs(logsRes.value)
|
||||
|
||||
if (statsRes.status === 'rejected' && usageRes.status === 'rejected' && logsRes.status === 'rejected') {
|
||||
setError('加载数据失败,请检查后端服务是否启动')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -140,9 +142,9 @@ export default function DashboardPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const chartData = usageData.map((r) => ({
|
||||
day: r.day.slice(5), // MM-DD
|
||||
请求量: r.count,
|
||||
const chartData = (usageStats?.by_day ?? []).map((r) => ({
|
||||
day: r.date.slice(5), // MM-DD
|
||||
请求量: r.request_count,
|
||||
Input: r.input_tokens,
|
||||
Output: r.output_tokens,
|
||||
}))
|
||||
|
||||
154
admin/src/app/(dashboard)/profile/page.tsx
Normal file
154
admin/src/app/(dashboard)/profile/page.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Lock, Loader2, Eye, EyeOff, Check } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { ApiRequestError } from '@/lib/api-client'
|
||||
|
||||
export default function ProfilePage() {
|
||||
const [oldPassword, setOldPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [showOld, setShowOld] = useState(false)
|
||||
const [showNew, setShowNew] = useState(false)
|
||||
const [showConfirm, setShowConfirm] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setSuccess('')
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setError('新密码至少 8 个字符')
|
||||
return
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('两次输入的新密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.auth.changePassword({ old_password: oldPassword, new_password: newPassword })
|
||||
setSuccess('密码修改成功')
|
||||
setOldPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) {
|
||||
setError(err.body.message || '修改失败')
|
||||
} else {
|
||||
setError('网络错误,请稍后重试')
|
||||
}
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-lg">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Lock className="h-5 w-5" />
|
||||
修改密码
|
||||
</CardTitle>
|
||||
<CardDescription>修改您的登录密码。修改后需要重新登录。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="old-password">当前密码</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="old-password"
|
||||
type={showOld ? 'text' : 'password'}
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
placeholder="请输入当前密码"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOld(!showOld)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
>
|
||||
{showOld ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password">新密码</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="new-password"
|
||||
type={showNew ? 'text' : 'password'}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="至少 8 个字符"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNew(!showNew)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
>
|
||||
{showNew ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password">确认新密码</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type={showConfirm ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="再次输入新密码"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirm(!showConfirm)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
>
|
||||
{showConfirm ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="rounded-md bg-emerald-500/10 border border-emerald-500/20 px-4 py-3 text-sm text-emerald-500 flex items-center gap-2">
|
||||
<Check className="h-4 w-4" />
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={saving || !oldPassword || !newPassword || !confirmPassword}>
|
||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
修改密码
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { ApiRequestError } from '@/lib/api-client'
|
||||
import { formatDate, maskApiKey } from '@/lib/utils'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import type { Provider } from '@/lib/types'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
@@ -49,7 +49,6 @@ interface ProviderForm {
|
||||
display_name: string
|
||||
base_url: string
|
||||
api_protocol: 'openai' | 'anthropic'
|
||||
api_key: string
|
||||
enabled: boolean
|
||||
rate_limit_rpm: string
|
||||
rate_limit_tpm: string
|
||||
@@ -60,7 +59,6 @@ const emptyForm: ProviderForm = {
|
||||
display_name: '',
|
||||
base_url: '',
|
||||
api_protocol: 'openai',
|
||||
api_key: '',
|
||||
enabled: true,
|
||||
rate_limit_rpm: '',
|
||||
rate_limit_tpm: '',
|
||||
@@ -117,7 +115,6 @@ export default function ProvidersPage() {
|
||||
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() || '',
|
||||
@@ -134,7 +131,6 @@ export default function ProvidersPage() {
|
||||
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,
|
||||
@@ -202,7 +198,6 @@ export default function ProvidersPage() {
|
||||
<TableHead>显示名</TableHead>
|
||||
<TableHead>Base URL</TableHead>
|
||||
<TableHead>协议</TableHead>
|
||||
<TableHead>API Key</TableHead>
|
||||
<TableHead>启用</TableHead>
|
||||
<TableHead>RPM 限制</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
@@ -222,9 +217,6 @@ export default function ProvidersPage() {
|
||||
{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 ? '是' : '否'}
|
||||
@@ -316,15 +308,6 @@ export default function ProvidersPage() {
|
||||
</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}
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
Search,
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
RotateCcw,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -55,6 +55,7 @@ export default function RelayPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [retryingId, setRetryingId] = useState<string | null>(null)
|
||||
|
||||
const fetchTasks = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -83,6 +84,20 @@ export default function RelayPage() {
|
||||
setExpandedId((prev) => (prev === id ? null : id))
|
||||
}
|
||||
|
||||
async function handleRetry(taskId: string, e: React.MouseEvent) {
|
||||
e.stopPropagation()
|
||||
setRetryingId(taskId)
|
||||
try {
|
||||
await api.relay.retry(taskId)
|
||||
fetchTasks()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
else setError('重试失败')
|
||||
} finally {
|
||||
setRetryingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 筛选 */}
|
||||
@@ -131,6 +146,7 @@ export default function RelayPage() {
|
||||
<TableHead>Output Tokens</TableHead>
|
||||
<TableHead>错误信息</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -169,10 +185,27 @@ export default function RelayPage() {
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{formatDate(task.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{task.status === 'failed' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => handleRetry(task.id, e)}
|
||||
disabled={retryingId === task.id}
|
||||
title="重试"
|
||||
>
|
||||
{retryingId === task.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expandedId === task.id && (
|
||||
<TableRow key={`${task.id}-detail`}>
|
||||
<TableCell colSpan={10} className="bg-muted/20 px-8 py-4">
|
||||
<TableCell colSpan={11} 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>
|
||||
|
||||
203
admin/src/app/(dashboard)/security/page.tsx
Normal file
203
admin/src/app/(dashboard)/security/page.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ShieldCheck, Loader2, Eye, EyeOff, QrCode, Key, AlertTriangle } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { useAuth } from '@/components/auth-guard'
|
||||
import { ApiRequestError } from '@/lib/api-client'
|
||||
|
||||
export default function SecurityPage() {
|
||||
const { account } = useAuth()
|
||||
const totpEnabled = account?.totp_enabled ?? false
|
||||
|
||||
// Setup state
|
||||
const [step, setStep] = useState<'idle' | 'verify' | 'done'>('idle')
|
||||
const [otpauthUri, setOtpauthUri] = useState('')
|
||||
const [secret, setSecret] = useState('')
|
||||
const [verifyCode, setVerifyCode] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Disable state
|
||||
const [disablePassword, setDisablePassword] = useState('')
|
||||
const [showDisablePassword, setShowDisablePassword] = useState(false)
|
||||
const [disabling, setDisabling] = useState(false)
|
||||
|
||||
async function handleSetup() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.auth.totpSetup()
|
||||
setOtpauthUri(res.otpauth_uri)
|
||||
setSecret(res.secret)
|
||||
setStep('verify')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message || '获取密钥失败')
|
||||
else setError('网络错误')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVerify() {
|
||||
if (verifyCode.length !== 6) {
|
||||
setError('请输入 6 位验证码')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.auth.totpVerify({ code: verifyCode })
|
||||
setStep('done')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message || '验证失败')
|
||||
else setError('网络错误')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisable() {
|
||||
if (!disablePassword) {
|
||||
setError('请输入密码以确认禁用')
|
||||
return
|
||||
}
|
||||
setDisabling(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.auth.totpDisable({ password: disablePassword })
|
||||
setDisablePassword('')
|
||||
window.location.reload()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message || '禁用失败')
|
||||
else setError('网络错误')
|
||||
} finally {
|
||||
setDisabling(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-lg space-y-6">
|
||||
{/* TOTP 状态 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
双因素认证 (TOTP)
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
使用 Google Authenticator 等应用生成一次性验证码,增强账号安全。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="text-sm text-muted-foreground">当前状态:</span>
|
||||
<Badge variant={totpEnabled ? 'success' : 'secondary'}>
|
||||
{totpEnabled ? '已启用' : '未启用'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive mb-4">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 未启用: 设置流程 */}
|
||||
{!totpEnabled && step === 'idle' && (
|
||||
<Button onClick={handleSetup} disabled={loading}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
<Key className="mr-2 h-4 w-4" />
|
||||
启用双因素认证
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!totpEnabled && step === 'verify' && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-border p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<QrCode className="h-4 w-4" />
|
||||
步骤 1: 扫描二维码或手动输入密钥
|
||||
</div>
|
||||
<div className="bg-muted rounded-md p-3 font-mono text-xs break-all">
|
||||
{otpauthUri}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">手动输入密钥:</p>
|
||||
<p className="font-mono text-sm font-medium select-all">{secret}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
步骤 2: 输入 6 位验证码
|
||||
</Label>
|
||||
<Input
|
||||
value={verifyCode}
|
||||
onChange={(e) => setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="请输入应用中显示的 6 位数字"
|
||||
maxLength={6}
|
||||
className="font-mono tracking-widest text-center"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => { setStep('idle'); setVerifyCode('') }}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleVerify} disabled={loading || verifyCode.length !== 6}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
验证并启用
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!totpEnabled && step === 'done' && (
|
||||
<div className="rounded-md bg-emerald-500/10 border border-emerald-500/20 p-4 text-sm text-emerald-500">
|
||||
双因素认证已成功启用。下次登录时需要输入验证码。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 已启用: 禁用流程 */}
|
||||
{totpEnabled && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md bg-amber-500/10 border border-amber-500/20 p-3 flex items-start gap-2 text-sm text-amber-600">
|
||||
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<span>禁用双因素认证会降低账号安全性,建议仅在必要时操作。</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>输入当前密码以确认禁用</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showDisablePassword ? 'text' : 'password'}
|
||||
value={disablePassword}
|
||||
onChange={(e) => setDisablePassword(e.target.value)}
|
||||
placeholder="请输入当前密码"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDisablePassword(!showDisablePassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
>
|
||||
{showDisablePassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="destructive" onClick={handleDisable} disabled={disabling || !disablePassword}>
|
||||
{disabling && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
禁用双因素认证
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -25,12 +25,11 @@ import {
|
||||
import { api } from '@/lib/api-client'
|
||||
import { ApiRequestError } from '@/lib/api-client'
|
||||
import { formatNumber } from '@/lib/utils'
|
||||
import type { UsageRecord, UsageByModel } from '@/lib/types'
|
||||
import type { UsageStats } from '@/lib/types'
|
||||
|
||||
export default function UsagePage() {
|
||||
const [days, setDays] = useState(7)
|
||||
const [dailyData, setDailyData] = useState<UsageRecord[]>([])
|
||||
const [modelData, setModelData] = useState<UsageByModel[]>([])
|
||||
const [usageStats, setUsageStats] = useState<UsageStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
@@ -38,13 +37,11 @@ export default function UsagePage() {
|
||||
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)
|
||||
const from = new Date()
|
||||
from.setDate(from.getDate() - days)
|
||||
const fromStr = from.toISOString().slice(0, 10)
|
||||
const res = await api.usage.get({ from: fromStr })
|
||||
setUsageStats(res)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
else setError('加载数据失败')
|
||||
@@ -57,22 +54,24 @@ export default function UsagePage() {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
const lineChartData = dailyData.map((r) => ({
|
||||
day: r.day.slice(5),
|
||||
const byDay = usageStats?.by_day ?? []
|
||||
|
||||
const lineChartData = byDay.map((r) => ({
|
||||
day: r.date.slice(5),
|
||||
Input: r.input_tokens,
|
||||
Output: r.output_tokens,
|
||||
}))
|
||||
|
||||
const barChartData = modelData.map((r) => ({
|
||||
const barChartData = (usageStats?.by_model ?? []).map((r) => ({
|
||||
model: r.model_id,
|
||||
请求量: r.count,
|
||||
请求量: r.request_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)
|
||||
const totalInput = byDay.reduce((s, r) => s + r.input_tokens, 0)
|
||||
const totalOutput = byDay.reduce((s, r) => s + r.output_tokens, 0)
|
||||
const totalRequests = byDay.reduce((s, r) => s + r.request_count, 0)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Toaster } from 'sonner'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -21,6 +22,7 @@ export default function RootLayout({
|
||||
</head>
|
||||
<body className="min-h-screen bg-background font-sans antialiased">
|
||||
{children}
|
||||
<Toaster richColors position="top-right" />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Lock, User, Loader2, Eye, EyeOff } from 'lucide-react'
|
||||
import { Lock, User, Loader2, Eye, EyeOff, ShieldCheck } from 'lucide-react'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { login } from '@/lib/auth'
|
||||
import { ApiRequestError } from '@/lib/api-client'
|
||||
@@ -12,7 +12,8 @@ export default function LoginPage() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [remember, setRemember] = useState(false)
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [showTotp, setShowTotp] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
@@ -31,12 +32,22 @@ export default function LoginPage() {
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.auth.login({ username: username.trim(), password })
|
||||
const res = await api.auth.login({
|
||||
username: username.trim(),
|
||||
password,
|
||||
totp_code: showTotp ? totpCode.trim() || undefined : undefined,
|
||||
})
|
||||
login(res.token, res.account)
|
||||
router.replace('/')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) {
|
||||
setError(err.body.message || '登录失败,请检查用户名和密码')
|
||||
// 检测 TOTP 错误码,自动显示验证码输入框
|
||||
if (err.body.error === 'totp_required' || err.body.message?.includes('双因素认证') || err.body.message?.includes('TOTP')) {
|
||||
setShowTotp(true)
|
||||
setError(err.body.message || '此账号已启用双因素认证,请输入验证码')
|
||||
} else {
|
||||
setError(err.body.message || '登录失败,请检查用户名和密码')
|
||||
}
|
||||
} else {
|
||||
setError('网络错误,请稍后重试')
|
||||
}
|
||||
@@ -152,22 +163,30 @@ export default function LoginPage() {
|
||||
</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>
|
||||
{/* TOTP 验证码 (仅账号启用 2FA 时显示) */}
|
||||
{showTotp && (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="totp_code"
|
||||
className="text-sm font-medium text-foreground"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
双因素验证码
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="totp_code"
|
||||
type="text"
|
||||
placeholder="请输入 6 位验证码"
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm tracking-widest text-center font-mono shadow-sm transition-colors duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误信息 */}
|
||||
{error && (
|
||||
|
||||
@@ -1,29 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { isAuthenticated, getAccount } from '@/lib/auth'
|
||||
import { isAuthenticated, getAccount, logout as clearCredentials, scheduleTokenRefresh, cancelTokenRefresh, setOnSessionExpired } from '@/lib/auth'
|
||||
import { api } from '@/lib/api-client'
|
||||
import type { AccountPublic } from '@/lib/types'
|
||||
|
||||
interface AuthContextValue {
|
||||
account: AccountPublic | null
|
||||
loading: boolean
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue>({
|
||||
account: null,
|
||||
loading: true,
|
||||
refresh: async () => {},
|
||||
})
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext)
|
||||
}
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function AuthGuard({ children }: AuthGuardProps) {
|
||||
const router = useRouter()
|
||||
const [authorized, setAuthorized] = useState(false)
|
||||
const [account, setAccount] = useState<AccountPublic | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const me = await api.auth.me()
|
||||
setAccount(me)
|
||||
} catch {
|
||||
clearCredentials()
|
||||
router.replace('/login')
|
||||
}
|
||||
}, [router])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated()) {
|
||||
router.replace('/login')
|
||||
return
|
||||
}
|
||||
setAccount(getAccount())
|
||||
setAuthorized(true)
|
||||
// 验证 token 有效性并获取最新账号信息
|
||||
refresh().finally(() => setLoading(false))
|
||||
}, [router, refresh])
|
||||
|
||||
// Set up proactive token refresh with session-expired handler
|
||||
useEffect(() => {
|
||||
const handleSessionExpired = () => {
|
||||
clearCredentials()
|
||||
router.replace('/login')
|
||||
}
|
||||
setOnSessionExpired(handleSessionExpired)
|
||||
scheduleTokenRefresh()
|
||||
|
||||
return () => {
|
||||
cancelTokenRefresh()
|
||||
setOnSessionExpired(null)
|
||||
}
|
||||
}, [router])
|
||||
|
||||
if (!authorized) {
|
||||
if (loading) {
|
||||
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" />
|
||||
@@ -31,18 +73,13 @@ export function AuthGuard({ children }: AuthGuardProps) {
|
||||
)
|
||||
}
|
||||
|
||||
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() }
|
||||
if (!account) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ account, loading, refresh }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
// ZCLAW SaaS Admin — 类型化 HTTP 客户端
|
||||
// ============================================================
|
||||
|
||||
import { getToken, logout } from './auth'
|
||||
import { getToken, logout, refreshToken } from './auth'
|
||||
import { toast } from 'sonner'
|
||||
import type {
|
||||
AccountPublic,
|
||||
ApiError,
|
||||
ConfigItem,
|
||||
CreateTokenRequest,
|
||||
DashboardStats,
|
||||
DeviceInfo,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
Model,
|
||||
@@ -18,7 +20,7 @@ import type {
|
||||
RelayTask,
|
||||
TokenInfo,
|
||||
UsageByModel,
|
||||
UsageRecord,
|
||||
UsageStats,
|
||||
} from './types'
|
||||
|
||||
// ── 错误类 ────────────────────────────────────────────────
|
||||
@@ -36,6 +38,7 @@ export class ApiRequestError extends Error {
|
||||
// ── 基础请求 ──────────────────────────────────────────────
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SAAS_API_URL || 'http://localhost:8080'
|
||||
const API_PREFIX = '/api/v1'
|
||||
|
||||
async function request<T>(
|
||||
method: string,
|
||||
@@ -50,13 +53,34 @@ async function request<T>(
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
const res = await fetch(`${BASE_URL}${API_PREFIX}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
if (res.status === 401) {
|
||||
// 尝试刷新 token 后重试
|
||||
try {
|
||||
const newToken = await refreshToken()
|
||||
headers['Authorization'] = `Bearer ${newToken}`
|
||||
const retryRes = await fetch(`${BASE_URL}${API_PREFIX}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
if (retryRes.ok || retryRes.status === 204) {
|
||||
return retryRes.status === 204 ? (undefined as T) : retryRes.json()
|
||||
}
|
||||
// 刷新成功但重试仍失败,走正常错误处理
|
||||
if (!retryRes.ok) {
|
||||
let errorBody: ApiError
|
||||
try { errorBody = await retryRes.json() } catch { errorBody = { error: 'unknown', message: `请求失败 (${retryRes.status})` } }
|
||||
throw new ApiRequestError(retryRes.status, errorBody)
|
||||
}
|
||||
} catch {
|
||||
// 刷新失败,执行登出
|
||||
}
|
||||
logout()
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login'
|
||||
@@ -71,6 +95,9 @@ async function request<T>(
|
||||
} catch {
|
||||
errorBody = { error: 'unknown', message: `请求失败 (${res.status})` }
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
toast.error(errorBody.message || `请求失败 (${res.status})`)
|
||||
}
|
||||
throw new ApiRequestError(res.status, errorBody)
|
||||
}
|
||||
|
||||
@@ -88,7 +115,7 @@ export const api = {
|
||||
// ── 认证 ──────────────────────────────────────────────
|
||||
auth: {
|
||||
async login(data: LoginRequest): Promise<LoginResponse> {
|
||||
return request<LoginResponse>('POST', '/api/auth/login', data)
|
||||
return request<LoginResponse>('POST', '/auth/login', data)
|
||||
},
|
||||
|
||||
async register(data: {
|
||||
@@ -97,11 +124,27 @@ export const api = {
|
||||
email: string
|
||||
display_name?: string
|
||||
}): Promise<LoginResponse> {
|
||||
return request<LoginResponse>('POST', '/api/auth/register', data)
|
||||
return request<LoginResponse>('POST', '/auth/register', data)
|
||||
},
|
||||
|
||||
async me(): Promise<AccountPublic> {
|
||||
return request<AccountPublic>('GET', '/api/auth/me')
|
||||
return request<AccountPublic>('GET', '/auth/me')
|
||||
},
|
||||
|
||||
async changePassword(data: { old_password: string; new_password: string }): Promise<void> {
|
||||
return request<void>('PUT', '/auth/password', data)
|
||||
},
|
||||
|
||||
async totpSetup(): Promise<{ otpauth_uri: string; secret: string; issuer: string }> {
|
||||
return request<{ otpauth_uri: string; secret: string; issuer: string }>('POST', '/auth/totp/setup')
|
||||
},
|
||||
|
||||
async totpVerify(data: { code: string }): Promise<void> {
|
||||
return request<void>('POST', '/auth/totp/verify', data)
|
||||
},
|
||||
|
||||
async totpDisable(data: { password: string }): Promise<void> {
|
||||
return request<void>('POST', '/auth/totp/disable', data)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -115,25 +158,25 @@ export const api = {
|
||||
status?: string
|
||||
}): Promise<PaginatedResponse<AccountPublic>> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<PaginatedResponse<AccountPublic>>('GET', `/api/accounts${qs}`)
|
||||
return request<PaginatedResponse<AccountPublic>>('GET', `/accounts${qs}`)
|
||||
},
|
||||
|
||||
async get(id: string): Promise<AccountPublic> {
|
||||
return request<AccountPublic>('GET', `/api/accounts/${id}`)
|
||||
return request<AccountPublic>('GET', `/accounts/${id}`)
|
||||
},
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
data: Partial<Pick<AccountPublic, 'display_name' | 'email' | 'role'>>,
|
||||
): Promise<AccountPublic> {
|
||||
return request<AccountPublic>('PATCH', `/api/accounts/${id}`, data)
|
||||
return request<AccountPublic>('PUT', `/accounts/${id}`, data)
|
||||
},
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
data: { status: AccountPublic['status'] },
|
||||
): Promise<void> {
|
||||
return request<void>('PATCH', `/api/accounts/${id}/status`, data)
|
||||
return request<void>('PATCH', `/accounts/${id}/status`, data)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -144,22 +187,26 @@ export const api = {
|
||||
page_size?: number
|
||||
}): Promise<PaginatedResponse<Provider>> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<PaginatedResponse<Provider>>('GET', `/api/providers${qs}`)
|
||||
return request<PaginatedResponse<Provider>>('GET', `/providers${qs}`)
|
||||
},
|
||||
|
||||
async get(id: string): Promise<Provider> {
|
||||
return request<Provider>('GET', `/providers/${id}`)
|
||||
},
|
||||
|
||||
async create(data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>): Promise<Provider> {
|
||||
return request<Provider>('POST', '/api/providers', data)
|
||||
return request<Provider>('POST', '/providers', data)
|
||||
},
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>,
|
||||
): Promise<Provider> {
|
||||
return request<Provider>('PATCH', `/api/providers/${id}`, data)
|
||||
return request<Provider>('PUT', `/providers/${id}`, data)
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
return request<void>('DELETE', `/api/providers/${id}`)
|
||||
return request<void>('DELETE', `/providers/${id}`)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -171,19 +218,23 @@ export const api = {
|
||||
provider_id?: string
|
||||
}): Promise<PaginatedResponse<Model>> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<PaginatedResponse<Model>>('GET', `/api/models${qs}`)
|
||||
return request<PaginatedResponse<Model>>('GET', `/models${qs}`)
|
||||
},
|
||||
|
||||
async get(id: string): Promise<Model> {
|
||||
return request<Model>('GET', `/models/${id}`)
|
||||
},
|
||||
|
||||
async create(data: Partial<Omit<Model, 'id'>>): Promise<Model> {
|
||||
return request<Model>('POST', '/api/models', data)
|
||||
return request<Model>('POST', '/models', data)
|
||||
},
|
||||
|
||||
async update(id: string, data: Partial<Omit<Model, 'id'>>): Promise<Model> {
|
||||
return request<Model>('PATCH', `/api/models/${id}`, data)
|
||||
return request<Model>('PUT', `/models/${id}`, data)
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
return request<void>('DELETE', `/api/models/${id}`)
|
||||
return request<void>('DELETE', `/models/${id}`)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -194,28 +245,23 @@ export const api = {
|
||||
page_size?: number
|
||||
}): Promise<PaginatedResponse<TokenInfo>> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<PaginatedResponse<TokenInfo>>('GET', `/api/tokens${qs}`)
|
||||
return request<PaginatedResponse<TokenInfo>>('GET', `/tokens${qs}`)
|
||||
},
|
||||
|
||||
async create(data: CreateTokenRequest): Promise<TokenInfo> {
|
||||
return request<TokenInfo>('POST', '/api/tokens', data)
|
||||
return request<TokenInfo>('POST', '/tokens', data)
|
||||
},
|
||||
|
||||
async revoke(id: string): Promise<void> {
|
||||
return request<void>('DELETE', `/api/tokens/${id}`)
|
||||
return request<void>('DELETE', `/tokens/${id}`)
|
||||
},
|
||||
},
|
||||
|
||||
// ── 用量统计 ──────────────────────────────────────────
|
||||
usage: {
|
||||
async daily(params?: { days?: number }): Promise<UsageRecord[]> {
|
||||
async get(params?: { from?: string; to?: string; provider_id?: string; model_id?: string }): Promise<UsageStats> {
|
||||
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}`)
|
||||
return request<UsageStats>('GET', `/usage${qs}`)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -227,11 +273,15 @@ export const api = {
|
||||
status?: string
|
||||
}): Promise<PaginatedResponse<RelayTask>> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<PaginatedResponse<RelayTask>>('GET', `/api/relay/tasks${qs}`)
|
||||
return request<PaginatedResponse<RelayTask>>('GET', `/relay/tasks${qs}`)
|
||||
},
|
||||
|
||||
async get(id: string): Promise<RelayTask> {
|
||||
return request<RelayTask>('GET', `/api/relay/tasks/${id}`)
|
||||
return request<RelayTask>('GET', `/relay/tasks/${id}`)
|
||||
},
|
||||
|
||||
async retry(id: string): Promise<void> {
|
||||
return request<void>('POST', `/relay/tasks/${id}/retry`)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -241,11 +291,11 @@ export const api = {
|
||||
category?: string
|
||||
}): Promise<ConfigItem[]> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<ConfigItem[]>('GET', `/api/config${qs}`)
|
||||
return request<ConfigItem[]>('GET', `/config/items${qs}`)
|
||||
},
|
||||
|
||||
async update(id: string, data: { value: string | number | boolean }): Promise<ConfigItem> {
|
||||
return request<ConfigItem>('PATCH', `/api/config/${id}`, data)
|
||||
async update(id: string, data: { current_value: string | number | boolean }): Promise<ConfigItem> {
|
||||
return request<ConfigItem>('PUT', `/config/items/${id}`, data)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -255,16 +305,29 @@ export const api = {
|
||||
page?: number
|
||||
page_size?: number
|
||||
action?: string
|
||||
}): Promise<PaginatedResponse<OperationLog>> {
|
||||
}): Promise<OperationLog[]> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<PaginatedResponse<OperationLog>>('GET', `/api/logs${qs}`)
|
||||
return request<OperationLog[]>('GET', `/logs/operations${qs}`)
|
||||
},
|
||||
},
|
||||
|
||||
// ── 仪表盘 ────────────────────────────────────────────
|
||||
stats: {
|
||||
async dashboard(): Promise<DashboardStats> {
|
||||
return request<DashboardStats>('GET', '/api/stats/dashboard')
|
||||
return request<DashboardStats>('GET', '/stats/dashboard')
|
||||
},
|
||||
},
|
||||
|
||||
// ── 设备管理 ──────────────────────────────────────────
|
||||
devices: {
|
||||
async list(): Promise<DeviceInfo[]> {
|
||||
return request<DeviceInfo[]>('GET', '/devices')
|
||||
},
|
||||
async register(data: { device_id: string; device_name?: string; platform?: string; app_version?: string }) {
|
||||
return request<{ ok: boolean; device_id: string }>('POST', '/devices/register', data)
|
||||
},
|
||||
async heartbeat(data: { device_id: string }) {
|
||||
return request<{ ok: boolean }>('POST', '/devices/heartbeat', data)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,21 +2,74 @@
|
||||
// ZCLAW SaaS Admin — JWT Token 管理
|
||||
// ============================================================
|
||||
|
||||
import type { AccountPublic } from './types'
|
||||
import type { AccountPublic, LoginResponse } from './types'
|
||||
|
||||
const TOKEN_KEY = 'zclaw_admin_token'
|
||||
const ACCOUNT_KEY = 'zclaw_admin_account'
|
||||
|
||||
/** 保存登录凭证 */
|
||||
// ── JWT 辅助函数 ────────────────────────────────────────────
|
||||
|
||||
interface JwtPayload {
|
||||
exp?: number
|
||||
iat?: number
|
||||
sub?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a JWT payload without verifying the signature.
|
||||
* Returns the parsed JSON payload, or null if the token is malformed.
|
||||
*/
|
||||
function decodeJwtPayload<T = Record<string, unknown>>(token: string): T | null {
|
||||
try {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) return null
|
||||
const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/')
|
||||
const json = decodeURIComponent(
|
||||
atob(base64)
|
||||
.split('')
|
||||
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join(''),
|
||||
)
|
||||
return JSON.parse(json) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the delay (ms) until 80% of the token's remaining lifetime
|
||||
* has elapsed. Returns null if the token is already past that point.
|
||||
*/
|
||||
function getRefreshDelay(exp: number): number | null {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const totalLifetime = exp - now
|
||||
if (totalLifetime <= 0) return null
|
||||
|
||||
const refreshAt = now + Math.floor(totalLifetime * 0.8)
|
||||
const delayMs = (refreshAt - now) * 1000
|
||||
return delayMs > 5000 ? delayMs : 5000
|
||||
}
|
||||
|
||||
// ── 定时刷新状态 ────────────────────────────────────────────
|
||||
|
||||
let refreshTimerId: ReturnType<typeof setTimeout> | null = null
|
||||
let visibilityHandler: (() => void) | null = null
|
||||
let sessionExpiredCallback: (() => void) | null = null
|
||||
|
||||
// ── 凭证操作 ────────────────────────────────────────────────
|
||||
|
||||
/** 保存登录凭证并启动自动刷新 */
|
||||
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))
|
||||
scheduleTokenRefresh()
|
||||
}
|
||||
|
||||
/** 清除登录凭证 */
|
||||
/** 清除登录凭证并停止自动刷新 */
|
||||
export function logout(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
cancelTokenRefresh()
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(ACCOUNT_KEY)
|
||||
}
|
||||
@@ -43,3 +96,121 @@ export function getAccount(): AccountPublic | null {
|
||||
export function isAuthenticated(): boolean {
|
||||
return !!getToken()
|
||||
}
|
||||
|
||||
/** 尝试刷新 token,成功则更新 localStorage 并返回新 token */
|
||||
export async function refreshToken(): Promise<string> {
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SAAS_API_URL || 'http://localhost:8080'}/api/v1/auth/refresh`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${getToken()}`,
|
||||
},
|
||||
},
|
||||
)
|
||||
if (!res.ok) {
|
||||
throw new Error('Token 刷新失败')
|
||||
}
|
||||
const data: LoginResponse = await res.json()
|
||||
login(data.token, data.account)
|
||||
return data.token
|
||||
}
|
||||
|
||||
// ── 自动刷新调度 ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Register a callback invoked when the proactive token refresh fails.
|
||||
* The caller should use this to trigger a logout/redirect flow.
|
||||
*/
|
||||
export function setOnSessionExpired(handler: (() => void) | null): void {
|
||||
sessionExpiredCallback = handler
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a proactive token refresh at 80% of the token's remaining lifetime.
|
||||
* Also registers a visibilitychange listener to re-check when the tab regains focus.
|
||||
*/
|
||||
export function scheduleTokenRefresh(): void {
|
||||
cancelTokenRefresh()
|
||||
|
||||
const token = getToken()
|
||||
if (!token) return
|
||||
|
||||
const payload = decodeJwtPayload<JwtPayload>(token)
|
||||
if (!payload?.exp) return
|
||||
|
||||
const delay = getRefreshDelay(payload.exp)
|
||||
if (delay === null) {
|
||||
attemptTokenRefresh()
|
||||
return
|
||||
}
|
||||
|
||||
refreshTimerId = setTimeout(() => {
|
||||
attemptTokenRefresh()
|
||||
}, delay)
|
||||
|
||||
if (typeof document !== 'undefined' && !visibilityHandler) {
|
||||
visibilityHandler = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
checkAndRefreshToken()
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', visibilityHandler)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel any pending token refresh timer and remove the visibility listener.
|
||||
*/
|
||||
export function cancelTokenRefresh(): void {
|
||||
if (refreshTimerId !== null) {
|
||||
clearTimeout(refreshTimerId)
|
||||
refreshTimerId = null
|
||||
}
|
||||
if (visibilityHandler !== null && typeof document !== 'undefined') {
|
||||
document.removeEventListener('visibilitychange', visibilityHandler)
|
||||
visibilityHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current token is close to expiry and refresh if needed.
|
||||
* Called on visibility change to handle clock skew / long background tabs.
|
||||
*/
|
||||
function checkAndRefreshToken(): void {
|
||||
const token = getToken()
|
||||
if (!token) return
|
||||
|
||||
const payload = decodeJwtPayload<JwtPayload>(token)
|
||||
if (!payload?.exp) return
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const remaining = payload.exp - now
|
||||
|
||||
if (remaining <= 0) {
|
||||
attemptTokenRefresh()
|
||||
return
|
||||
}
|
||||
|
||||
const delay = getRefreshDelay(payload.exp)
|
||||
if (delay !== null && delay < 60_000) {
|
||||
attemptTokenRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to refresh the token. On success, the new token is persisted via
|
||||
* login() which also reschedules the next refresh. On failure, invoke the
|
||||
* session-expired callback.
|
||||
*/
|
||||
async function attemptTokenRefresh(): Promise<void> {
|
||||
try {
|
||||
await refreshToken()
|
||||
} catch {
|
||||
cancelTokenRefresh()
|
||||
if (sessionExpiredCallback) {
|
||||
sessionExpiredCallback()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface AccountPublic {
|
||||
email: string
|
||||
display_name: string
|
||||
role: 'super_admin' | 'admin' | 'user'
|
||||
permissions: string[]
|
||||
status: 'active' | 'disabled' | 'suspended'
|
||||
totp_enabled: boolean
|
||||
created_at: string
|
||||
@@ -18,6 +19,7 @@ export interface AccountPublic {
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
totp_code?: string
|
||||
}
|
||||
|
||||
/** 登录响应 */
|
||||
@@ -47,7 +49,6 @@ export interface Provider {
|
||||
id: string
|
||||
name: string
|
||||
display_name: string
|
||||
api_key?: string
|
||||
base_url: string
|
||||
api_protocol: 'openai' | 'anthropic'
|
||||
enabled: boolean
|
||||
@@ -109,18 +110,28 @@ export interface RelayTask {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 用量记录 */
|
||||
export interface UsageRecord {
|
||||
day: string
|
||||
count: number
|
||||
/** 用量统计 — 后端返回的完整结构 */
|
||||
export interface UsageStats {
|
||||
total_requests: number
|
||||
total_input_tokens: number
|
||||
total_output_tokens: number
|
||||
by_model: UsageByModel[]
|
||||
by_day: DailyUsage[]
|
||||
}
|
||||
|
||||
/** 每日用量 */
|
||||
export interface DailyUsage {
|
||||
date: string
|
||||
request_count: number
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
}
|
||||
|
||||
/** 按模型用量 */
|
||||
export interface UsageByModel {
|
||||
provider_id: string
|
||||
model_id: string
|
||||
count: number
|
||||
request_count: number
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
}
|
||||
@@ -131,21 +142,23 @@ export interface ConfigItem {
|
||||
category: string
|
||||
key_path: string
|
||||
value_type: 'string' | 'number' | 'boolean'
|
||||
current_value?: string | number | boolean
|
||||
default_value?: string | number | boolean
|
||||
current_value?: string
|
||||
default_value?: string
|
||||
source: 'default' | 'env' | 'db'
|
||||
description?: string
|
||||
requires_restart: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 操作日志 */
|
||||
export interface OperationLog {
|
||||
id: string
|
||||
id: number
|
||||
account_id: string
|
||||
action: string
|
||||
target_type: string
|
||||
target_id: string
|
||||
details?: string
|
||||
details?: Record<string, unknown>
|
||||
ip_address?: string
|
||||
created_at: string
|
||||
}
|
||||
@@ -161,6 +174,17 @@ export interface DashboardStats {
|
||||
tokens_today_output: number
|
||||
}
|
||||
|
||||
/** 设备信息 */
|
||||
export interface DeviceInfo {
|
||||
id: string
|
||||
device_id: string
|
||||
device_name?: string
|
||||
platform?: string
|
||||
app_version?: string
|
||||
last_seen_at: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** API 错误响应 */
|
||||
export interface ApiError {
|
||||
error: string
|
||||
|
||||
Reference in New Issue
Block a user