feat: 全新的Vue3管理后台(admin-spa)和路由重构

🎨 新增功能:
- 使用Vue3 + Vite构建的全新管理后台界面
- 支持Tab切换的API统计页面(统计查询/使用教程)
- 优雅的胶囊式Tab切换设计
- 同步了PR #106的会话窗口管理功能
- 完整的响应式设计和骨架屏加载状态

🔧 路由调整:
- 新版管理后台部署在 /admin-next/ 路径
- 将根路径 / 重定向到 /admin-next/api-stats
- 将 /web 页面路由重定向到新版,保留 /web/auth/* 认证路由
- 将 /apiStats 页面路由重定向到新版,保留API端点

🗑️ 清理工作:
- 删除旧版 web/admin/ 静态文件
- 删除旧版 web/apiStats/ 静态文件
- 清理相关的文件服务代码

🐛 修复问题:
- 修复重定向循环问题
- 修复环境变量配置
- 修复路由404错误
- 优化构建配置

🚀 生成方式:使用 Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
shaw
2025-07-29 12:40:51 +08:00
parent c98de2aca5
commit 414856f152
70 changed files with 18748 additions and 10314 deletions

View File

@@ -0,0 +1,534 @@
<template>
<Teleport to="body">
<div class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
<div class="modal-content w-full max-w-md p-8 mx-auto max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center">
<i class="fas fa-key text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900">创建新的 API Key</h3>
</div>
<button
@click="$emit('close')"
class="text-gray-400 hover:text-gray-600 transition-colors"
>
<i class="fas fa-times text-xl"></i>
</button>
</div>
<form @submit.prevent="createApiKey" class="space-y-6 modal-scroll-content custom-scrollbar flex-1">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">名称</label>
<input
v-model="form.name"
type="text"
required
class="form-input w-full"
placeholder="为您的 API Key 取一个名称"
>
</div>
<!-- 速率限制设置 -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-4">
<div class="flex items-start gap-3 mb-3">
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
<i class="fas fa-tachometer-alt text-white text-sm"></i>
</div>
<div class="flex-1">
<h4 class="font-semibold text-gray-800 mb-1">速率限制设置 (可选)</h4>
<p class="text-sm text-gray-600">控制 API Key 的使用频率和资源消耗</p>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口 (分钟)</label>
<input
v-model="form.rateLimitWindow"
type="number"
min="1"
placeholder="留空表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">设置一个时间段以分钟为单位用于计算速率限制</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口内的请求次数限制</label>
<input
v-model="form.rateLimitRequests"
type="number"
min="1"
placeholder="留空表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">在时间窗口内允许的最大请求次数需要先设置时间窗口</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口内的 Token 使用量限制</label>
<input
v-model="form.tokenLimit"
type="number"
placeholder="留空表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">在时间窗口内允许消耗的最大 Token 数量需要先设置时间窗口</p>
</div>
<!-- 示例说明 -->
<div class="bg-blue-100 rounded-lg p-3 mt-3">
<h5 class="text-sm font-semibold text-blue-800 mb-2">💡 使用示例</h5>
<div class="text-xs text-blue-700 space-y-1">
<p><strong>示例1:</strong> 时间窗口=60请求次数限制=1000</p>
<p class="ml-4"> 每60分钟内最多1000次请求</p>
<p class="mt-2"><strong>示例2:</strong> 时间窗口=1Token限制=10000</p>
<p class="ml-4"> 每分钟最多消耗10,000个Token</p>
<p class="mt-2"><strong>示例3:</strong> 时间窗口=30请求次数限制=50Token限制=100000</p>
<p class="ml-4"> 每30分钟内最多50次请求且总Token不超过100,000</p>
</div>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">每日费用限制 (美元)</label>
<div class="space-y-3">
<div class="flex gap-2">
<button type="button" @click="form.dailyCostLimit = '50'" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">$50</button>
<button type="button" @click="form.dailyCostLimit = '100'" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">$100</button>
<button type="button" @click="form.dailyCostLimit = '200'" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">$200</button>
<button type="button" @click="form.dailyCostLimit = ''" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">自定义</button>
</div>
<input
v-model="form.dailyCostLimit"
type="number"
min="0"
step="0.01"
placeholder="0 表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500">设置此 API Key 每日的费用限制超过限制将拒绝请求0 或留空表示无限制</p>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">并发限制 (可选)</label>
<input
v-model="form.concurrencyLimit"
type="number"
min="0"
placeholder="0 表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">设置此 API Key 可同时处理的最大请求数0 或留空表示无限制</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">备注 (可选)</label>
<textarea
v-model="form.description"
rows="3"
class="form-input w-full resize-none"
placeholder="描述此 API Key 的用途..."
></textarea>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">有效期限</label>
<select
v-model="form.expireDuration"
@change="updateExpireAt"
class="form-input w-full"
>
<option value="">永不过期</option>
<option value="1d">1 </option>
<option value="7d">7 </option>
<option value="30d">30 </option>
<option value="90d">90 </option>
<option value="180d">180 </option>
<option value="365d">365 </option>
<option value="custom">自定义日期</option>
</select>
<div v-if="form.expireDuration === 'custom'" class="mt-3">
<input
v-model="form.customExpireDate"
type="datetime-local"
class="form-input w-full"
:min="minDateTime"
@change="updateCustomExpireAt"
>
</div>
<p v-if="form.expiresAt" class="text-xs text-gray-500 mt-2">
将于 {{ formatExpireDate(form.expiresAt) }} 过期
</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">服务权限</label>
<div class="flex gap-4">
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="form.permissions"
value="all"
class="mr-2"
>
<span class="text-sm text-gray-700">全部服务</span>
</label>
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="form.permissions"
value="claude"
class="mr-2"
>
<span class="text-sm text-gray-700"> Claude</span>
</label>
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="form.permissions"
value="gemini"
class="mr-2"
>
<span class="text-sm text-gray-700"> Gemini</span>
</label>
</div>
<p class="text-xs text-gray-500 mt-2">控制此 API Key 可以访问哪些服务</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">专属账号绑定 (可选)</label>
<div class="grid grid-cols-1 gap-3">
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">Claude 专属账号</label>
<select
v-model="form.claudeAccountId"
class="form-input w-full"
:disabled="form.permissions === 'gemini'"
>
<option value="">使用共享账号池</option>
<option
v-for="account in accounts.claude.filter(a => a.isDedicated)"
:key="account.id"
:value="account.id"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">Gemini 专属账号</label>
<select
v-model="form.geminiAccountId"
class="form-input w-full"
:disabled="form.permissions === 'claude'"
>
<option value="">使用共享账号池</option>
<option
v-for="account in accounts.gemini.filter(a => a.isDedicated)"
:key="account.id"
:value="account.id"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
</select>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">选择专属账号后此API Key将只使用该账号不选择则使用共享账号池</p>
</div>
<div>
<div class="flex items-center mb-3">
<input
type="checkbox"
v-model="form.enableModelRestriction"
id="enableModelRestriction"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
>
<label for="enableModelRestriction" class="ml-2 text-sm font-semibold text-gray-700 cursor-pointer">
启用模型限制
</label>
</div>
<div v-if="form.enableModelRestriction" class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-600 mb-2">限制的模型列表</label>
<div class="flex flex-wrap gap-2 mb-3 min-h-[32px] p-2 bg-gray-50 rounded-lg border border-gray-200">
<span
v-for="(model, index) in form.restrictedModels"
:key="index"
class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-red-100 text-red-800"
>
{{ model }}
<button
type="button"
@click="removeRestrictedModel(index)"
class="ml-2 text-red-600 hover:text-red-800"
>
<i class="fas fa-times text-xs"></i>
</button>
</span>
<span v-if="form.restrictedModels.length === 0" class="text-gray-400 text-sm">
暂无限制的模型
</span>
</div>
<div class="flex gap-2">
<input
v-model="form.modelInput"
@keydown.enter.prevent="addRestrictedModel"
type="text"
placeholder="输入模型名称,按回车添加"
class="form-input flex-1"
>
<button
type="button"
@click="addRestrictedModel"
class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
>
<i class="fas fa-plus"></i>
</button>
</div>
<p class="text-xs text-gray-500 mt-2">设置此API Key无法访问的模型例如claude-opus-4-20250514</p>
</div>
</div>
</div>
<!-- 客户端限制 -->
<div>
<div class="flex items-center mb-3">
<input
type="checkbox"
v-model="form.enableClientRestriction"
id="enableClientRestriction"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
>
<label for="enableClientRestriction" class="ml-2 text-sm font-semibold text-gray-700 cursor-pointer">
启用客户端限制
</label>
</div>
<div v-if="form.enableClientRestriction" class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-600 mb-2">允许的客户端</label>
<p class="text-xs text-gray-500 mb-3">勾选允许使用此API Key的客户端</p>
<div class="space-y-2">
<div v-for="client in supportedClients" :key="client.id" class="flex items-start">
<input
type="checkbox"
:id="`client_${client.id}`"
:value="client.id"
v-model="form.allowedClients"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 mt-0.5"
>
<label :for="`client_${client.id}`" class="ml-2 flex-1 cursor-pointer">
<span class="text-sm font-medium text-gray-700">{{ client.name }}</span>
<span class="text-xs text-gray-500 block">{{ client.description }}</span>
</label>
</div>
</div>
</div>
</div>
</div>
<div class="flex gap-3 pt-4">
<button
type="button"
@click="$emit('close')"
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
>
取消
</button>
<button
type="submit"
:disabled="loading || !form.name"
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
>
<div v-if="loading" class="loading-spinner mr-2"></div>
<i v-else class="fas fa-plus mr-2"></i>
{{ loading ? '创建中...' : '创建' }}
</button>
</div>
</form>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { showToast } from '@/utils/toast'
import { useAuthStore } from '@/stores/auth'
import { useClientsStore } from '@/stores/clients'
import { apiClient } from '@/config/api'
const props = defineProps({
accounts: {
type: Object,
default: () => ({ claude: [], gemini: [] })
}
})
const emit = defineEmits(['close', 'success'])
const authStore = useAuthStore()
const clientsStore = useClientsStore()
const loading = ref(false)
// 支持的客户端列表
const supportedClients = ref([])
// 表单数据
const form = reactive({
name: '',
description: '',
tokenLimit: '',
rateLimitWindow: '',
rateLimitRequests: '',
concurrencyLimit: '',
dailyCostLimit: '',
expireDuration: '',
customExpireDate: '',
expiresAt: null,
permissions: 'all',
claudeAccountId: '',
geminiAccountId: '',
enableModelRestriction: false,
restrictedModels: [],
modelInput: '',
enableClientRestriction: false,
allowedClients: []
})
// 加载支持的客户端
onMounted(async () => {
supportedClients.value = await clientsStore.loadSupportedClients()
})
// 计算最小日期时间
const minDateTime = computed(() => {
const now = new Date()
now.setMinutes(now.getMinutes() + 1)
return now.toISOString().slice(0, 16)
})
// 更新过期时间
const updateExpireAt = () => {
if (!form.expireDuration) {
form.expiresAt = null
return
}
if (form.expireDuration === 'custom') {
return
}
const now = new Date()
const duration = form.expireDuration
const match = duration.match(/(\d+)([dhmy])/)
if (match) {
const [, value, unit] = match
const num = parseInt(value)
switch (unit) {
case 'd':
now.setDate(now.getDate() + num)
break
case 'h':
now.setHours(now.getHours() + num)
break
case 'm':
now.setMonth(now.getMonth() + num)
break
case 'y':
now.setFullYear(now.getFullYear() + num)
break
}
form.expiresAt = now.toISOString()
}
}
// 更新自定义过期时间
const updateCustomExpireAt = () => {
if (form.customExpireDate) {
form.expiresAt = new Date(form.customExpireDate).toISOString()
}
}
// 格式化过期日期
const formatExpireDate = (dateString) => {
const date = new Date(dateString)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
// 添加限制的模型
const addRestrictedModel = () => {
if (form.modelInput && !form.restrictedModels.includes(form.modelInput)) {
form.restrictedModels.push(form.modelInput)
form.modelInput = ''
}
}
// 移除限制的模型
const removeRestrictedModel = (index) => {
form.restrictedModels.splice(index, 1)
}
// 创建 API Key
const createApiKey = async () => {
loading.value = true
try {
// 准备提交的数据
const data = {
name: form.name,
description: form.description || undefined,
tokenLimit: form.tokenLimit !== '' && form.tokenLimit !== null ? parseInt(form.tokenLimit) : null,
rateLimitWindow: form.rateLimitWindow !== '' && form.rateLimitWindow !== null ? parseInt(form.rateLimitWindow) : null,
rateLimitRequests: form.rateLimitRequests !== '' && form.rateLimitRequests !== null ? parseInt(form.rateLimitRequests) : null,
concurrencyLimit: form.concurrencyLimit !== '' && form.concurrencyLimit !== null ? parseInt(form.concurrencyLimit) : 0,
dailyCostLimit: form.dailyCostLimit !== '' && form.dailyCostLimit !== null ? parseFloat(form.dailyCostLimit) : 0,
expiresAt: form.expiresAt || undefined,
permissions: form.permissions,
claudeAccountId: form.claudeAccountId || undefined,
geminiAccountId: form.geminiAccountId || undefined
}
// 模型限制
if (form.enableModelRestriction && form.restrictedModels.length > 0) {
data.restrictedModels = form.restrictedModels
}
// 客户端限制
if (form.enableClientRestriction && form.allowedClients.length > 0) {
data.allowedClients = form.allowedClients
}
const result = await apiClient.post('/admin/api-keys', data)
if (result.success) {
showToast('API Key 创建成功', 'success')
emit('success', result.apiKey)
emit('close')
} else {
showToast(result.message || '创建失败', 'error')
}
} catch (error) {
showToast('创建失败', 'error')
} finally {
loading.value = false
}
}
</script>
<style scoped>
/* 表单样式由全局样式提供 */
</style>

View File

@@ -0,0 +1,447 @@
<template>
<Teleport to="body">
<div class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
<div class="modal-content w-full max-w-md p-8 mx-auto max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center">
<i class="fas fa-edit text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900">编辑 API Key</h3>
</div>
<button
@click="$emit('close')"
class="text-gray-400 hover:text-gray-600 transition-colors"
>
<i class="fas fa-times text-xl"></i>
</button>
</div>
<form @submit.prevent="updateApiKey" class="space-y-6 modal-scroll-content custom-scrollbar flex-1">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">名称</label>
<input
:value="form.name"
type="text"
disabled
class="form-input w-full bg-gray-100 cursor-not-allowed"
>
<p class="text-xs text-gray-500 mt-2">名称不可修改</p>
</div>
<!-- 速率限制设置 -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-4">
<div class="flex items-start gap-3 mb-3">
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
<i class="fas fa-tachometer-alt text-white text-sm"></i>
</div>
<div class="flex-1">
<h4 class="font-semibold text-gray-800 mb-1">速率限制设置</h4>
<p class="text-sm text-gray-600">控制 API Key 的使用频率和资源消耗</p>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口 (分钟)</label>
<input
v-model="form.rateLimitWindow"
type="number"
min="1"
placeholder="留空表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">设置一个时间段以分钟为单位用于计算速率限制</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口内的请求次数限制</label>
<input
v-model="form.rateLimitRequests"
type="number"
min="1"
placeholder="留空表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">在时间窗口内允许的最大请求次数需要先设置时间窗口</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">时间窗口内的 Token 使用量限制</label>
<input
v-model="form.tokenLimit"
type="number"
min="0"
placeholder="0 表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">在时间窗口内允许消耗的最大 Token 数量需要先设置时间窗口0 或留空表示无限制</p>
</div>
<!-- 示例说明 -->
<div class="bg-blue-100 rounded-lg p-3 mt-3">
<h5 class="text-sm font-semibold text-blue-800 mb-2">💡 使用示例</h5>
<div class="text-xs text-blue-700 space-y-1">
<p><strong>示例1:</strong> 时间窗口=60请求次数限制=100</p>
<p class="ml-4"> 每60分钟内最多允许100次请求</p>
<p class="mt-2"><strong>示例2:</strong> 时间窗口=10Token限制=50000</p>
<p class="ml-4"> 每10分钟内最多消耗50,000个Token</p>
<p class="mt-2"><strong>示例3:</strong> 时间窗口=30请求次数限制=50Token限制=100000</p>
<p class="ml-4"> 每30分钟内最多50次请求且总Token不超过100,000</p>
</div>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">每日费用限制 (美元)</label>
<div class="space-y-3">
<div class="flex gap-2">
<button type="button" @click="form.dailyCostLimit = '50'" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">$50</button>
<button type="button" @click="form.dailyCostLimit = '100'" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">$100</button>
<button type="button" @click="form.dailyCostLimit = '200'" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">$200</button>
<button type="button" @click="form.dailyCostLimit = ''" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium">自定义</button>
</div>
<input
v-model="form.dailyCostLimit"
type="number"
min="0"
step="0.01"
placeholder="0 表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500">设置此 API Key 每日的费用限制超过限制将拒绝请求0 或留空表示无限制</p>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">并发限制</label>
<input
v-model="form.concurrencyLimit"
type="number"
min="0"
placeholder="0 表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500 mt-2">设置此 API Key 可同时处理的最大请求数</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">服务权限</label>
<div class="flex gap-4">
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="form.permissions"
value="all"
class="mr-2"
>
<span class="text-sm text-gray-700">全部服务</span>
</label>
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="form.permissions"
value="claude"
class="mr-2"
>
<span class="text-sm text-gray-700"> Claude</span>
</label>
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="form.permissions"
value="gemini"
class="mr-2"
>
<span class="text-sm text-gray-700"> Gemini</span>
</label>
</div>
<p class="text-xs text-gray-500 mt-2">控制此 API Key 可以访问哪些服务</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">专属账号绑定</label>
<div class="grid grid-cols-1 gap-3">
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">Claude 专属账号</label>
<select
v-model="form.claudeAccountId"
class="form-input w-full"
:disabled="form.permissions === 'gemini'"
>
<option value="">使用共享账号池</option>
<option
v-for="account in accounts.claude"
:key="account.id"
:value="account.id"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">Gemini 专属账号</label>
<select
v-model="form.geminiAccountId"
class="form-input w-full"
:disabled="form.permissions === 'claude'"
>
<option value="">使用共享账号池</option>
<option
v-for="account in accounts.gemini"
:key="account.id"
:value="account.id"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
</select>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">修改绑定账号将影响此API Key的请求路由</p>
</div>
<div>
<div class="flex items-center mb-3">
<input
type="checkbox"
v-model="form.enableModelRestriction"
id="editEnableModelRestriction"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
>
<label for="editEnableModelRestriction" class="ml-2 text-sm font-semibold text-gray-700 cursor-pointer">
启用模型限制
</label>
</div>
<div v-if="form.enableModelRestriction" class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-600 mb-2">限制的模型列表</label>
<div class="flex flex-wrap gap-2 mb-3 min-h-[32px] p-2 bg-gray-50 rounded-lg border border-gray-200">
<span
v-for="(model, index) in form.restrictedModels"
:key="index"
class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-red-100 text-red-800"
>
{{ model }}
<button
type="button"
@click="removeRestrictedModel(index)"
class="ml-2 text-red-600 hover:text-red-800"
>
<i class="fas fa-times text-xs"></i>
</button>
</span>
<span v-if="form.restrictedModels.length === 0" class="text-gray-400 text-sm">
暂无限制的模型
</span>
</div>
<div class="flex gap-2">
<input
v-model="form.modelInput"
@keydown.enter.prevent="addRestrictedModel"
type="text"
placeholder="输入模型名称,按回车添加"
class="form-input flex-1"
>
<button
type="button"
@click="addRestrictedModel"
class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
>
<i class="fas fa-plus"></i>
</button>
</div>
<p class="text-xs text-gray-500 mt-2">设置此API Key无法访问的模型例如claude-opus-4-20250514</p>
</div>
</div>
</div>
<!-- 客户端限制 -->
<div>
<div class="flex items-center mb-3">
<input
type="checkbox"
v-model="form.enableClientRestriction"
id="editEnableClientRestriction"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
>
<label for="editEnableClientRestriction" class="ml-2 text-sm font-semibold text-gray-700 cursor-pointer">
启用客户端限制
</label>
</div>
<div v-if="form.enableClientRestriction" class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-600 mb-2">允许的客户端</label>
<p class="text-xs text-gray-500 mb-3">勾选允许使用此API Key的客户端</p>
<div class="space-y-2">
<div v-for="client in supportedClients" :key="client.id" class="flex items-start">
<input
type="checkbox"
:id="`edit_client_${client.id}`"
:value="client.id"
v-model="form.allowedClients"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 mt-0.5"
>
<label :for="`edit_client_${client.id}`" class="ml-2 flex-1 cursor-pointer">
<span class="text-sm font-medium text-gray-700">{{ client.name }}</span>
<span class="text-xs text-gray-500 block">{{ client.description }}</span>
</label>
</div>
</div>
</div>
</div>
</div>
<div class="flex gap-3 pt-4">
<button
type="button"
@click="$emit('close')"
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
>
取消
</button>
<button
type="submit"
:disabled="loading"
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
>
<div v-if="loading" class="loading-spinner mr-2"></div>
<i v-else class="fas fa-save mr-2"></i>
{{ loading ? '保存中...' : '保存修改' }}
</button>
</div>
</form>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { showToast } from '@/utils/toast'
import { useAuthStore } from '@/stores/auth'
import { useClientsStore } from '@/stores/clients'
import { apiClient } from '@/config/api'
const props = defineProps({
apiKey: {
type: Object,
required: true
},
accounts: {
type: Object,
default: () => ({ claude: [], gemini: [] })
}
})
const emit = defineEmits(['close', 'success'])
const authStore = useAuthStore()
const clientsStore = useClientsStore()
const loading = ref(false)
// 支持的客户端列表
const supportedClients = ref([])
// 表单数据
const form = reactive({
name: '',
tokenLimit: '',
rateLimitWindow: '',
rateLimitRequests: '',
concurrencyLimit: '',
dailyCostLimit: '',
permissions: 'all',
claudeAccountId: '',
geminiAccountId: '',
enableModelRestriction: false,
restrictedModels: [],
modelInput: '',
enableClientRestriction: false,
allowedClients: []
})
// 添加限制的模型
const addRestrictedModel = () => {
if (form.modelInput && !form.restrictedModels.includes(form.modelInput)) {
form.restrictedModels.push(form.modelInput)
form.modelInput = ''
}
}
// 移除限制的模型
const removeRestrictedModel = (index) => {
form.restrictedModels.splice(index, 1)
}
// 更新 API Key
const updateApiKey = async () => {
loading.value = true
try {
// 准备提交的数据
const data = {
tokenLimit: form.tokenLimit !== '' && form.tokenLimit !== null ? parseInt(form.tokenLimit) : 0,
rateLimitWindow: form.rateLimitWindow !== '' && form.rateLimitWindow !== null ? parseInt(form.rateLimitWindow) : 0,
rateLimitRequests: form.rateLimitRequests !== '' && form.rateLimitRequests !== null ? parseInt(form.rateLimitRequests) : 0,
concurrencyLimit: form.concurrencyLimit !== '' && form.concurrencyLimit !== null ? parseInt(form.concurrencyLimit) : 0,
dailyCostLimit: form.dailyCostLimit !== '' && form.dailyCostLimit !== null ? parseFloat(form.dailyCostLimit) : 0,
permissions: form.permissions,
claudeAccountId: form.claudeAccountId || null,
geminiAccountId: form.geminiAccountId || null
}
// 模型限制
if (form.enableModelRestriction && form.restrictedModels.length > 0) {
data.restrictedModels = form.restrictedModels
} else {
data.restrictedModels = []
}
// 客户端限制
if (form.enableClientRestriction && form.allowedClients.length > 0) {
data.allowedClients = form.allowedClients
} else {
data.allowedClients = []
}
const result = await apiClient.put(`/admin/api-keys/${props.apiKey.id}`, data)
if (result.success) {
emit('success')
emit('close')
} else {
showToast(result.message || '更新失败', 'error')
}
} catch (error) {
showToast('更新失败', 'error')
} finally {
loading.value = false
}
}
// 初始化表单数据
onMounted(async () => {
// 加载支持的客户端
supportedClients.value = await clientsStore.loadSupportedClients()
form.name = props.apiKey.name
form.tokenLimit = props.apiKey.tokenLimit || ''
form.rateLimitWindow = props.apiKey.rateLimitWindow || ''
form.rateLimitRequests = props.apiKey.rateLimitRequests || ''
form.concurrencyLimit = props.apiKey.concurrencyLimit || ''
form.dailyCostLimit = props.apiKey.dailyCostLimit || ''
form.permissions = props.apiKey.permissions || 'all'
form.claudeAccountId = props.apiKey.claudeAccountId || ''
form.geminiAccountId = props.apiKey.geminiAccountId || ''
form.restrictedModels = props.apiKey.restrictedModels || []
form.allowedClients = props.apiKey.allowedClients || []
form.enableModelRestriction = form.restrictedModels.length > 0
form.enableClientRestriction = form.allowedClients.length > 0
})
</script>
<style scoped>
/* 表单样式由全局样式提供 */
</style>

View File

@@ -0,0 +1,155 @@
<template>
<Teleport to="body">
<div class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
<div class="modal-content w-full max-w-lg p-8 mx-auto">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center">
<i class="fas fa-check text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900">API Key 创建成功</h3>
</div>
<button
@click="$emit('close')"
class="text-gray-400 hover:text-gray-600 transition-colors"
>
<i class="fas fa-times text-xl"></i>
</button>
</div>
<!-- 成功提示 -->
<div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
<div class="flex items-start gap-3">
<div class="w-8 h-8 bg-green-500 rounded-lg flex items-center justify-center flex-shrink-0">
<i class="fas fa-shield-alt text-white text-sm"></i>
</div>
<div>
<h4 class="font-semibold text-gray-800 mb-1">请妥善保管您的 API Key</h4>
<p class="text-sm text-gray-600">API Key 只会显示一次关闭此窗口后将无法再次查看完整密钥请立即复制并保存到安全的地方</p>
</div>
</div>
</div>
<!-- API Key 信息 -->
<div class="space-y-4 mb-6">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">名称</label>
<p class="text-gray-900">{{ apiKey.name }}</p>
</div>
<div v-if="apiKey.description">
<label class="block text-sm font-semibold text-gray-700 mb-2">描述</label>
<p class="text-gray-600 text-sm">{{ apiKey.description }}</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">API Key</label>
<div class="relative">
<input
:type="showFullKey ? 'text' : 'password'"
:value="apiKey.key"
readonly
class="form-input w-full pr-24 font-mono text-sm bg-gray-50"
>
<div class="absolute right-1 top-1 flex gap-1">
<button
@click="toggleKeyVisibility"
type="button"
class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm transition-colors"
:title="showFullKey ? '隐藏' : '显示'"
>
<i :class="showFullKey ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
</button>
<button
@click="copyApiKey"
type="button"
class="px-3 py-1.5 bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg text-sm transition-colors"
title="复制"
>
<i class="fas fa-copy"></i>
{{ copied ? '已复制' : '复制' }}
</button>
</div>
</div>
</div>
</div>
<!-- 使用说明 -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<h4 class="font-semibold text-gray-800 mb-2">
<i class="fas fa-info-circle mr-2 text-blue-500"></i>使用说明
</h4>
<div class="text-sm text-gray-700 space-y-2">
<p>1. HTTP 请求头中添加</p>
<code class="block bg-white rounded px-3 py-2 text-xs">Authorization: Bearer {{ apiKey.key }}</code>
<p class="pt-2">2. 请求示例</p>
<pre class="bg-white rounded px-3 py-2 text-xs overflow-x-auto">curl -X POST {{ currentBaseUrl }}v1/messages \
-H "Authorization: Bearer {{ apiKey.key }}" \
-H "Content-Type: application/json" \
-d '{"model": "claude-3-opus-20240229", "messages": [...]}'</pre>
</div>
</div>
<div class="flex justify-end">
<button
@click="$emit('close')"
class="btn btn-primary px-6 py-2.5"
>
我已保存
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, computed } from 'vue'
import { showToast } from '@/utils/toast'
const props = defineProps({
apiKey: {
type: Object,
required: true
}
})
const emit = defineEmits(['close'])
const showFullKey = ref(false)
const copied = ref(false)
// 计算基础 URL
const currentBaseUrl = computed(() => {
return `${window.location.protocol}//${window.location.host}/api/`
})
// 切换密钥可见性
const toggleKeyVisibility = () => {
showFullKey.value = !showFullKey.value
}
// 复制 API Key
const copyApiKey = async () => {
try {
await navigator.clipboard.writeText(props.apiKey.key)
copied.value = true
showToast('API Key 已复制到剪贴板', 'success')
// 3秒后重置复制状态
setTimeout(() => {
copied.value = false
}, 3000)
} catch (error) {
showToast('复制失败,请手动复制', 'error')
}
}
</script>
<style scoped>
pre {
white-space: pre-wrap;
word-wrap: break-word;
}
</style>

View File

@@ -0,0 +1,224 @@
<template>
<Teleport to="body">
<div class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
<div class="modal-content w-full max-w-md p-8 mx-auto max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center">
<i class="fas fa-clock text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900">续期 API Key</h3>
</div>
<button
@click="$emit('close')"
class="text-gray-400 hover:text-gray-600 transition-colors"
>
<i class="fas fa-times text-xl"></i>
</button>
</div>
<div class="space-y-6 modal-scroll-content custom-scrollbar flex-1">
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-start gap-3">
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
<i class="fas fa-info text-white text-sm"></i>
</div>
<div>
<h4 class="font-semibold text-gray-800 mb-1">API Key 信息</h4>
<p class="text-sm text-gray-700">{{ apiKey.name }}</p>
<p class="text-xs text-gray-600 mt-1">
当前过期时间{{ apiKey.expiresAt ? formatExpireDate(apiKey.expiresAt) : '永不过期' }}
</p>
</div>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">续期时长</label>
<select
v-model="form.renewDuration"
@change="updateRenewExpireAt"
class="form-input w-full"
>
<option value="7d">延长 7 </option>
<option value="30d">延长 30 </option>
<option value="90d">延长 90 </option>
<option value="180d">延长 180 </option>
<option value="365d">延长 365 </option>
<option value="custom">自定义日期</option>
<option value="permanent">设为永不过期</option>
</select>
<div v-if="form.renewDuration === 'custom'" class="mt-3">
<input
v-model="form.customExpireDate"
type="datetime-local"
class="form-input w-full"
:min="minDateTime"
@change="updateCustomRenewExpireAt"
>
</div>
<p v-if="form.newExpiresAt" class="text-xs text-gray-500 mt-2">
新的过期时间{{ formatExpireDate(form.newExpiresAt) }}
</p>
</div>
</div>
<div class="flex gap-3 pt-4">
<button
type="button"
@click="$emit('close')"
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
>
取消
</button>
<button
type="button"
@click="renewApiKey"
:disabled="loading || !form.renewDuration"
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
>
<div v-if="loading" class="loading-spinner mr-2"></div>
<i v-else class="fas fa-clock mr-2"></i>
{{ loading ? '续期中...' : '确认续期' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { showToast } from '@/utils/toast'
import { useAuthStore } from '@/stores/auth'
import { apiClient } from '@/config/api'
const props = defineProps({
apiKey: {
type: Object,
required: true
}
})
const emit = defineEmits(['close', 'success'])
const authStore = useAuthStore()
const loading = ref(false)
// 表单数据
const form = reactive({
renewDuration: '30d',
customExpireDate: '',
newExpiresAt: null
})
// 计算最小日期时间
const minDateTime = computed(() => {
const now = new Date()
// 如果有当前过期时间且未过期,从当前过期时间开始
if (props.apiKey.expiresAt && new Date(props.apiKey.expiresAt) > now) {
return new Date(props.apiKey.expiresAt).toISOString().slice(0, 16)
}
// 否则从现在开始
now.setMinutes(now.getMinutes() + 1)
return now.toISOString().slice(0, 16)
})
// 格式化过期日期
const formatExpireDate = (dateString) => {
const date = new Date(dateString)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
// 更新续期后的过期时间
const updateRenewExpireAt = () => {
if (!form.renewDuration) {
form.newExpiresAt = null
return
}
if (form.renewDuration === 'permanent') {
form.newExpiresAt = null
return
}
if (form.renewDuration === 'custom') {
return
}
// 计算新的过期时间
const baseDate = props.apiKey.expiresAt && new Date(props.apiKey.expiresAt) > new Date()
? new Date(props.apiKey.expiresAt)
: new Date()
const duration = form.renewDuration
const match = duration.match(/(\d+)([dhmy])/)
if (match) {
const [, value, unit] = match
const num = parseInt(value)
switch (unit) {
case 'd':
baseDate.setDate(baseDate.getDate() + num)
break
case 'h':
baseDate.setHours(baseDate.getHours() + num)
break
case 'm':
baseDate.setMonth(baseDate.getMonth() + num)
break
case 'y':
baseDate.setFullYear(baseDate.getFullYear() + num)
break
}
form.newExpiresAt = baseDate.toISOString()
}
}
// 更新自定义续期时间
const updateCustomRenewExpireAt = () => {
if (form.customExpireDate) {
form.newExpiresAt = new Date(form.customExpireDate).toISOString()
}
}
// 续期 API Key
const renewApiKey = async () => {
loading.value = true
try {
const data = {
expiresAt: form.renewDuration === 'permanent' ? null : form.newExpiresAt
}
const result = await apiClient.put(`/admin/api-keys/${props.apiKey.id}/renew`, data)
if (result.success) {
showToast('API Key 续期成功', 'success')
emit('success')
emit('close')
} else {
showToast(result.message || '续期失败', 'error')
}
} catch (error) {
showToast('续期失败', 'error')
} finally {
loading.value = false
}
}
// 初始化
updateRenewExpireAt()
</script>
<style scoped>
/* 表单样式由全局样式提供 */
</style>