Files
BYW-VoiceHub/app/components/Admin/ApiKeyManager.vue
T
Henry_Du 9fc5e3e607
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Canceled after 0s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Canceled after 0s
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Canceled after 1m27s
fix(ui): 批量替换所有组件中残留的硬编码深色值以彻底支持浅色主题
2026-09-12 15:56:10 +08:00

1078 lines
37 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="max-w-[1400px] mx-auto space-y-6 pb-20 px-2">
<!-- 头部区域 -->
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">API密钥管理</h2>
<p class="text-xs text-zinc-500 mt-1">管理开放API的访问密钥,控制第三方应用的访问权限</p>
</div>
<button
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold rounded-xl transition-all shadow-lg shadow-blue-900/20 active:scale-95"
@click="openCreateModal"
>
<Plus :size="14" /> 创建API密钥
</button>
</div>
<!-- 过滤器栏 -->
<div
class="bg-zinc-900/40 border border-zinc-800/60 rounded-2xl p-3 flex flex-col lg:flex-row gap-3 items-center"
>
<div class="relative flex-1 w-full">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-700" :size="16" />
<input
v-model="filters.search"
type="text"
placeholder="搜索API密钥名称或描述..."
class="w-full bg-zinc-950 border border-zinc-800/80 rounded-xl pl-10 pr-4 py-2.5 text-xs focus:outline-none focus:border-blue-500/30 transition-all placeholder:text-zinc-800 text-zinc-200"
@input="debouncedSearch"
>
</div>
<div class="flex items-center gap-2 w-full lg:w-auto">
<CustomSelect
v-model="statusFilterText"
label="状态"
:options="['全部状态', '活跃', '非活跃', '已过期']"
class-name="flex-1 lg:w-40"
@change="handleStatusFilterChange"
/>
<div class="relative flex-1 lg:w-48">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-700" :size="14" />
<input
v-model="filters.createdBy"
type="text"
placeholder="创建者用户名"
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl pl-9 pr-4 py-2.5 text-xs focus:outline-none text-zinc-400 placeholder:text-zinc-800"
@input="debouncedSearch"
>
</div>
<button
class="p-2.5 bg-zinc-950 border border-zinc-800 rounded-xl text-zinc-600 hover:text-blue-400 transition-all"
@click="loadApiKeys"
>
<RefreshCw :size="14" :class="{ 'animate-spin': loading }" />
</button>
</div>
</div>
<!-- 内容区域 -->
<div
v-if="loading && apiKeys.length === 0"
class="flex flex-col items-center justify-center py-20"
>
<div class="loading-spinner mb-4" />
<p class="text-zinc-500 text-xs">加载中...</p>
</div>
<div
v-else-if="apiKeys.length === 0"
class="flex flex-col items-center justify-center py-20 px-4 text-center"
>
<div
class="w-20 h-20 rounded-3xl bg-zinc-900 border border-zinc-800 flex items-center justify-center mb-6 text-zinc-700 shadow-xl"
>
<Key :size="32" :stroke-width="1.5" />
</div>
<h3 class="text-lg font-bold text-zinc-200">暂无API密钥</h3>
<p class="text-xs text-zinc-500 mt-2 max-w-xs leading-relaxed">
您还没有创建任何访问密钥。创建密钥后,您可以安全地将 VoiceHub 集成到第三方应用程序中。
</p>
<button
class="mt-8 flex items-center gap-2 px-6 py-3 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-300 text-xs font-bold rounded-2xl transition-all"
@click="openCreateModal"
>
<Plus :size="16" /> 创建您的第一个密钥
</button>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
<div
v-for="apiKey in apiKeys"
:key="apiKey.id"
class="bg-zinc-900/30 border border-zinc-800/60 rounded-2xl p-6 group hover:border-zinc-700 transition-all relative overflow-hidden"
>
<div class="flex items-start justify-between relative z-10">
<div class="space-y-1">
<div class="flex items-center gap-3">
<h4 class="text-sm font-black text-zinc-100 uppercase tracking-widest">
{{ apiKey.name }}
</h4>
<span
v-if="apiKey.status === 'active'"
class="inline-flex items-center gap-1.5 px-2 py-0.5 bg-emerald-500/10 text-emerald-500 text-[10px] font-black rounded uppercase border border-emerald-500/20"
>活跃</span
>
<span
v-else-if="apiKey.status === 'inactive'"
class="inline-flex items-center gap-1.5 px-2 py-0.5 bg-zinc-800 text-zinc-500 text-[10px] font-black rounded uppercase border border-zinc-700/50"
>非活跃</span
>
<span
v-else-if="apiKey.status === 'expired'"
class="inline-flex items-center gap-1.5 px-2 py-0.5 bg-red-500/10 text-red-500 text-[10px] font-black rounded uppercase border border-red-500/20"
>已过期</span
>
</div>
<p class="text-xs text-zinc-500 font-medium">{{ apiKey.description || '暂无描述' }}</p>
</div>
<div
class="flex items-center gap-1 opacity-100 lg:opacity-0 group-hover:opacity-100 transition-all"
>
<button
class="p-2 text-zinc-500 hover:text-blue-400 transition-colors"
@click="viewApiKey(apiKey)"
>
<Eye :size="14" />
</button>
<button
class="p-2 text-zinc-500 hover:text-amber-400 transition-colors"
@click="editApiKey(apiKey)"
>
<Edit2 :size="14" />
</button>
<button
class="p-2 text-zinc-500 hover:text-red-400 transition-colors"
@click="deleteApiKey(apiKey)"
>
<Trash2 :size="14" />
</button>
</div>
</div>
<div class="mt-8 grid grid-cols-2 gap-4 border-t border-zinc-800/40 pt-6 relative z-10">
<div class="space-y-0.5">
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest"
>创建者</span
>
<p class="text-xs font-bold text-zinc-400">{{ apiKey.creatorName || '未知' }}</p>
</div>
<div class="space-y-0.5">
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest"
>创建时间</span
>
<p class="text-xs font-bold text-zinc-400">
{{ formatDate(apiKey.createdAt).split(' ')[0] }}
</p>
</div>
<div class="col-span-2 space-y-1">
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest"
>权限列表</span
>
<div class="flex flex-wrap gap-1.5">
<span
v-for="perm in apiKey.permissions"
:key="perm"
class="text-[9px] font-mono bg-zinc-950 px-1.5 py-0.5 rounded text-zinc-500 border border-zinc-800/50"
>
{{ perm }}
</span>
</div>
</div>
</div>
<!-- 背景点缀 -->
<div
class="absolute -right-4 -bottom-4 w-24 h-24 bg-blue-500/5 blur-3xl rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
/>
</div>
</div>
<!-- 分页 -->
<Pagination
v-model:current-page="pagination.page"
:total-pages="pagination.totalPages"
:total-items="pagination.total"
item-name="个API密钥"
@change="loadApiKeys"
/>
<!-- 模态框组 -->
<Transition name="modal">
<div
v-if="showCreateModal || showEditModal || showViewModal || showSuccessModal"
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" @click="closeModals" />
<!-- 创建/编辑模态框 -->
<div
v-if="showCreateModal || showEditModal"
class="relative w-full max-w-lg bg-zinc-900 border border-zinc-800 rounded-3xl shadow-2xl overflow-hidden animate-in fade-in zoom-in duration-200"
>
<div class="p-6 border-b border-zinc-800 flex items-center justify-between">
<h3 class="text-lg font-black text-zinc-100 uppercase tracking-widest">
{{ showCreateModal ? '创建API密钥' : '编辑API密钥' }}
</h3>
<button
class="text-zinc-500 hover:text-zinc-200 transition-colors"
@click="closeModals"
>
<X :size="20" />
</button>
</div>
<div class="p-6 space-y-5 max-h-[70vh] overflow-y-auto custom-scrollbar">
<div class="space-y-1.5">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-0.5"
>名称 *</label
>
<input
v-model="form.name"
type="text"
placeholder="输入API密钥名称"
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-3 py-2.5 text-xs focus:outline-none focus:border-blue-500/30 text-zinc-200 placeholder:text-zinc-800"
>
</div>
<div class="space-y-1.5">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-0.5"
>描述</label
>
<textarea
v-model="form.description"
placeholder="输入API密钥描述 (可选)"
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-3 py-2.5 text-xs focus:outline-none focus:border-blue-500/30 text-zinc-200 min-h-[80px] resize-none placeholder:text-zinc-800"
/>
</div>
<div class="space-y-1.5">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-0.5"
>过期时间</label
>
<CustomSelect
v-model="expiresAtText"
:options="[
'永不过期',
'3天后过期',
'7天后过期',
'30天后过期',
'60天后过期',
'90天后过期'
]"
class-name="w-full"
@change="handleExpiresAtChange"
/>
</div>
<div class="space-y-3">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-0.5"
>权限设置 *</label
>
<div class="grid grid-cols-2 gap-2">
<label
v-for="perm in availablePermissions"
:key="perm.value"
class="flex items-start gap-3 p-3 bg-zinc-950 border border-zinc-800 rounded-xl cursor-pointer hover:border-zinc-600 transition-all"
:class="{
'border-blue-500/30 bg-blue-500/5': form.permissions.includes(perm.value)
}"
>
<input
v-model="form.permissions"
type="checkbox"
:value="perm.value"
class="mt-1 w-3.5 h-3.5 rounded border-zinc-800 bg-zinc-900 accent-blue-600"
>
<div>
<p
class="text-xs font-bold"
:class="
form.permissions.includes(perm.value) ? 'text-blue-400' : 'text-zinc-300'
"
>
{{ perm.label }}
</p>
<p class="text-[9px] text-zinc-600 mt-0.5">{{ perm.description }}</p>
</div>
</label>
</div>
</div>
<label
v-if="showEditModal"
class="flex items-center gap-2 p-3 bg-blue-600/5 border border-blue-500/10 rounded-xl cursor-pointer group"
>
<input
v-model="form.isActive"
type="checkbox"
class="w-3.5 h-3.5 rounded border-zinc-800 bg-zinc-900 accent-blue-600"
>
<span
class="text-xs font-bold text-zinc-300 group-hover:text-blue-400 transition-colors"
>启用此API密钥</span
>
</label>
</div>
<div class="p-6 border-t border-zinc-800 flex gap-2 justify-end">
<button
class="px-4 py-2 text-xs font-bold text-zinc-500 hover:text-zinc-300"
@click="closeModals"
>
取消
</button>
<button
:disabled="submitting"
class="px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold rounded-xl shadow-lg shadow-blue-900/20 disabled:opacity-50 transition-all"
@click="showCreateModal ? createApiKey() : updateApiKey()"
>
{{ submitting ? '保存中...' : showCreateModal ? '创建密钥' : '保存更改' }}
</button>
</div>
</div>
<!-- 成功模态框 -->
<div
v-if="showSuccessModal"
class="relative w-full max-w-lg bg-zinc-900 border border-zinc-800 rounded-3xl shadow-2xl overflow-hidden animate-in fade-in zoom-in duration-200"
>
<div class="p-6 border-b border-zinc-800 flex items-center justify-between">
<h3 class="text-lg font-black text-zinc-100 uppercase tracking-widest">
API密钥创建成功
</h3>
<button
class="text-zinc-500 hover:text-zinc-200 transition-colors"
@click="closeModals"
>
<X :size="20" />
</button>
</div>
<div class="p-6 space-y-6">
<div
class="p-6 bg-emerald-500/10 border border-emerald-500/20 rounded-2xl flex flex-col items-center text-center"
>
<div
class="w-12 h-12 rounded-full bg-emerald-500 text-white flex items-center justify-center mb-4 shadow-lg shadow-emerald-900/20"
>
<Check :size="24" :stroke-width="3" />
</div>
<h4 class="text-lg font-black text-emerald-400">API密钥创建成功!</h4>
<p class="text-xs text-zinc-500 mt-2">请妥善保管以下API密钥,它只会显示这一次。</p>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-0.5"
>完整API密钥</label
>
<div class="flex items-center gap-2">
<div
class="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 font-mono text-xs text-blue-400 break-all select-all"
>
{{ newApiKey?.apiKey }}
</div>
<button
class="p-3 rounded-xl transition-all"
:class="
copied
? 'bg-emerald-600 text-white'
: 'bg-zinc-800 hover:bg-zinc-700 text-zinc-300'
"
@click="copyToClipboard(newApiKey?.apiKey)"
>
<Check v-if="copied" :size="16" />
<Copy v-else :size="16" />
</button>
</div>
<div
class="flex items-center gap-2 p-3 bg-amber-500/10 border border-amber-500/20 rounded-xl text-amber-500"
>
<AlertTriangle :size="14" class="shrink-0" />
<p class="text-[10px] font-bold">
请立即复制并保存此API密钥,关闭此窗口后将无法再次查看完整密钥
</p>
</div>
</div>
</div>
<div class="p-6 border-t border-zinc-800">
<button
class="w-full py-2.5 bg-zinc-950 border border-zinc-800 hover:border-zinc-700 text-zinc-200 text-xs font-black rounded-xl transition-all"
@click="closeModals"
>
我已保存,关闭窗口
</button>
</div>
</div>
<!-- 详情模态框 -->
<div
v-if="showViewModal && selectedApiKey"
class="relative w-full max-w-2xl bg-zinc-900 border border-zinc-800 rounded-3xl shadow-2xl overflow-hidden animate-in fade-in zoom-in duration-200"
>
<div class="p-6 border-b border-zinc-800 flex items-center justify-between">
<h3 class="text-lg font-black text-zinc-100 uppercase tracking-widest">API密钥详情</h3>
<button
class="text-zinc-500 hover:text-zinc-200 transition-colors"
@click="closeModals"
>
<X :size="20" />
</button>
</div>
<div class="p-6 space-y-8 max-h-[80vh] overflow-y-auto custom-scrollbar">
<section class="space-y-4">
<h5
class="text-[10px] font-black text-zinc-600 uppercase tracking-widest border-b border-zinc-800 pb-2"
>
基本信息
</h5>
<div class="grid grid-cols-2 gap-6">
<div class="space-y-1">
<span class="text-[10px] text-zinc-600 font-bold">名称:</span>
<p class="text-xs font-bold text-zinc-200">{{ selectedApiKey.name }}</p>
</div>
<div class="space-y-1">
<span class="text-[10px] text-zinc-600 font-bold">状态:</span>
<div>
<span
v-if="selectedApiKey.status === 'active'"
class="inline-flex items-center gap-1.5 px-2 py-0.5 bg-emerald-500/10 text-emerald-500 text-[10px] font-black rounded uppercase border border-emerald-500/20"
>活跃</span
>
<span
v-else-if="selectedApiKey.status === 'inactive'"
class="inline-flex items-center gap-1.5 px-2 py-0.5 bg-zinc-800 text-zinc-500 text-[10px] font-black rounded uppercase border border-zinc-700/50"
>非活跃</span
>
<span
v-else-if="selectedApiKey.status === 'expired'"
class="inline-flex items-center gap-1.5 px-2 py-0.5 bg-red-500/10 text-red-500 text-[10px] font-black rounded uppercase border border-red-500/20"
>已过期</span
>
</div>
</div>
<div class="col-span-2 space-y-1">
<span class="text-[10px] text-zinc-600 font-bold">描述:</span>
<p class="text-xs font-bold text-zinc-400 leading-relaxed">
{{ selectedApiKey.description || '暂无描述' }}
</p>
</div>
<div class="space-y-1">
<span class="text-[10px] text-zinc-600 font-bold">创建者:</span>
<p class="text-xs font-bold text-zinc-200">
{{ selectedApiKey.creatorName || '未知' }}
</p>
</div>
<div class="space-y-1">
<span class="text-[10px] text-zinc-600 font-bold">创建时间:</span>
<p class="text-xs font-bold text-zinc-200">
{{ formatDate(selectedApiKey.createdAt) }}
</p>
</div>
<div v-if="selectedApiKey.expiresAt" class="space-y-1">
<span class="text-[10px] text-zinc-600 font-bold">过期时间:</span>
<p
class="text-xs font-bold"
:class="selectedApiKey.isExpired ? 'text-red-400' : 'text-zinc-200'"
>
{{ formatDate(selectedApiKey.expiresAt) }}
</p>
</div>
</div>
</section>
<section class="space-y-4">
<h5
class="text-[10px] font-black text-zinc-600 uppercase tracking-widest border-b border-zinc-800 pb-2"
>
使用统计
</h5>
<div class="grid grid-cols-2 gap-4">
<div
class="p-4 bg-zinc-950 border border-zinc-800 rounded-2xl flex items-center justify-between group hover:border-blue-500/30 transition-all"
>
<div class="space-y-0.5">
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest"
>总调用次数</span
>
<p class="text-xl font-black text-zinc-100">
{{ selectedApiKey.usageCount || 0 }}
</p>
</div>
<div
class="p-2 bg-blue-500/10 rounded-xl text-blue-500 group-hover:scale-110 transition-transform"
>
<BarChart :size="20" />
</div>
</div>
<div
class="p-4 bg-zinc-950 border border-zinc-800 rounded-2xl flex items-center justify-between group hover:border-amber-500/30 transition-all"
>
<div class="space-y-0.5">
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest"
>最后使用时间</span
>
<p class="text-xs font-bold text-zinc-100">
{{
selectedApiKey.lastUsedAt
? formatDate(selectedApiKey.lastUsedAt)
: '从未核对'
}}
</p>
</div>
<div
class="p-2 bg-amber-500/10 rounded-xl text-amber-500 group-hover:scale-110 transition-transform"
>
<Clock :size="20" />
</div>
</div>
</div>
</section>
<section class="space-y-4">
<div class="flex items-center justify-between border-b border-zinc-800 pb-2">
<h5 class="text-[10px] font-black text-zinc-600 uppercase tracking-widest">
最近调用日志
</h5>
<Pagination
v-model:current-page="logsPagination.page"
:total-pages="logsPagination.totalPages"
@change="loadApiLogs"
/>
</div>
<div class="space-y-2">
<div
v-if="loadingLogs"
class="flex flex-col items-center justify-center py-10 text-zinc-600 gap-2"
>
<RefreshCw :size="24" class="animate-spin" />
<span class="text-[10px] font-bold">正在加载日志...</span>
</div>
<div
v-else-if="apiLogs.length === 0"
class="flex flex-col items-center justify-center py-10 bg-zinc-950/50 border border-zinc-800/50 rounded-2xl"
>
<History :size="24" class="text-zinc-800 mb-2" />
<span class="text-[10px] font-bold text-zinc-700 uppercase tracking-widest"
>暂无调用记录</span
>
</div>
<div
v-for="log in apiLogs"
v-else
:key="log.id"
class="p-3 bg-zinc-950 border border-zinc-800/40 rounded-xl flex items-center justify-between group hover:border-zinc-700 transition-all"
>
<div class="flex items-center gap-4">
<div
class="px-2 py-0.5 rounded text-[9px] font-black uppercase tracking-widest border"
:class="getMethodClass(log.method)"
>
{{ log.method }}
</div>
<div class="space-y-0.5">
<p class="text-xs font-mono text-zinc-400">{{ log.endpoint }}</p>
<div class="flex items-center gap-2 text-[9px] font-bold text-zinc-600">
<span>{{ formatDate(log.createdAt) }}</span>
<span>•</span>
<span>{{ log.ipAddress }}</span>
</div>
</div>
</div>
<div class="flex items-center gap-4">
<div class="text-right space-y-0.5">
<p class="text-xs font-black" :class="getStatusColorClass(log.statusCode)">
{{ log.statusCode }}
</p>
<p class="text-[9px] font-bold text-zinc-600">{{ log.responseTimeMs }}ms</p>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
</Transition>
<!-- 确认删除对话框 -->
<ConfirmDialog
v-model:show="showConfirmDialog"
:cancel-text="confirmDialogConfig.cancelText"
:confirm-text="confirmDialogConfig.confirmText"
:message="confirmDialogConfig.message"
:title="confirmDialogConfig.title"
:type="confirmDialogConfig.type"
@cancel="cancelDelete"
@confirm="confirmDelete"
/>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import {
Plus,
Search,
Key,
Trash2,
Edit2,
Eye,
Copy,
Check,
AlertTriangle,
RefreshCw,
BarChart,
Clock,
History,
ChevronLeft,
ChevronRight,
X
} from '@lucide/vue'
import { useToast } from '~/composables/useToast'
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
import Pagination from '~/components/UI/Common/Pagination.vue'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
// 响应式数据
const loading = ref(false)
const submitting = ref(false)
const apiKeys = ref([])
const selectedApiKey = ref(null)
const showCreateModal = ref(false)
const showEditModal = ref(false)
const showViewModal = ref(false)
const showSuccessModal = ref(false)
const newApiKey = ref(null)
const loadingLogs = ref(false)
const apiLogs = ref([])
const copied = ref(false)
// 文本映射
const statusFilterText = ref('全部状态')
const expiresAtText = ref('永不过期')
const logsPagination = ref({
page: 1,
limit: 5,
total: 0,
totalPages: 0
})
// 确认对话框相关
const showConfirmDialog = ref(false)
const confirmDialogConfig = ref({
title: '确认删除',
message: '',
type: 'danger',
confirmText: '删除',
cancelText: '取消'
})
const pendingDeleteApiKey = ref(null)
// 分页信息
const pagination = ref({
page: 1,
limit: 12,
total: 0,
totalPages: 0
})
// 筛选条件
const filters = reactive({
search: '',
status: '',
createdBy: ''
})
// 表单数据
const form = reactive({
name: '',
description: '',
expiresAt: '',
permissions: [],
isActive: true
})
// 可用权限列表
const availablePermissions = [
{
value: 'schedules:read',
label: '排期查询',
description: '查看排期列表和详情'
},
{
value: 'songs:read',
label: '歌曲查询',
description: '查看歌曲列表和详情'
}
]
// Toast通知
const toast = useToast()
// 方法
const handleStatusFilterChange = (val) => {
const statusMap = { 活跃: 'active', 非活跃: 'inactive', 已过期: 'expired' }
filters.status = statusMap[val] || ''
loadApiKeys()
}
const handleExpiresAtChange = (val) => {
const map = {
永不过期: '',
'3天后过期': '3d',
'7天后过期': '7d',
'30天后过期': '30d',
'60天后过期': '60d',
'90天后过期': '90d'
}
form.expiresAt = map[val] || ''
}
// 搜索防抖
let searchTimeout
const debouncedSearch = () => {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
loadApiKeys()
}, 500)
}
const loadApiKeys = async () => {
loading.value = true
try {
const params = new URLSearchParams({
page: pagination.value.page.toString(),
limit: pagination.value.limit.toString()
})
if (filters.search) params.append('search', filters.search)
if (filters.status) params.append('status', filters.status)
if (filters.createdBy) params.append('createdBy', filters.createdBy)
const response = await $fetch(`/api/admin/api-keys?${params}`)
if (response.success) {
apiKeys.value = response.data.items
pagination.value = response.data.pagination
}
} catch (error) {
console.error('加载API密钥失败:', error)
toast.error('加载API密钥失败')
} finally {
loading.value = false
}
}
const openCreateModal = () => {
resetForm()
showCreateModal.value = true
}
const createApiKey = async () => {
if (!form.name) return toast.error('请输入密钥名称')
if (form.permissions.length === 0) return toast.error('请至少选择一个权限')
submitting.value = true
try {
const data = {
name: form.name,
description: form.description || null,
expiresAt: form.expiresAt || null,
permissions: form.permissions,
isActive: true
}
const response = await $fetch('/api/admin/api-keys', {
method: 'POST',
body: data
})
if (response.success) {
toast.success('API密钥创建成功')
newApiKey.value = response.data
showCreateModal.value = false
showSuccessModal.value = true
resetForm()
await loadApiKeys()
}
} catch (error) {
console.error('创建API密钥失败:', error)
toast.error(error.data?.message || '创建API密钥失败')
} finally {
submitting.value = false
}
}
const updateApiKey = async () => {
if (!selectedApiKey.value) return
if (!form.name) return toast.error('请输入密钥名称')
if (form.permissions.length === 0) return toast.error('请至少选择一个权限')
submitting.value = true
try {
const data = {
name: form.name,
description: form.description || null,
// 如果是 'keep',则不发送 expiresAt 字段,后端将不更新该字段
...(form.expiresAt !== 'keep' && { expiresAt: form.expiresAt || null }),
permissions: form.permissions,
isActive: form.isActive
}
const response = await $fetch(`/api/admin/api-keys/${selectedApiKey.value.id}`, {
method: 'PUT',
body: data
})
if (response.success) {
toast.success('API密钥更新成功')
closeModals()
await loadApiKeys()
}
} catch (error) {
console.error('更新API密钥失败:', error)
toast.error(error.data?.message || '更新API密钥失败')
} finally {
submitting.value = false
}
}
const deleteApiKey = (apiKey) => {
pendingDeleteApiKey.value = apiKey
confirmDialogConfig.value = {
title: '确认删除',
message: `确定要删除API密钥 "${apiKey.name}" 吗?此操作不可撤销。`,
type: 'danger',
confirmText: '删除',
cancelText: '取消'
}
showConfirmDialog.value = true
}
const confirmDelete = async () => {
if (!pendingDeleteApiKey.value) return
try {
const response = await $fetch(`/api/admin/api-keys/${pendingDeleteApiKey.value.id}`, {
method: 'DELETE'
})
if (response.success) {
toast.success('API密钥删除成功')
await loadApiKeys()
}
} catch (error) {
console.error('删除API密钥失败:', error)
toast.error(error.data?.message || '删除API密钥失败')
} finally {
showConfirmDialog.value = false
pendingDeleteApiKey.value = null
}
}
const cancelDelete = () => {
showConfirmDialog.value = false
pendingDeleteApiKey.value = null
}
const viewApiKey = async (apiKey) => {
try {
const response = await $fetch(`/api/admin/api-keys/${apiKey.id}`)
if (response.success) {
selectedApiKey.value = response.data
showViewModal.value = true
// 加载API使用日志
await loadApiLogs(1)
}
} catch (error) {
console.error('获取API密钥详情失败:', error)
toast.error('获取API密钥详情失败')
}
}
const editApiKey = async (apiKey) => {
try {
const response = await $fetch(`/api/admin/api-keys/${apiKey.id}`)
if (response.success) {
selectedApiKey.value = response.data
// 填充表单
form.name = response.data.name
form.description = response.data.description || ''
// 处理过期时间显示
if (response.data.expiresAt) {
const date = new Date(response.data.expiresAt)
expiresAtText.value = `到期: ${date.toLocaleDateString()}`
form.expiresAt = 'keep' // 特殊值,表示不修改过期时间
} else {
expiresAtText.value = '永不过期'
form.expiresAt = ''
}
form.permissions = response.data.permissions || []
form.isActive = response.data.isActive
showEditModal.value = true
}
} catch (error) {
console.error('获取API密钥详情失败:', error)
toast.error('获取API密钥详情失败')
}
}
const loadApiLogs = async (page = 1) => {
if (!selectedApiKey.value) return
if (page < 1 || (logsPagination.value.totalPages > 0 && page > logsPagination.value.totalPages))
return
loadingLogs.value = true
try {
const response = await $fetch('/api/admin/api-keys/logs', {
query: {
apiKeyId: selectedApiKey.value.id,
page,
limit: logsPagination.value.limit,
sortBy: 'createdAt',
sortOrder: 'desc'
}
})
if (response.logs) {
apiLogs.value = response.logs
logsPagination.value = {
...logsPagination.value,
...response.pagination,
page
}
}
} catch (error) {
console.error('获取API使用日志失败:', error)
toast.error('获取API使用日志失败')
apiLogs.value = []
} finally {
loadingLogs.value = false
}
}
const closeModals = () => {
showCreateModal.value = false
showEditModal.value = false
showViewModal.value = false
showSuccessModal.value = false
selectedApiKey.value = null
newApiKey.value = null
// 重置日志相关状态
apiLogs.value = []
loadingLogs.value = false
logsPagination.value = {
page: 1,
limit: 5,
total: 0,
totalPages: 0
}
resetForm()
}
const resetForm = () => {
form.name = ''
form.description = ''
form.expiresAt = ''
expiresAtText.value = '永不过期'
form.permissions = []
form.isActive = true
}
const formatDate = (dateString) => {
if (!dateString) return ''
return new Date(dateString).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text)
copied.value = true
toast.success('已复制到剪贴板')
setTimeout(() => (copied.value = false), 2000)
} catch (error) {
console.error('复制失败:', error)
toast.error('复制失败')
}
}
const getMethodClass = (method) => {
const map = {
GET: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20',
POST: 'bg-blue-500/10 text-blue-500 border-blue-500/20',
PUT: 'bg-amber-500/10 text-amber-500 border-amber-500/20',
DELETE: 'bg-red-500/10 text-red-500 border-red-500/20'
}
return map[method] || 'bg-zinc-500/10 text-zinc-500 border-zinc-500/20'
}
const getStatusColorClass = (code) => {
if (code >= 200 && code < 300) return 'text-emerald-500'
if (code >= 300 && code < 400) return 'text-amber-500'
return 'text-red-500'
}
// 生命周期
onMounted(() => {
loadApiKeys()
})
</script>
<style scoped>
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid rgba(255, 255, 255, 0.1);
border-top-color: var(--border-secondary, #3b82f6);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: var(--bg-tertiary, #27272a);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #3f3f46;
}
/* 模态框动画 */
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .relative,
.modal-leave-active .relative {
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.modal-enter-from .relative {
transform: scale(0.9) translateY(20px);
}
.modal-leave-to .relative {
transform: scale(0.9) translateY(20px);
}
</style>