Files
Henry_Du 3eff710d5d feat(微信订阅): 接入歌曲排期模板并加固部署流程
订阅消息(小程序侧唯一能主动触达用户的通道):
- 新增 server/utils/wechatSubscribeFields.ts:按微信字段类型裁剪值(thing≤20、
  phrase 必须 5 个汉字、time/date/character_string 各自上限),空白压成一行,
  北京时间 time 由 playTime 的开始时间生成,解析不出退化为当天 00:00。
- wechatSubscribeService 内置 SONG_SELECTED 模板(站长新给的那个模板 ID),
  并转发上述纯函数;notificationService.createSongSelectedNotification 现在会带上
  playDate/playTime/sequence,三个排期接口(schedule / publish / bulk-publish)补传 sequence。
- 新增 test/wechatSubscribeFields.test.mjs(10 例,node --test 全绿)。
- test-subscribe 管理接口支持 vars 覆盖并返回真实发送值,方便站长核对字段名。

部署流程(Gitea Actions 容器里没有 buildx 插件,旧流程在此硬失败):
- 每个 job 各自安装/探测 docker-buildx(v0.19.3,走 gh-proxy 再退直连),
  不把 `buildx version` 当唯一依据;装不上时降级为普通 `docker build` 继续发布,
  不再因为缺插件整条流水线失败。

注意:SONG_SELECTED 的字段名(thing2/date4/time1/character_string3/phrase5)是按常见
命名推断的,需在公众平台后台核对;不一致时用环境变量覆盖,模板 ID 变更后老用户需重新授权。
2026-10-11 00:04:14 +08:00

1078 lines
37 KiB
Vue
Raw Permalink 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-900 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-50/40 border border-zinc-200/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-white border border-zinc-200/80 rounded-xl pl-10 pr-4 py-2.5 text-xs focus:outline-none focus:border-blue-200 transition-all placeholder:text-zinc-500 text-zinc-700"
@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-white border border-zinc-200 rounded-xl pl-9 pr-4 py-2.5 text-xs focus:outline-none text-zinc-400 placeholder:text-zinc-500"
@input="debouncedSearch"
>
</div>
<button
class="p-2.5 bg-white border border-zinc-200 rounded-xl text-zinc-600 hover:text-blue-700 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-white border border-zinc-200 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-700">暂无API密钥</h3>
<p class="text-xs text-zinc-500 mt-2 max-w-xs leading-relaxed">
您还没有创建任何访问密钥。创建密钥后,您可以安全地将宝一外校园点歌系统集成到第三方应用程序中。
</p>
<button
class="mt-8 flex items-center gap-2 px-6 py-3 bg-white border border-zinc-200 hover:border-zinc-200 text-zinc-600 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-50/30 border border-zinc-200/60 rounded-2xl p-6 group hover:border-zinc-200 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-900 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-100 text-zinc-500 text-[10px] font-black rounded uppercase border border-zinc-200"
>非活跃</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-700 transition-colors"
@click="viewApiKey(apiKey)"
>
<Eye :size="14" />
</button>
<button
class="p-2 text-zinc-500 hover:text-amber-700 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-200 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-white px-1.5 py-0.5 rounded text-zinc-500 border border-zinc-200"
>
{{ 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-white border border-zinc-200 rounded-3xl shadow-2xl overflow-hidden animate-in fade-in zoom-in duration-200"
>
<div class="p-6 border-b border-zinc-200 flex items-center justify-between">
<h3 class="text-lg font-black text-zinc-900 uppercase tracking-widest">
{{ showCreateModal ? '创建API密钥' : '编辑API密钥' }}
</h3>
<button
class="text-zinc-500 hover:text-zinc-700 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-white border border-zinc-200 rounded-xl px-3 py-2.5 text-xs focus:outline-none focus:border-blue-200 text-zinc-700 placeholder:text-zinc-500"
>
</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-white border border-zinc-200 rounded-xl px-3 py-2.5 text-xs focus:outline-none focus:border-blue-200 text-zinc-700 min-h-[80px] resize-none placeholder:text-zinc-500"
/>
</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-white border border-zinc-200 rounded-xl cursor-pointer hover:border-zinc-300 transition-all"
:class="{
'border-blue-200 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-200 bg-white accent-blue-600"
>
<div>
<p
class="text-xs font-bold"
:class="
form.permissions.includes(perm.value) ? 'text-blue-700' : 'text-zinc-600'
"
>
{{ 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-200 bg-white accent-blue-600"
>
<span
class="text-xs font-bold text-zinc-600 group-hover:text-blue-700 transition-colors"
>启用此API密钥</span
>
</label>
</div>
<div class="p-6 border-t border-zinc-200 flex gap-2 justify-end">
<button
class="px-4 py-2 text-xs font-bold text-zinc-500 hover:text-zinc-600"
@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-white border border-zinc-200 rounded-3xl shadow-2xl overflow-hidden animate-in fade-in zoom-in duration-200"
>
<div class="p-6 border-b border-zinc-200 flex items-center justify-between">
<h3 class="text-lg font-black text-zinc-900 uppercase tracking-widest">
API密钥创建成功
</h3>
<button
class="text-zinc-500 hover:text-zinc-700 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-700">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-white border border-zinc-200 rounded-xl px-4 py-3 font-mono text-xs text-blue-700 break-all select-all"
>
{{ newApiKey?.apiKey }}
</div>
<button
class="p-3 rounded-xl transition-all"
:class="
copied
? 'bg-emerald-600 text-white'
: 'bg-zinc-100 hover:bg-zinc-200 text-zinc-600'
"
@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-200">
<button
class="w-full py-2.5 bg-white border border-zinc-200 hover:border-zinc-200 text-zinc-700 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-white border border-zinc-200 rounded-3xl shadow-2xl overflow-hidden animate-in fade-in zoom-in duration-200"
>
<div class="p-6 border-b border-zinc-200 flex items-center justify-between">
<h3 class="text-lg font-black text-zinc-900 uppercase tracking-widest">API密钥详情</h3>
<button
class="text-zinc-500 hover:text-zinc-700 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-200 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-700">{{ 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-100 text-zinc-500 text-[10px] font-black rounded uppercase border border-zinc-200"
>非活跃</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-700">
{{ 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-700">
{{ 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-700'"
>
{{ 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-200 pb-2"
>
使用统计
</h5>
<div class="grid grid-cols-2 gap-4">
<div
class="p-4 bg-white border border-zinc-200 rounded-2xl flex items-center justify-between group hover:border-blue-200 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-900">
{{ 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-white border border-zinc-200 rounded-2xl flex items-center justify-between group hover:border-amber-200 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-900">
{{
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-200 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-white/70 border border-zinc-200 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-white border border-zinc-200 rounded-xl flex items-center justify-between group hover:border-zinc-200 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-200',
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-100 text-zinc-500 border-zinc-200'
}
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(0, 0, 0, 0.08);
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>