mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-11 03:58:28 +08:00
feat: 添加访问令牌管理功能并升级至 0.2.4
- 新增 Management Token(访问令牌)功能,支持创建、更新、删除和管理 - 前端添加访问令牌管理页面,支持普通用户和管理员 - 后端实现完整的令牌生命周期管理 API - 添加数据库迁移脚本创建 management_tokens 表 - Nginx 配置添加 gzip 压缩,优化响应传输 - Dialog 组件添加 persistent 属性,防止意外关闭 - 为管理后台 API 添加详细的中文文档注释 - 简化多处类型注解,统一代码风格
This commit is contained in:
203
frontend/src/api/management-tokens.ts
Normal file
203
frontend/src/api/management-tokens.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 监听器)
|
||||
}
|
||||
|
||||
@@ -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 登录
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
859
frontend/src/views/user/ManagementTokens.vue
Normal file
859
frontend/src/views/user/ManagementTokens.vue
Normal 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>
|
||||
Reference in New Issue
Block a user