# Conflicts:
#	web/admin-spa/dist/assets/LoginView-BJ0LLv16.js
#	web/admin-spa/dist/assets/LogoTitle-DHj-MjwS.js
#	web/admin-spa/dist/assets/MainLayout-CLydIeqJ.js
#	web/admin-spa/dist/assets/SettingsView-DicW12bL.js
#	web/admin-spa/dist/assets/index-HYE9xPuR.js
#	web/admin-spa/dist/index.html
#	web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue
#	web/admin-spa/src/components/apikeys/EditApiKeyModal.vue
#	web/admin-spa/src/views/ApiKeysView.vue
This commit is contained in:
KevinLiao
2025-07-30 20:41:10 +08:00
49 changed files with 4461 additions and 2427 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.custom-date-picker[data-v-e2cbd0e3] .el-input__inner{--tw-border-opacity: 1;border-color:rgb(209 213 219 / var(--tw-border-opacity, 1));--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.custom-date-picker[data-v-e2cbd0e3] .el-input__inner:focus{--tw-border-opacity: 1;border-color:rgb(59 130 246 / var(--tw-border-opacity, 1));--tw-ring-opacity: 1;--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity, 1))}.custom-date-picker[data-v-e2cbd0e3] .el-input__inner{font-size:13px;padding:0 10px}.custom-date-picker[data-v-e2cbd0e3] .el-range-separator{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity, 1));padding:0 2px}.custom-date-picker[data-v-e2cbd0e3] .el-range-input{font-size:13px}@keyframes spin-e2cbd0e3{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.animate-spin[data-v-e2cbd0e3]{animation:spin-e2cbd0e3 1s linear infinite}

View File

@@ -1 +0,0 @@
.custom-date-picker[data-v-d355f079] .el-input__inner{--tw-border-opacity: 1;border-color:rgb(209 213 219 / var(--tw-border-opacity, 1));--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.custom-date-picker[data-v-d355f079] .el-input__inner:focus{--tw-border-opacity: 1;border-color:rgb(59 130 246 / var(--tw-border-opacity, 1));--tw-ring-opacity: 1;--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity, 1))}.custom-date-picker[data-v-d355f079] .el-input__inner{font-size:13px;padding:0 10px}.custom-date-picker[data-v-d355f079] .el-range-separator{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity, 1));padding:0 2px}.custom-date-picker[data-v-d355f079] .el-range-input{font-size:13px}

View File

@@ -0,0 +1 @@
@keyframes pulse-718feedc{0%{opacity:.7}50%{opacity:.4}to{opacity:.7}}.animate-pulse[data-v-718feedc]{animation:pulse-718feedc 2s cubic-bezier(.4,0,.6,1) infinite}.header-title[data-v-718feedc]{text-shadow:0 1px 2px rgba(0,0,0,.1)}

View File

@@ -1 +0,0 @@
@keyframes pulse-a75bf797{0%{opacity:.7}50%{opacity:.4}to{opacity:.7}}.animate-pulse[data-v-a75bf797]{animation:pulse-a75bf797 2s cubic-bezier(.4,0,.6,1) infinite}.header-title[data-v-a75bf797]{text-shadow:0 1px 2px rgba(0,0,0,.1)}

View File

@@ -1 +0,0 @@
.user-menu-dropdown[data-v-9c2dcb55]{margin-top:8px}.fade-enter-active[data-v-9c2dcb55],.fade-leave-active[data-v-9c2dcb55]{transition:opacity .3s}.fade-enter-from[data-v-9c2dcb55],.fade-leave-to[data-v-9c2dcb55]{opacity:0}

View File

@@ -0,0 +1 @@
.user-menu-dropdown[data-v-590374fc]{margin-top:8px}.fade-enter-active[data-v-590374fc],.fade-leave-active[data-v-590374fc]{transition:opacity .3s}.fade-enter-from[data-v-590374fc],.fade-leave-to[data-v-590374fc]{opacity:0}

View File

@@ -1 +1 @@
.settings-container[data-v-3508e0de]{min-height:calc(100vh - 300px)}.card[data-v-3508e0de]{background:#fff;border-radius:12px;box-shadow:0 2px 12px #0000001a;border:1px solid #e5e7eb}.table-container[data-v-3508e0de]{overflow:hidden;border-radius:8px;border:1px solid #f3f4f6}.table-row[data-v-3508e0de]{transition:background-color .2s ease}.table-row[data-v-3508e0de]:hover{background-color:#f9fafb}.form-input[data-v-3508e0de]{width:100%;border-radius:.5rem;border-width:1px;--tw-border-opacity: 1;border-color:rgb(209 213 219 / var(--tw-border-opacity, 1));padding:.5rem 1rem;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.2s}.form-input[data-v-3508e0de]:focus{border-color:transparent;--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000);--tw-ring-opacity: 1;--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity, 1))}.btn[data-v-3508e0de]{display:inline-flex;align-items:center;justify-content:center;border-radius:.5rem;padding:.5rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:600;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.2s}.btn[data-v-3508e0de]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000);--tw-ring-offset-width: 2px}.btn-primary[data-v-3508e0de]{--tw-bg-opacity: 1;background-color:rgb(37 99 235 / var(--tw-bg-opacity, 1));--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.btn-primary[data-v-3508e0de]:hover{--tw-bg-opacity: 1;background-color:rgb(29 78 216 / var(--tw-bg-opacity, 1))}.btn-primary[data-v-3508e0de]:focus{--tw-ring-opacity: 1;--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity, 1))}.btn-success[data-v-3508e0de]{--tw-bg-opacity: 1;background-color:rgb(22 163 74 / var(--tw-bg-opacity, 1));--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.btn-success[data-v-3508e0de]:hover{--tw-bg-opacity: 1;background-color:rgb(21 128 61 / var(--tw-bg-opacity, 1))}.btn-success[data-v-3508e0de]:focus{--tw-ring-opacity: 1;--tw-ring-color: rgb(34 197 94 / var(--tw-ring-opacity, 1))}.loading-spinner[data-v-3508e0de]{height:1.25rem;width:1.25rem}@keyframes spin-3508e0de{to{transform:rotate(360deg)}}.loading-spinner[data-v-3508e0de]{animation:spin-3508e0de 1s linear infinite;border-radius:9999px;border-width:2px;border-color:rgb(209 213 219 / var(--tw-border-opacity, 1));--tw-border-opacity: 1;border-top-color:rgb(37 99 235 / var(--tw-border-opacity, 1))}
.settings-container[data-v-d29d5f49]{min-height:calc(100vh - 300px)}.card[data-v-d29d5f49]{background:#fff;border-radius:12px;box-shadow:0 2px 12px #0000001a;border:1px solid #e5e7eb}.table-container[data-v-d29d5f49]{overflow:hidden;border-radius:8px;border:1px solid #f3f4f6}.table-row[data-v-d29d5f49]{transition:background-color .2s ease}.table-row[data-v-d29d5f49]:hover{background-color:#f9fafb}.form-input[data-v-d29d5f49]{width:100%;border-radius:.5rem;border-width:1px;--tw-border-opacity: 1;border-color:rgb(209 213 219 / var(--tw-border-opacity, 1));padding:.5rem 1rem;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.2s}.form-input[data-v-d29d5f49]:focus{border-color:transparent;--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000);--tw-ring-opacity: 1;--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity, 1))}.btn[data-v-d29d5f49]{display:inline-flex;align-items:center;justify-content:center;border-radius:.5rem;padding:.5rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:600;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.2s}.btn[data-v-d29d5f49]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000);--tw-ring-offset-width: 2px}.btn-primary[data-v-d29d5f49]{--tw-bg-opacity: 1;background-color:rgb(37 99 235 / var(--tw-bg-opacity, 1));--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.btn-primary[data-v-d29d5f49]:hover{--tw-bg-opacity: 1;background-color:rgb(29 78 216 / var(--tw-bg-opacity, 1))}.btn-primary[data-v-d29d5f49]:focus{--tw-ring-opacity: 1;--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity, 1))}.btn-success[data-v-d29d5f49]{--tw-bg-opacity: 1;background-color:rgb(22 163 74 / var(--tw-bg-opacity, 1));--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.btn-success[data-v-d29d5f49]:hover{--tw-bg-opacity: 1;background-color:rgb(21 128 61 / var(--tw-bg-opacity, 1))}.btn-success[data-v-d29d5f49]:focus{--tw-ring-opacity: 1;--tw-ring-color: rgb(34 197 94 / var(--tw-ring-opacity, 1))}.loading-spinner[data-v-d29d5f49]{height:1.25rem;width:1.25rem}@keyframes spin-d29d5f49{to{transform:rotate(360deg)}}.loading-spinner[data-v-d29d5f49]{animation:spin-d29d5f49 1s linear infinite;border-radius:9999px;border-width:2px;border-color:rgb(209 213 219 / var(--tw-border-opacity, 1));--tw-border-opacity: 1;border-top-color:rgb(37 99 235 / var(--tw-border-opacity, 1))}

View File

@@ -0,0 +1 @@
.tutorial-container[data-v-508c8654]{min-height:calc(100vh - 300px)}.tutorial-content[data-v-508c8654]{animation:fadeIn-508c8654 .3s ease-in-out}@keyframes fadeIn-508c8654{0%{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}code[data-v-508c8654]{font-family:Fira Code,Monaco,Menlo,Ubuntu Mono,monospace}.tutorial-content h4[data-v-508c8654]{scroll-margin-top:100px}.tutorial-content .bg-gradient-to-r[data-v-508c8654]{transition:all .2s ease}.tutorial-content .bg-gradient-to-r[data-v-508c8654]:hover{transform:translateY(-1px);box-shadow:0 4px 12px #0000001a}

View File

@@ -1 +0,0 @@
.tutorial-container[data-v-58e5d8f5]{min-height:calc(100vh - 300px)}.tutorial-content[data-v-58e5d8f5]{animation:fadeIn-58e5d8f5 .3s ease-in-out}@keyframes fadeIn-58e5d8f5{0%{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}code[data-v-58e5d8f5]{font-family:Fira Code,Monaco,Menlo,Ubuntu Mono,monospace}.tutorial-content h4[data-v-58e5d8f5]{scroll-margin-top:100px}.tutorial-content .bg-gradient-to-r[data-v-58e5d8f5]{transition:all .2s ease}.tutorial-content .bg-gradient-to-r[data-v-58e5d8f5]:hover{transform:translateY(-1px);box-shadow:0 4px 12px #0000001a}

File diff suppressed because one or more lines are too long

View File

@@ -18,12 +18,12 @@
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net">
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
<script type="module" crossorigin src="/admin-next/assets/index-CxLQf0HX.js"></script>
<script type="module" crossorigin src="/admin-next/assets/index-COOF1SF1.js"></script>
<link rel="modulepreload" crossorigin href="/admin-next/assets/vue-vendor-CKToUHZx.js">
<link rel="modulepreload" crossorigin href="/admin-next/assets/vendor-BDiMbLwQ.js">
<link rel="modulepreload" crossorigin href="/admin-next/assets/element-plus-B8Fs_0jW.js">
<link rel="stylesheet" crossorigin href="/admin-next/assets/element-plus-CPnoEkWW.css">
<link rel="stylesheet" crossorigin href="/admin-next/assets/index-C8t_nyXa.css">
<link rel="stylesheet" crossorigin href="/admin-next/assets/index-Ba0i43MQ.css">
</head>
<body>
<div id="app"></div>

View File

@@ -1,36 +1,48 @@
<template>
<Teleport to="body">
<div v-if="show" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
<div
v-if="show"
class="fixed inset-0 modal z-50 flex items-center justify-center p-4"
>
<div class="modal-content w-full max-w-2xl p-8 mx-auto max-h-[90vh] overflow-y-auto custom-scrollbar">
<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-user-circle text-white"></i>
<i class="fas fa-user-circle text-white" />
</div>
<h3 class="text-xl font-bold text-gray-900">{{ isEdit ? '编辑账户' : '添加账户' }}</h3>
<h3 class="text-xl font-bold text-gray-900">
{{ isEdit ? '编辑账户' : '添加账户' }}
</h3>
</div>
<button
@click="$emit('close')"
class="text-gray-400 hover:text-gray-600 transition-colors"
@click="$emit('close')"
>
<i class="fas fa-times text-xl"></i>
<i class="fas fa-times text-xl" />
</button>
</div>
<!-- 步骤指示器 -->
<div v-if="!isEdit && form.addType === 'oauth'" class="flex items-center justify-center mb-8">
<div
v-if="!isEdit && form.addType === 'oauth'"
class="flex items-center justify-center mb-8"
>
<div class="flex items-center space-x-4">
<div class="flex items-center">
<div :class="['w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold',
oauthStep >= 1 ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-500']">
<div
:class="['w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold',
oauthStep >= 1 ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-500']"
>
1
</div>
<span class="ml-2 text-sm font-medium text-gray-700">基本信息</span>
</div>
<div class="w-8 h-0.5 bg-gray-300"></div>
<div class="w-8 h-0.5 bg-gray-300" />
<div class="flex items-center">
<div :class="['w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold',
oauthStep >= 2 ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-500']">
<div
:class="['w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold',
oauthStep >= 2 ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-500']"
>
2
</div>
<span class="ml-2 text-sm font-medium text-gray-700">授权认证</span>
@@ -46,8 +58,8 @@
<div class="flex gap-4">
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="form.platform"
type="radio"
value="claude"
class="mr-2"
>
@@ -55,8 +67,8 @@
</label>
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="form.platform"
type="radio"
value="claude-console"
class="mr-2"
>
@@ -64,8 +76,8 @@
</label>
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="form.platform"
type="radio"
value="gemini"
class="mr-2"
>
@@ -79,8 +91,8 @@
<div class="flex gap-4">
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="form.addType"
type="radio"
value="oauth"
class="mr-2"
>
@@ -88,8 +100,8 @@
</label>
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="form.addType"
type="radio"
value="manual"
class="mr-2"
>
@@ -108,7 +120,12 @@
:class="{ 'border-red-500': errors.name }"
placeholder="为账户设置一个易识别的名称"
>
<p v-if="errors.name" class="text-red-500 text-xs mt-1">{{ errors.name }}</p>
<p
v-if="errors.name"
class="text-red-500 text-xs mt-1"
>
{{ errors.name }}
</p>
</div>
<div>
@@ -118,7 +135,7 @@
rows="3"
class="form-input w-full resize-none"
placeholder="账户用途说明..."
></textarea>
/>
</div>
<div>
@@ -126,8 +143,8 @@
<div class="flex gap-4">
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="form.accountType"
type="radio"
value="shared"
class="mr-2"
>
@@ -135,8 +152,8 @@
</label>
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="form.accountType"
type="radio"
value="dedicated"
class="mr-2"
>
@@ -159,26 +176,43 @@
>
<div class="mt-2 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div class="flex items-start gap-2">
<i class="fas fa-info-circle text-yellow-600 mt-0.5"></i>
<i class="fas fa-info-circle text-yellow-600 mt-0.5" />
<div class="text-xs text-yellow-700">
<p class="font-medium mb-1">Google Cloud/Workspace 账号需要提供项目编号</p>
<p class="font-medium mb-1">
Google Cloud/Workspace 账号需要提供项目编号
</p>
<p>某些 Google 账号特别是绑定了 Google Cloud 的账号会被识别为 Workspace 账号需要提供额外的项目编号</p>
<div class="mt-2 p-2 bg-white rounded border border-yellow-300">
<p class="font-medium mb-1">如何获取项目编号</p>
<p class="font-medium mb-1">
如何获取项目编号
</p>
<ol class="list-decimal list-inside space-y-1 ml-2">
<li>访问 <a href="https://console.cloud.google.com/welcome" target="_blank" class="text-blue-600 hover:underline font-medium">Google Cloud Console</a></li>
<li>
访问 <a
href="https://console.cloud.google.com/welcome"
target="_blank"
class="text-blue-600 hover:underline font-medium"
>Google Cloud Console</a>
</li>
<li>复制<span class="font-semibold text-red-600">项目编号Project Number</span>通常是12位纯数字</li>
<li class="text-red-600"> 注意不要复制项目IDProject ID要复制项目编号</li>
<li class="text-red-600">
注意不要复制项目IDProject ID要复制项目编号
</li>
</ol>
</div>
<p class="mt-2"><strong>提示</strong>如果您的账号是普通个人账号未绑定 Google Cloud请留空此字段</p>
<p class="mt-2">
<strong>提示</strong>如果您的账号是普通个人账号未绑定 Google Cloud请留空此字段
</p>
</div>
</div>
</div>
</div>
<!-- Claude Console 特定字段 -->
<div v-if="form.platform === 'claude-console' && !isEdit" class="space-y-4">
<div
v-if="form.platform === 'claude-console' && !isEdit"
class="space-y-4"
>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">API URL *</label>
<input
@@ -189,7 +223,12 @@
:class="{ 'border-red-500': errors.apiUrl }"
placeholder="例如https://api.example.com"
>
<p v-if="errors.apiUrl" class="text-red-500 text-xs mt-1">{{ errors.apiUrl }}</p>
<p
v-if="errors.apiUrl"
class="text-red-500 text-xs mt-1"
>
{{ errors.apiUrl }}
</p>
</div>
<div>
@@ -202,7 +241,12 @@
:class="{ 'border-red-500': errors.apiKey }"
placeholder="请输入API Key"
>
<p v-if="errors.apiKey" class="text-red-500 text-xs mt-1">{{ errors.apiKey }}</p>
<p
v-if="errors.apiKey"
class="text-red-500 text-xs mt-1"
>
{{ errors.apiKey }}
</p>
</div>
<div>
@@ -210,22 +254,22 @@
<div class="mb-2 flex gap-2">
<button
type="button"
@click="addPresetModel('claude-sonnet-4-20250514')"
class="px-3 py-1 text-xs bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 transition-colors"
@click="addPresetModel('claude-sonnet-4-20250514')"
>
+ claude-sonnet-4-20250514
</button>
<button
type="button"
@click="addPresetModel('claude-opus-4-20250514')"
class="px-3 py-1 text-xs bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors"
@click="addPresetModel('claude-opus-4-20250514')"
>
+ claude-opus-4-20250514
</button>
<button
type="button"
@click="addPresetModel('claude-3-5-haiku-20241022')"
class="px-3 py-1 text-xs bg-green-100 text-green-700 rounded-lg hover:bg-purple-200 transition-colors"
@click="addPresetModel('claude-3-5-haiku-20241022')"
>
+ claude-3-5-haiku-20241022
</button>
@@ -235,8 +279,10 @@
rows="3"
class="form-input w-full resize-none"
placeholder="每行一个模型,留空表示支持所有模型。特别注意,ClaudeCode必须加上hiku模型"
></textarea>
<p class="text-xs text-gray-500 mt-1">留空表示支持所有模型如果指定模型请求中的模型不在列表内将不会调度到此账号</p>
/>
<p class="text-xs text-gray-500 mt-1">
留空表示支持所有模型如果指定模型请求中的模型不在列表内将不会调度到此账号
</p>
</div>
<div>
@@ -258,7 +304,9 @@
class="form-input w-full"
placeholder="默认60分钟"
>
<p class="text-xs text-gray-500 mt-1">当账号返回429错误时暂停调度的时间分钟</p>
<p class="text-xs text-gray-500 mt-1">
当账号返回429错误时暂停调度的时间分钟
</p>
</div>
</div>
@@ -273,37 +321,58 @@
class="form-input w-full"
placeholder="数字越小优先级越高默认50"
>
<p class="text-xs text-gray-500 mt-1">数字越小优先级越高建议范围1-100</p>
<p class="text-xs text-gray-500 mt-1">
数字越小优先级越高建议范围1-100
</p>
</div>
<!-- 手动输入 Token 字段 -->
<div v-if="form.addType === 'manual' && form.platform !== 'claude-console'" class="space-y-4 bg-blue-50 p-4 rounded-lg border border-blue-200">
<div
v-if="form.addType === 'manual' && form.platform !== 'claude-console'"
class="space-y-4 bg-blue-50 p-4 rounded-lg border border-blue-200"
>
<div class="flex items-start gap-3 mb-4">
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0 mt-1">
<i class="fas fa-info text-white text-sm"></i>
<i class="fas fa-info text-white text-sm" />
</div>
<div>
<h5 class="font-semibold text-blue-900 mb-2">手动输入 Token</h5>
<p v-if="form.platform === 'claude'" class="text-sm text-blue-800 mb-2">
<h5 class="font-semibold text-blue-900 mb-2">
手动输入 Token
</h5>
<p
v-if="form.platform === 'claude'"
class="text-sm text-blue-800 mb-2"
>
请输入有效的 Claude Access Token如果您有 Refresh Token建议也一并填写以支持自动刷新
</p>
<p v-else-if="form.platform === 'gemini'" class="text-sm text-blue-800 mb-2">
<p
v-else-if="form.platform === 'gemini'"
class="text-sm text-blue-800 mb-2"
>
请输入有效的 Gemini Access Token如果您有 Refresh Token建议也一并填写以支持自动刷新
</p>
<div class="bg-white/80 rounded-lg p-3 mt-2 mb-2 border border-blue-300">
<p class="text-sm text-blue-900 font-medium mb-1">
<i class="fas fa-folder-open mr-1"></i>
<i class="fas fa-folder-open mr-1" />
获取 Access Token 的方法
</p>
<p v-if="form.platform === 'claude'" class="text-xs text-blue-800">
<p
v-if="form.platform === 'claude'"
class="text-xs text-blue-800"
>
请从已登录 Claude Code 的机器上获取 <code class="bg-blue-100 px-1 py-0.5 rounded font-mono">~/.claude/.credentials.json</code> 文件中的凭证
请勿使用 Claude 官网 API Keys 页面的密钥
</p>
<p v-else-if="form.platform === 'gemini'" class="text-xs text-blue-800">
<p
v-else-if="form.platform === 'gemini'"
class="text-xs text-blue-800"
>
请从已登录 Gemini CLI 的机器上获取 <code class="bg-blue-100 px-1 py-0.5 rounded font-mono">~/.config/gemini/credentials.json</code>
</p>
</div>
<p class="text-xs text-blue-600">💡 如果未填写 Refresh TokenToken 过期后需要手动更新</p>
<p class="text-xs text-blue-600">
💡 如果未填写 Refresh TokenToken 过期后需要手动更新
</p>
</div>
</div>
@@ -316,8 +385,13 @@
class="form-input w-full resize-none font-mono text-xs"
:class="{ 'border-red-500': errors.accessToken }"
placeholder="请输入 Access Token..."
></textarea>
<p v-if="errors.accessToken" class="text-red-500 text-xs mt-1">{{ errors.accessToken }}</p>
/>
<p
v-if="errors.accessToken"
class="text-red-500 text-xs mt-1"
>
{{ errors.accessToken }}
</p>
</div>
<div>
@@ -327,7 +401,7 @@
rows="4"
class="form-input w-full resize-none font-mono text-xs"
placeholder="请输入 Refresh Token..."
></textarea>
/>
</div>
</div>
@@ -337,28 +411,31 @@
<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"
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
@click="$emit('close')"
>
取消
</button>
<button
v-if="form.addType === 'oauth' && form.platform !== 'claude-console'"
type="button"
@click="nextStep"
:disabled="loading"
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
@click="nextStep"
>
下一步
</button>
<button
v-else
type="button"
@click="createAccount"
:disabled="loading"
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
@click="createAccount"
>
<div v-if="loading" class="loading-spinner mr-2"></div>
<div
v-if="loading"
class="loading-spinner mr-2"
/>
{{ loading ? '创建中...' : '创建' }}
</button>
</div>
@@ -375,7 +452,10 @@
/>
<!-- 编辑模式 -->
<div v-if="isEdit" class="space-y-6">
<div
v-if="isEdit"
class="space-y-6"
>
<!-- 基本信息 -->
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">账户名称</label>
@@ -395,7 +475,7 @@
rows="3"
class="form-input w-full resize-none"
placeholder="账户用途说明..."
></textarea>
/>
</div>
<div>
@@ -403,8 +483,8 @@
<div class="flex gap-4">
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="form.accountType"
type="radio"
value="shared"
class="mr-2"
>
@@ -412,8 +492,8 @@
</label>
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="form.accountType"
type="radio"
value="dedicated"
class="mr-2"
>
@@ -450,11 +530,16 @@
class="form-input w-full"
placeholder="数字越小优先级越高"
>
<p class="text-xs text-gray-500 mt-1">数字越小优先级越高建议范围1-100</p>
<p class="text-xs text-gray-500 mt-1">
数字越小优先级越高建议范围1-100
</p>
</div>
<!-- Claude Console 特定字段编辑模式-->
<div v-if="form.platform === 'claude-console'" class="space-y-4">
<div
v-if="form.platform === 'claude-console'"
class="space-y-4"
>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">API URL</label>
<input
@@ -474,7 +559,9 @@
class="form-input w-full"
placeholder="留空表示不更新"
>
<p class="text-xs text-gray-500 mt-1">留空表示不更新 API Key</p>
<p class="text-xs text-gray-500 mt-1">
留空表示不更新 API Key
</p>
</div>
<div>
@@ -482,32 +569,32 @@
<div class="mb-2 flex gap-2">
<button
type="button"
@click="addPresetModel('claude-sonnet-4-20250514')"
class="px-3 py-1 text-xs bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 transition-colors"
@click="addPresetModel('claude-sonnet-4-20250514')"
>
+ claude-sonnet-4-20250514
</button>
<button
type="button"
@click="addPresetModel('claude-opus-4-20250514')"
class="px-3 py-1 text-xs bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors"
@click="addPresetModel('claude-opus-4-20250514')"
>
+ claude-opus-4-20250514
</button>
<button
type="button"
@click="addPresetModel('claude-3-5-haiku-20241022')"
class="px-3 py-1 text-xs bg-green-100 text-green-700 rounded-lg hover:bg-purple-200 transition-colors"
>
+ claude-3-5-haiku-20241022
</button>
type="button"
class="px-3 py-1 text-xs bg-green-100 text-green-700 rounded-lg hover:bg-purple-200 transition-colors"
@click="addPresetModel('claude-3-5-haiku-20241022')"
>
+ claude-3-5-haiku-20241022
</button>
</div>
<textarea
v-model="form.supportedModels"
rows="3"
class="form-input w-full resize-none"
placeholder="每行一个模型,留空表示支持所有模型。特别注意,ClaudeCode必须加上hiku模型"
></textarea>
/>
</div>
<div>
@@ -532,15 +619,24 @@
</div>
<!-- Token 更新 -->
<div v-if="form.platform !== 'claude-console'" class="bg-amber-50 p-4 rounded-lg border border-amber-200">
<div
v-if="form.platform !== 'claude-console'"
class="bg-amber-50 p-4 rounded-lg border border-amber-200"
>
<div class="flex items-start gap-3 mb-4">
<div class="w-8 h-8 bg-amber-500 rounded-lg flex items-center justify-center flex-shrink-0 mt-1">
<i class="fas fa-key text-white text-sm"></i>
<i class="fas fa-key text-white text-sm" />
</div>
<div>
<h5 class="font-semibold text-amber-900 mb-2">更新 Token</h5>
<p class="text-sm text-amber-800 mb-2">可以更新 Access Token Refresh Token为了安全起见不会显示当前的 Token </p>
<p class="text-xs text-amber-600">💡 留空表示不更新该字段</p>
<h5 class="font-semibold text-amber-900 mb-2">
更新 Token
</h5>
<p class="text-sm text-amber-800 mb-2">
可以更新 Access Token Refresh Token为了安全起见不会显示当前的 Token
</p>
<p class="text-xs text-amber-600">
💡 留空表示不更新该字段
</p>
</div>
</div>
@@ -552,7 +648,7 @@
rows="4"
class="form-input w-full resize-none font-mono text-xs"
placeholder="留空表示不更新..."
></textarea>
/>
</div>
<div>
@@ -562,7 +658,7 @@
rows="4"
class="form-input w-full resize-none font-mono text-xs"
placeholder="留空表示不更新..."
></textarea>
/>
</div>
</div>
</div>
@@ -573,18 +669,21 @@
<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"
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
@click="$emit('close')"
>
取消
</button>
<button
type="button"
@click="updateAccount"
:disabled="loading"
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
@click="updateAccount"
>
<div v-if="loading" class="loading-spinner mr-2"></div>
<div
v-if="loading"
class="loading-spinner mr-2"
/>
{{ loading ? '更新中...' : '更新' }}
</button>
</div>

View File

@@ -5,10 +5,12 @@
<div class="bg-blue-50 p-6 rounded-lg border border-blue-200">
<div class="flex items-start gap-4">
<div class="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
<i class="fas fa-link text-white"></i>
<i class="fas fa-link text-white" />
</div>
<div class="flex-1">
<h4 class="font-semibold text-blue-900 mb-3">Claude 账户授权</h4>
<h4 class="font-semibold text-blue-900 mb-3">
Claude 账户授权
</h4>
<p class="text-sm text-blue-800 mb-4">
请按照以下步骤完成 Claude 账户的授权
</p>
@@ -17,20 +19,33 @@
<!-- 步骤1: 生成授权链接 -->
<div class="bg-white/80 rounded-lg p-4 border border-blue-300">
<div class="flex items-start gap-3">
<div class="w-6 h-6 bg-blue-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">1</div>
<div class="w-6 h-6 bg-blue-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">
1
</div>
<div class="flex-1">
<p class="font-medium text-blue-900 mb-2">点击下方按钮生成授权链接</p>
<p class="font-medium text-blue-900 mb-2">
点击下方按钮生成授权链接
</p>
<button
v-if="!authUrl"
@click="generateAuthUrl"
:disabled="loading"
class="btn btn-primary px-4 py-2 text-sm"
@click="generateAuthUrl"
>
<i v-if="!loading" class="fas fa-link mr-2"></i>
<div v-else class="loading-spinner mr-2"></div>
<i
v-if="!loading"
class="fas fa-link mr-2"
/>
<div
v-else
class="loading-spinner mr-2"
/>
{{ loading ? '生成中...' : '生成授权链接' }}
</button>
<div v-else class="space-y-3">
<div
v-else
class="space-y-3"
>
<div class="flex items-center gap-2">
<input
type="text"
@@ -39,18 +54,18 @@
class="form-input flex-1 text-xs font-mono bg-gray-50"
>
<button
@click="copyAuthUrl"
class="px-3 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
title="复制链接"
@click="copyAuthUrl"
>
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'"></i>
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'" />
</button>
</div>
<button
@click="regenerateAuthUrl"
class="text-xs text-blue-600 hover:text-blue-700"
@click="regenerateAuthUrl"
>
<i class="fas fa-sync-alt mr-1"></i>重新生成
<i class="fas fa-sync-alt mr-1" />重新生成
</button>
</div>
</div>
@@ -60,15 +75,19 @@
<!-- 步骤2: 访问链接并授权 -->
<div class="bg-white/80 rounded-lg p-4 border border-blue-300">
<div class="flex items-start gap-3">
<div class="w-6 h-6 bg-blue-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">2</div>
<div class="w-6 h-6 bg-blue-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">
2
</div>
<div class="flex-1">
<p class="font-medium text-blue-900 mb-2">在浏览器中打开链接并完成授权</p>
<p class="font-medium text-blue-900 mb-2">
在浏览器中打开链接并完成授权
</p>
<p class="text-sm text-blue-700 mb-2">
请在新标签页中打开授权链接登录您的 Claude 账户并授权
</p>
<div class="bg-yellow-50 p-3 rounded border border-yellow-300">
<p class="text-xs text-yellow-800">
<i class="fas fa-exclamation-triangle mr-1"></i>
<i class="fas fa-exclamation-triangle mr-1" />
<strong>注意</strong>如果您设置了代理请确保浏览器也使用相同的代理访问授权页面
</p>
</div>
@@ -79,26 +98,30 @@
<!-- 步骤3: 输入授权码 -->
<div class="bg-white/80 rounded-lg p-4 border border-blue-300">
<div class="flex items-start gap-3">
<div class="w-6 h-6 bg-blue-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">3</div>
<div class="w-6 h-6 bg-blue-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">
3
</div>
<div class="flex-1">
<p class="font-medium text-blue-900 mb-2">输入 Authorization Code</p>
<p class="font-medium text-blue-900 mb-2">
输入 Authorization Code
</p>
<p class="text-sm text-blue-700 mb-3">
授权完成后页面会显示一个 <strong>Authorization Code</strong>请将其复制并粘贴到下方输入框
</p>
<div class="space-y-3">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">
<i class="fas fa-key text-blue-500 mr-2"></i>Authorization Code
<i class="fas fa-key text-blue-500 mr-2" />Authorization Code
</label>
<textarea
v-model="authCode"
rows="3"
class="form-input w-full resize-none font-mono text-sm"
placeholder="粘贴从Claude页面获取的Authorization Code..."
></textarea>
/>
</div>
<p class="text-xs text-gray-500 mt-2">
<i class="fas fa-info-circle mr-1"></i>
<i class="fas fa-info-circle mr-1" />
请粘贴从Claude页面复制的Authorization Code
</p>
</div>
@@ -116,10 +139,12 @@
<div class="bg-green-50 p-6 rounded-lg border border-green-200">
<div class="flex items-start gap-4">
<div class="w-10 h-10 bg-green-500 rounded-lg flex items-center justify-center flex-shrink-0">
<i class="fas fa-robot text-white"></i>
<i class="fas fa-robot text-white" />
</div>
<div class="flex-1">
<h4 class="font-semibold text-green-900 mb-3">Gemini 账户授权</h4>
<h4 class="font-semibold text-green-900 mb-3">
Gemini 账户授权
</h4>
<p class="text-sm text-green-800 mb-4">
请按照以下步骤完成 Gemini 账户的授权
</p>
@@ -128,20 +153,33 @@
<!-- 步骤1: 生成授权链接 -->
<div class="bg-white/80 rounded-lg p-4 border border-green-300">
<div class="flex items-start gap-3">
<div class="w-6 h-6 bg-green-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">1</div>
<div class="w-6 h-6 bg-green-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">
1
</div>
<div class="flex-1">
<p class="font-medium text-green-900 mb-2">点击下方按钮生成授权链接</p>
<p class="font-medium text-green-900 mb-2">
点击下方按钮生成授权链接
</p>
<button
v-if="!authUrl"
@click="generateAuthUrl"
:disabled="loading"
class="btn btn-primary px-4 py-2 text-sm"
@click="generateAuthUrl"
>
<i v-if="!loading" class="fas fa-link mr-2"></i>
<div v-else class="loading-spinner mr-2"></div>
<i
v-if="!loading"
class="fas fa-link mr-2"
/>
<div
v-else
class="loading-spinner mr-2"
/>
{{ loading ? '生成中...' : '生成授权链接' }}
</button>
<div v-else class="space-y-3">
<div
v-else
class="space-y-3"
>
<div class="flex items-center gap-2">
<input
type="text"
@@ -150,18 +188,18 @@
class="form-input flex-1 text-xs font-mono bg-gray-50"
>
<button
@click="copyAuthUrl"
class="px-3 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
title="复制链接"
@click="copyAuthUrl"
>
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'"></i>
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'" />
</button>
</div>
<button
@click="regenerateAuthUrl"
class="text-xs text-green-600 hover:text-green-700"
@click="regenerateAuthUrl"
>
<i class="fas fa-sync-alt mr-1"></i>重新生成
<i class="fas fa-sync-alt mr-1" />重新生成
</button>
</div>
</div>
@@ -171,9 +209,13 @@
<!-- 步骤2: 操作说明 -->
<div class="bg-white/80 rounded-lg p-4 border border-green-300">
<div class="flex items-start gap-3">
<div class="w-6 h-6 bg-green-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">2</div>
<div class="w-6 h-6 bg-green-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">
2
</div>
<div class="flex-1">
<p class="font-medium text-green-900 mb-2">在浏览器中打开链接并完成授权</p>
<p class="font-medium text-green-900 mb-2">
在浏览器中打开链接并完成授权
</p>
<ol class="text-sm text-green-800 space-y-1 list-decimal list-inside mb-3">
<li>点击上方的授权链接在新页面中完成Google账号登录</li>
<li>点击登录按钮后可能会加载很慢这是正常的</li>
@@ -182,7 +224,7 @@
</ol>
<div class="bg-green-100 p-3 rounded border border-green-300">
<p class="text-xs text-green-700">
<i class="fas fa-lightbulb mr-1"></i>
<i class="fas fa-lightbulb mr-1" />
<strong>提示</strong>如果页面一直无法跳转可以打开浏览器开发者工具F12F5刷新一下授权页再点击页面的登录按钮网络标签中找到以 localhost:45462 开头的请求复制其完整URL
</p>
</div>
@@ -193,31 +235,35 @@
<!-- 步骤3: 输入授权码 -->
<div class="bg-white/80 rounded-lg p-4 border border-green-300">
<div class="flex items-start gap-3">
<div class="w-6 h-6 bg-green-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">3</div>
<div class="w-6 h-6 bg-green-600 text-white rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">
3
</div>
<div class="flex-1">
<p class="font-medium text-green-900 mb-2">复制oauth后的链接</p>
<p class="font-medium text-green-900 mb-2">
复制oauth后的链接
</p>
<p class="text-sm text-green-700 mb-3">
复制浏览器地址栏的完整链接并粘贴到下方输入框
</p>
<div class="space-y-3">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">
<i class="fas fa-key text-green-500 mr-2"></i>复制oauth后的链接
<i class="fas fa-key text-green-500 mr-2" />复制oauth后的链接
</label>
<textarea
v-model="authCode"
rows="3"
class="form-input w-full resize-none font-mono text-sm"
placeholder="粘贴以 http://localhost:45462 开头的完整链接..."
></textarea>
/>
</div>
<div class="mt-2 space-y-1">
<p class="text-xs text-gray-600">
<i class="fas fa-check-circle text-green-500 mr-1"></i>
<i class="fas fa-check-circle text-green-500 mr-1" />
支持粘贴完整链接系统会自动提取授权码
</p>
<p class="text-xs text-gray-600">
<i class="fas fa-check-circle text-green-500 mr-1"></i>
<i class="fas fa-check-circle text-green-500 mr-1" />
也可以直接粘贴授权码code参数的值
</p>
</div>
@@ -234,18 +280,21 @@
<div class="flex gap-3 pt-4">
<button
type="button"
@click="$emit('back')"
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
@click="$emit('back')"
>
上一步
</button>
<button
type="button"
@click="exchangeCode"
:disabled="!canExchange || exchanging"
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
@click="exchangeCode"
>
<div v-if="exchanging" class="loading-spinner mr-2"></div>
<div
v-if="exchanging"
class="loading-spinner mr-2"
/>
{{ exchanging ? '验证中...' : '完成授权' }}
</button>
</div>

View File

@@ -1,21 +1,26 @@
<template>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-gray-700">代理设置 (可选)</h4>
<h4 class="text-sm font-semibold text-gray-700">
代理设置 (可选)
</h4>
<label class="flex items-center cursor-pointer">
<input
type="checkbox"
v-model="proxy.enabled"
v-model="proxy.enabled"
type="checkbox"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
>
<span class="ml-2 text-sm text-gray-700">启用代理</span>
</label>
</div>
<div v-if="proxy.enabled" class="bg-gray-50 p-4 rounded-lg border border-gray-200 space-y-4">
<div
v-if="proxy.enabled"
class="bg-gray-50 p-4 rounded-lg border border-gray-200 space-y-4"
>
<div class="flex items-start gap-3 mb-3">
<div class="w-8 h-8 bg-gray-500 rounded-lg flex items-center justify-center flex-shrink-0">
<i class="fas fa-server text-white text-sm"></i>
<i class="fas fa-server text-white text-sm" />
</div>
<div class="flex-1">
<p class="text-sm text-gray-700">
@@ -33,9 +38,15 @@
v-model="proxy.type"
class="form-input w-full"
>
<option value="socks5">SOCKS5</option>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
<option value="socks5">
SOCKS5
</option>
<option value="http">
HTTP
</option>
<option value="https">
HTTPS
</option>
</select>
</div>
@@ -63,17 +74,23 @@
<div class="space-y-4">
<div class="flex items-center">
<input
type="checkbox"
id="proxyAuth"
v-model="showAuth"
id="proxyAuth"
type="checkbox"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
>
<label for="proxyAuth" class="ml-2 text-sm text-gray-700 cursor-pointer">
<label
for="proxyAuth"
class="ml-2 text-sm text-gray-700 cursor-pointer"
>
需要身份验证
</label>
</div>
<div v-if="showAuth" class="grid grid-cols-2 gap-4">
<div
v-if="showAuth"
class="grid grid-cols-2 gap-4"
>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">用户名</label>
<input
@@ -94,10 +111,10 @@
>
<button
type="button"
@click="showPassword = !showPassword"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600"
@click="showPassword = !showPassword"
>
<i :class="showPassword ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
<i :class="showPassword ? 'fas fa-eye-slash' : 'fas fa-eye'" />
</button>
</div>
</div>
@@ -106,7 +123,7 @@
<div class="bg-blue-50 p-3 rounded-lg border border-blue-200">
<p class="text-xs text-blue-700">
<i class="fas fa-info-circle mr-1"></i>
<i class="fas fa-info-circle mr-1" />
<strong>提示</strong>代理设置将用于所有与此账户相关的API请求请确保代理服务器支持HTTPS流量转发
</p>
</div>

View File

@@ -2,425 +2,550 @@
<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-4xl p-6 mx-auto max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between mb-4">
<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 class="flex items-center justify-between mb-4">
<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" />
</div>
<h3 class="text-xl font-bold text-gray-900">
创建新的 API Key
</h3>
</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-4 modal-scroll-content custom-scrollbar flex-1">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">名称 <span class="text-red-500">*</span></label>
<input
v-model="form.name"
type="text"
required
class="form-input w-full"
:class="{ 'border-red-500': errors.name }"
placeholder="为您的 API Key 取一个名称"
@input="errors.name = ''"
<button
class="text-gray-400 hover:text-gray-600 transition-colors"
@click="$emit('close')"
>
<p v-if="errors.name" class="text-red-500 text-xs mt-1">{{ errors.name }}</p>
<i class="fas fa-times text-xl" />
</button>
</div>
<!-- 标签 -->
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">标签</label>
<div class="space-y-4">
<!-- 已选择的标签 -->
<div v-if="form.tags.length > 0">
<div class="text-xs font-medium text-gray-600 mb-2">已选择的标签:</div>
<div class="flex flex-wrap gap-2">
<span v-for="(tag, index) in form.tags" :key="'selected-' + index"
class="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-800 text-sm rounded-full">
{{ tag }}
<button type="button" @click="removeTag(index)"
class="ml-1 hover:text-blue-900">
<i class="fas fa-times text-xs"></i>
</button>
</span>
</div>
</div>
<!-- 可选择的已有标签 -->
<div v-if="unselectedTags.length > 0">
<div class="text-xs font-medium text-gray-600 mb-2">点击选择已有标签:</div>
<div class="flex flex-wrap gap-2">
<button
v-for="tag in unselectedTags"
:key="'available-' + tag"
type="button"
@click="selectTag(tag)"
class="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 text-gray-700 text-sm rounded-full hover:bg-blue-100 hover:text-blue-700 transition-colors"
>
<i class="fas fa-tag text-gray-500 text-xs"></i>
{{ tag }}
</button>
</div>
</div>
<!-- 创建新标签 -->
<div>
<div class="text-xs font-medium text-gray-600 mb-2">创建新标签:</div>
<div class="flex gap-2">
<input
v-model="newTag"
type="text"
class="form-input flex-1"
placeholder="输入新标签名称"
@keypress.enter.prevent="addTag"
>
<button type="button" @click="addTag"
class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors">
<i class="fas fa-plus"></i>
</button>
</div>
</div>
<p class="text-xs text-gray-500">用于标记不同团队或用途方便筛选管理</p>
</div>
</div>
<!-- 速率限制设置 -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div class="flex items-center gap-2 mb-2">
<div class="w-6 h-6 bg-blue-500 rounded flex items-center justify-center flex-shrink-0">
<i class="fas fa-tachometer-alt text-white text-xs"></i>
</div>
<h4 class="font-semibold text-gray-800 text-sm">速率限制设置 (可选)</h4>
</div>
<div class="space-y-2">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-2">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">时间窗口 (分钟)</label>
<input
v-model="form.rateLimitWindow"
type="number"
min="1"
placeholder="无限制"
class="form-input w-full text-sm"
>
<p class="text-xs text-gray-500 mt-0.5 ml-2">时间段单位</p>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">请求次数限制</label>
<input
v-model="form.rateLimitRequests"
type="number"
min="1"
placeholder="无限制"
class="form-input w-full text-sm"
>
<p class="text-xs text-gray-500 mt-0.5 ml-2">窗口内最大请求</p>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Token 限制</label>
<input
v-model="form.tokenLimit"
type="number"
placeholder="无限制"
class="form-input w-full text-sm"
>
<p class="text-xs text-gray-500 mt-0.5 ml-2">窗口内最大Token</p>
</div>
</div>
<!-- 示例说明 -->
<div class="bg-blue-100 rounded-lg p-2">
<h5 class="text-xs font-semibold text-blue-800 mb-1">💡 使用示例</h5>
<div class="text-xs text-blue-700 space-y-0.5">
<div><strong>示例1:</strong> 时间窗口=60请求次数=1000 每60分钟最多1000次请求</div>
<div><strong>示例2:</strong> 时间窗口=1Token=10000 每分钟最多10,000个Token</div>
<div><strong>示例3:</strong> 窗口=30请求=50Token=100000 每30分钟50次请求且不超10万Token</div>
</div>
</div>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">每日费用限制 (美元)</label>
<div class="space-y-2">
<div class="flex gap-2">
<button type="button" @click="form.dailyCostLimit = '50'" class="px-2 py-1 bg-gray-100 hover:bg-gray-200 rounded text-xs font-medium">$50</button>
<button type="button" @click="form.dailyCostLimit = '100'" class="px-2 py-1 bg-gray-100 hover:bg-gray-200 rounded text-xs font-medium">$100</button>
<button type="button" @click="form.dailyCostLimit = '200'" class="px-2 py-1 bg-gray-100 hover:bg-gray-200 rounded text-xs font-medium">$200</button>
<button type="button" @click="form.dailyCostLimit = ''" class="px-2 py-1 bg-gray-100 hover:bg-gray-200 rounded text-xs font-medium">自定义</button>
</div>
<form
class="space-y-4 modal-scroll-content custom-scrollbar flex-1"
@submit.prevent="createApiKey"
>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">名称 <span class="text-red-500">*</span></label>
<input
v-model="form.dailyCostLimit"
v-model="form.name"
type="text"
required
class="form-input w-full"
:class="{ 'border-red-500': errors.name }"
placeholder="为您的 API Key 取一个名称"
@input="errors.name = ''"
>
<p
v-if="errors.name"
class="text-red-500 text-xs mt-1"
>
{{ errors.name }}
</p>
</div>
<!-- 标签 -->
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">标签</label>
<div class="space-y-4">
<!-- 已选择的标签 -->
<div v-if="form.tags.length > 0">
<div class="text-xs font-medium text-gray-600 mb-2">
已选择的标签:
</div>
<div class="flex flex-wrap gap-2">
<span
v-for="(tag, index) in form.tags"
:key="'selected-' + index"
class="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-800 text-sm rounded-full"
>
{{ tag }}
<button
type="button"
class="ml-1 hover:text-blue-900"
@click="removeTag(index)"
>
<i class="fas fa-times text-xs" />
</button>
</span>
</div>
</div>
<!-- 可选择的已有标签 -->
<div v-if="unselectedTags.length > 0">
<div class="text-xs font-medium text-gray-600 mb-2">
点击选择已有标签:
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="tag in unselectedTags"
:key="'available-' + tag"
type="button"
class="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 text-gray-700 text-sm rounded-full hover:bg-blue-100 hover:text-blue-700 transition-colors"
@click="selectTag(tag)"
>
<i class="fas fa-tag text-gray-500 text-xs" />
{{ tag }}
</button>
</div>
</div>
<!-- 创建新标签 -->
<div>
<div class="text-xs font-medium text-gray-600 mb-2">
创建新标签:
</div>
<div class="flex gap-2">
<input
v-model="newTag"
type="text"
class="form-input flex-1"
placeholder="输入新标签名称"
@keypress.enter.prevent="addTag"
>
<button
type="button"
class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors"
@click="addTag"
>
<i class="fas fa-plus" />
</button>
</div>
</div>
<p class="text-xs text-gray-500">
用于标记不同团队或用途方便筛选管理
</p>
</div>
</div>
<!-- 速率限制设置 -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div class="flex items-center gap-2 mb-2">
<div class="w-6 h-6 bg-blue-500 rounded flex items-center justify-center flex-shrink-0">
<i class="fas fa-tachometer-alt text-white text-xs" />
</div>
<h4 class="font-semibold text-gray-800 text-sm">
速率限制设置 (可选)
</h4>
</div>
<div class="space-y-2">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-2">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">时间窗口 (分钟)</label>
<input
v-model="form.rateLimitWindow"
type="number"
min="1"
placeholder="无限制"
class="form-input w-full text-sm"
>
<p class="text-xs text-gray-500 mt-0.5 ml-2">
时间段单位
</p>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">请求次数限制</label>
<input
v-model="form.rateLimitRequests"
type="number"
min="1"
placeholder="无限制"
class="form-input w-full text-sm"
>
<p class="text-xs text-gray-500 mt-0.5 ml-2">
窗口内最大请求
</p>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Token 限制</label>
<input
v-model="form.tokenLimit"
type="number"
placeholder="无限制"
class="form-input w-full text-sm"
>
<p class="text-xs text-gray-500 mt-0.5 ml-2">
窗口内最大Token
</p>
</div>
</div>
<!-- 示例说明 -->
<div class="bg-blue-100 rounded-lg p-2">
<h5 class="text-xs font-semibold text-blue-800 mb-1">
💡 使用示例
</h5>
<div class="text-xs text-blue-700 space-y-0.5">
<div><strong>示例1:</strong> 时间窗口=60请求次数=1000 每60分钟最多1000次请求</div>
<div><strong>示例2:</strong> 时间窗口=1Token=10000 每分钟最多10,000个Token</div>
<div><strong>示例3:</strong> 窗口=30请求=50Token=100000 每30分钟50次请求且不超10万Token</div>
</div>
</div>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">每日费用限制 (美元)</label>
<div class="space-y-2">
<div class="flex gap-2">
<button
type="button"
class="px-2 py-1 bg-gray-100 hover:bg-gray-200 rounded text-xs font-medium"
@click="form.dailyCostLimit = '50'"
>
$50
</button>
<button
type="button"
class="px-2 py-1 bg-gray-100 hover:bg-gray-200 rounded text-xs font-medium"
@click="form.dailyCostLimit = '100'"
>
$100
</button>
<button
type="button"
class="px-2 py-1 bg-gray-100 hover:bg-gray-200 rounded text-xs font-medium"
@click="form.dailyCostLimit = '200'"
>
$200
</button>
<button
type="button"
class="px-2 py-1 bg-gray-100 hover:bg-gray-200 rounded text-xs font-medium"
@click="form.dailyCostLimit = ''"
>
自定义
</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-2">并发限制 (可选)</label>
<input
v-model="form.concurrencyLimit"
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>
<p class="text-xs text-gray-500 mt-2">
设置此 API Key 可同时处理的最大请求数0 或留空表示无限制
</p>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">并发限制 (可选)</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-2">备注 (可选)</label>
<textarea
v-model="form.description"
rows="2"
class="form-input w-full resize-none text-sm"
placeholder="描述此 API Key 的用途..."
/>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">备注 (可选)</label>
<textarea
v-model="form.description"
rows="2"
class="form-input w-full resize-none text-sm"
placeholder="描述此 API Key 的用途..."
></textarea>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">有效期限</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"
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">有效期限</label>
<select
v-model="form.expireDuration"
class="form-input w-full"
:min="minDateTime"
@change="updateCustomExpireAt"
@change="updateExpireAt"
>
<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"
>
</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-2">服务权限</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-2">专属账号绑定 (可选)</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"
v-model="form.customExpireDate"
type="datetime-local"
class="form-input w-full"
:disabled="form.permissions === 'gemini'"
:min="minDateTime"
@change="updateCustomExpireAt"
>
<option value="">使用共享账号池</option>
<optgroup v-if="accounts.claude.filter(a => a.isDedicated && a.platform === 'claude-oauth').length > 0" label="Claude OAuth 账号">
</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-2">服务权限</label>
<div class="flex gap-4">
<label class="flex items-center cursor-pointer">
<input
v-model="form.permissions"
type="radio"
value="all"
class="mr-2"
>
<span class="text-sm text-gray-700">全部服务</span>
</label>
<label class="flex items-center cursor-pointer">
<input
v-model="form.permissions"
type="radio"
value="claude"
class="mr-2"
>
<span class="text-sm text-gray-700"> Claude</span>
</label>
<label class="flex items-center cursor-pointer">
<input
v-model="form.permissions"
type="radio"
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-2">专属账号绑定 (可选)</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>
<optgroup v-if="accounts.claude.filter(a => a.isDedicated && a.platform === 'claude-oauth').length > 0" label="Claude OAuth 账号">
<option
v-for="account in accounts.claude.filter(a => a.isDedicated && a.platform === 'claude-oauth')"
:key="account.id"
:value="account.id"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
</optgroup>
<optgroup v-if="accounts.claude.filter(a => a.isDedicated && a.platform === 'claude-console').length > 0" label="Claude Console 账号">
<option
v-for="account in accounts.claude.filter(a => a.isDedicated && a.platform === 'claude-console')"
:key="account.id"
:value="`console:${account.id}`"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
</optgroup>
</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.claude.filter(a => a.isDedicated && a.platform === 'claude-oauth')"
v-for="account in accounts.gemini.filter(a => a.isDedicated)"
:key="account.id"
:value="account.id"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
</optgroup>
<optgroup v-if="accounts.claude.filter(a => a.isDedicated && a.platform === 'claude-console').length > 0" label="Claude Console 账号">
<option
v-for="account in accounts.claude.filter(a => a.isDedicated && a.platform === 'claude-console')"
:key="account.id"
:value="`console:${account.id}`"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
</optgroup>
</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>
</select>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">
选择专属账号后此API Key将只使用该账号不选择则使用共享账号池
</p>
</div>
<p class="text-xs text-gray-500 mt-2">选择专属账号后此API Key将只使用该账号不选择则使用共享账号池</p>
</div>
<div>
<div class="flex items-center mb-2">
<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>
<div class="flex items-center mb-2">
<input
id="enableModelRestriction"
v-model="form.enableModelRestriction"
type="checkbox"
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-2 bg-red-50 border border-red-200 rounded-lg p-3">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">限制的模型列表</label>
<div class="flex flex-wrap gap-1 mb-2 min-h-[24px]">
<span
v-for="(model, index) in form.restrictedModels"
:key="index"
class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-red-100 text-red-800"
>
{{ model }}
<div
v-if="form.enableModelRestriction"
class="space-y-2 bg-red-50 border border-red-200 rounded-lg p-3"
>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">限制的模型列表</label>
<div class="flex flex-wrap gap-1 mb-2 min-h-[24px]">
<span
v-for="(model, index) in form.restrictedModels"
:key="index"
class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-red-100 text-red-800"
>
{{ model }}
<button
type="button"
class="ml-1 text-red-600 hover:text-red-800"
@click="removeRestrictedModel(index)"
>
<i class="fas fa-times text-xs" />
</button>
</span>
<span
v-if="form.restrictedModels.length === 0"
class="text-gray-400 text-xs"
>
暂无限制的模型
</span>
</div>
<div class="flex gap-2">
<input
v-model="form.modelInput"
type="text"
placeholder="输入模型名称,按回车添加"
class="form-input flex-1 text-sm"
@keydown.enter.prevent="addRestrictedModel"
>
<button
type="button"
@click="removeRestrictedModel(index)"
class="ml-1 text-red-600 hover:text-red-800"
class="px-3 py-1.5 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors text-sm"
@click="addRestrictedModel"
>
<i class="fas fa-times text-xs"></i>
<i class="fas fa-plus" />
</button>
</span>
<span v-if="form.restrictedModels.length === 0" class="text-gray-400 text-xs">
暂无限制的模型
</span>
</div>
<p class="text-xs text-gray-500 mt-1">
例如claude-opus-4-20250514
</p>
</div>
<div class="flex gap-2">
<input
v-model="form.modelInput"
@keydown.enter.prevent="addRestrictedModel"
type="text"
placeholder="输入模型名称,按回车添加"
class="form-input flex-1 text-sm"
>
<button
type="button"
@click="addRestrictedModel"
class="px-3 py-1.5 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors text-sm"
>
<i class="fas fa-plus"></i>
</button>
</div>
<p class="text-xs text-gray-500 mt-1">例如claude-opus-4-20250514</p>
</div>
</div>
</div>
<!-- 客户端限制 -->
<div>
<div class="flex items-center mb-2">
<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>
<div class="flex items-center mb-2">
<input
id="enableClientRestriction"
v-model="form.enableClientRestriction"
type="checkbox"
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="bg-green-50 border border-green-200 rounded-lg p-3">
<div>
<label class="block text-xs font-medium text-gray-700 mb-2">允许的客户端</label>
<div class="space-y-1">
<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"
<div
v-if="form.enableClientRestriction"
class="bg-green-50 border border-green-200 rounded-lg p-3"
>
<div>
<label class="block text-xs font-medium text-gray-700 mb-2">允许的客户端</label>
<div class="space-y-1">
<div
v-for="client in supportedClients"
:key="client.id"
class="flex items-start"
>
<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>
<input
:id="`client_${client.id}`"
v-model="form.allowedClients"
type="checkbox"
:value="client.id"
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>
<div class="flex gap-3 pt-2">
<button
type="button"
@click="$emit('close')"
class="flex-1 px-4 py-2.5 bg-gray-100 text-gray-700 rounded-lg font-semibold hover:bg-gray-200 transition-colors text-sm"
>
取消
</button>
<button
type="submit"
:disabled="loading"
class="btn btn-primary flex-1 py-2.5 px-4 font-semibold text-sm"
>
<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 class="flex gap-3 pt-2">
<button
type="button"
class="flex-1 px-4 py-2.5 bg-gray-100 text-gray-700 rounded-lg font-semibold hover:bg-gray-200 transition-colors text-sm"
@click="$emit('close')"
>
取消
</button>
<button
type="submit"
:disabled="loading"
class="btn btn-primary flex-1 py-2.5 px-4 font-semibold text-sm"
>
<div
v-if="loading"
class="loading-spinner mr-2"
/>
<i
v-else
class="fas fa-plus mr-2"
/>
{{ loading ? '创建中...' : '创建' }}
</button>
</div>
</form>
</div>
</div>
</div>
</Teleport>
</template>

View File

@@ -2,390 +2,491 @@
<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-4xl 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 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" />
</div>
<h3 class="text-xl font-bold text-gray-900">
编辑 API Key
</h3>
</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"
<button
class="text-gray-400 hover:text-gray-600 transition-colors"
@click="$emit('close')"
>
<p class="text-xs text-gray-500 mt-2">名称不可修改</p>
<i class="fas fa-times text-xl" />
</button>
</div>
<!-- 标签 -->
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">标签</label>
<div class="space-y-4">
<!-- 已选择的标签 -->
<div v-if="form.tags.length > 0">
<div class="text-xs font-medium text-gray-600 mb-2">已选择的标签:</div>
<div class="flex flex-wrap gap-2">
<span v-for="(tag, index) in form.tags" :key="'selected-' + index"
class="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-800 text-sm rounded-full">
{{ tag }}
<button type="button" @click="removeTag(index)"
class="ml-1 hover:text-blue-900">
<i class="fas fa-times text-xs"></i>
</button>
</span>
</div>
</div>
<!-- 可选择的已有标签 -->
<div v-if="unselectedTags.length > 0">
<div class="text-xs font-medium text-gray-600 mb-2">点击选择已有标签:</div>
<div class="flex flex-wrap gap-2">
<button
v-for="tag in unselectedTags"
:key="'available-' + tag"
type="button"
@click="selectTag(tag)"
class="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 text-gray-700 text-sm rounded-full hover:bg-blue-100 hover:text-blue-700 transition-colors"
>
<i class="fas fa-tag text-gray-500 text-xs"></i>
{{ tag }}
</button>
</div>
</div>
<!-- 创建新标签 -->
<div>
<div class="text-xs font-medium text-gray-600 mb-2">创建新标签:</div>
<div class="flex gap-2">
<input
v-model="newTag"
type="text"
class="form-input flex-1"
placeholder="输入新标签名称"
@keypress.enter.prevent="addTag"
>
<button type="button" @click="addTag"
class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors">
<i class="fas fa-plus"></i>
</button>
</div>
</div>
<p class="text-xs text-gray-500">用于标记不同团队或用途方便筛选管理</p>
</div>
</div>
<!-- 速率限制设置 -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div class="flex items-center gap-2 mb-2">
<div class="w-6 h-6 bg-blue-500 rounded flex items-center justify-center flex-shrink-0">
<i class="fas fa-tachometer-alt text-white text-xs"></i>
</div>
<h4 class="font-semibold text-gray-800 text-sm">速率限制设置 (可选)</h4>
</div>
<div class="space-y-2">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-2">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">时间窗口 (分钟)</label>
<input
v-model="form.rateLimitWindow"
type="number"
min="1"
placeholder="无限制"
class="form-input w-full text-sm"
>
<p class="text-xs text-gray-500 mt-0.5 ml-2">时间段单位</p>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">请求次数限制</label>
<input
v-model="form.rateLimitRequests"
type="number"
min="1"
placeholder="无限制"
class="form-input w-full text-sm"
>
<p class="text-xs text-gray-500 mt-0.5 ml-2">窗口内最大请求</p>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Token 限制</label>
<input
v-model="form.tokenLimit"
type="number"
placeholder="无限制"
class="form-input w-full text-sm"
>
<p class="text-xs text-gray-500 mt-0.5 ml-2">窗口内最大Token</p>
</div>
</div>
<!-- 示例说明 -->
<div class="bg-blue-100 rounded-lg p-2">
<h5 class="text-xs font-semibold text-blue-800 mb-1">💡 使用示例</h5>
<div class="text-xs text-blue-700 space-y-0.5">
<div><strong>示例1:</strong> 时间窗口=60请求次数=1000 每60分钟最多1000次请求</div>
<div><strong>示例2:</strong> 时间窗口=1Token=10000 每分钟最多10,000个Token</div>
<div><strong>示例3:</strong> 窗口=30请求=50Token=100000 每30分钟50次请求且不超10万Token</div>
</div>
</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>
<form
class="space-y-6 modal-scroll-content custom-scrollbar flex-1"
@submit.prevent="updateApiKey"
>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">名称</label>
<input
v-model="form.dailyCostLimit"
: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>
<label class="block text-sm font-semibold text-gray-700 mb-3">标签</label>
<div class="space-y-4">
<!-- 已选择的标签 -->
<div v-if="form.tags.length > 0">
<div class="text-xs font-medium text-gray-600 mb-2">
已选择的标签:
</div>
<div class="flex flex-wrap gap-2">
<span
v-for="(tag, index) in form.tags"
:key="'selected-' + index"
class="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-800 text-sm rounded-full"
>
{{ tag }}
<button
type="button"
class="ml-1 hover:text-blue-900"
@click="removeTag(index)"
>
<i class="fas fa-times text-xs" />
</button>
</span>
</div>
</div>
<!-- 可选择的已有标签 -->
<div v-if="unselectedTags.length > 0">
<div class="text-xs font-medium text-gray-600 mb-2">
点击选择已有标签:
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="tag in unselectedTags"
:key="'available-' + tag"
type="button"
class="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 text-gray-700 text-sm rounded-full hover:bg-blue-100 hover:text-blue-700 transition-colors"
@click="selectTag(tag)"
>
<i class="fas fa-tag text-gray-500 text-xs" />
{{ tag }}
</button>
</div>
</div>
<!-- 创建新标签 -->
<div>
<div class="text-xs font-medium text-gray-600 mb-2">
创建新标签:
</div>
<div class="flex gap-2">
<input
v-model="newTag"
type="text"
class="form-input flex-1"
placeholder="输入新标签名称"
@keypress.enter.prevent="addTag"
>
<button
type="button"
class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors"
@click="addTag"
>
<i class="fas fa-plus" />
</button>
</div>
</div>
<p class="text-xs text-gray-500">
用于标记不同团队或用途方便筛选管理
</p>
</div>
</div>
<!-- 速率限制设置 -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div class="flex items-center gap-2 mb-2">
<div class="w-6 h-6 bg-blue-500 rounded flex items-center justify-center flex-shrink-0">
<i class="fas fa-tachometer-alt text-white text-xs" />
</div>
<h4 class="font-semibold text-gray-800 text-sm">
速率限制设置 (可选)
</h4>
</div>
<div class="space-y-2">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-2">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">时间窗口 (分钟)</label>
<input
v-model="form.rateLimitWindow"
type="number"
min="1"
placeholder="无限制"
class="form-input w-full text-sm"
>
<p class="text-xs text-gray-500 mt-0.5 ml-2">
时间段单位
</p>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">请求次数限制</label>
<input
v-model="form.rateLimitRequests"
type="number"
min="1"
placeholder="无限制"
class="form-input w-full text-sm"
>
<p class="text-xs text-gray-500 mt-0.5 ml-2">
窗口内最大请求
</p>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Token 限制</label>
<input
v-model="form.tokenLimit"
type="number"
placeholder="无限制"
class="form-input w-full text-sm"
>
<p class="text-xs text-gray-500 mt-0.5 ml-2">
窗口内最大Token
</p>
</div>
</div>
<!-- 示例说明 -->
<div class="bg-blue-100 rounded-lg p-2">
<h5 class="text-xs font-semibold text-blue-800 mb-1">
💡 使用示例
</h5>
<div class="text-xs text-blue-700 space-y-0.5">
<div><strong>示例1:</strong> 时间窗口=60请求次数=1000 每60分钟最多1000次请求</div>
<div><strong>示例2:</strong> 时间窗口=1Token=10000 每分钟最多10,000个Token</div>
<div><strong>示例3:</strong> 窗口=30请求=50Token=100000 每30分钟50次请求且不超10万Token</div>
</div>
</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"
class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium"
@click="form.dailyCostLimit = '50'"
>
$50
</button>
<button
type="button"
class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium"
@click="form.dailyCostLimit = '100'"
>
$100
</button>
<button
type="button"
class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium"
@click="form.dailyCostLimit = '200'"
>
$200
</button>
<button
type="button"
class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium"
@click="form.dailyCostLimit = ''"
>
自定义
</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"
step="0.01"
placeholder="0 表示无限制"
class="form-input w-full"
>
<p class="text-xs text-gray-500">设置此 API Key 每日的费用限制超过限制将拒绝请求0 或留空表示无限制</p>
<p class="text-xs text-gray-500 mt-2">
设置此 API Key 可同时处理的最大请求数
</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>
<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
v-model="form.permissions"
type="radio"
value="all"
class="mr-2"
>
<span class="text-sm text-gray-700">全部服务</span>
</label>
<label class="flex items-center cursor-pointer">
<input
v-model="form.permissions"
type="radio"
value="claude"
class="mr-2"
>
<span class="text-sm text-gray-700"> Claude</span>
</label>
<label class="flex items-center cursor-pointer">
<input
v-model="form.permissions"
type="radio"
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>
<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>
<optgroup v-if="accounts.claude.filter(a => a.isDedicated && a.platform === 'claude-oauth').length > 0" label="Claude OAuth 账号">
<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>
<optgroup v-if="accounts.claude.filter(a => a.isDedicated && a.platform === 'claude-oauth').length > 0" label="Claude OAuth 账号">
<option
v-for="account in accounts.claude.filter(a => a.isDedicated && a.platform === 'claude-oauth')"
:key="account.id"
:value="account.id"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
</optgroup>
<optgroup v-if="accounts.claude.filter(a => a.isDedicated && a.platform === 'claude-console').length > 0" label="Claude Console 账号">
<option
v-for="account in accounts.claude.filter(a => a.isDedicated && a.platform === 'claude-console')"
:key="account.id"
:value="`console:${account.id}`"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
</optgroup>
</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.claude.filter(a => a.isDedicated && a.platform === 'claude-oauth')"
v-for="account in accounts.gemini"
:key="account.id"
:value="account.id"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
</optgroup>
<optgroup v-if="accounts.claude.filter(a => a.isDedicated && a.platform === 'claude-console').length > 0" label="Claude Console 账号">
<option
v-for="account in accounts.claude.filter(a => a.isDedicated && a.platform === 'claude-console')"
:key="account.id"
:value="`console:${account.id}`"
>
{{ account.name }} ({{ account.status === 'active' ? '正常' : '异常' }})
</option>
</optgroup>
</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>
</select>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">
修改绑定账号将影响此API Key的请求路由
</p>
</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>
<div class="flex items-center mb-3">
<input
id="editEnableModelRestriction"
v-model="form.enableModelRestriction"
type="checkbox"
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 }}
<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"
class="ml-2 text-red-600 hover:text-red-800"
@click="removeRestrictedModel(index)"
>
<i class="fas fa-times text-xs" />
</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"
type="text"
placeholder="输入模型名称,按回车添加"
class="form-input flex-1"
@keydown.enter.prevent="addRestrictedModel"
>
<button
type="button"
@click="removeRestrictedModel(index)"
class="ml-2 text-red-600 hover:text-red-800"
class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
@click="addRestrictedModel"
>
<i class="fas fa-times text-xs"></i>
<i class="fas fa-plus" />
</button>
</span>
<span v-if="form.restrictedModels.length === 0" class="text-gray-400 text-sm">
暂无限制的模型
</span>
</div>
<p class="text-xs text-gray-500 mt-2">
设置此API Key无法访问的模型例如claude-opus-4-20250514
</p>
</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>
<div class="flex items-center mb-3">
<input
id="editEnableClientRestriction"
v-model="form.enableClientRestriction"
type="checkbox"
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"
<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"
>
<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>
<input
:id="`edit_client_${client.id}`"
v-model="form.allowedClients"
type="checkbox"
:value="client.id"
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>
<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 class="flex gap-3 pt-4">
<button
type="button"
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
@click="$emit('close')"
>
取消
</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"
/>
<i
v-else
class="fas fa-save mr-2"
/>
{{ loading ? '保存中...' : '保存修改' }}
</button>
</div>
</form>
</div>
</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 { useApiKeysStore } from '@/stores/apiKeys'
import { apiClient } from '@/config/api'
@@ -403,7 +504,6 @@ const props = defineProps({
const emit = defineEmits(['close', 'success'])
const authStore = useAuthStore()
const clientsStore = useClientsStore()
const apiKeysStore = useApiKeysStore()
const loading = ref(false)
@@ -558,8 +658,9 @@ onMounted(async () => {
form.restrictedModels = props.apiKey.restrictedModels || []
form.allowedClients = props.apiKey.allowedClients || []
form.tags = props.apiKey.tags || []
form.enableModelRestriction = form.restrictedModels.length > 0
form.enableClientRestriction = form.allowedClients.length > 0
// 从后端数据中获取实际的启用状态,而不是根据数组长度推断
form.enableModelRestriction = props.apiKey.enableModelRestriction || false
form.enableClientRestriction = props.apiKey.enableClientRestriction || false
})
</script>

View File

@@ -2,97 +2,103 @@
<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 max-h-[90vh] overflow-y-auto custom-scrollbar">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="w-12 h-12 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center">
<i class="fas fa-check text-white text-lg"></i>
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="w-12 h-12 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center">
<i class="fas fa-check text-white text-lg" />
</div>
<div>
<h3 class="text-xl font-bold text-gray-900">
API Key 创建成功
</h3>
<p class="text-sm text-gray-600">
请妥善保存您的 API Key
</p>
</div>
</div>
<div>
<h3 class="text-xl font-bold text-gray-900">API Key 创建成功</h3>
<p class="text-sm text-gray-600">请妥善保存您的 API Key</p>
<button
class="text-gray-400 hover:text-gray-600 transition-colors"
title="直接关闭(不推荐)"
@click="handleDirectClose"
>
<i class="fas fa-times text-xl" />
</button>
</div>
<!-- 警告提示 -->
<div class="bg-amber-50 border-l-4 border-amber-400 p-4 mb-6">
<div class="flex items-start">
<div class="w-6 h-6 bg-amber-400 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
<i class="fas fa-exclamation-triangle text-white text-sm" />
</div>
<div class="ml-3">
<h5 class="font-semibold text-amber-900 mb-1">
重要提醒
</h5>
<p class="text-sm text-amber-800">
这是您唯一能看到完整 API Key 的机会关闭此窗口后系统将不再显示完整的 API Key请立即复制并妥善保存
</p>
</div>
</div>
</div>
<button
@click="handleDirectClose"
class="text-gray-400 hover:text-gray-600 transition-colors"
title="直接关闭(不推荐)"
>
<i class="fas fa-times text-xl"></i>
</button>
</div>
<!-- 警告提示 -->
<div class="bg-amber-50 border-l-4 border-amber-400 p-4 mb-6">
<div class="flex items-start">
<div class="w-6 h-6 bg-amber-400 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
<i class="fas fa-exclamation-triangle text-white text-sm"></i>
<!-- API Key 信息 -->
<div class="space-y-4 mb-6">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">API Key 名称</label>
<div class="p-3 bg-gray-50 rounded-lg border">
<span class="text-gray-900 font-medium">{{ apiKey.name }}</span>
</div>
</div>
<div class="ml-3">
<h5 class="font-semibold text-amber-900 mb-1">重要提醒</h5>
<p class="text-sm text-amber-800">
这是您唯一能看到完整 API Key 的机会关闭此窗口后系统将不再显示完整的 API Key请立即复制并妥善保存
<div v-if="apiKey.description">
<label class="block text-sm font-semibold text-gray-700 mb-2">备注</label>
<div class="p-3 bg-gray-50 rounded-lg border">
<span class="text-gray-700">{{ apiKey.description || '无描述' }}</span>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">API Key</label>
<div class="relative">
<div class="p-4 pr-14 bg-gray-900 rounded-lg border font-mono text-sm text-white break-all min-h-[60px] flex items-center">
{{ getDisplayedApiKey() }}
</div>
<div class="absolute top-3 right-3">
<button
type="button"
class="btn-icon-sm hover:bg-gray-800 bg-gray-700"
:title="showFullKey ? '隐藏API Key' : '显示完整API Key'"
@click="toggleKeyVisibility"
>
<i :class="['fas', showFullKey ? 'fa-eye-slash' : 'fa-eye', 'text-gray-300']" />
</button>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">
点击眼睛图标切换显示模式使用下方按钮复制完整 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">API Key 名称</label>
<div class="p-3 bg-gray-50 rounded-lg border">
<span class="text-gray-900 font-medium">{{ apiKey.name }}</span>
</div>
<!-- 操作按钮 -->
<div class="flex gap-3">
<button
class="flex-1 btn btn-primary py-3 px-6 font-semibold flex items-center justify-center gap-2"
@click="copyApiKey"
>
<i class="fas fa-copy" />
复制 API Key
</button>
<button
class="px-6 py-3 bg-gray-200 text-gray-800 rounded-xl font-semibold hover:bg-gray-300 transition-colors border border-gray-300"
@click="handleClose"
>
我已保存
</button>
</div>
<div v-if="apiKey.description">
<label class="block text-sm font-semibold text-gray-700 mb-2">备注</label>
<div class="p-3 bg-gray-50 rounded-lg border">
<span class="text-gray-700">{{ apiKey.description || '无描述' }}</span>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">API Key</label>
<div class="relative">
<div class="p-4 pr-14 bg-gray-900 rounded-lg border font-mono text-sm text-white break-all min-h-[60px] flex items-center">
{{ getDisplayedApiKey() }}
</div>
<div class="absolute top-3 right-3">
<button
@click="toggleKeyVisibility"
type="button"
class="btn-icon-sm hover:bg-gray-800 bg-gray-700"
:title="showFullKey ? '隐藏API Key' : '显示完整API Key'"
>
<i :class="['fas', showFullKey ? 'fa-eye-slash' : 'fa-eye', 'text-gray-300']"></i>
</button>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">
点击眼睛图标切换显示模式使用下方按钮复制完整 API Key
</p>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex gap-3">
<button
@click="copyApiKey"
class="flex-1 btn btn-primary py-3 px-6 font-semibold flex items-center justify-center gap-2"
>
<i class="fas fa-copy"></i>
复制 API Key
</button>
<button
@click="handleClose"
class="px-6 py-3 bg-gray-200 text-gray-800 rounded-xl font-semibold hover:bg-gray-300 transition-colors border border-gray-300"
>
我已保存
</button>
</div>
</div>
</div>
</Teleport>
</template>

View File

@@ -2,88 +2,120 @@
<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 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" />
</div>
<h3 class="text-xl font-bold text-gray-900">
续期 API Key
</h3>
</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"
<button
class="text-gray-400 hover:text-gray-600 transition-colors"
@click="$emit('close')"
>
<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>
<i class="fas fa-times text-xl" />
</button>
</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 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" />
</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"
class="form-input w-full"
@change="updateRenewExpireAt"
>
<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"
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
@click="$emit('close')"
>
取消
</button>
<button
type="button"
:disabled="loading || !form.renewDuration"
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
@click="renewApiKey"
>
<div
v-if="loading"
class="loading-spinner mr-2"
/>
<i
v-else
class="fas fa-clock mr-2"
/>
{{ loading ? '续期中...' : '确认续期' }}
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>

View File

@@ -3,10 +3,12 @@
<!-- 标题区域 -->
<div class="wide-card-title text-center mb-6">
<h2 class="text-2xl font-bold mb-2">
<i class="fas fa-chart-line mr-3"></i>
<i class="fas fa-chart-line mr-3" />
使用统计查询
</h2>
<p class="text-base text-gray-600">查询您的 API Key 使用情况和统计数据</p>
<p class="text-base text-gray-600">
查询您的 API Key 使用情况和统计数据
</p>
</div>
<!-- 输入区域 -->
@@ -15,7 +17,7 @@
<!-- API Key 输入 -->
<div class="lg:col-span-3">
<label class="block text-sm font-medium mb-2 text-gray-700">
<i class="fas fa-key mr-2"></i>
<i class="fas fa-key mr-2" />
输入您的 API Key
</label>
<input
@@ -23,8 +25,8 @@
type="password"
placeholder="请输入您的 API Key (cr_...)"
class="wide-card-input w-full"
@keyup.enter="queryStats"
:disabled="loading"
@keyup.enter="queryStats"
>
</div>
@@ -34,12 +36,18 @@
&nbsp;
</label>
<button
@click="queryStats"
:disabled="loading || !apiKey.trim()"
class="btn btn-primary btn-query w-full h-full flex items-center justify-center gap-2"
@click="queryStats"
>
<i v-if="loading" class="fas fa-spinner loading-spinner"></i>
<i v-else class="fas fa-search"></i>
<i
v-if="loading"
class="fas fa-spinner loading-spinner"
/>
<i
v-else
class="fas fa-search"
/>
{{ loading ? '查询中...' : '查询统计' }}
</button>
</div>
@@ -47,7 +55,7 @@
<!-- 安全提示 -->
<div class="security-notice mt-4">
<i class="fas fa-shield-alt mr-2"></i>
<i class="fas fa-shield-alt mr-2" />
您的 API Key 仅用于查询自己的统计数据不会被存储或用于其他用途
</div>
</div>

View File

@@ -3,7 +3,7 @@
<!-- 限制配置 -->
<div class="card p-6">
<h3 class="text-xl font-bold mb-4 flex items-center text-gray-900">
<i class="fas fa-shield-alt mr-3 text-red-500"></i>
<i class="fas fa-shield-alt mr-3 text-red-500" />
限制配置
</h3>
<div class="space-y-3">
@@ -19,8 +19,8 @@
<span class="text-gray-600">速率限制</span>
<span class="font-medium text-gray-900">
{{ statsData.limits.rateLimitRequests > 0 && statsData.limits.rateLimitWindow > 0
? `${statsData.limits.rateLimitRequests}次/${statsData.limits.rateLimitWindow}分钟`
: '无限制' }}
? `${statsData.limits.rateLimitRequests}次/${statsData.limits.rateLimitWindow}分钟`
: '无限制' }}
</span>
</div>
<div class="flex justify-between items-center">
@@ -30,13 +30,18 @@
<div class="flex justify-between items-center">
<span class="text-gray-600">模型限制</span>
<span class="font-medium text-gray-900">
<span v-if="statsData.restrictions.enableModelRestriction && statsData.restrictions.restrictedModels.length > 0"
class="text-orange-600">
<i class="fas fa-exclamation-triangle mr-1"></i>
<span
v-if="statsData.restrictions.enableModelRestriction && statsData.restrictions.restrictedModels.length > 0"
class="text-orange-600"
>
<i class="fas fa-exclamation-triangle mr-1" />
限制 {{ statsData.restrictions.restrictedModels.length }} 个模型
</span>
<span v-else class="text-green-600">
<i class="fas fa-check-circle mr-1"></i>
<span
v-else
class="text-green-600"
>
<i class="fas fa-check-circle mr-1" />
允许所有模型
</span>
</span>
@@ -44,13 +49,18 @@
<div class="flex justify-between items-center">
<span class="text-gray-600">客户端限制</span>
<span class="font-medium text-gray-900">
<span v-if="statsData.restrictions.enableClientRestriction && statsData.restrictions.allowedClients.length > 0"
class="text-orange-600">
<i class="fas fa-exclamation-triangle mr-1"></i>
<span
v-if="statsData.restrictions.enableClientRestriction && statsData.restrictions.allowedClients.length > 0"
class="text-orange-600"
>
<i class="fas fa-exclamation-triangle mr-1" />
限制 {{ statsData.restrictions.allowedClients.length }} 个客户端
</span>
<span v-else class="text-green-600">
<i class="fas fa-check-circle mr-1"></i>
<span
v-else
class="text-green-600"
>
<i class="fas fa-check-circle mr-1" />
允许所有客户端
</span>
</span>
@@ -59,53 +69,63 @@
</div>
<!-- 详细限制信息 -->
<div v-if="(statsData.restrictions.enableModelRestriction && statsData.restrictions.restrictedModels.length > 0) ||
(statsData.restrictions.enableClientRestriction && statsData.restrictions.allowedClients.length > 0)"
class="card p-6 mt-6">
<div
v-if="(statsData.restrictions.enableModelRestriction && statsData.restrictions.restrictedModels.length > 0) ||
(statsData.restrictions.enableClientRestriction && statsData.restrictions.allowedClients.length > 0)"
class="card p-6 mt-6"
>
<h3 class="text-xl font-bold mb-4 flex items-center text-gray-900">
<i class="fas fa-list-alt mr-3 text-amber-500"></i>
<i class="fas fa-list-alt mr-3 text-amber-500" />
详细限制信息
</h3>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 模型限制详情 -->
<div v-if="statsData.restrictions.enableModelRestriction && statsData.restrictions.restrictedModels.length > 0"
class="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div
v-if="statsData.restrictions.enableModelRestriction && statsData.restrictions.restrictedModels.length > 0"
class="bg-amber-50 border border-amber-200 rounded-lg p-4"
>
<h4 class="font-bold text-amber-800 mb-3 flex items-center">
<i class="fas fa-robot mr-2"></i>
<i class="fas fa-robot mr-2" />
受限模型列表
</h4>
<div class="space-y-2">
<div v-for="model in statsData.restrictions.restrictedModels"
:key="model"
class="bg-white rounded px-3 py-2 text-sm border border-amber-200">
<i class="fas fa-ban mr-2 text-red-500"></i>
<div
v-for="model in statsData.restrictions.restrictedModels"
:key="model"
class="bg-white rounded px-3 py-2 text-sm border border-amber-200"
>
<i class="fas fa-ban mr-2 text-red-500" />
<span class="text-gray-800">{{ model }}</span>
</div>
</div>
<p class="text-xs text-amber-700 mt-3">
<i class="fas fa-info-circle mr-1"></i>
<i class="fas fa-info-circle mr-1" />
此 API Key 不能访问以上列出的模型
</p>
</div>
<!-- 客户端限制详情 -->
<div v-if="statsData.restrictions.enableClientRestriction && statsData.restrictions.allowedClients.length > 0"
class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div
v-if="statsData.restrictions.enableClientRestriction && statsData.restrictions.allowedClients.length > 0"
class="bg-blue-50 border border-blue-200 rounded-lg p-4"
>
<h4 class="font-bold text-blue-800 mb-3 flex items-center">
<i class="fas fa-desktop mr-2"></i>
<i class="fas fa-desktop mr-2" />
允许的客户端
</h4>
<div class="space-y-2">
<div v-for="client in statsData.restrictions.allowedClients"
:key="client"
class="bg-white rounded px-3 py-2 text-sm border border-blue-200">
<i class="fas fa-check mr-2 text-green-500"></i>
<div
v-for="client in statsData.restrictions.allowedClients"
:key="client"
class="bg-white rounded px-3 py-2 text-sm border border-blue-200"
>
<i class="fas fa-check mr-2 text-green-500" />
<span class="text-gray-800">{{ client }}</span>
</div>
</div>
<p class="text-xs text-blue-700 mt-3">
<i class="fas fa-info-circle mr-1"></i>
<i class="fas fa-info-circle mr-1" />
API Key 只能被以上列出的客户端使用
</p>
</div>

View File

@@ -2,19 +2,27 @@
<div class="card p-6">
<div class="mb-6">
<h3 class="text-xl font-bold flex items-center text-gray-900">
<i class="fas fa-robot mr-3 text-indigo-500"></i>
<i class="fas fa-robot mr-3 text-indigo-500" />
模型使用统计 <span class="text-sm font-normal text-gray-600 ml-2">({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span>
</h3>
</div>
<!-- 模型统计加载状态 -->
<div v-if="modelStatsLoading" class="text-center py-8">
<i class="fas fa-spinner loading-spinner text-2xl mb-2 text-gray-600"></i>
<p class="text-gray-600">加载模型统计数据中...</p>
<div
v-if="modelStatsLoading"
class="text-center py-8"
>
<i class="fas fa-spinner loading-spinner text-2xl mb-2 text-gray-600" />
<p class="text-gray-600">
加载模型统计数据中...
</p>
</div>
<!-- 模型统计数据 -->
<div v-else-if="modelStats.length > 0" class="space-y-4">
<div
v-else-if="modelStats.length > 0"
class="space-y-4"
>
<div
v-for="(model, index) in modelStats"
:key="index"
@@ -22,39 +30,66 @@
>
<div class="flex justify-between items-start mb-3">
<div>
<h4 class="font-bold text-lg text-gray-900">{{ model.model }}</h4>
<p class="text-gray-600 text-sm">{{ model.requests }} 次请求</p>
<h4 class="font-bold text-lg text-gray-900">
{{ model.model }}
</h4>
<p class="text-gray-600 text-sm">
{{ model.requests }} 次请求
</p>
</div>
<div class="text-right">
<div class="text-lg font-bold text-green-600">{{ model.formatted?.total || '$0.000000' }}</div>
<div class="text-sm text-gray-600">总费用</div>
<div class="text-lg font-bold text-green-600">
{{ model.formatted?.total || '$0.000000' }}
</div>
<div class="text-sm text-gray-600">
总费用
</div>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
<div class="bg-gray-50 rounded p-2">
<div class="text-gray-600">输入 Token</div>
<div class="font-medium text-gray-900">{{ formatNumber(model.inputTokens) }}</div>
<div class="text-gray-600">
输入 Token
</div>
<div class="font-medium text-gray-900">
{{ formatNumber(model.inputTokens) }}
</div>
</div>
<div class="bg-gray-50 rounded p-2">
<div class="text-gray-600">输出 Token</div>
<div class="font-medium text-gray-900">{{ formatNumber(model.outputTokens) }}</div>
<div class="text-gray-600">
输出 Token
</div>
<div class="font-medium text-gray-900">
{{ formatNumber(model.outputTokens) }}
</div>
</div>
<div class="bg-gray-50 rounded p-2">
<div class="text-gray-600">缓存创建</div>
<div class="font-medium text-gray-900">{{ formatNumber(model.cacheCreateTokens) }}</div>
<div class="text-gray-600">
缓存创建
</div>
<div class="font-medium text-gray-900">
{{ formatNumber(model.cacheCreateTokens) }}
</div>
</div>
<div class="bg-gray-50 rounded p-2">
<div class="text-gray-600">缓存读取</div>
<div class="font-medium text-gray-900">{{ formatNumber(model.cacheReadTokens) }}</div>
<div class="text-gray-600">
缓存读取
</div>
<div class="font-medium text-gray-900">
{{ formatNumber(model.cacheReadTokens) }}
</div>
</div>
</div>
</div>
</div>
<!-- 无模型数据 -->
<div v-else class="text-center py-8 text-gray-500">
<i class="fas fa-chart-pie text-3xl mb-3"></i>
<div
v-else
class="text-center py-8 text-gray-500"
>
<i class="fas fa-chart-pie text-3xl mb-3" />
<p>暂无{{ statsPeriod === 'daily' ? '今日' : '本月' }}模型使用数据</p>
</div>
</div>

View File

@@ -3,7 +3,7 @@
<!-- API Key 基本信息 -->
<div class="card p-6">
<h3 class="text-xl font-bold mb-4 flex items-center text-gray-900">
<i class="fas fa-info-circle mr-3 text-blue-500"></i>
<i class="fas fa-info-circle mr-3 text-blue-500" />
API Key 信息
</h3>
<div class="space-y-3">
@@ -13,8 +13,14 @@
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600">状态</span>
<span :class="statsData.isActive ? 'text-green-600' : 'text-red-600'" class="font-medium">
<i :class="statsData.isActive ? 'fas fa-check-circle' : 'fas fa-times-circle'" class="mr-1"></i>
<span
:class="statsData.isActive ? 'text-green-600' : 'text-red-600'"
class="font-medium"
>
<i
:class="statsData.isActive ? 'fas fa-check-circle' : 'fas fa-times-circle'"
class="mr-1"
/>
{{ statsData.isActive ? '活跃' : '已停用' }}
</span>
</div>
@@ -29,20 +35,32 @@
<div class="flex justify-between items-center">
<span class="text-gray-600">过期时间</span>
<div v-if="statsData.expiresAt">
<div v-if="isApiKeyExpired(statsData.expiresAt)" class="text-red-600 font-medium">
<i class="fas fa-exclamation-circle mr-1"></i>
<div
v-if="isApiKeyExpired(statsData.expiresAt)"
class="text-red-600 font-medium"
>
<i class="fas fa-exclamation-circle mr-1" />
已过期
</div>
<div v-else-if="isApiKeyExpiringSoon(statsData.expiresAt)" class="text-orange-600 font-medium">
<i class="fas fa-clock mr-1"></i>
<div
v-else-if="isApiKeyExpiringSoon(statsData.expiresAt)"
class="text-orange-600 font-medium"
>
<i class="fas fa-clock mr-1" />
{{ formatExpireDate(statsData.expiresAt) }}
</div>
<div v-else class="text-gray-900 font-medium">
<div
v-else
class="text-gray-900 font-medium"
>
{{ formatExpireDate(statsData.expiresAt) }}
</div>
</div>
<div v-else class="text-gray-400 font-medium">
<i class="fas fa-infinity mr-1"></i>
<div
v-else
class="text-gray-400 font-medium"
>
<i class="fas fa-infinity mr-1" />
永不过期
</div>
</div>
@@ -52,25 +70,41 @@
<!-- 使用统计概览 -->
<div class="card p-6">
<h3 class="text-xl font-bold mb-4 flex items-center text-gray-900">
<i class="fas fa-chart-bar mr-3 text-green-500"></i>
<i class="fas fa-chart-bar mr-3 text-green-500" />
使用统计概览 <span class="text-sm font-normal text-gray-600 ml-2">({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span>
</h3>
<div class="grid grid-cols-2 gap-4">
<div class="stat-card text-center">
<div class="text-3xl font-bold text-green-600">{{ formatNumber(currentPeriodData.requests) }}</div>
<div class="text-sm text-gray-600">{{ statsPeriod === 'daily' ? '今日' : '本月' }}请求数</div>
<div class="text-3xl font-bold text-green-600">
{{ formatNumber(currentPeriodData.requests) }}
</div>
<div class="text-sm text-gray-600">
{{ statsPeriod === 'daily' ? '今日' : '本月' }}请求数
</div>
</div>
<div class="stat-card text-center">
<div class="text-3xl font-bold text-blue-600">{{ formatNumber(currentPeriodData.allTokens) }}</div>
<div class="text-sm text-gray-600">{{ statsPeriod === 'daily' ? '今日' : '本月' }}Token</div>
<div class="text-3xl font-bold text-blue-600">
{{ formatNumber(currentPeriodData.allTokens) }}
</div>
<div class="text-sm text-gray-600">
{{ statsPeriod === 'daily' ? '今日' : '本月' }}Token数
</div>
</div>
<div class="stat-card text-center">
<div class="text-3xl font-bold text-purple-600">{{ currentPeriodData.formattedCost || '$0.000000' }}</div>
<div class="text-sm text-gray-600">{{ statsPeriod === 'daily' ? '今日' : '本月' }}费用</div>
<div class="text-3xl font-bold text-purple-600">
{{ currentPeriodData.formattedCost || '$0.000000' }}
</div>
<div class="text-sm text-gray-600">
{{ statsPeriod === 'daily' ? '今日' : '本月' }}费用
</div>
</div>
<div class="stat-card text-center">
<div class="text-3xl font-bold text-yellow-600">{{ formatNumber(currentPeriodData.inputTokens) }}</div>
<div class="text-sm text-gray-600">{{ statsPeriod === 'daily' ? '今日' : '本月' }}输入Token</div>
<div class="text-3xl font-bold text-yellow-600">
{{ formatNumber(currentPeriodData.inputTokens) }}
</div>
<div class="text-sm text-gray-600">
{{ statsPeriod === 'daily' ? '今日' : '本月' }}输入Token
</div>
</div>
</div>
</div>

View File

@@ -1,34 +1,34 @@
<template>
<div class="card p-6">
<h3 class="text-xl font-bold mb-4 flex items-center text-gray-900">
<i class="fas fa-coins mr-3 text-yellow-500"></i>
<i class="fas fa-coins mr-3 text-yellow-500" />
Token 使用分布 <span class="text-sm font-normal text-gray-600 ml-2">({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span>
</h3>
<div class="space-y-3">
<div class="flex justify-between items-center">
<span class="text-gray-600 flex items-center">
<i class="fas fa-arrow-right mr-2 text-green-500"></i>
<i class="fas fa-arrow-right mr-2 text-green-500" />
输入 Token
</span>
<span class="font-medium text-gray-900">{{ formatNumber(currentPeriodData.inputTokens) }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 flex items-center">
<i class="fas fa-arrow-left mr-2 text-blue-500"></i>
<i class="fas fa-arrow-left mr-2 text-blue-500" />
输出 Token
</span>
<span class="font-medium text-gray-900">{{ formatNumber(currentPeriodData.outputTokens) }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 flex items-center">
<i class="fas fa-save mr-2 text-purple-500"></i>
<i class="fas fa-save mr-2 text-purple-500" />
缓存创建 Token
</span>
<span class="font-medium text-gray-900">{{ formatNumber(currentPeriodData.cacheCreateTokens) }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 flex items-center">
<i class="fas fa-download mr-2 text-orange-500"></i>
<i class="fas fa-download mr-2 text-orange-500" />
缓存读取 Token
</span>
<span class="font-medium text-gray-900">{{ formatNumber(currentPeriodData.cacheReadTokens) }}</span>

View File

@@ -1,6 +1,9 @@
<template>
<Teleport to="body">
<Transition name="modal" appear>
<Transition
name="modal"
appear
>
<div
v-if="isVisible"
class="fixed inset-0 modal z-[100] flex items-center justify-center p-4"
@@ -9,29 +12,36 @@
<div class="modal-content w-full max-w-md p-6 mx-auto">
<div class="flex items-start gap-4 mb-6">
<div class="flex-shrink-0 w-12 h-12 bg-gradient-to-br from-amber-500 to-amber-600 rounded-xl flex items-center justify-center">
<i class="fas fa-exclamation-triangle text-white text-lg"></i>
<i class="fas fa-exclamation-triangle text-white text-lg" />
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold text-gray-900 mb-2">{{ title }}</h3>
<div class="text-gray-600 leading-relaxed whitespace-pre-line">{{ message }}</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">
{{ title }}
</h3>
<div class="text-gray-600 leading-relaxed whitespace-pre-line">
{{ message }}
</div>
</div>
</div>
<div class="flex items-center justify-end gap-3">
<button
@click="handleCancel"
class="btn bg-gray-100 text-gray-700 hover:bg-gray-200 px-6 py-3"
:disabled="isProcessing"
@click="handleCancel"
>
{{ cancelText }}
</button>
<button
@click="handleConfirm"
class="btn btn-warning px-6 py-3"
:class="{ 'opacity-50 cursor-not-allowed': isProcessing }"
:disabled="isProcessing"
@click="handleConfirm"
>
<div v-if="isProcessing" class="loading-spinner mr-2"></div>
<div
v-if="isProcessing"
class="loading-spinner mr-2"
/>
{{ confirmText }}
</button>
</div>

View File

@@ -1,27 +1,34 @@
<template>
<Teleport to="body">
<div v-if="show" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
<div
v-if="show"
class="fixed inset-0 modal z-50 flex items-center justify-center p-4"
>
<div class="modal-content w-full max-w-md p-6 mx-auto">
<div class="flex items-start gap-4 mb-6">
<div class="w-12 h-12 bg-gradient-to-br from-yellow-400 to-yellow-500 rounded-full flex items-center justify-center flex-shrink-0">
<i class="fas fa-exclamation text-white text-xl"></i>
<i class="fas fa-exclamation text-white text-xl" />
</div>
<div class="flex-1">
<h3 class="text-lg font-bold text-gray-900 mb-2">{{ title }}</h3>
<p class="text-gray-600 text-sm leading-relaxed whitespace-pre-line">{{ message }}</p>
<h3 class="text-lg font-bold text-gray-900 mb-2">
{{ title }}
</h3>
<p class="text-gray-600 text-sm leading-relaxed whitespace-pre-line">
{{ message }}
</p>
</div>
</div>
<div class="flex gap-3">
<button
@click="$emit('cancel')"
class="flex-1 px-4 py-2.5 bg-gray-100 text-gray-700 rounded-xl font-medium hover:bg-gray-200 transition-colors"
@click="$emit('cancel')"
>
{{ cancelText }}
</button>
<button
@click="$emit('confirm')"
class="flex-1 px-4 py-2.5 bg-gradient-to-r from-yellow-500 to-orange-500 text-white rounded-xl font-medium hover:from-yellow-600 hover:to-orange-600 transition-colors shadow-sm"
@click="$emit('confirm')"
>
{{ confirmText }}
</button>

View File

@@ -3,27 +3,45 @@
<!-- Logo区域 -->
<div class="w-12 h-12 bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-gray-300/30 rounded-xl flex items-center justify-center backdrop-blur-sm flex-shrink-0 overflow-hidden">
<template v-if="!loading">
<img v-if="logoSrc"
:src="logoSrc"
alt="Logo"
class="w-8 h-8 object-contain"
@error="handleLogoError">
<i v-else class="fas fa-cloud text-xl text-gray-700"></i>
<img
v-if="logoSrc"
:src="logoSrc"
alt="Logo"
class="w-8 h-8 object-contain"
@error="handleLogoError"
>
<i
v-else
class="fas fa-cloud text-xl text-gray-700"
/>
</template>
<div v-else class="w-8 h-8 bg-gray-300/50 rounded animate-pulse"></div>
<div
v-else
class="w-8 h-8 bg-gray-300/50 rounded animate-pulse"
/>
</div>
<!-- 标题区域 -->
<div class="flex flex-col justify-center min-h-[48px]">
<div class="flex items-center gap-3">
<template v-if="!loading && title">
<h1 :class="['text-2xl font-bold header-title leading-tight', titleClass]">{{ title }}</h1>
<h1 :class="['text-2xl font-bold header-title leading-tight', titleClass]">
{{ title }}
</h1>
</template>
<div v-else-if="loading" class="h-8 w-64 bg-gray-300/50 rounded animate-pulse"></div>
<div
v-else-if="loading"
class="h-8 w-64 bg-gray-300/50 rounded animate-pulse"
/>
<!-- 插槽用于版本信息等额外内容 -->
<slot name="after-title"></slot>
<slot name="after-title" />
</div>
<p v-if="subtitle" class="text-gray-600 text-sm leading-tight mt-0.5">{{ subtitle }}</p>
<p
v-if="subtitle"
class="text-gray-600 text-sm leading-tight mt-0.5"
>
{{ subtitle }}
</p>
</div>
</div>
</template>

View File

@@ -2,12 +2,21 @@
<div class="stat-card">
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="text-sm font-medium text-gray-600 mb-1">{{ title }}</p>
<p class="text-3xl font-bold text-gray-800">{{ value }}</p>
<p v-if="subtitle" class="text-sm text-gray-500 mt-2">{{ subtitle }}</p>
<p class="text-sm font-medium text-gray-600 mb-1">
{{ title }}
</p>
<p class="text-3xl font-bold text-gray-800">
{{ value }}
</p>
<p
v-if="subtitle"
class="text-sm text-gray-500 mt-2"
>
{{ subtitle }}
</p>
</div>
<div :class="['stat-icon', iconBgClass]">
<i :class="icon"></i>
<i :class="icon" />
</div>
</div>
</div>

View File

@@ -13,24 +13,31 @@
>
<div class="toast-content">
<div class="toast-icon">
<i :class="getIconClass(toast.type)"></i>
<i :class="getIconClass(toast.type)" />
</div>
<div class="toast-body">
<div v-if="toast.title" class="toast-title">{{ toast.title }}</div>
<div class="toast-message">{{ toast.message }}</div>
<div
v-if="toast.title"
class="toast-title"
>
{{ toast.title }}
</div>
<div class="toast-message">
{{ toast.message }}
</div>
</div>
<button
class="toast-close"
@click.stop="removeToast(toast.id)"
>
<i class="fas fa-times"></i>
<i class="fas fa-times" />
</button>
</div>
<div
v-if="toast.duration > 0"
class="toast-progress"
:style="{ animationDuration: `${toast.duration}ms` }"
></div>
/>
</div>
</div>
</Teleport>

View File

@@ -2,38 +2,65 @@
<div class="glass-strong rounded-3xl p-6">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6 gap-4">
<h2 class="text-xl font-bold text-gray-800 flex items-center">
<i class="fas fa-robot mr-2 text-purple-500"></i>
<i class="fas fa-robot mr-2 text-purple-500" />
模型使用分布
</h2>
<el-radio-group v-model="modelPeriod" size="small" @change="handlePeriodChange">
<el-radio-button label="daily">今日</el-radio-button>
<el-radio-button label="total">累计</el-radio-button>
<el-radio-group
v-model="modelPeriod"
size="small"
@change="handlePeriodChange"
>
<el-radio-button label="daily">
今日
</el-radio-button>
<el-radio-button label="total">
累计
</el-radio-button>
</el-radio-group>
</div>
<div v-if="dashboardStore.dashboardModelStats.length === 0" class="text-center py-12 text-gray-500">
<i class="fas fa-chart-pie text-4xl mb-3 opacity-30"></i>
<div
v-if="dashboardStore.dashboardModelStats.length === 0"
class="text-center py-12 text-gray-500"
>
<i class="fas fa-chart-pie text-4xl mb-3 opacity-30" />
<p>暂无模型使用数据</p>
</div>
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div
v-else
class="grid grid-cols-1 lg:grid-cols-2 gap-6"
>
<!-- 饼图 -->
<div class="relative" style="height: 300px;">
<canvas ref="chartCanvas"></canvas>
<div
class="relative"
style="height: 300px;"
>
<canvas ref="chartCanvas" />
</div>
<!-- 数据列表 -->
<div class="space-y-3">
<div v-for="(stat, index) in sortedStats" :key="stat.model"
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div
v-for="(stat, index) in sortedStats"
:key="stat.model"
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div class="flex items-center gap-3">
<div class="w-4 h-4 rounded" :style="`background-color: ${getColor(index)}`"></div>
<div
class="w-4 h-4 rounded"
:style="`background-color: ${getColor(index)}`"
/>
<span class="font-medium text-gray-700">{{ stat.model }}</span>
</div>
<div class="text-right">
<p class="font-semibold text-gray-800">{{ formatNumber(stat.requests) }} 请求</p>
<p class="text-sm text-gray-500">{{ formatNumber(stat.totalTokens) }} tokens</p>
<p class="font-semibold text-gray-800">
{{ formatNumber(stat.requests) }} 请求
</p>
<p class="text-sm text-gray-500">
{{ formatNumber(stat.totalTokens) }} tokens
</p>
</div>
</div>
</div>

View File

@@ -2,24 +2,45 @@
<div class="glass-strong rounded-3xl p-6 mb-8">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6 gap-4">
<h2 class="text-xl font-bold text-gray-800 flex items-center">
<i class="fas fa-chart-area mr-2 text-blue-500"></i>
<i class="fas fa-chart-area mr-2 text-blue-500" />
使用趋势
</h2>
<div class="flex items-center gap-3">
<el-radio-group v-model="granularity" size="small" @change="handleGranularityChange">
<el-radio-button label="day">按天</el-radio-button>
<el-radio-button label="hour">按小时</el-radio-button>
<el-radio-group
v-model="granularity"
size="small"
@change="handleGranularityChange"
>
<el-radio-button label="day">
按天
</el-radio-button>
<el-radio-button label="hour">
按小时
</el-radio-button>
</el-radio-group>
<el-select v-model="trendPeriod" size="small" style="width: 120px" @change="handlePeriodChange">
<el-option :label="`最近${period.days}天`" :value="period.days" v-for="period in periodOptions" :key="period.days" />
<el-select
v-model="trendPeriod"
size="small"
style="width: 120px"
@change="handlePeriodChange"
>
<el-option
v-for="period in periodOptions"
:key="period.days"
:label="`最近${period.days}天`"
:value="period.days"
/>
</el-select>
</div>
</div>
<div class="relative" style="height: 300px;">
<canvas ref="chartCanvas"></canvas>
<div
class="relative"
style="height: 300px;"
>
<canvas ref="chartCanvas" />
</div>
</div>
</template>

View File

@@ -1,6 +1,9 @@
<template>
<!-- 顶部导航 -->
<div class="glass-strong rounded-3xl p-6 mb-8 shadow-xl" style="z-index: 10; position: relative;">
<div
class="glass-strong rounded-3xl p-6 mb-8 shadow-xl"
style="z-index: 10; position: relative;"
>
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
<div class="flex items-center gap-4">
<LogoTitle
@@ -22,7 +25,7 @@
class="inline-flex items-center gap-1 px-2 py-0.5 bg-green-500 border border-green-600 rounded-full text-xs text-white hover:bg-green-600 transition-colors animate-pulse"
title="有新版本可用"
>
<i class="fas fa-arrow-up text-[10px]"></i>
<i class="fas fa-arrow-up text-[10px]" />
<span>新版本</span>
</a>
</div>
@@ -32,12 +35,15 @@
<!-- 用户菜单 -->
<div class="relative user-menu-container">
<button
@click="userMenuOpen = !userMenuOpen"
class="btn btn-primary px-4 py-3 flex items-center gap-2 relative"
@click="userMenuOpen = !userMenuOpen"
>
<i class="fas fa-user-circle"></i>
<i class="fas fa-user-circle" />
<span>{{ currentUser.username || 'Admin' }}</span>
<i class="fas fa-chevron-down text-xs transition-transform duration-200" :class="{ 'rotate-180': userMenuOpen }"></i>
<i
class="fas fa-chevron-down text-xs transition-transform duration-200"
:class="{ 'rotate-180': userMenuOpen }"
/>
</button>
<!-- 悬浮菜单 -->
@@ -53,10 +59,13 @@
<span class="text-gray-500">当前版本</span>
<span class="font-mono text-gray-700">v{{ versionInfo.current || '...' }}</span>
</div>
<div v-if="versionInfo.hasUpdate" class="mt-2">
<div
v-if="versionInfo.hasUpdate"
class="mt-2"
>
<div class="flex items-center justify-between text-sm mb-2">
<span class="text-green-600 font-medium">
<i class="fas fa-arrow-up mr-1"></i>有新版本
<i class="fas fa-arrow-up mr-1" />有新版本
</span>
<span class="font-mono text-green-600">v{{ versionInfo.latest }}</span>
</div>
@@ -65,47 +74,60 @@
target="_blank"
class="block w-full text-center px-3 py-1.5 bg-green-500 text-white text-sm rounded-lg hover:bg-green-600 transition-colors"
>
<i class="fas fa-external-link-alt mr-1"></i>查看更新
<i class="fas fa-external-link-alt mr-1" />查看更新
</a>
</div>
<div v-else-if="versionInfo.checkingUpdate" class="mt-2 text-center text-xs text-gray-500">
<i class="fas fa-spinner fa-spin mr-1"></i>检查更新中...
<div
v-else-if="versionInfo.checkingUpdate"
class="mt-2 text-center text-xs text-gray-500"
>
<i class="fas fa-spinner fa-spin mr-1" />检查更新中...
</div>
<div v-else class="mt-2 text-center">
<div
v-else
class="mt-2 text-center"
>
<!-- 已是最新版提醒 -->
<transition name="fade" mode="out-in">
<div v-if="versionInfo.noUpdateMessage" key="message" class="px-3 py-1.5 bg-green-100 border border-green-200 rounded-lg inline-block">
<transition
name="fade"
mode="out-in"
>
<div
v-if="versionInfo.noUpdateMessage"
key="message"
class="px-3 py-1.5 bg-green-100 border border-green-200 rounded-lg inline-block"
>
<p class="text-xs text-green-700 font-medium">
<i class="fas fa-check-circle mr-1"></i>当前已是最新版本
<i class="fas fa-check-circle mr-1" />当前已是最新版本
</p>
</div>
<button
v-else
key="button"
@click="checkForUpdates()"
class="text-xs text-blue-500 hover:text-blue-700 transition-colors"
@click="checkForUpdates()"
>
<i class="fas fa-sync-alt mr-1"></i>检查更新
<i class="fas fa-sync-alt mr-1" />检查更新
</button>
</transition>
</div>
</div>
<button
@click="openChangePasswordModal"
class="w-full px-4 py-3 text-left text-gray-700 hover:bg-gray-50 transition-colors flex items-center gap-3"
@click="openChangePasswordModal"
>
<i class="fas fa-key text-blue-500"></i>
<i class="fas fa-key text-blue-500" />
<span>修改账户信息</span>
</button>
<hr class="my-2 border-gray-200">
<button
@click="logout"
class="w-full px-4 py-3 text-left text-gray-700 hover:bg-gray-50 transition-colors flex items-center gap-3"
@click="logout"
>
<i class="fas fa-sign-out-alt text-red-500"></i>
<i class="fas fa-sign-out-alt text-red-500" />
<span>退出登录</span>
</button>
</div>
@@ -114,24 +136,32 @@
</div>
<!-- 修改账户信息模态框 -->
<div v-if="showChangePasswordModal" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
<div
v-if="showChangePasswordModal"
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>
<i class="fas fa-key text-white" />
</div>
<h3 class="text-xl font-bold text-gray-900">修改账户信息</h3>
<h3 class="text-xl font-bold text-gray-900">
修改账户信息
</h3>
</div>
<button
@click="closeChangePasswordModal"
class="text-gray-400 hover:text-gray-600 transition-colors"
@click="closeChangePasswordModal"
>
<i class="fas fa-times text-xl"></i>
<i class="fas fa-times text-xl" />
</button>
</div>
<form @submit.prevent="changePassword" class="space-y-6 modal-scroll-content custom-scrollbar flex-1">
<form
class="space-y-6 modal-scroll-content custom-scrollbar flex-1"
@submit.prevent="changePassword"
>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">当前用户名</label>
<input
@@ -140,7 +170,9 @@
disabled
class="form-input w-full bg-gray-100 cursor-not-allowed"
>
<p class="text-xs text-gray-500 mt-2">当前用户名输入新用户名以修改</p>
<p class="text-xs text-gray-500 mt-2">
当前用户名输入新用户名以修改
</p>
</div>
<div>
@@ -151,7 +183,9 @@
class="form-input w-full"
placeholder="输入新用户名(留空保持不变)"
>
<p class="text-xs text-gray-500 mt-2">留空表示不修改用户名</p>
<p class="text-xs text-gray-500 mt-2">
留空表示不修改用户名
</p>
</div>
<div>
@@ -174,7 +208,9 @@
class="form-input w-full"
placeholder="请输入新密码"
>
<p class="text-xs text-gray-500 mt-2">密码长度至少8位</p>
<p class="text-xs text-gray-500 mt-2">
密码长度至少8位
</p>
</div>
<div>
@@ -191,8 +227,8 @@
<div class="flex gap-3 pt-4">
<button
type="button"
@click="closeChangePasswordModal"
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
@click="closeChangePasswordModal"
>
取消
</button>
@@ -201,8 +237,14 @@
:disabled="changePasswordLoading"
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
>
<div v-if="changePasswordLoading" class="loading-spinner mr-2"></div>
<i v-else class="fas fa-save mr-2"></i>
<div
v-if="changePasswordLoading"
class="loading-spinner mr-2"
/>
<i
v-else
class="fas fa-save mr-2"
/>
{{ changePasswordLoading ? '保存中...' : '保存修改' }}
</button>
</div>

View File

@@ -4,14 +4,23 @@
<AppHeader />
<!-- 主内容区域 -->
<div class="glass-strong rounded-3xl p-6 shadow-xl" style="z-index: 1; min-height: calc(100vh - 240px);">
<div
class="glass-strong rounded-3xl p-6 shadow-xl"
style="z-index: 1; min-height: calc(100vh - 240px);"
>
<!-- 标签栏 -->
<TabBar :active-tab="activeTab" @tab-change="handleTabChange" />
<TabBar
:active-tab="activeTab"
@tab-change="handleTabChange"
/>
<!-- 内容区域 -->
<div class="tab-content">
<router-view v-slot="{ Component }">
<transition name="slide-up" mode="out-in">
<transition
name="slide-up"
mode="out-in"
>
<keep-alive :include="['DashboardView', 'ApiKeysView']">
<component :is="Component" />
</keep-alive>

View File

@@ -3,13 +3,13 @@
<button
v-for="tab in tabs"
:key="tab.key"
@click="$emit('tab-change', tab.key)"
:class="[
'tab-btn flex-1 py-3 px-6 text-sm font-semibold transition-all duration-300',
activeTab === tab.key ? 'active' : 'text-gray-700 hover:bg-white/10 hover:text-gray-900'
]"
@click="$emit('tab-change', tab.key)"
>
<i :class="tab.icon + ' mr-2'"></i>{{ tab.name }}
<i :class="tab.icon + ' mr-2'" />{{ tab.name }}
</button>
</div>
</template>

View File

@@ -26,6 +26,10 @@ export const useDashboardStore = defineStore('dashboard', () => {
todayCacheReadTokens: 0,
systemRPM: 0,
systemTPM: 0,
realtimeRPM: 0,
realtimeTPM: 0,
metricsWindow: 5,
isHistoricalMetrics: false,
systemStatus: '正常',
uptime: 0,
systemTimezone: 8 // 默认 UTC+8
@@ -129,6 +133,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
const overview = dashboardResponse.data.overview || {}
const recentActivity = dashboardResponse.data.recentActivity || {}
const systemAverages = dashboardResponse.data.systemAverages || {}
const realtimeMetrics = dashboardResponse.data.realtimeMetrics || {}
const systemHealth = dashboardResponse.data.systemHealth || {}
dashboardData.value = {
@@ -151,6 +156,10 @@ export const useDashboardStore = defineStore('dashboard', () => {
todayCacheReadTokens: recentActivity.cacheReadTokensToday || 0,
systemRPM: systemAverages.rpm || 0,
systemTPM: systemAverages.tpm || 0,
realtimeRPM: realtimeMetrics.rpm || 0,
realtimeTPM: realtimeMetrics.tpm || 0,
metricsWindow: realtimeMetrics.windowMinutes || 5,
isHistoricalMetrics: realtimeMetrics.isHistorical || false,
systemStatus: systemHealth.redisConnected ? '正常' : '异常',
uptime: systemHealth.uptime || 0,
systemTimezone: dashboardResponse.data.systemTimezone || 8

View File

@@ -3,199 +3,355 @@
<div class="card p-6">
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-6">
<div>
<h3 class="text-xl font-bold text-gray-900 mb-2">账户管理</h3>
<p class="text-gray-600">管理您的 Claude Gemini 账户及代理配置</p>
<h3 class="text-xl font-bold text-gray-900 mb-2">
账户管理
</h3>
<p class="text-gray-600">
管理您的 Claude Gemini 账户及代理配置
</p>
</div>
<div class="flex gap-2">
<select v-model="accountSortBy" @change="sortAccounts()" class="form-input px-3 py-2 text-sm">
<option value="name">按名称排序</option>
<option value="dailyTokens">按今日Token排序</option>
<option value="dailyRequests">按今日请求数排序</option>
<option value="totalTokens">按总Token排序</option>
<option value="lastUsed">按最后使用排序</option>
<select
v-model="accountSortBy"
class="form-input px-3 py-2 text-sm"
@change="sortAccounts()"
>
<option value="name">
按名称排序
</option>
<option value="dailyTokens">
按今日Token排序
</option>
<option value="dailyRequests">
按今日请求数排序
</option>
<option value="totalTokens">
按总Token排序
</option>
<option value="lastUsed">
按最后使用排序
</option>
</select>
<button
@click.stop="openCreateAccountModal"
class="btn btn-success px-6 py-3 flex items-center gap-2"
@click.stop="openCreateAccountModal"
>
<i class="fas fa-plus"></i>添加账户
<i class="fas fa-plus" />添加账户
</button>
</div>
</div>
<div v-if="accountsLoading" class="text-center py-12">
<div class="loading-spinner mx-auto mb-4"></div>
<p class="text-gray-500">正在加载账户...</p>
<div
v-if="accountsLoading"
class="text-center py-12"
>
<div class="loading-spinner mx-auto mb-4" />
<p class="text-gray-500">
正在加载账户...
</p>
</div>
<div v-else-if="sortedAccounts.length === 0" class="text-center py-12">
<div
v-else-if="sortedAccounts.length === 0"
class="text-center py-12"
>
<div class="w-16 h-16 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
<i class="fas fa-user-circle text-gray-400 text-xl"></i>
<i class="fas fa-user-circle text-gray-400 text-xl" />
</div>
<p class="text-gray-500 text-lg">暂无账户</p>
<p class="text-gray-400 text-sm mt-2">点击上方按钮添加您的第一个账户</p>
<p class="text-gray-500 text-lg">
暂无账户
</p>
<p class="text-gray-400 text-sm mt-2">
点击上方按钮添加您的第一个账户
</p>
</div>
<div v-else class="table-container">
<div
v-else
class="table-container"
>
<table class="min-w-full">
<thead class="bg-gray-50/80 backdrop-blur-sm">
<tr>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortAccounts('name')">
<th
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
@click="sortAccounts('name')"
>
名称
<i v-if="accountsSortBy === 'name'" :class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
<i
v-if="accountsSortBy === 'name'"
:class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"
/>
<i
v-else
class="fas fa-sort ml-1 text-gray-400"
/>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortAccounts('platform')">
<th
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
@click="sortAccounts('platform')"
>
平台
<i v-if="accountsSortBy === 'platform'" :class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
<i
v-if="accountsSortBy === 'platform'"
:class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"
/>
<i
v-else
class="fas fa-sort ml-1 text-gray-400"
/>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortAccounts('accountType')">
<th
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
@click="sortAccounts('accountType')"
>
类型
<i v-if="accountsSortBy === 'accountType'" :class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
<i
v-if="accountsSortBy === 'accountType'"
:class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"
/>
<i
v-else
class="fas fa-sort ml-1 text-gray-400"
/>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortAccounts('status')">
<th
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
@click="sortAccounts('status')"
>
状态
<i v-if="accountsSortBy === 'status'" :class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
<i
v-if="accountsSortBy === 'status'"
:class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"
/>
<i
v-else
class="fas fa-sort ml-1 text-gray-400"
/>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortAccounts('priority')">
<th
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
@click="sortAccounts('priority')"
>
优先级
<i v-if="accountsSortBy === 'priority'" :class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
<i
v-if="accountsSortBy === 'priority'"
:class="['fas', accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"
/>
<i
v-else
class="fas fa-sort ml-1 text-gray-400"
/>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
代理
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
今日使用
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
会话窗口
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
最后使用
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
操作
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">代理</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">今日使用</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">会话窗口</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">最后使用</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200/50">
<tr v-for="account in sortedAccounts" :key="account.id" class="table-row">
<tr
v-for="account in sortedAccounts"
:key="account.id"
class="table-row"
>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="w-8 h-8 bg-gradient-to-br from-green-500 to-green-600 rounded-lg flex items-center justify-center mr-3">
<i class="fas fa-user-circle text-white text-xs"></i>
<i class="fas fa-user-circle text-white text-xs" />
</div>
<div>
<div class="flex items-center gap-2">
<div class="text-sm font-semibold text-gray-900">{{ account.name }}</div>
<span v-if="account.accountType === 'dedicated'"
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
<i class="fas fa-lock mr-1"></i>专属
<div class="text-sm font-semibold text-gray-900">
{{ account.name }}
</div>
<span
v-if="account.accountType === 'dedicated'"
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800"
>
<i class="fas fa-lock mr-1" />专属
</span>
<span v-else
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<i class="fas fa-share-alt mr-1"></i>共享
<span
v-else
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
>
<i class="fas fa-share-alt mr-1" />共享
</span>
</div>
<div class="text-xs text-gray-500">{{ account.id }}</div>
<div class="text-xs text-gray-500">
{{ account.id }}
</div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span v-if="account.platform === 'gemini'"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-yellow-100 text-yellow-800">
<i class="fas fa-robot mr-1"></i>Gemini
<span
v-if="account.platform === 'gemini'"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-yellow-100 text-yellow-800"
>
<i class="fas fa-robot mr-1" />Gemini
</span>
<span v-else-if="account.platform === 'claude-console'"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-purple-100 text-purple-800">
<i class="fas fa-terminal mr-1"></i>Claude Console
<span
v-else-if="account.platform === 'claude-console'"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-purple-100 text-purple-800"
>
<i class="fas fa-terminal mr-1" />Claude Console
</span>
<span v-else
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-indigo-100 text-indigo-800">
<i class="fas fa-brain mr-1"></i>Claude
<span
v-else
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-indigo-100 text-indigo-800"
>
<i class="fas fa-brain mr-1" />Claude
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span v-if="account.platform === 'claude-console'"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800">
<i class="fas fa-key mr-1"></i>API Key
<span
v-if="account.platform === 'claude-console'"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800"
>
<i class="fas fa-key mr-1" />API Key
</span>
<span v-else-if="account.scopes && account.scopes.length > 0"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">
<i class="fas fa-lock mr-1"></i>OAuth
<span
v-else-if="account.scopes && account.scopes.length > 0"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-800"
>
<i class="fas fa-lock mr-1" />OAuth
</span>
<span v-else
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-orange-100 text-orange-800">
<i class="fas fa-key mr-1"></i>传统
<span
v-else
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-orange-100 text-orange-800"
>
<i class="fas fa-key mr-1" />传统
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex flex-col gap-1">
<span :class="['inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold',
account.status === 'blocked' ? 'bg-orange-100 text-orange-800' :
account.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800']">
<div :class="['w-2 h-2 rounded-full mr-2',
<span
:class="['inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold',
account.status === 'blocked' ? 'bg-orange-100 text-orange-800' :
account.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800']"
>
<div
:class="['w-2 h-2 rounded-full mr-2',
account.status === 'blocked' ? 'bg-orange-500' :
account.isActive ? 'bg-green-500' : 'bg-red-500']"></div>
account.isActive ? 'bg-green-500' : 'bg-red-500']"
/>
{{ account.status === 'blocked' ? '已封锁' : account.isActive ? '正常' : '异常' }}
</span>
<span v-if="account.rateLimitStatus && account.rateLimitStatus.isRateLimited"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-yellow-100 text-yellow-800">
<i class="fas fa-exclamation-triangle mr-1"></i>
<span
v-if="account.rateLimitStatus && account.rateLimitStatus.isRateLimited"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-yellow-100 text-yellow-800"
>
<i class="fas fa-exclamation-triangle mr-1" />
限流中 ({{ account.rateLimitStatus.minutesRemaining }}分钟)
</span>
<span v-if="account.schedulable === false"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-gray-100 text-gray-700">
<i class="fas fa-pause-circle mr-1"></i>
<span
v-if="account.schedulable === false"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-gray-100 text-gray-700"
>
<i class="fas fa-pause-circle mr-1" />
不可调度
</span>
<span v-if="account.status === 'blocked' && account.errorMessage"
class="text-xs text-gray-500 mt-1 max-w-xs truncate"
:title="account.errorMessage">
<span
v-if="account.status === 'blocked' && account.errorMessage"
class="text-xs text-gray-500 mt-1 max-w-xs truncate"
:title="account.errorMessage"
>
{{ account.errorMessage }}
</span>
<span v-if="account.accountType === 'dedicated'"
class="text-xs text-gray-500">
<span
v-if="account.accountType === 'dedicated'"
class="text-xs text-gray-500"
>
绑定: {{ account.boundApiKeysCount || 0 }} 个API Key
</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div v-if="account.platform === 'claude' || account.platform === 'claude-console'" class="flex items-center gap-2">
<div
v-if="account.platform === 'claude' || account.platform === 'claude-console'"
class="flex items-center gap-2"
>
<div class="w-16 bg-gray-200 rounded-full h-2">
<div class="bg-gradient-to-r from-green-500 to-blue-600 h-2 rounded-full transition-all duration-300"
:style="{ width: ((101 - (account.priority || 50)) + '%') }"></div>
<div
class="bg-gradient-to-r from-green-500 to-blue-600 h-2 rounded-full transition-all duration-300"
:style="{ width: ((101 - (account.priority || 50)) + '%') }"
/>
</div>
<span class="text-xs text-gray-700 font-medium min-w-[20px]">
{{ account.priority || 50 }}
</span>
</div>
<div v-else class="text-gray-400 text-sm">
<div
v-else
class="text-gray-400 text-sm"
>
<span class="text-xs">N/A</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
<div v-if="formatProxyDisplay(account.proxy)" class="text-xs bg-blue-50 px-2 py-1 rounded font-mono">
<div
v-if="formatProxyDisplay(account.proxy)"
class="text-xs bg-blue-50 px-2 py-1 rounded font-mono"
>
{{ formatProxyDisplay(account.proxy) }}
</div>
<div v-else class="text-gray-400">无代理</div>
<div
v-else
class="text-gray-400"
>
无代理
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<div v-if="account.usage && account.usage.daily" class="space-y-1">
<div
v-if="account.usage && account.usage.daily"
class="space-y-1"
>
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
<div class="w-2 h-2 bg-green-500 rounded-full" />
<span class="text-sm font-medium text-gray-900">{{ account.usage.daily.requests || 0 }} </span>
</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-blue-500 rounded-full"></div>
<div class="w-2 h-2 bg-blue-500 rounded-full" />
<span class="text-xs text-gray-600">{{ formatNumber(account.usage.daily.allTokens || 0) }} tokens</span>
</div>
<div v-if="account.usage.averages && account.usage.averages.rpm > 0" class="text-xs text-gray-500">
<div
v-if="account.usage.averages && account.usage.averages.rpm > 0"
class="text-xs text-gray-500"
>
平均 {{ account.usage.averages.rpm.toFixed(2) }} RPM
</div>
</div>
<div v-else class="text-gray-400 text-xs">暂无数据</div>
<div
v-else
class="text-gray-400 text-xs"
>
暂无数据
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div v-if="account.platform === 'claude' && account.sessionWindow && account.sessionWindow.hasActiveWindow" class="space-y-2">
<div
v-if="account.platform === 'claude' && account.sessionWindow && account.sessionWindow.hasActiveWindow"
class="space-y-2"
>
<div class="flex items-center gap-2">
<div class="w-24 bg-gray-200 rounded-full h-2">
<div class="bg-gradient-to-r from-blue-500 to-indigo-600 h-2 rounded-full transition-all duration-300"
:style="{ width: account.sessionWindow.progress + '%' }"></div>
<div
class="bg-gradient-to-r from-blue-500 to-indigo-600 h-2 rounded-full transition-all duration-300"
:style="{ width: account.sessionWindow.progress + '%' }"
/>
</div>
<span class="text-xs text-gray-700 font-medium min-w-[32px]">
{{ account.sessionWindow.progress }}%
@@ -203,15 +359,24 @@
</div>
<div class="text-xs text-gray-600">
<div>{{ formatSessionWindow(account.sessionWindow.windowStart, account.sessionWindow.windowEnd) }}</div>
<div v-if="account.sessionWindow.remainingTime > 0" class="text-indigo-600 font-medium">
<div
v-if="account.sessionWindow.remainingTime > 0"
class="text-indigo-600 font-medium"
>
剩余 {{ formatRemainingTime(account.sessionWindow.remainingTime) }}
</div>
</div>
</div>
<div v-else-if="account.platform === 'claude'" class="text-gray-400 text-sm">
<i class="fas fa-minus"></i>
<div
v-else-if="account.platform === 'claude'"
class="text-gray-400 text-sm"
>
<i class="fas fa-minus" />
</div>
<div v-else class="text-gray-400 text-sm">
<div
v-else
class="text-gray-400 text-sm"
>
<span class="text-xs">N/A</span>
</div>
</td>
@@ -222,7 +387,6 @@
<div class="flex items-center gap-2">
<button
v-if="account.platform === 'claude' && account.scopes"
@click="refreshToken(account)"
:disabled="account.isRefreshing"
:class="[
'px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
@@ -231,41 +395,46 @@
: 'bg-blue-100 text-blue-700 hover:bg-blue-200'
]"
:title="account.isRefreshing ? '刷新中...' : '刷新Token'"
@click="refreshToken(account)"
>
<i :class="[
'fas fa-sync-alt',
account.isRefreshing ? 'animate-spin' : ''
]"></i>
<i
:class="[
'fas fa-sync-alt',
account.isRefreshing ? 'animate-spin' : ''
]"
/>
</button>
<button
@click="toggleSchedulable(account)"
:disabled="account.isTogglingSchedulable"
:class="[
'px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
account.isTogglingSchedulable
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: account.schedulable
? 'bg-green-100 text-green-700 hover:bg-green-200'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
? 'bg-green-100 text-green-700 hover:bg-green-200'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
]"
:title="account.schedulable ? '点击禁用调度' : '点击启用调度'"
@click="toggleSchedulable(account)"
>
<i :class="[
'fas',
account.schedulable ? 'fa-toggle-on' : 'fa-toggle-off'
]"></i>
<i
:class="[
'fas',
account.schedulable ? 'fa-toggle-on' : 'fa-toggle-off'
]"
/>
</button>
<button
@click="editAccount(account)"
class="px-3 py-1.5 bg-blue-100 text-blue-700 rounded-lg text-xs font-medium hover:bg-blue-200 transition-colors"
@click="editAccount(account)"
>
<i class="fas fa-edit"></i>
<i class="fas fa-edit" />
</button>
<button
@click="deleteAccount(account)"
class="px-3 py-1.5 bg-red-100 text-red-700 rounded-lg text-xs font-medium hover:bg-red-200 transition-colors"
@click="deleteAccount(account)"
>
<i class="fas fa-trash"></i>
<i class="fas fa-trash" />
</button>
</div>
</td>

View File

@@ -3,107 +3,200 @@
<div class="card p-6">
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-6">
<div>
<h3 class="text-xl font-bold text-gray-900 mb-2">API Keys 管理</h3>
<p class="text-gray-600">管理和监控您的 API 密钥</p>
<h3 class="text-xl font-bold text-gray-900 mb-2">
API Keys 管理
</h3>
<p class="text-gray-600">
管理和监控您的 API 密钥
</p>
</div>
<div class="flex items-center gap-3">
<!-- Token统计时间范围选择 -->
<select
v-model="apiKeyStatsTimeRange"
@change="loadApiKeys()"
class="form-input px-3 py-2 text-sm"
@change="loadApiKeys()"
>
<option value="today">今日</option>
<option value="7days">最近7天</option>
<option value="monthly">本月</option>
<option value="all">全部时间</option>
<option value="today">
今日
</option>
<option value="7days">
最近7天
</option>
<option value="monthly">
本月
</option>
<option value="all">
全部时间
</option>
</select>
<!-- 标签筛选器 -->
<select
v-model="selectedTagFilter"
class="form-input px-3 py-2 text-sm"
>
<option value="">所有标签</option>
<option v-for="tag in availableTags" :key="tag" :value="tag">{{ tag }}</option>
<option value="">
所有标签
</option>
<option
v-for="tag in availableTags"
:key="tag"
:value="tag"
>
{{ tag }}
</option>
</select>
<button
@click.stop="openCreateApiKeyModal"
class="btn btn-primary px-6 py-3 flex items-center gap-2"
@click.stop="openCreateApiKeyModal"
>
<i class="fas fa-plus"></i>创建新 Key
<i class="fas fa-plus" />创建新 Key
</button>
</div>
</div>
<div v-if="apiKeysLoading" class="text-center py-12">
<div class="loading-spinner mx-auto mb-4"></div>
<p class="text-gray-500">正在加载 API Keys...</p>
<div
v-if="apiKeysLoading"
class="text-center py-12"
>
<div class="loading-spinner mx-auto mb-4" />
<p class="text-gray-500">
正在加载 API Keys...
</p>
</div>
<div v-else-if="apiKeys.length === 0" class="text-center py-12">
<div
v-else-if="apiKeys.length === 0"
class="text-center py-12"
>
<div class="w-16 h-16 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
<i class="fas fa-key text-gray-400 text-xl"></i>
<i class="fas fa-key text-gray-400 text-xl" />
</div>
<p class="text-gray-500 text-lg">暂无 API Keys</p>
<p class="text-gray-400 text-sm mt-2">点击上方按钮创建您的第一个 API Key</p>
<p class="text-gray-500 text-lg">
暂无 API Keys
</p>
<p class="text-gray-400 text-sm mt-2">
点击上方按钮创建您的第一个 API Key
</p>
</div>
<div v-else class="table-container">
<div
v-else
class="table-container"
>
<table class="min-w-full">
<thead class="bg-gray-50/80 backdrop-blur-sm">
<tr>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortApiKeys('name')">
<th
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
@click="sortApiKeys('name')"
>
名称
<i v-if="apiKeysSortBy === 'name'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
<i
v-if="apiKeysSortBy === 'name'"
:class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"
/>
<i
v-else
class="fas fa-sort ml-1 text-gray-400"
/>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">标签</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">API Key</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortApiKeys('status')">
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
标签
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
API Key
</th>
<th
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
@click="sortApiKeys('status')"
>
状态
<i v-if="apiKeysSortBy === 'status'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
<i
v-if="apiKeysSortBy === 'status'"
:class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"
/>
<i
v-else
class="fas fa-sort ml-1 text-gray-400"
/>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
使用统计
<span class="cursor-pointer hover:bg-gray-100 px-2 py-1 rounded" @click="sortApiKeys('cost')">
<span
class="cursor-pointer hover:bg-gray-100 px-2 py-1 rounded"
@click="sortApiKeys('cost')"
>
(费用
<i v-if="apiKeysSortBy === 'cost'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>)
<i
v-if="apiKeysSortBy === 'cost'"
:class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"
/>
<i
v-else
class="fas fa-sort ml-1 text-gray-400"
/>)
</span>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortApiKeys('createdAt')">
<th
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
@click="sortApiKeys('createdAt')"
>
创建时间
<i v-if="apiKeysSortBy === 'createdAt'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
<i
v-if="apiKeysSortBy === 'createdAt'"
:class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"
/>
<i
v-else
class="fas fa-sort ml-1 text-gray-400"
/>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100" @click="sortApiKeys('expiresAt')">
<th
class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
@click="sortApiKeys('expiresAt')"
>
过期时间
<i v-if="apiKeysSortBy === 'expiresAt'" :class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"></i>
<i v-else class="fas fa-sort ml-1 text-gray-400"></i>
<i
v-if="apiKeysSortBy === 'expiresAt'"
:class="['fas', apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down', 'ml-1']"
/>
<i
v-else
class="fas fa-sort ml-1 text-gray-400"
/>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
操作
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200/50">
<template v-for="key in sortedApiKeys" :key="key.id">
<template
v-for="key in sortedApiKeys"
:key="key.id"
>
<!-- API Key 主行 -->
<tr class="table-row">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center mr-3">
<i class="fas fa-key text-white text-xs"></i>
<i class="fas fa-key text-white text-xs" />
</div>
<div>
<div class="text-sm font-semibold text-gray-900">{{ key.name }}</div>
<div class="text-xs text-gray-500">{{ key.id }}</div>
<div class="text-sm font-semibold text-gray-900">
{{ key.name }}
</div>
<div class="text-xs text-gray-500">
{{ key.id }}
</div>
<div class="text-xs text-gray-500 mt-1">
<span v-if="key.claudeAccountId || key.claudeConsoleAccountId">
<i class="fas fa-link mr-1"></i>
<i class="fas fa-link mr-1" />
绑定: {{ getBoundAccountName(key.claudeAccountId, key.claudeConsoleAccountId) }}
</span>
<span v-else>
<i class="fas fa-share-alt mr-1"></i>
<i class="fas fa-share-alt mr-1" />
使用共享池
</span>
</div>
@@ -112,12 +205,17 @@
</td>
<td class="px-6 py-4">
<div class="flex flex-wrap gap-1">
<span v-for="tag in (key.tags || [])" :key="tag"
class="inline-flex items-center px-2 py-0.5 bg-blue-100 text-blue-800 text-xs rounded-full">
<span
v-for="tag in (key.tags || [])"
:key="tag"
class="inline-flex items-center px-2 py-0.5 bg-blue-100 text-blue-800 text-xs rounded-full"
>
{{ tag }}
</span>
<span v-if="!key.tags || key.tags.length === 0"
class="text-xs text-gray-400">无标签</span>
<span
v-if="!key.tags || key.tags.length === 0"
class="text-xs text-gray-400"
>无标签</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
@@ -126,10 +224,14 @@
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span :class="['inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold',
key.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800']">
<div :class="['w-2 h-2 rounded-full mr-2',
key.isActive ? 'bg-green-500' : 'bg-red-500']"></div>
<span
:class="['inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold',
key.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800']"
>
<div
:class="['w-2 h-2 rounded-full mr-2',
key.isActive ? 'bg-green-500' : 'bg-red-500']"
/>
{{ key.isActive ? '活跃' : '禁用' }}
</span>
</td>
@@ -151,7 +253,10 @@
<span class="font-medium text-green-600">{{ calculateApiKeyCost(key.usage) }}</span>
</div>
<!-- 每日费用限制 -->
<div v-if="key.dailyCostLimit > 0" class="flex justify-between text-sm">
<div
v-if="key.dailyCostLimit > 0"
class="flex justify-between text-sm"
>
<span class="text-gray-600">今日费用:</span>
<span :class="['font-medium', (key.dailyCost || 0) >= key.dailyCostLimit ? 'text-red-600' : 'text-blue-600']">
${{ (key.dailyCost || 0).toFixed(2) }} / ${{ key.dailyCostLimit.toFixed(2) }}
@@ -167,16 +272,25 @@
<span class="text-gray-600">当前并发:</span>
<span :class="['font-medium', key.currentConcurrency > 0 ? 'text-orange-600' : 'text-gray-600']">
{{ key.currentConcurrency || 0 }}
<span v-if="key.concurrencyLimit > 0" class="text-xs text-gray-500">/ {{ key.concurrencyLimit }}</span>
<span
v-if="key.concurrencyLimit > 0"
class="text-xs text-gray-500"
>/ {{ key.concurrencyLimit }}</span>
</span>
</div>
<!-- 时间窗口限流 -->
<div v-if="key.rateLimitWindow > 0" class="flex justify-between text-sm">
<div
v-if="key.rateLimitWindow > 0"
class="flex justify-between text-sm"
>
<span class="text-gray-600">时间窗口:</span>
<span class="font-medium text-indigo-600">{{ key.rateLimitWindow }} 分钟</span>
</div>
<!-- 请求次数限制 -->
<div v-if="key.rateLimitRequests > 0" class="flex justify-between text-sm">
<div
v-if="key.rateLimitRequests > 0"
class="flex justify-between text-sm"
>
<span class="text-gray-600">请求限制:</span>
<span class="font-medium text-indigo-600">{{ key.rateLimitRequests }} /窗口</span>
</div>
@@ -186,7 +300,10 @@
<span>输出: {{ formatNumber((key.usage && key.usage.total && key.usage.total.outputTokens) || 0) }}</span>
</div>
<!-- 缓存Token细节 -->
<div v-if="((key.usage && key.usage.total && key.usage.total.cacheCreateTokens) || 0) > 0 || ((key.usage && key.usage.total && key.usage.total.cacheReadTokens) || 0) > 0" class="flex justify-between text-xs text-orange-500">
<div
v-if="((key.usage && key.usage.total && key.usage.total.cacheCreateTokens) || 0) > 0 || ((key.usage && key.usage.total && key.usage.total.cacheReadTokens) || 0) > 0"
class="flex justify-between text-xs text-orange-500"
>
<span>缓存创建: {{ formatNumber((key.usage && key.usage.total && key.usage.total.cacheCreateTokens) || 0) }}</span>
<span>缓存读取: {{ formatNumber((key.usage && key.usage.total && key.usage.total.cacheReadTokens) || 0) }}</span>
</div>
@@ -204,8 +321,12 @@
</div>
<!-- 模型分布按钮 -->
<div class="pt-2">
<button @click="toggleApiKeyModelStats(key.id)" v-if="key && key.id" class="text-xs text-indigo-600 hover:text-indigo-800 font-medium">
<i :class="['fas', expandedApiKeys[key.id] ? 'fa-chevron-up' : 'fa-chevron-down', 'mr-1']"></i>
<button
v-if="key && key.id"
class="text-xs text-indigo-600 hover:text-indigo-800 font-medium"
@click="toggleApiKeyModelStats(key.id)"
>
<i :class="['fas', expandedApiKeys[key.id] ? 'fa-chevron-up' : 'fa-chevron-down', 'mr-1']" />
模型使用分布
</button>
</div>
@@ -216,50 +337,62 @@
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<div v-if="key.expiresAt">
<div v-if="isApiKeyExpired(key.expiresAt)" class="text-red-600">
<i class="fas fa-exclamation-circle mr-1"></i>
<div
v-if="isApiKeyExpired(key.expiresAt)"
class="text-red-600"
>
<i class="fas fa-exclamation-circle mr-1" />
已过期
</div>
<div v-else-if="isApiKeyExpiringSoon(key.expiresAt)" class="text-orange-600">
<i class="fas fa-clock mr-1"></i>
<div
v-else-if="isApiKeyExpiringSoon(key.expiresAt)"
class="text-orange-600"
>
<i class="fas fa-clock mr-1" />
{{ formatExpireDate(key.expiresAt) }}
</div>
<div v-else class="text-gray-600">
<div
v-else
class="text-gray-600"
>
{{ formatExpireDate(key.expiresAt) }}
</div>
</div>
<div v-else class="text-gray-400">
<i class="fas fa-infinity mr-1"></i>
<div
v-else
class="text-gray-400"
>
<i class="fas fa-infinity mr-1" />
永不过期
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<div class="flex gap-2">
<button
@click="copyApiStatsLink(key)"
class="text-purple-600 hover:text-purple-900 font-medium hover:bg-purple-50 px-3 py-1 rounded-lg transition-colors"
class="text-purple-600 hover:text-purple-900 font-medium hover:bg-purple-50 px-3 py-1 rounded-lg transition-colors"
title="复制统计页面链接"
@click="copyApiStatsLink(key)"
>
<i class="fas fa-chart-bar mr-1"></i>统计
<i class="fas fa-chart-bar mr-1" />统计
</button>
<button
@click="openEditApiKeyModal(key)"
class="text-blue-600 hover:text-blue-900 font-medium hover:bg-blue-50 px-3 py-1 rounded-lg transition-colors"
class="text-blue-600 hover:text-blue-900 font-medium hover:bg-blue-50 px-3 py-1 rounded-lg transition-colors"
@click="openEditApiKeyModal(key)"
>
<i class="fas fa-edit mr-1"></i>编辑
<i class="fas fa-edit mr-1" />编辑
</button>
<button
v-if="key.expiresAt && (isApiKeyExpired(key.expiresAt) || isApiKeyExpiringSoon(key.expiresAt))"
@click="openRenewApiKeyModal(key)"
class="text-green-600 hover:text-green-900 font-medium hover:bg-green-50 px-3 py-1 rounded-lg transition-colors"
class="text-green-600 hover:text-green-900 font-medium hover:bg-green-50 px-3 py-1 rounded-lg transition-colors"
@click="openRenewApiKeyModal(key)"
>
<i class="fas fa-clock mr-1"></i>续期
<i class="fas fa-clock mr-1" />续期
</button>
<button
@click="deleteApiKey(key.id)"
class="text-red-600 hover:text-red-900 font-medium hover:bg-red-50 px-3 py-1 rounded-lg transition-colors"
class="text-red-600 hover:text-red-900 font-medium hover:bg-red-50 px-3 py-1 rounded-lg transition-colors"
@click="deleteApiKey(key.id)"
>
<i class="fas fa-trash mr-1"></i>删除
<i class="fas fa-trash mr-1" />删除
</button>
</div>
</td>
@@ -267,20 +400,31 @@
<!-- 模型统计展开区域 -->
<tr v-if="key && key.id && expandedApiKeys[key.id]">
<td colspan="7" class="px-6 py-4 bg-gray-50">
<div v-if="!apiKeyModelStats[key.id]" class="text-center py-4">
<div class="loading-spinner mx-auto"></div>
<p class="text-sm text-gray-500 mt-2">加载模型统计...</p>
<td
colspan="7"
class="px-6 py-4 bg-gray-50"
>
<div
v-if="!apiKeyModelStats[key.id]"
class="text-center py-4"
>
<div class="loading-spinner mx-auto" />
<p class="text-sm text-gray-500 mt-2">
加载模型统计...
</p>
</div>
<div class="space-y-4">
<!-- 通用的标题和时间筛选器无论是否有数据都显示 -->
<div class="flex items-center justify-between mb-4">
<h5 class="text-sm font-semibold text-gray-700 flex items-center">
<i class="fas fa-chart-pie text-indigo-500 mr-2"></i>
<i class="fas fa-chart-pie text-indigo-500 mr-2" />
模型使用分布
</h5>
<div class="flex items-center gap-2">
<span v-if="apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length > 0" class="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded-full">
<span
v-if="apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length > 0"
class="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded-full"
>
{{ apiKeyModelStats[key.id].length }} 个模型
</span>
@@ -291,13 +435,13 @@
<button
v-for="option in getApiKeyDateFilter(key.id).presetOptions"
:key="option.value"
@click="setApiKeyDateFilterPreset(option.value, key.id)"
:class="[
'px-2 py-1 rounded text-xs font-medium transition-colors',
getApiKeyDateFilter(key.id).preset === option.value && getApiKeyDateFilter(key.id).type === 'preset'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
]"
@click="setApiKeyDateFilterPreset(option.value, key.id)"
>
{{ option.label }}
</button>
@@ -306,7 +450,6 @@
<!-- Element Plus 日期范围选择器 -->
<el-date-picker
:model-value="getApiKeyDateFilter(key.id).customRange"
@update:model-value="(value) => onApiKeyCustomDateRangeChange(key.id, value)"
type="datetimerange"
range-separator=""
start-placeholder="开始日期"
@@ -317,33 +460,47 @@
:default-time="defaultTime"
size="small"
style="width: 280px;"
@update:model-value="(value) => onApiKeyCustomDateRangeChange(key.id, value)"
class="api-key-date-picker"
:clearable="true"
:unlink-panels="false"
></el-date-picker>
/>
</div>
</div>
</div>
<!-- 数据展示区域 -->
<div v-if="apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length === 0" class="text-center py-8">
<div
v-if="apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length === 0"
class="text-center py-8"
>
<div class="flex items-center justify-center gap-2 mb-3">
<i class="fas fa-chart-line text-gray-400 text-lg"></i>
<p class="text-sm text-gray-500">暂无模型使用数据</p>
<i class="fas fa-chart-line text-gray-400 text-lg" />
<p class="text-sm text-gray-500">
暂无模型使用数据
</p>
<button
@click="resetApiKeyDateFilter(key.id)"
class="text-blue-500 hover:text-blue-700 text-sm ml-2 flex items-center gap-1 transition-colors"
title="重置筛选条件并刷新"
@click="resetApiKeyDateFilter(key.id)"
>
<i class="fas fa-sync-alt text-xs"></i>
<i class="fas fa-sync-alt text-xs" />
<span class="text-xs">刷新</span>
</button>
</div>
<p class="text-xs text-gray-400">尝试调整时间范围或点击刷新重新加载数据</p>
<p class="text-xs text-gray-400">
尝试调整时间范围或点击刷新重新加载数据
</p>
</div>
<div v-else-if="apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="stat in apiKeyModelStats[key.id]" :key="stat.model"
class="bg-gradient-to-br from-white to-gray-50 rounded-xl p-4 border border-gray-200 hover:border-indigo-300 hover:shadow-lg transition-all duration-200">
<div
v-else-if="apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length > 0"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
>
<div
v-for="stat in apiKeyModelStats[key.id]"
:key="stat.model"
class="bg-gradient-to-br from-white to-gray-50 rounded-xl p-4 border border-gray-200 hover:border-indigo-300 hover:shadow-lg transition-all duration-200"
>
<div class="flex justify-between items-start mb-3">
<div class="flex-1">
<span class="text-sm font-semibold text-gray-800 block mb-1">{{ stat.model }}</span>
@@ -354,14 +511,14 @@
<div class="space-y-2 mb-3">
<div class="flex justify-between items-center text-sm">
<span class="text-gray-600 flex items-center">
<i class="fas fa-coins text-yellow-500 mr-1 text-xs"></i>
<i class="fas fa-coins text-yellow-500 mr-1 text-xs" />
总Token:
</span>
<span class="font-semibold text-gray-900">{{ formatNumber(stat.allTokens) }}</span>
</div>
<div class="flex justify-between items-center text-sm">
<span class="text-gray-600 flex items-center">
<i class="fas fa-dollar-sign text-green-500 mr-1 text-xs"></i>
<i class="fas fa-dollar-sign text-green-500 mr-1 text-xs" />
费用:
</span>
<span class="font-semibold text-green-600">{{ calculateModelCost(stat) }}</span>
@@ -369,28 +526,34 @@
<div class="pt-2 mt-2 border-t border-gray-100">
<div class="flex justify-between items-center text-xs text-gray-500">
<span class="flex items-center">
<i class="fas fa-arrow-down text-green-500 mr-1"></i>
<i class="fas fa-arrow-down text-green-500 mr-1" />
输入:
</span>
<span class="font-medium">{{ formatNumber(stat.inputTokens) }}</span>
</div>
<div class="flex justify-between items-center text-xs text-gray-500">
<span class="flex items-center">
<i class="fas fa-arrow-up text-blue-500 mr-1"></i>
<i class="fas fa-arrow-up text-blue-500 mr-1" />
输出:
</span>
<span class="font-medium">{{ formatNumber(stat.outputTokens) }}</span>
</div>
<div v-if="stat.cacheCreateTokens > 0" class="flex justify-between items-center text-xs text-purple-600">
<div
v-if="stat.cacheCreateTokens > 0"
class="flex justify-between items-center text-xs text-purple-600"
>
<span class="flex items-center">
<i class="fas fa-save mr-1"></i>
<i class="fas fa-save mr-1" />
缓存创建:
</span>
<span class="font-medium">{{ formatNumber(stat.cacheCreateTokens) }}</span>
</div>
<div v-if="stat.cacheReadTokens > 0" class="flex justify-between items-center text-xs text-purple-600">
<div
v-if="stat.cacheReadTokens > 0"
class="flex justify-between items-center text-xs text-purple-600"
>
<span class="flex items-center">
<i class="fas fa-download mr-1"></i>
<i class="fas fa-download mr-1" />
缓存读取:
</span>
<span class="font-medium">{{ formatNumber(stat.cacheReadTokens) }}</span>
@@ -400,9 +563,10 @@
<!-- 进度条 -->
<div class="w-full bg-gray-200 rounded-full h-2 mt-3">
<div class="bg-gradient-to-r from-indigo-500 to-purple-600 h-2 rounded-full transition-all duration-500"
:style="{ width: calculateApiKeyModelPercentage(stat.allTokens, apiKeyModelStats[key.id]) + '%' }">
</div>
<div
class="bg-gradient-to-r from-indigo-500 to-purple-600 h-2 rounded-full transition-all duration-500"
:style="{ width: calculateApiKeyModelPercentage(stat.allTokens, apiKeyModelStats[key.id]) + '%' }"
/>
</div>
<div class="text-right mt-1">
<span class="text-xs font-medium text-indigo-600">
@@ -413,10 +577,13 @@
</div>
<!-- 总计统计,仅在有数据时显示 -->
<div v-if="apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length > 0" class="mt-4 p-3 bg-gradient-to-r from-indigo-50 to-purple-50 rounded-lg border border-indigo-100">
<div
v-if="apiKeyModelStats[key.id] && apiKeyModelStats[key.id].length > 0"
class="mt-4 p-3 bg-gradient-to-r from-indigo-50 to-purple-50 rounded-lg border border-indigo-100"
>
<div class="flex items-center justify-between text-sm">
<span class="font-semibold text-gray-700 flex items-center">
<i class="fas fa-calculator text-indigo-500 mr-2"></i>
<i class="fas fa-calculator text-indigo-500 mr-2" />
总计统计
</span>
<div class="flex gap-4 text-xs">
@@ -448,7 +615,7 @@
<EditApiKeyModal
v-if="showEditApiKeyModal"
:apiKey="editingApiKey"
:api-key="editingApiKey"
:accounts="accounts"
@close="showEditApiKeyModal = false"
@success="handleEditSuccess"
@@ -456,14 +623,14 @@
<RenewApiKeyModal
v-if="showRenewApiKeyModal"
:apiKey="renewingApiKey"
:api-key="renewingApiKey"
@close="showRenewApiKeyModal = false"
@success="handleRenewSuccess"
/>
<NewApiKeyModal
v-if="showNewApiKeyModal"
:apiKey="newApiKeyData"
:api-key="newApiKeyData"
@close="showNewApiKeyModal = false"
/>
</div>

View File

@@ -10,8 +10,11 @@
:logo-src="oemSettings.siteIconData || oemSettings.siteIcon"
/>
<div class="flex items-center gap-3">
<router-link to="/dashboard" class="admin-button rounded-xl px-4 py-2 text-white transition-all duration-300 flex items-center gap-2">
<i class="fas fa-cog text-sm"></i>
<router-link
to="/dashboard"
class="admin-button rounded-xl px-4 py-2 text-white transition-all duration-300 flex items-center gap-2"
>
<i class="fas fa-cog text-sm" />
<span class="text-sm font-medium">管理后台</span>
</router-link>
</div>
@@ -23,23 +26,23 @@
<div class="flex justify-center">
<div class="inline-flex bg-white/10 backdrop-blur-xl rounded-full p-1 shadow-lg border border-white/20">
<button
@click="currentTab = 'stats'"
:class="[
'tab-pill-button',
currentTab === 'stats' ? 'active' : ''
]"
@click="currentTab = 'stats'"
>
<i class="fas fa-chart-line mr-2"></i>
<i class="fas fa-chart-line mr-2" />
<span>统计查询</span>
</button>
<button
@click="currentTab = 'tutorial'"
:class="[
'tab-pill-button',
currentTab === 'tutorial' ? 'active' : ''
]"
@click="currentTab = 'tutorial'"
>
<i class="fas fa-graduation-cap mr-2"></i>
<i class="fas fa-graduation-cap mr-2" />
<span>使用教程</span>
</button>
</div>
@@ -47,45 +50,54 @@
</div>
<!-- 统计内容 -->
<div v-if="currentTab === 'stats'" class="tab-content">
<div
v-if="currentTab === 'stats'"
class="tab-content"
>
<!-- API Key 输入区域 -->
<ApiKeyInput />
<!-- 错误提示 -->
<div v-if="error" class="mb-8">
<div
v-if="error"
class="mb-8"
>
<div class="bg-red-500/20 border border-red-500/30 rounded-xl p-4 text-red-800 backdrop-blur-sm">
<i class="fas fa-exclamation-triangle mr-2"></i>
<i class="fas fa-exclamation-triangle mr-2" />
{{ error }}
</div>
</div>
<!-- 统计数据展示区域 -->
<div v-if="statsData" class="fade-in">
<div
v-if="statsData"
class="fade-in"
>
<div class="glass-strong rounded-3xl p-6 shadow-xl">
<!-- 时间范围选择器 -->
<div class="mb-6 pb-6 border-b border-gray-200">
<div class="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<div class="flex items-center gap-3">
<i class="fas fa-clock text-blue-500 text-lg"></i>
<i class="fas fa-clock text-blue-500 text-lg" />
<span class="text-lg font-medium text-gray-700">统计时间范围</span>
</div>
<div class="flex gap-2">
<button
@click="switchPeriod('daily')"
:class="['period-btn', { 'active': statsPeriod === 'daily' }]"
class="px-6 py-2 text-sm font-medium flex items-center gap-2"
:disabled="loading || modelStatsLoading"
@click="switchPeriod('daily')"
>
<i class="fas fa-calendar-day"></i>
<i class="fas fa-calendar-day" />
今日
</button>
<button
@click="switchPeriod('monthly')"
:class="['period-btn', { 'active': statsPeriod === 'monthly' }]"
class="px-6 py-2 text-sm font-medium flex items-center gap-2"
:disabled="loading || modelStatsLoading"
@click="switchPeriod('monthly')"
>
<i class="fas fa-calendar-alt"></i>
<i class="fas fa-calendar-alt" />
本月
</button>
</div>
@@ -108,7 +120,10 @@
</div>
<!-- 教程内容 -->
<div v-if="currentTab === 'tutorial'" class="tab-content">
<div
v-if="currentTab === 'tutorial'"
class="tab-content"
>
<div class="glass-strong rounded-3xl shadow-xl">
<TutorialView />
</div>

View File

@@ -5,12 +5,18 @@
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-gray-600 mb-1">总API Keys</p>
<p class="text-3xl font-bold text-gray-900">{{ dashboardData.totalApiKeys }}</p>
<p class="text-xs text-gray-500 mt-1">活跃: {{ dashboardData.activeApiKeys || 0 }}</p>
<p class="text-sm font-semibold text-gray-600 mb-1">
总API Keys
</p>
<p class="text-3xl font-bold text-gray-900">
{{ dashboardData.totalApiKeys }}
</p>
<p class="text-xs text-gray-500 mt-1">
活跃: {{ dashboardData.activeApiKeys || 0 }}
</p>
</div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-blue-500 to-blue-600">
<i class="fas fa-key"></i>
<i class="fas fa-key" />
</div>
</div>
</div>
@@ -18,17 +24,24 @@
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-gray-600 mb-1">服务账户</p>
<p class="text-3xl font-bold text-gray-900">{{ dashboardData.totalAccounts }}</p>
<p class="text-sm font-semibold text-gray-600 mb-1">
服务账户
</p>
<p class="text-3xl font-bold text-gray-900">
{{ dashboardData.totalAccounts }}
</p>
<p class="text-xs text-gray-500 mt-1">
活跃: {{ dashboardData.activeAccounts || 0 }}
<span v-if="dashboardData.rateLimitedAccounts > 0" class="text-yellow-600">
<span
v-if="dashboardData.rateLimitedAccounts > 0"
class="text-yellow-600"
>
| 限流: {{ dashboardData.rateLimitedAccounts }}
</span>
</p>
</div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-green-500 to-green-600">
<i class="fas fa-user-circle"></i>
<i class="fas fa-user-circle" />
</div>
</div>
</div>
@@ -36,12 +49,18 @@
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-gray-600 mb-1">今日请求</p>
<p class="text-3xl font-bold text-gray-900">{{ dashboardData.todayRequests }}</p>
<p class="text-xs text-gray-500 mt-1">总请求: {{ formatNumber(dashboardData.totalRequests || 0) }}</p>
<p class="text-sm font-semibold text-gray-600 mb-1">
今日请求
</p>
<p class="text-3xl font-bold text-gray-900">
{{ dashboardData.todayRequests }}
</p>
<p class="text-xs text-gray-500 mt-1">
总请求: {{ formatNumber(dashboardData.totalRequests || 0) }}
</p>
</div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-purple-500 to-purple-600">
<i class="fas fa-chart-line"></i>
<i class="fas fa-chart-line" />
</div>
</div>
</div>
@@ -49,12 +68,18 @@
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-gray-600 mb-1">系统状态</p>
<p class="text-3xl font-bold text-green-600">{{ dashboardData.systemStatus }}</p>
<p class="text-xs text-gray-500 mt-1">运行时间: {{ formattedUptime }}</p>
<p class="text-sm font-semibold text-gray-600 mb-1">
系统状态
</p>
<p class="text-3xl font-bold text-green-600">
{{ dashboardData.systemStatus }}
</p>
<p class="text-xs text-gray-500 mt-1">
运行时间: {{ formattedUptime }}
</p>
</div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-yellow-500 to-orange-500">
<i class="fas fa-heartbeat"></i>
<i class="fas fa-heartbeat" />
</div>
</div>
</div>
@@ -65,22 +90,32 @@
<div class="stat-card">
<div class="flex items-center justify-between">
<div class="flex-1 mr-8">
<p class="text-sm font-semibold text-gray-600 mb-1">今日Token</p>
<p class="text-sm font-semibold text-gray-600 mb-1">
今日Token
</p>
<div class="flex items-baseline gap-2 mb-2 flex-wrap">
<p class="text-3xl font-bold text-blue-600">{{ formatNumber((dashboardData.todayInputTokens || 0) + (dashboardData.todayOutputTokens || 0) + (dashboardData.todayCacheCreateTokens || 0) + (dashboardData.todayCacheReadTokens || 0)) }}</p>
<p class="text-3xl font-bold text-blue-600">
{{ formatNumber((dashboardData.todayInputTokens || 0) + (dashboardData.todayOutputTokens || 0) + (dashboardData.todayCacheCreateTokens || 0) + (dashboardData.todayCacheReadTokens || 0)) }}
</p>
<span class="text-sm text-green-600 font-medium">/ {{ costsData.todayCosts.formatted.totalCost }}</span>
</div>
<div class="text-xs text-gray-500">
<div class="flex justify-between items-center flex-wrap gap-x-4">
<span>输入: <span class="font-medium">{{ formatNumber(dashboardData.todayInputTokens || 0) }}</span></span>
<span>输出: <span class="font-medium">{{ formatNumber(dashboardData.todayOutputTokens || 0) }}</span></span>
<span v-if="(dashboardData.todayCacheCreateTokens || 0) > 0" class="text-purple-600">缓存创建: <span class="font-medium">{{ formatNumber(dashboardData.todayCacheCreateTokens || 0) }}</span></span>
<span v-if="(dashboardData.todayCacheReadTokens || 0) > 0" class="text-purple-600">缓存读取: <span class="font-medium">{{ formatNumber(dashboardData.todayCacheReadTokens || 0) }}</span></span>
<span
v-if="(dashboardData.todayCacheCreateTokens || 0) > 0"
class="text-purple-600"
>缓存创建: <span class="font-medium">{{ formatNumber(dashboardData.todayCacheCreateTokens || 0) }}</span></span>
<span
v-if="(dashboardData.todayCacheReadTokens || 0) > 0"
class="text-purple-600"
>缓存读取: <span class="font-medium">{{ formatNumber(dashboardData.todayCacheReadTokens || 0) }}</span></span>
</div>
</div>
</div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-indigo-500 to-indigo-600">
<i class="fas fa-coins"></i>
<i class="fas fa-coins" />
</div>
</div>
</div>
@@ -88,22 +123,32 @@
<div class="stat-card">
<div class="flex items-center justify-between">
<div class="flex-1 mr-8">
<p class="text-sm font-semibold text-gray-600 mb-1">总Token消耗</p>
<p class="text-sm font-semibold text-gray-600 mb-1">
总Token消耗
</p>
<div class="flex items-baseline gap-2 mb-2 flex-wrap">
<p class="text-3xl font-bold text-emerald-600">{{ formatNumber((dashboardData.totalInputTokens || 0) + (dashboardData.totalOutputTokens || 0) + (dashboardData.totalCacheCreateTokens || 0) + (dashboardData.totalCacheReadTokens || 0)) }}</p>
<p class="text-3xl font-bold text-emerald-600">
{{ formatNumber((dashboardData.totalInputTokens || 0) + (dashboardData.totalOutputTokens || 0) + (dashboardData.totalCacheCreateTokens || 0) + (dashboardData.totalCacheReadTokens || 0)) }}
</p>
<span class="text-sm text-green-600 font-medium">/ {{ costsData.totalCosts.formatted.totalCost }}</span>
</div>
<div class="text-xs text-gray-500">
<div class="flex justify-between items-center flex-wrap gap-x-4">
<span>输入: <span class="font-medium">{{ formatNumber(dashboardData.totalInputTokens || 0) }}</span></span>
<span>输出: <span class="font-medium">{{ formatNumber(dashboardData.totalOutputTokens || 0) }}</span></span>
<span v-if="(dashboardData.totalCacheCreateTokens || 0) > 0" class="text-purple-600">缓存创建: <span class="font-medium">{{ formatNumber(dashboardData.totalCacheCreateTokens || 0) }}</span></span>
<span v-if="(dashboardData.totalCacheReadTokens || 0) > 0" class="text-purple-600">缓存读取: <span class="font-medium">{{ formatNumber(dashboardData.totalCacheReadTokens || 0) }}</span></span>
<span
v-if="(dashboardData.totalCacheCreateTokens || 0) > 0"
class="text-purple-600"
>缓存创建: <span class="font-medium">{{ formatNumber(dashboardData.totalCacheCreateTokens || 0) }}</span></span>
<span
v-if="(dashboardData.totalCacheReadTokens || 0) > 0"
class="text-purple-600"
>缓存读取: <span class="font-medium">{{ formatNumber(dashboardData.totalCacheReadTokens || 0) }}</span></span>
</div>
</div>
</div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-emerald-500 to-emerald-600">
<i class="fas fa-database"></i>
<i class="fas fa-database" />
</div>
</div>
</div>
@@ -111,12 +156,25 @@
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-gray-600 mb-1">平均RPM</p>
<p class="text-3xl font-bold text-orange-600">{{ dashboardData.systemRPM || 0 }}</p>
<p class="text-xs text-gray-500 mt-1">分钟请求数</p>
<p class="text-sm font-semibold text-gray-600 mb-1">
实时RPM
<span class="text-xs text-gray-400">({{ dashboardData.metricsWindow }}分钟)</span>
</p>
<p class="text-3xl font-bold text-orange-600">
{{ dashboardData.realtimeRPM || 0 }}
</p>
<p class="text-xs text-gray-500 mt-1">
每分钟请求数
<span
v-if="dashboardData.isHistoricalMetrics"
class="text-yellow-600"
>
<i class="fas fa-exclamation-circle" /> 历史数据
</span>
</p>
</div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-orange-500 to-orange-600">
<i class="fas fa-tachometer-alt"></i>
<i class="fas fa-tachometer-alt" />
</div>
</div>
</div>
@@ -124,12 +182,25 @@
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-gray-600 mb-1">平均TPM</p>
<p class="text-3xl font-bold text-rose-600">{{ dashboardData.systemTPM || 0 }}</p>
<p class="text-xs text-gray-500 mt-1">分钟Token数</p>
<p class="text-sm font-semibold text-gray-600 mb-1">
实时TPM
<span class="text-xs text-gray-400">({{ dashboardData.metricsWindow }}分钟)</span>
</p>
<p class="text-3xl font-bold text-rose-600">
{{ formatNumber(dashboardData.realtimeTPM || 0) }}
</p>
<p class="text-xs text-gray-500 mt-1">
每分钟Token数
<span
v-if="dashboardData.isHistoricalMetrics"
class="text-yellow-600"
>
<i class="fas fa-exclamation-circle" /> 历史数据
</span>
</p>
</div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-rose-500 to-rose-600">
<i class="fas fa-rocket"></i>
<i class="fas fa-rocket" />
</div>
</div>
</div>
@@ -137,21 +208,23 @@
<!-- 模型消费统计 -->
<div class="mb-8">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-bold text-gray-900">模型使用分布与Token使用趋势</h3>
<div class="flex gap-2 items-center">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mb-6">
<h3 class="text-xl font-bold text-gray-900">
模型使用分布与Token使用趋势
</h3>
<div class="flex flex-wrap gap-2 items-center">
<!-- 快捷日期选择 -->
<div class="flex gap-1 bg-gray-100 rounded-lg p-1">
<button
v-for="option in dateFilter.presetOptions"
:key="option.value"
@click="setDateFilterPreset(option.value)"
:class="[
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
dateFilter.preset === option.value && dateFilter.type === 'preset'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
]"
@click="setDateFilterPreset(option.value)"
>
{{ option.label }}
</button>
@@ -160,89 +233,163 @@
<!-- 粒度切换按钮 -->
<div class="flex gap-1 bg-gray-100 rounded-lg p-1">
<button
@click="setTrendGranularity('day')"
:class="[
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
trendGranularity === 'day'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
]"
@click="setTrendGranularity('day')"
>
<i class="fas fa-calendar-day mr-1"></i>按天
<i class="fas fa-calendar-day mr-1" />按天
</button>
<button
@click="setTrendGranularity('hour')"
:class="[
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
trendGranularity === 'hour'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
]"
@click="setTrendGranularity('hour')"
>
<i class="fas fa-clock mr-1"></i>按小时
<i class="fas fa-clock mr-1" />按小时
</button>
</div>
<!-- Element Plus 日期范围选择器 -->
<div class="flex items-center gap-2">
<el-date-picker
:default-time="defaultTime"
v-model="dateFilter.customRange"
:default-time="defaultTime"
type="datetimerange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
@change="onCustomDateRangeChange"
:disabled-date="disabledDate"
size="default"
style="width: 400px;"
class="custom-date-picker"
></el-date-picker>
<span v-if="trendGranularity === 'hour'" class="text-xs text-orange-600">
<i class="fas fa-info-circle"></i> 最多24小时
@change="onCustomDateRangeChange"
/>
<span
v-if="trendGranularity === 'hour'"
class="text-xs text-orange-600"
>
<i class="fas fa-info-circle" /> 最多24小时
</span>
</div>
<button @click="refreshChartsData()" class="btn btn-primary px-4 py-2 flex items-center gap-2">
<i class="fas fa-sync-alt"></i>刷新
</button>
<!-- 刷新控制 -->
<div class="flex items-center gap-2">
<!-- 自动刷新控制 -->
<div class="flex items-center bg-gray-100 rounded-lg px-3 py-1">
<label class="relative inline-flex items-center cursor-pointer">
<input
v-model="autoRefreshEnabled"
type="checkbox"
class="sr-only peer"
>
<!-- 更小的开关 -->
<div class="relative w-9 h-5 bg-gray-300 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-300 rounded-full peer peer-checked:bg-blue-500 transition-all duration-200 after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:w-4 after:h-4 after:rounded-full after:shadow-sm after:transition-transform after:duration-200 peer-checked:after:translate-x-4" />
<span class="ml-2.5 text-sm font-medium text-gray-600 select-none flex items-center gap-1">
<i class="fas fa-redo-alt text-xs text-gray-500" />
<span>自动刷新</span>
<span
v-if="autoRefreshEnabled"
class="ml-1 text-xs text-blue-600 font-mono transition-opacity"
:class="refreshCountdown > 0 ? 'opacity-100' : 'opacity-0'"
>
{{ refreshCountdown }}s
</span>
</span>
</label>
</div>
<!-- 刷新按钮 -->
<button
:disabled="isRefreshing"
class="px-3 py-1 rounded-md text-sm font-medium transition-colors bg-white text-blue-600 shadow-sm hover:bg-gray-50 border border-gray-300 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
title="立即刷新数据"
@click="refreshAllData()"
>
<i :class="['fas fa-sync-alt text-xs', { 'animate-spin': isRefreshing }]" />
<span>{{ isRefreshing ? '刷新中' : '刷新' }}</span>
</button>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 饼图 -->
<div class="card p-6">
<h4 class="text-lg font-semibold text-gray-800 mb-4">Token使用分布</h4>
<div class="relative" style="height: 300px;">
<canvas ref="modelUsageChart"></canvas>
<h4 class="text-lg font-semibold text-gray-800 mb-4">
Token使用分布
</h4>
<div
class="relative"
style="height: 300px;"
>
<canvas ref="modelUsageChart" />
</div>
</div>
<!-- 详细数据表格 -->
<div class="card p-6">
<h4 class="text-lg font-semibold text-gray-800 mb-4">详细统计数据</h4>
<div v-if="dashboardModelStats.length === 0" class="text-center py-8">
<p class="text-gray-500">暂无模型使用数据</p>
<h4 class="text-lg font-semibold text-gray-800 mb-4">
详细统计数据
</h4>
<div
v-if="dashboardModelStats.length === 0"
class="text-center py-8"
>
<p class="text-gray-500">
暂无模型使用数据
</p>
</div>
<div v-else class="overflow-auto max-h-[300px]">
<div
v-else
class="overflow-auto max-h-[300px]"
>
<table class="min-w-full">
<thead class="bg-gray-50 sticky top-0">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-700">模型</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-700">请求数</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-700">总Token</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-700">费用</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-700">占比</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-700">
模型
</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-700">
请求数
</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-700">
总Token
</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-700">
费用
</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-700">
占比
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-for="stat in dashboardModelStats" :key="stat.model" class="hover:bg-gray-50">
<td class="px-4 py-2 text-sm text-gray-900">{{ stat.model }}</td>
<td class="px-4 py-2 text-sm text-gray-600 text-right">{{ formatNumber(stat.requests) }}</td>
<td class="px-4 py-2 text-sm text-gray-600 text-right">{{ formatNumber(stat.allTokens) }}</td>
<td class="px-4 py-2 text-sm text-green-600 text-right font-medium">{{ stat.formatted ? stat.formatted.total : '$0.000000' }}</td>
<tr
v-for="stat in dashboardModelStats"
:key="stat.model"
class="hover:bg-gray-50"
>
<td class="px-4 py-2 text-sm text-gray-900">
{{ stat.model }}
</td>
<td class="px-4 py-2 text-sm text-gray-600 text-right">
{{ formatNumber(stat.requests) }}
</td>
<td class="px-4 py-2 text-sm text-gray-600 text-right">
{{ formatNumber(stat.allTokens) }}
</td>
<td class="px-4 py-2 text-sm text-green-600 text-right font-medium">
{{ stat.formatted ? stat.formatted.total : '$0.000000' }}
</td>
<td class="px-4 py-2 text-sm font-medium text-right">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{{ calculatePercentage(stat.allTokens, dashboardModelStats) }}%
@@ -260,7 +407,7 @@
<div class="mb-8">
<div class="card p-6">
<div style="height: 300px;">
<canvas ref="usageTrendChart"></canvas>
<canvas ref="usageTrendChart" />
</div>
</div>
</div>
@@ -269,30 +416,32 @@
<div class="mb-8">
<div class="card p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">API Keys 使用趋势</h3>
<h3 class="text-lg font-semibold text-gray-900">
API Keys 使用趋势
</h3>
<!-- 维度切换按钮 -->
<div class="flex gap-1 bg-gray-100 rounded-lg p-1">
<button
@click="apiKeysTrendMetric = 'requests'; updateApiKeysUsageTrendChart()"
:class="[
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
apiKeysTrendMetric === 'requests'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
]"
@click="apiKeysTrendMetric = 'requests'; updateApiKeysUsageTrendChart()"
>
<i class="fas fa-exchange-alt mr-1"></i>请求次数
<i class="fas fa-exchange-alt mr-1" />请求次数
</button>
<button
@click="apiKeysTrendMetric = 'tokens'; updateApiKeysUsageTrendChart()"
:class="[
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
apiKeysTrendMetric === 'tokens'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
]"
@click="apiKeysTrendMetric = 'tokens'; updateApiKeysUsageTrendChart()"
>
<i class="fas fa-coins mr-1"></i>Token 数量
<i class="fas fa-coins mr-1" />Token 数量
</button>
</div>
</div>
@@ -305,7 +454,7 @@
</span>
</div>
<div style="height: 350px;">
<canvas ref="apiKeysUsageTrendChart"></canvas>
<canvas ref="apiKeysUsageTrendChart" />
</div>
</div>
</div>
@@ -313,7 +462,7 @@
</template>
<script setup>
import { ref, onMounted, watch, nextTick } from 'vue'
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useDashboardStore } from '@/stores/dashboard'
import Chart from 'chart.js/auto'
@@ -334,8 +483,6 @@ const {
const {
loadDashboardData,
loadUsageTrend,
loadModelStats,
loadApiKeysTrend,
setDateFilterPreset,
onCustomDateRangeChange,
@@ -352,6 +499,20 @@ let modelUsageChartInstance = null
let usageTrendChartInstance = null
let apiKeysUsageTrendChartInstance = null
// 自动刷新相关
const autoRefreshEnabled = ref(false)
const autoRefreshInterval = ref(30) // 秒
const autoRefreshTimer = ref(null)
const refreshCountdown = ref(0)
const countdownTimer = ref(null)
const isRefreshing = ref(false)
// 计算倒计时显示
const refreshCountdownDisplay = computed(() => {
if (!autoRefreshEnabled.value || refreshCountdown.value <= 0) return ''
return `${refreshCountdown.value}秒后刷新`
})
// 格式化数字
function formatNumber(num) {
if (num >= 1000000) {
@@ -543,6 +704,22 @@ function createUsageTrendChart() {
tooltip: {
mode: 'index',
intersect: false,
itemSort: function(a, b) {
// 按值倒序排列,费用和请求数特殊处理
const aLabel = a.dataset.label || ''
const bLabel = b.dataset.label || ''
// 费用和请求数使用不同的轴,单独处理
if (aLabel === '费用 (USD)' || bLabel === '费用 (USD)') {
return aLabel === '费用 (USD)' ? -1 : 1
}
if (aLabel === '请求数' || bLabel === '请求数') {
return aLabel === '请求数' ? 1 : -1
}
// 其他按token值倒序
return b.parsed.y - a.parsed.y
},
callbacks: {
label: function(context) {
const label = context.dataset.label || ''
@@ -558,7 +735,14 @@ function createUsageTrendChart() {
} else if (label === '请求数') {
return label + ': ' + value.toLocaleString() + ' 次'
} else {
return label + ': ' + value.toLocaleString() + ' tokens'
// 格式化token数显示
if (value >= 1000000) {
return label + ': ' + (value / 1000000).toFixed(2) + 'M tokens'
} else if (value >= 1000) {
return label + ': ' + (value / 1000).toFixed(2) + 'K tokens'
} else {
return label + ': ' + value.toLocaleString() + ' tokens'
}
}
}
}
@@ -706,12 +890,52 @@ function createApiKeysUsageTrendChart() {
tooltip: {
mode: 'index',
intersect: false,
itemSort: function(a, b) {
// 按值倒序排列
return b.parsed.y - a.parsed.y
},
callbacks: {
label: function(context) {
const label = context.dataset.label || ''
const value = context.parsed.y
const unit = apiKeysTrendMetric.value === 'tokens' ? ' tokens' : ' 次'
return label + ': ' + value.toLocaleString() + unit
const dataIndex = context.dataIndex
const dataPoint = apiKeysTrendData.value.data[dataIndex]
// 获取所有数据集在这个时间点的值,用于排名
const allValues = context.chart.data.datasets.map((dataset, idx) => ({
value: dataset.data[dataIndex] || 0,
index: idx
})).sort((a, b) => b.value - a.value)
// 找出当前数据集的排名
const rank = allValues.findIndex(item => item.index === context.datasetIndex) + 1
// 准备排名标识
let rankIcon = ''
if (rank === 1) rankIcon = '🥇 '
else if (rank === 2) rankIcon = '🥈 '
else if (rank === 3) rankIcon = '🥉 '
if (apiKeysTrendMetric.value === 'tokens') {
// 格式化token显示
let formattedValue = ''
if (value >= 1000000) {
formattedValue = (value / 1000000).toFixed(2) + 'M'
} else if (value >= 1000) {
formattedValue = (value / 1000).toFixed(2) + 'K'
} else {
formattedValue = value.toLocaleString()
}
// 获取对应API Key的费用信息
const apiKeyId = apiKeysTrendData.value.topApiKeys[context.datasetIndex]
const apiKeyData = dataPoint?.apiKeys?.[apiKeyId]
const cost = apiKeyData?.formattedCost || '$0.00'
return `${rankIcon}${label}: ${formattedValue} tokens (${cost})`
} else {
return `${rankIcon}${label}: ${value.toLocaleString()}`
}
}
}
}
@@ -762,13 +986,90 @@ watch(apiKeysTrendData, () => {
nextTick(() => createApiKeysUsageTrendChart())
})
// 刷新所有数据
async function refreshAllData() {
if (isRefreshing.value) return
isRefreshing.value = true
try {
await Promise.all([
loadDashboardData(),
refreshChartsData()
])
} finally {
isRefreshing.value = false
}
}
// 启动自动刷新
function startAutoRefresh() {
if (!autoRefreshEnabled.value) return
// 重置倒计时
refreshCountdown.value = autoRefreshInterval.value
// 清除现有定时器
if (countdownTimer.value) {
clearInterval(countdownTimer.value)
}
if (autoRefreshTimer.value) {
clearTimeout(autoRefreshTimer.value)
}
// 启动倒计时
countdownTimer.value = setInterval(() => {
refreshCountdown.value--
if (refreshCountdown.value <= 0) {
clearInterval(countdownTimer.value)
}
}, 1000)
// 设置刷新定时器
autoRefreshTimer.value = setTimeout(async () => {
await refreshAllData()
// 递归调用以继续自动刷新
if (autoRefreshEnabled.value) {
startAutoRefresh()
}
}, autoRefreshInterval.value * 1000)
}
// 停止自动刷新
function stopAutoRefresh() {
if (countdownTimer.value) {
clearInterval(countdownTimer.value)
countdownTimer.value = null
}
if (autoRefreshTimer.value) {
clearTimeout(autoRefreshTimer.value)
autoRefreshTimer.value = null
}
refreshCountdown.value = 0
}
// 切换自动刷新
function toggleAutoRefresh() {
autoRefreshEnabled.value = !autoRefreshEnabled.value
if (autoRefreshEnabled.value) {
startAutoRefresh()
} else {
stopAutoRefresh()
}
}
// 监听自动刷新状态变化
watch(autoRefreshEnabled, (newVal) => {
if (newVal) {
startAutoRefresh()
} else {
stopAutoRefresh()
}
})
// 初始化
onMounted(async () => {
// 加载所有数据
await Promise.all([
loadDashboardData(),
refreshChartsData() // 使用refreshChartsData来确保根据当前筛选条件加载数据
])
await refreshAllData()
// 创建图表
await nextTick()
@@ -776,6 +1077,21 @@ onMounted(async () => {
createUsageTrendChart()
createApiKeysUsageTrendChart()
})
// 清理
onUnmounted(() => {
stopAutoRefresh()
// 销毁图表实例
if (modelUsageChartInstance) {
modelUsageChartInstance.destroy()
}
if (usageTrendChartInstance) {
usageTrendChartInstance.destroy()
}
if (apiKeysUsageTrendChartInstance) {
apiKeysUsageTrendChartInstance.destroy()
}
})
</script>
<style scoped>
@@ -794,4 +1110,18 @@ onMounted(async () => {
.custom-date-picker :deep(.el-range-input) {
font-size: 13px;
}
/* 旋转动画 */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>

View File

@@ -5,23 +5,41 @@
<!-- 使用自定义布局来保持登录页面的居中大logo样式 -->
<div class="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-gray-300/30 rounded-2xl flex items-center justify-center backdrop-blur-sm overflow-hidden">
<template v-if="!oemLoading">
<img v-if="authStore.oemSettings.siteIconData || authStore.oemSettings.siteIcon"
:src="authStore.oemSettings.siteIconData || authStore.oemSettings.siteIcon"
alt="Logo"
class="w-12 h-12 object-contain"
@error="(e) => e.target.style.display = 'none'">
<i v-else class="fas fa-cloud text-3xl text-gray-700"></i>
<img
v-if="authStore.oemSettings.siteIconData || authStore.oemSettings.siteIcon"
:src="authStore.oemSettings.siteIconData || authStore.oemSettings.siteIcon"
alt="Logo"
class="w-12 h-12 object-contain"
@error="(e) => e.target.style.display = 'none'"
>
<i
v-else
class="fas fa-cloud text-3xl text-gray-700"
/>
</template>
<div v-else class="w-12 h-12 bg-gray-300/50 rounded animate-pulse"></div>
<div
v-else
class="w-12 h-12 bg-gray-300/50 rounded animate-pulse"
/>
</div>
<template v-if="!oemLoading && authStore.oemSettings.siteName">
<h1 class="text-3xl font-bold text-white mb-2 header-title">{{ authStore.oemSettings.siteName }}</h1>
<h1 class="text-3xl font-bold text-white mb-2 header-title">
{{ authStore.oemSettings.siteName }}
</h1>
</template>
<div v-else-if="oemLoading" class="h-9 w-64 bg-gray-300/50 rounded animate-pulse mx-auto mb-2"></div>
<p class="text-gray-600 text-lg">管理后台</p>
<div
v-else-if="oemLoading"
class="h-9 w-64 bg-gray-300/50 rounded animate-pulse mx-auto mb-2"
/>
<p class="text-gray-600 text-lg">
管理后台
</p>
</div>
<form @submit.prevent="handleLogin" class="space-y-6">
<form
class="space-y-6"
@submit.prevent="handleLogin"
>
<div>
<label class="block text-sm font-semibold text-gray-900 mb-3">用户名</label>
<input
@@ -49,14 +67,23 @@
:disabled="authStore.loginLoading"
class="btn btn-primary w-full py-4 px-6 text-lg font-semibold"
>
<i v-if="!authStore.loginLoading" class="fas fa-sign-in-alt mr-2"></i>
<div v-if="authStore.loginLoading" class="loading-spinner mr-2"></div>
<i
v-if="!authStore.loginLoading"
class="fas fa-sign-in-alt mr-2"
/>
<div
v-if="authStore.loginLoading"
class="loading-spinner mr-2"
/>
{{ authStore.loginLoading ? '登录中...' : '登录' }}
</button>
</form>
<div v-if="authStore.loginError" class="mt-6 p-4 bg-red-500/20 border border-red-500/30 rounded-xl text-red-800 text-sm text-center backdrop-blur-sm">
<i class="fas fa-exclamation-triangle mr-2"></i>{{ authStore.loginError }}
<div
v-if="authStore.loginError"
class="mt-6 p-4 bg-red-500/20 border border-red-500/30 rounded-xl text-red-800 text-sm text-center backdrop-blur-sm"
>
<i class="fas fa-exclamation-triangle mr-2" />{{ authStore.loginError }}
</div>
</div>
</div>

View File

@@ -3,17 +3,29 @@
<div class="card p-6">
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-6">
<div>
<h3 class="text-xl font-bold text-gray-900 mb-2">其他设置</h3>
<p class="text-gray-600">自定义网站名称和图标</p>
<h3 class="text-xl font-bold text-gray-900 mb-2">
其他设置
</h3>
<p class="text-gray-600">
自定义网站名称和图标
</p>
</div>
</div>
<div v-if="loading" class="text-center py-12">
<div class="loading-spinner mx-auto mb-4"></div>
<p class="text-gray-500">正在加载设置...</p>
<div
v-if="loading"
class="text-center py-12"
>
<div class="loading-spinner mx-auto mb-4" />
<p class="text-gray-500">
正在加载设置...
</p>
</div>
<div v-else class="table-container">
<div
v-else
class="table-container"
>
<table class="min-w-full">
<tbody class="divide-y divide-gray-200/50">
<!-- 网站名称 -->
@@ -21,11 +33,15 @@
<td class="px-6 py-4 whitespace-nowrap w-48">
<div class="flex items-center">
<div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center mr-3">
<i class="fas fa-font text-white text-xs"></i>
<i class="fas fa-font text-white text-xs" />
</div>
<div>
<div class="text-sm font-semibold text-gray-900">网站名称</div>
<div class="text-xs text-gray-500">品牌标识</div>
<div class="text-sm font-semibold text-gray-900">
网站名称
</div>
<div class="text-xs text-gray-500">
品牌标识
</div>
</div>
</div>
</td>
@@ -37,7 +53,9 @@
placeholder="Claude Relay Service"
maxlength="100"
>
<p class="text-xs text-gray-500 mt-1">将显示在浏览器标题和页面头部</p>
<p class="text-xs text-gray-500 mt-1">
将显示在浏览器标题和页面头部
</p>
</td>
</tr>
@@ -46,18 +64,25 @@
<td class="px-6 py-4 whitespace-nowrap w-48">
<div class="flex items-center">
<div class="w-8 h-8 bg-gradient-to-br from-purple-500 to-purple-600 rounded-lg flex items-center justify-center mr-3">
<i class="fas fa-image text-white text-xs"></i>
<i class="fas fa-image text-white text-xs" />
</div>
<div>
<div class="text-sm font-semibold text-gray-900">网站图标</div>
<div class="text-xs text-gray-500">Favicon</div>
<div class="text-sm font-semibold text-gray-900">
网站图标
</div>
<div class="text-xs text-gray-500">
Favicon
</div>
</div>
</div>
</td>
<td class="px-6 py-4">
<div class="space-y-3">
<!-- 图标预览 -->
<div v-if="oemSettings.siteIconData || oemSettings.siteIcon" class="inline-flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
<div
v-if="oemSettings.siteIconData || oemSettings.siteIcon"
class="inline-flex items-center gap-3 p-3 bg-gray-50 rounded-lg"
>
<img
:src="oemSettings.siteIconData || oemSettings.siteIcon"
alt="图标预览"
@@ -66,27 +91,27 @@
>
<span class="text-sm text-gray-600">当前图标</span>
<button
@click="removeIcon"
class="text-red-600 hover:text-red-900 font-medium hover:bg-red-50 px-3 py-1 rounded-lg transition-colors"
@click="removeIcon"
>
<i class="fas fa-trash mr-1"></i>删除
<i class="fas fa-trash mr-1" />删除
</button>
</div>
<!-- 文件上传 -->
<div>
<input
type="file"
ref="iconFileInput"
@change="handleIconUpload"
ref="iconFileInput"
type="file"
accept=".ico,.png,.jpg,.jpeg,.svg"
class="hidden"
@change="handleIconUpload"
>
<button
@click="$refs.iconFileInput.click()"
class="btn btn-success px-4 py-2"
@click="$refs.iconFileInput.click()"
>
<i class="fas fa-upload mr-2"></i>
<i class="fas fa-upload mr-2" />
上传图标
</button>
<span class="text-xs text-gray-500 ml-3">支持 .ico, .png, .jpg, .svg 格式最大 350KB</span>
@@ -97,32 +122,44 @@
<!-- 操作按钮 -->
<tr>
<td class="px-6 py-6" colspan="2">
<td
class="px-6 py-6"
colspan="2"
>
<div class="flex items-center justify-between">
<div class="flex gap-3">
<button
@click="saveOemSettings"
:disabled="saving"
class="btn btn-primary px-6 py-3"
:class="{ 'opacity-50 cursor-not-allowed': saving }"
@click="saveOemSettings"
>
<div v-if="saving" class="loading-spinner mr-2"></div>
<i v-else class="fas fa-save mr-2"></i>
<div
v-if="saving"
class="loading-spinner mr-2"
/>
<i
v-else
class="fas fa-save mr-2"
/>
{{ saving ? '保存中...' : '保存设置' }}
</button>
<button
@click="resetOemSettings"
class="btn bg-gray-100 text-gray-700 hover:bg-gray-200 px-6 py-3"
:disabled="saving"
@click="resetOemSettings"
>
<i class="fas fa-undo mr-2"></i>
<i class="fas fa-undo mr-2" />
重置为默认
</button>
</div>
<div v-if="oemSettings.updatedAt" class="text-sm text-gray-500">
<i class="fas fa-clock mr-1"></i>
<div
v-if="oemSettings.updatedAt"
class="text-sm text-gray-500"
>
<i class="fas fa-clock mr-1" />
最后更新{{ formatDateTime(oemSettings.updatedAt) }}
</div>
</div>

File diff suppressed because it is too large Load Diff