feat: 添加访问令牌管理功能并升级至 0.2.4

- 新增 Management Token(访问令牌)功能,支持创建、更新、删除和管理
- 前端添加访问令牌管理页面,支持普通用户和管理员
- 后端实现完整的令牌生命周期管理 API
- 添加数据库迁移脚本创建 management_tokens 表
- Nginx 配置添加 gzip 压缩,优化响应传输
- Dialog 组件添加 persistent 属性,防止意外关闭
- 为管理后台 API 添加详细的中文文档注释
- 简化多处类型注解,统一代码风格
This commit is contained in:
fawney19
2026-01-07 14:55:07 +08:00
parent f6a6410626
commit 0061fc04b7
59 changed files with 6265 additions and 648 deletions

View File

@@ -0,0 +1,203 @@
/**
* Management Token API
*/
import apiClient from './client'
// ============== 类型定义 ==============
export interface ManagementToken {
id: string
user_id: string
name: string
description?: string
token_display: string
allowed_ips?: string[] | null
expires_at?: string | null
last_used_at?: string | null
last_used_ip?: string | null
usage_count: number
is_active: boolean
created_at: string
updated_at: string
user?: {
id: string
email: string
username: string
role: string
}
}
export interface CreateManagementTokenRequest {
name: string
description?: string
allowed_ips?: string[]
expires_at?: string | null
}
export interface CreateManagementTokenResponse {
message: string
token: string
data: ManagementToken
}
export interface UpdateManagementTokenRequest {
name?: string
description?: string | null
allowed_ips?: string[] | null
expires_at?: string | null
is_active?: boolean
}
export interface ManagementTokenListResponse {
items: ManagementToken[]
total: number
skip: number
limit: number
quota?: {
used: number
max: number
}
}
// ============== 用户自助管理 API ==============
export const managementTokenApi = {
/**
* 列出当前用户的 Management Tokens
*/
async listTokens(params?: {
is_active?: boolean
skip?: number
limit?: number
}): Promise<ManagementTokenListResponse> {
const response = await apiClient.get<ManagementTokenListResponse>(
'/api/me/management-tokens',
{ params }
)
return response.data
},
/**
* 创建 Management Token
*/
async createToken(
data: CreateManagementTokenRequest
): Promise<CreateManagementTokenResponse> {
const response = await apiClient.post<CreateManagementTokenResponse>(
'/api/me/management-tokens',
data
)
return response.data
},
/**
* 获取 Token 详情
*/
async getToken(tokenId: string): Promise<ManagementToken> {
const response = await apiClient.get<ManagementToken>(
`/api/me/management-tokens/${tokenId}`
)
return response.data
},
/**
* 更新 Token
*/
async updateToken(
tokenId: string,
data: UpdateManagementTokenRequest
): Promise<{ message: string; data: ManagementToken }> {
const response = await apiClient.put<{ message: string; data: ManagementToken }>(
`/api/me/management-tokens/${tokenId}`,
data
)
return response.data
},
/**
* 删除 Token
*/
async deleteToken(tokenId: string): Promise<{ message: string }> {
const response = await apiClient.delete<{ message: string }>(
`/api/me/management-tokens/${tokenId}`
)
return response.data
},
/**
* 切换 Token 状态
*/
async toggleToken(
tokenId: string
): Promise<{ message: string; data: ManagementToken }> {
const response = await apiClient.patch<{ message: string; data: ManagementToken }>(
`/api/me/management-tokens/${tokenId}/status`
)
return response.data
},
/**
* 重新生成 Token
*/
async regenerateToken(
tokenId: string
): Promise<{ token: string; data: ManagementToken }> {
const response = await apiClient.post<{ token: string; data: ManagementToken }>(
`/api/me/management-tokens/${tokenId}/regenerate`
)
return response.data
}
}
// ============== 管理员 API ==============
export const adminManagementTokenApi = {
/**
* 列出所有 Management Tokens管理员
*/
async listAllTokens(params?: {
user_id?: string
is_active?: boolean
skip?: number
limit?: number
}): Promise<ManagementTokenListResponse> {
const response = await apiClient.get<ManagementTokenListResponse>(
'/api/admin/management-tokens',
{ params }
)
return response.data
},
/**
* 获取 Token 详情(管理员)
*/
async getToken(tokenId: string): Promise<ManagementToken> {
const response = await apiClient.get<ManagementToken>(
`/api/admin/management-tokens/${tokenId}`
)
return response.data
},
/**
* 删除任意 Token管理员
*/
async deleteToken(tokenId: string): Promise<{ message: string }> {
const response = await apiClient.delete<{ message: string }>(
`/api/admin/management-tokens/${tokenId}`
)
return response.data
},
/**
* 切换任意 Token 状态(管理员)
*/
async toggleToken(
tokenId: string
): Promise<{ message: string; data: ManagementToken }> {
const response = await apiClient.patch<{ message: string; data: ManagementToken }>(
`/api/admin/management-tokens/${tokenId}/status`
)
return response.data
}
}

View File

@@ -18,7 +18,7 @@
v-if="isOpen"
class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity pointer-events-auto"
:style="{ zIndex: backdropZIndex }"
@click="handleClose"
@click="handleBackdropClick"
/>
</Transition>
@@ -106,6 +106,7 @@ const props = defineProps<{
iconClass?: string // Custom icon color class
zIndex?: number // Custom z-index for nested dialogs (default: 60)
noPadding?: boolean // Disable default content padding
persistent?: boolean // Prevent closing on backdrop click
}>()
// Emits 定义
@@ -138,6 +139,13 @@ function handleClose() {
}
}
// 处理背景点击
function handleBackdropClick() {
if (!props.persistent) {
handleClose()
}
}
const maxWidthClass = computed(() => {
const sizeValue = props.maxWidth || props.size || 'md'
const sizes = {
@@ -162,7 +170,7 @@ const contentZIndex = computed(() => (props.zIndex || 60) + 10)
// 添加 ESC 键监听
useEscapeKey(() => {
if (isOpen.value) {
if (isOpen.value && !props.persistent) {
handleClose()
return true // 阻止其他监听器(如父级抽屉的 ESC 监听器)
}

View File

@@ -73,14 +73,16 @@
>
<button
type="button"
:class="['auth-tab', authType === 'local' && 'active']"
class="auth-tab"
:class="[authType === 'local' && 'active']"
@click="authType = 'local'"
>
本地登录
</button>
<button
type="button"
:class="['auth-tab', authType === 'ldap' && 'active']"
class="auth-tab"
:class="[authType === 'ldap' && 'active']"
@click="authType = 'ldap'"
>
LDAP 登录

View File

@@ -312,6 +312,7 @@ import {
Home,
Users,
Key,
KeyRound,
BarChart3,
Cog,
Settings,
@@ -398,6 +399,7 @@ const navigation = computed(() => {
items: [
{ name: '模型目录', href: '/dashboard/models', icon: Box },
{ name: 'API 密钥', href: '/dashboard/api-keys', icon: Key },
{ name: '访问令牌', href: '/dashboard/management-tokens', icon: KeyRound },
]
},
{
@@ -423,6 +425,7 @@ const navigation = computed(() => {
{ name: '提供商', href: '/admin/providers', icon: FolderTree },
{ name: '模型管理', href: '/admin/models', icon: Layers },
{ name: '独立密钥', href: '/admin/keys', icon: Key },
{ name: '访问令牌', href: '/admin/management-tokens', icon: KeyRound },
{ name: '使用记录', href: '/admin/usage', icon: BarChart3 },
]
},

View File

@@ -34,6 +34,11 @@ const routes: RouteRecordRaw[] = [
name: 'MyApiKeys',
component: () => importWithRetry(() => import('@/views/user/MyApiKeys.vue'))
},
{
path: 'management-tokens',
name: 'ManagementTokens',
component: () => importWithRetry(() => import('@/views/user/ManagementTokens.vue'))
},
{
path: 'announcements',
name: 'Announcements',
@@ -81,6 +86,11 @@ const routes: RouteRecordRaw[] = [
name: 'ApiKeys',
component: () => importWithRetry(() => import('@/views/admin/ApiKeys.vue'))
},
{
path: 'management-tokens',
name: 'AdminManagementTokens',
component: () => importWithRetry(() => import('@/views/user/ManagementTokens.vue'))
},
{
path: 'providers',
name: 'ProviderManagement',

View File

@@ -32,7 +32,10 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label for="server-url" class="block text-sm font-medium">
<Label
for="server-url"
class="block text-sm font-medium"
>
服务器地址
</Label>
<Input
@@ -48,7 +51,10 @@
</div>
<div>
<Label for="bind-dn" class="block text-sm font-medium">
<Label
for="bind-dn"
class="block text-sm font-medium"
>
绑定 DN
</Label>
<Input
@@ -64,7 +70,10 @@
</div>
<div>
<Label for="bind-password" class="block text-sm font-medium">
<Label
for="bind-password"
class="block text-sm font-medium"
>
绑定密码
</Label>
<div class="relative mt-1">
@@ -80,10 +89,30 @@
v-if="hasPassword || ldapConfig.bind_password"
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-full text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted/50 transition-colors"
@click="handleClearPassword"
title="清除密码"
@click="handleClearPassword"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><line
x1="18"
y1="6"
x2="6"
y2="18"
/><line
x1="6"
y1="6"
x2="18"
y2="18"
/></svg>
</button>
</div>
<p class="mt-1 text-xs text-muted-foreground">
@@ -92,7 +121,10 @@
</div>
<div>
<Label for="base-dn" class="block text-sm font-medium">
<Label
for="base-dn"
class="block text-sm font-medium"
>
基础 DN
</Label>
<Input
@@ -108,7 +140,10 @@
</div>
<div>
<Label for="user-search-filter" class="block text-sm font-medium">
<Label
for="user-search-filter"
class="block text-sm font-medium"
>
用户搜索过滤器
</Label>
<Input
@@ -124,7 +159,10 @@
</div>
<div>
<Label for="username-attr" class="block text-sm font-medium">
<Label
for="username-attr"
class="block text-sm font-medium"
>
用户名属性
</Label>
<Input
@@ -140,7 +178,10 @@
</div>
<div>
<Label for="email-attr" class="block text-sm font-medium">
<Label
for="email-attr"
class="block text-sm font-medium"
>
邮箱属性
</Label>
<Input
@@ -153,7 +194,10 @@
</div>
<div>
<Label for="display-name-attr" class="block text-sm font-medium">
<Label
for="display-name-attr"
class="block text-sm font-medium"
>
显示名称属性
</Label>
<Input
@@ -166,7 +210,10 @@
</div>
<div>
<Label for="connect-timeout" class="block text-sm font-medium">
<Label
for="connect-timeout"
class="block text-sm font-medium"
>
连接超时 ()
</Label>
<Input

View File

@@ -0,0 +1,859 @@
<template>
<div class="space-y-6 pb-8">
<!-- 访问令牌表格 -->
<Card
variant="default"
class="overflow-hidden"
>
<!-- 标题和操作栏 -->
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div>
<h3 class="text-sm sm:text-base font-semibold">
访问令牌
</h3>
<p class="text-xs text-muted-foreground mt-0.5">
<template v-if="quota">
已创建 {{ quota.used }}/{{ quota.max }} 个令牌
<span
v-if="quota.used >= quota.max"
class="text-destructive font-medium"
>已达上限</span>
</template>
<template v-else>
用于程序化访问管理 API 的令牌
</template>
</p>
</div>
<!-- 操作按钮 -->
<div class="flex items-center gap-2">
<!-- 新增按钮 -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="创建新令牌"
:disabled="quota ? quota.used >= quota.max : false"
@click="showCreateDialog = true"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<!-- 刷新按钮 -->
<RefreshButton
:loading="loading"
@click="loadTokens"
/>
</div>
</div>
</div>
<!-- 加载状态 -->
<div
v-if="loading"
class="flex items-center justify-center py-12"
>
<LoadingState message="加载中..." />
</div>
<!-- 空状态 -->
<div
v-else-if="tokens.length === 0"
class="flex items-center justify-center py-12"
>
<EmptyState
title="暂无访问令牌"
description="创建你的第一个访问令牌开始使用管理 API"
:icon="KeyRound"
>
<template #actions>
<Button
size="lg"
class="shadow-lg shadow-primary/20"
@click="showCreateDialog = true"
>
<Plus class="mr-2 h-4 w-4" />
创建访问令牌
</Button>
</template>
</EmptyState>
</div>
<!-- 桌面端表格 -->
<div
v-else
class="hidden md:block overflow-x-auto"
>
<Table>
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="min-w-[180px] h-12 font-semibold">
名称
</TableHead>
<TableHead class="min-w-[160px] h-12 font-semibold">
令牌
</TableHead>
<TableHead class="min-w-[80px] h-12 font-semibold text-center">
使用次数
</TableHead>
<TableHead class="min-w-[70px] h-12 font-semibold text-center">
状态
</TableHead>
<TableHead class="min-w-[100px] h-12 font-semibold">
时间
</TableHead>
<TableHead class="min-w-[100px] h-12 font-semibold text-center">
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="token in paginatedTokens"
:key="token.id"
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
>
<!-- 名称 -->
<TableCell class="py-4">
<div class="flex-1 min-w-0">
<div
class="text-sm font-semibold truncate"
:title="token.name"
>
{{ token.name }}
</div>
<div
v-if="token.description"
class="text-xs text-muted-foreground mt-0.5 truncate"
:title="token.description"
>
{{ token.description }}
</div>
</div>
</TableCell>
<!-- Token 显示 -->
<TableCell class="py-4">
<div class="flex items-center gap-1.5">
<code class="text-xs font-mono text-muted-foreground bg-muted/30 px-2 py-1 rounded">
{{ token.token_display }}
</code>
<Button
variant="ghost"
size="icon"
class="h-6 w-6"
title="重新生成令牌"
@click="confirmRegenerate(token)"
>
<RefreshCw class="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
<!-- 使用次数 -->
<TableCell class="py-4 text-center">
<span class="text-sm font-medium">
{{ formatNumber(token.usage_count || 0) }}
</span>
</TableCell>
<!-- 状态 -->
<TableCell class="py-4 text-center">
<Badge
:variant="getStatusVariant(token)"
class="font-medium px-3 py-1"
>
{{ getStatusText(token) }}
</Badge>
</TableCell>
<!-- 时间 -->
<TableCell class="py-4 text-sm text-muted-foreground">
<div class="text-xs">
创建于 {{ formatDate(token.created_at) }}
</div>
<div class="text-xs mt-1">
{{ token.last_used_at ? `最后使用 ${formatRelativeTime(token.last_used_at)}` : '从未使用' }}
</div>
</TableCell>
<!-- 操作按钮 -->
<TableCell class="py-4">
<div class="flex justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="编辑"
@click="openEditDialog(token)"
>
<Pencil class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:title="token.is_active ? '禁用' : '启用'"
@click="toggleToken(token)"
>
<Power class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="删除"
@click="confirmDelete(token)"
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- 移动端卡片列表 -->
<div
v-if="!loading && tokens.length > 0"
class="md:hidden space-y-3 p-4"
>
<Card
v-for="token in paginatedTokens"
:key="token.id"
variant="default"
class="group hover:shadow-md hover:border-primary/30 transition-all duration-200"
>
<div class="p-4">
<!-- 第一行名称状态操作 -->
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2 min-w-0 flex-1">
<h3 class="text-sm font-semibold text-foreground truncate">
{{ token.name }}
</h3>
<Badge
:variant="getStatusVariant(token)"
class="text-xs px-1.5 py-0"
>
{{ getStatusText(token) }}
</Badge>
</div>
<div class="flex items-center gap-0.5 flex-shrink-0">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
title="编辑"
@click="openEditDialog(token)"
>
<Pencil class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:title="token.is_active ? '禁用' : '启用'"
@click="toggleToken(token)"
>
<Power class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
title="删除"
@click="confirmDelete(token)"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<!-- Token 显示 -->
<div class="flex items-center gap-2 text-xs mb-2">
<code class="font-mono text-muted-foreground">{{ token.token_display }}</code>
<Button
variant="ghost"
size="icon"
class="h-5 w-5"
title="重新生成"
@click="confirmRegenerate(token)"
>
<RefreshCw class="h-3 w-3" />
</Button>
</div>
<!-- 统计信息 -->
<div class="flex items-center gap-3 text-xs text-muted-foreground">
<span>{{ formatNumber(token.usage_count || 0) }} 次使用</span>
<span>·</span>
<span>{{ token.last_used_at ? formatRelativeTime(token.last_used_at) : '从未使用' }}</span>
</div>
</div>
</Card>
</div>
<!-- 分页 -->
<Pagination
v-if="totalTokens > 0"
:current="currentPage"
:total="totalTokens"
:page-size="pageSize"
@update:current="currentPage = $event"
@update:page-size="handlePageSizeChange"
/>
</Card>
<!-- 创建/编辑 Token 对话框 -->
<Dialog
v-model="showCreateDialog"
size="lg"
>
<template #header>
<div class="border-b border-border px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
<KeyRound class="h-5 w-5 text-primary" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight">
{{ editingToken ? '编辑访问令牌' : '创建访问令牌' }}
</h3>
<p class="text-xs text-muted-foreground">
{{ editingToken ? '修改令牌配置' : '创建一个新的令牌用于访问管理 API' }}
</p>
</div>
</div>
</div>
</template>
<div class="space-y-4">
<!-- 名称 -->
<div class="space-y-2">
<Label
for="token-name"
class="text-sm font-semibold"
>名称 *</Label>
<Input
id="token-name"
v-model="formData.name"
placeholder="例如CI/CD 自动化"
class="h-11 border-border/60"
autocomplete="off"
required
/>
</div>
<!-- 描述 -->
<div class="space-y-2">
<Label
for="token-description"
class="text-sm font-semibold"
>描述</Label>
<Input
id="token-description"
v-model="formData.description"
placeholder="用途说明(可选)"
class="h-11 border-border/60"
autocomplete="off"
/>
</div>
<!-- IP 白名单 -->
<div class="space-y-2">
<Label
for="token-ips"
class="text-sm font-semibold"
>IP 白名单</Label>
<Input
id="token-ips"
v-model="formData.allowedIpsText"
placeholder="例如192.168.1.0/24, 10.0.0.1(逗号分隔,留空不限制)"
class="h-11 border-border/60"
autocomplete="off"
/>
<p class="text-xs text-muted-foreground">
限制只能从指定 IP 地址使用此令牌支持 CIDR 格式
</p>
</div>
<!-- 过期时间 -->
<div class="space-y-2">
<Label
for="token-expires"
class="text-sm font-semibold"
>过期时间</Label>
<Input
id="token-expires"
v-model="formData.expiresAt"
type="datetime-local"
class="h-11 border-border/60"
/>
<p class="text-xs text-muted-foreground">
留空表示永不过期
</p>
</div>
</div>
<template #footer>
<Button
variant="outline"
class="h-11 px-6"
@click="closeDialog"
>
取消
</Button>
<Button
class="h-11 px-6 shadow-lg shadow-primary/20"
:disabled="saving || !isFormValid"
@click="saveToken"
>
<Loader2
v-if="saving"
class="animate-spin h-4 w-4 mr-2"
/>
{{ saving ? '保存中...' : (editingToken ? '保存' : '创建') }}
</Button>
</template>
</Dialog>
<!-- Token 创建成功对话框 -->
<Dialog
v-model="showTokenDialog"
size="lg"
persistent
>
<template #header>
<div class="border-b border-border px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30 flex-shrink-0">
<CheckCircle class="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight">
{{ isRegenerating ? '令牌已重新生成' : '创建成功' }}
</h3>
<p class="text-xs text-muted-foreground">
请妥善保管此令牌只会显示一次
</p>
</div>
</div>
</div>
</template>
<div class="space-y-4">
<div class="space-y-2">
<Label class="text-sm font-medium">访问令牌</Label>
<div class="flex items-center gap-2">
<Input
type="text"
:value="newTokenValue"
readonly
class="flex-1 font-mono text-sm bg-muted/50 h-11"
@click="($event.target as HTMLInputElement)?.select()"
/>
<Button
class="h-11"
@click="copyToken(newTokenValue)"
>
复制
</Button>
</div>
</div>
<div class="p-3 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800">
<div class="flex gap-2">
<AlertTriangle class="h-4 w-4 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<p class="text-sm text-amber-800 dark:text-amber-200">
此令牌只会显示一次关闭后将无法再次查看请妥善保管
</p>
</div>
</div>
</div>
<template #footer>
<Button
class="h-10 px-5"
@click="showTokenDialog = false"
>
我已安全保存
</Button>
</template>
</Dialog>
<!-- 删除确认对话框 -->
<AlertDialog
v-model="showDeleteDialog"
type="danger"
title="确认删除"
:description="`确定要删除令牌「${tokenToDelete?.name}」吗?此操作不可恢复。`"
confirm-text="删除"
:loading="deleting"
@confirm="deleteToken"
@cancel="showDeleteDialog = false"
/>
<!-- 重新生成确认对话框 -->
<AlertDialog
v-model="showRegenerateDialog"
type="warning"
title="确认重新生成"
:description="`重新生成后,原令牌将立即失效。确定要重新生成「${tokenToRegenerate?.name}」吗?`"
confirm-text="重新生成"
:loading="regenerating"
@confirm="regenerateToken"
@cancel="showRegenerateDialog = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, reactive, watch } from 'vue'
import {
managementTokenApi,
type ManagementToken
} from '@/api/management-tokens'
import Card from '@/components/ui/card.vue'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import Badge from '@/components/ui/badge.vue'
import { Dialog, Pagination } from '@/components/ui'
import { LoadingState, AlertDialog, EmptyState } from '@/components/common'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui'
import RefreshButton from '@/components/ui/refresh-button.vue'
import {
Plus,
KeyRound,
Trash2,
Loader2,
CheckCircle,
Power,
Pencil,
RefreshCw,
AlertTriangle
} from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { log } from '@/utils/logger'
const { success, error: showError } = useToast()
// 数据
const tokens = ref<ManagementToken[]>([])
const totalTokens = ref(0)
const loading = ref(false)
const saving = ref(false)
const deleting = ref(false)
const regenerating = ref(false)
// 配额信息
const quota = ref<{ used: number; max: number } | null>(null)
// 分页
const currentPage = ref(1)
const pageSize = ref(10)
const paginatedTokens = computed(() => tokens.value)
// 监听分页变化
watch([currentPage, pageSize], () => {
loadTokens()
})
function handlePageSizeChange(newSize: number) {
pageSize.value = newSize
currentPage.value = 1
}
// 对话框状态
const showCreateDialog = ref(false)
const showTokenDialog = ref(false)
const showDeleteDialog = ref(false)
const showRegenerateDialog = ref(false)
// 表单数据
const editingToken = ref<ManagementToken | null>(null)
const formData = reactive({
name: '',
description: '',
allowedIpsText: '',
expiresAt: ''
})
const newTokenValue = ref('')
const isRegenerating = ref(false)
const tokenToDelete = ref<ManagementToken | null>(null)
const tokenToRegenerate = ref<ManagementToken | null>(null)
// 表单验证
const isFormValid = computed(() => {
return formData.name.trim().length > 0
})
function getStatusVariant(token: ManagementToken): 'success' | 'secondary' | 'destructive' {
if (token.expires_at && isExpired(token.expires_at)) {
return 'destructive'
}
return token.is_active ? 'success' : 'secondary'
}
function getStatusText(token: ManagementToken): string {
if (token.expires_at && isExpired(token.expires_at)) {
return '已过期'
}
return token.is_active ? '活跃' : '禁用'
}
function isExpired(dateString: string): boolean {
return new Date(dateString) < new Date()
}
// 加载数据
onMounted(() => {
loadTokens()
})
async function loadTokens() {
loading.value = true
try {
const skip = (currentPage.value - 1) * pageSize.value
const response = await managementTokenApi.listTokens({ skip, limit: pageSize.value })
tokens.value = response.items
totalTokens.value = response.total
if (response.quota) {
quota.value = response.quota
}
// 如果当前页超出范围,重置到第一页
if (tokens.value.length === 0 && currentPage.value > 1) {
currentPage.value = 1
}
} catch (err: any) {
log.error('加载 Management Tokens 失败:', err)
if (!err.response) {
showError('无法连接到服务器')
} else {
showError(`加载失败:${err.response?.data?.detail || err.message}`)
}
} finally {
loading.value = false
}
}
// 打开编辑对话框
function openEditDialog(token: ManagementToken) {
editingToken.value = token
formData.name = token.name
formData.description = token.description || ''
formData.allowedIpsText = (token.allowed_ips && token.allowed_ips.length > 0)
? token.allowed_ips.join(', ')
: ''
formData.expiresAt = token.expires_at
? toLocalDatetimeString(new Date(token.expires_at))
: ''
showCreateDialog.value = true
}
// 关闭对话框
function closeDialog() {
showCreateDialog.value = false
editingToken.value = null
formData.name = ''
formData.description = ''
formData.allowedIpsText = ''
formData.expiresAt = ''
}
// 保存 Token
async function saveToken() {
if (!isFormValid.value) return
saving.value = true
try {
const allowedIps = formData.allowedIpsText
.split(',')
.map(ip => ip.trim())
.filter(ip => ip)
// 将本地时间转换为 UTC ISO 字符串
const expiresAtUtc = formData.expiresAt
? new Date(formData.expiresAt).toISOString()
: null
if (editingToken.value) {
// 更新
await managementTokenApi.updateToken(editingToken.value.id, {
name: formData.name,
description: formData.description.trim() || null,
allowed_ips: allowedIps.length > 0 ? allowedIps : null,
expires_at: expiresAtUtc
})
success('令牌更新成功')
} else {
// 创建
const result = await managementTokenApi.createToken({
name: formData.name,
description: formData.description || undefined,
allowed_ips: allowedIps.length > 0 ? allowedIps : undefined,
expires_at: expiresAtUtc
})
newTokenValue.value = result.token
isRegenerating.value = false
showTokenDialog.value = true
success('令牌创建成功')
}
closeDialog()
await loadTokens()
} catch (err: any) {
log.error('保存 Token 失败:', err)
const message = err.response?.data?.error?.message
|| err.response?.data?.detail
|| '保存失败'
showError(message)
} finally {
saving.value = false
}
}
// 切换状态
async function toggleToken(token: ManagementToken) {
try {
const result = await managementTokenApi.toggleToken(token.id)
const index = tokens.value.findIndex(t => t.id === token.id)
if (index !== -1) {
tokens.value[index] = result.data
}
success(result.data.is_active ? '令牌已启用' : '令牌已禁用')
} catch (err: any) {
log.error('切换状态失败:', err)
showError('操作失败')
}
}
// 删除
function confirmDelete(token: ManagementToken) {
tokenToDelete.value = token
showDeleteDialog.value = true
}
async function deleteToken() {
if (!tokenToDelete.value) return
deleting.value = true
try {
await managementTokenApi.deleteToken(tokenToDelete.value.id)
showDeleteDialog.value = false
success('令牌已删除')
await loadTokens()
} catch (err: any) {
log.error('删除 Token 失败:', err)
showError('删除失败')
} finally {
deleting.value = false
tokenToDelete.value = null
}
}
// 重新生成
function confirmRegenerate(token: ManagementToken) {
tokenToRegenerate.value = token
showRegenerateDialog.value = true
}
async function regenerateToken() {
if (!tokenToRegenerate.value) return
regenerating.value = true
try {
const result = await managementTokenApi.regenerateToken(tokenToRegenerate.value.id)
newTokenValue.value = result.token
isRegenerating.value = true
showRegenerateDialog.value = false
showTokenDialog.value = true
await loadTokens()
success('令牌已重新生成')
} catch (err: any) {
log.error('重新生成失败:', err)
showError('重新生成失败')
} finally {
regenerating.value = false
tokenToRegenerate.value = null
}
}
// 复制 Token
async function copyToken(text: string) {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
success('已复制到剪贴板')
} else {
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
success('已复制到剪贴板')
}
} catch (err) {
log.error('复制失败:', err)
showError('复制失败')
}
}
// 格式化
function formatNumber(num: number): string {
return num.toLocaleString('zh-CN')
}
function toLocalDatetimeString(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day}T${hours}:${minutes}`
}
function formatDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
}
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (diffMins < 1) return '刚刚'
if (diffMins < 60) return `${diffMins}分钟前`
if (diffHours < 24) return `${diffHours}小时前`
if (diffDays < 7) return `${diffDays}天前`
return formatDate(dateString)
}
</script>